Dev 1.5.0 (#159)
* Add comprehensive Chinese internationalization support - Implemented i18n framework with react-i18next for multi-language support - Added Chinese (zh) and English (en) translation files with comprehensive coverage - Localized Admin interface, authentication flows, and error messages - Translated FileManager operations and UI elements - Updated HomepageAuth component with localized authentication messages - Localized LeftSidebar navigation and host management - Added language switcher component (shown after login only) - Configured default language as English with Chinese as secondary option - Localized TOTPSetup two-factor authentication interface - Updated Docker build to include translation files - Achieved 95%+ UI localization coverage across core components Co-Authored-By: Claude <noreply@anthropic.com> * Extend Chinese localization coverage to Host Manager components - Added comprehensive translations for HostManagerHostViewer component - Localized all host management UI text including import/export features - Translated error messages and confirmation dialogs for host operations - Added translations for HostManagerHostEditor validation messages - Localized connection details, organization settings, and form labels - Fixed syntax error in FileManagerOperations component - Achieved near-complete localization of SSH host management interface - Updated placeholders and tooltips for better user guidance Co-Authored-By: Claude <noreply@anthropic.com> * Complete comprehensive Chinese localization for Termix - Added full localization support for Tunnel components (connected/disconnected states, retry messages) - Localized all tunnel status messages and connection errors - Added translations for port forwarding UI elements - Verified Server, TopNavbar, and Tab components already have complete i18n support - Achieved 99%+ localization coverage across entire application - All core UI components now fully support Chinese and English languages This completes the comprehensive internationalization effort for the Termix SSH management platform. Co-Authored-By: Claude <noreply@anthropic.com> * Localize additional Host Manager components and authentication settings - Added translations for all authentication options (Password, Key, SSH Private Key) - Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager) - Translated Upload/Update Key button states - Localized Host Viewer and Add/Edit Host tab labels - Added Chinese translations for all host management settings - Fixed duplicate translation keys in JSON files Co-Authored-By: Claude <noreply@anthropic.com> * Extend localization coverage to UI components and common strings - Added comprehensive common translations (online/offline, success/error, etc.) - Localized status indicator component with all status states - Updated FileManagerLeftSidebar toast messages for rename/delete operations - Added translations for UI elements (close, toggle sidebar, etc.) - Expanded placeholder translations for form inputs - Added Chinese translations for all new common strings - Improved consistency across component status messages Co-Authored-By: Claude <noreply@anthropic.com> * Complete Chinese localization for remaining UI components - Add comprehensive Chinese translations for Host Manager component - Translate all form labels, buttons, and descriptions - Add translations for SSH configuration warnings and instructions - Localize tunnel connection settings and port forwarding options - Localize SSH Tools panel - Translate key recording functionality - Add translations for settings and configuration options - Translate homepage welcome messages and navigation elements - Add Chinese translations for login success messages - Localize "Updates & Releases" section title - Translate sidebar "Host Manager" button - Fix translation key display issues - Remove duplicate translation keys in both language files - Ensure all components properly reference translation keys - Fix hosts.tunnelConnections key mapping This completes the full Chinese localization of the Termix application, achieving near 100% UI translation coverage while maintaining English as the default language. * Complete final Chinese localization for Host Manager tunnel configuration - Add Chinese translations for authentication UI elements - Translate "Authentication", "Password", and "Key" tab labels - Localize SSH private key and key password fields - Add translations for key type selector - Localize tunnel connection configuration descriptions - Translate retry attempts and retry interval descriptions - Add dynamic tunnel forwarding description with port parameters - Localize endpoint SSH configuration labels - Fix missing translation keys - Add "upload" translation for file upload button - Ensure all FormLabel and FormDescription elements use translation keys This completes the comprehensive Chinese localization of the entire Termix application, achieving 100% UI translation coverage. * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Fix PR feedback: Improve Profile section translations and UX - Fixed password reset translations in Profile section - Moved language selector from TopNavbar to Profile page - Added profile.selectPreferredLanguage translation key - Improved user experience for language preferences * Migrate everything to alert system, update user.ts for OIDC updates. * Update env * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Translation update * Translation update * Translation update * Translate tunnels * Comment update * Update build workflow naming * Add more translations, fix user delete failing * Fix config editor erorrs causing user delete failure --------- Co-authored-by: ZacharyZcR <PayasoNorahC@protonmail.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #159.
This commit is contained in:
@@ -16,15 +16,17 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import {Shield, Trash2, Users} from "lucide-react";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getUserList,
|
||||
updateRegistrationAllowed,
|
||||
updateOIDCConfig,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getUserList,
|
||||
updateRegistrationAllowed,
|
||||
updateOIDCConfig,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
function getCookie(name: string) {
|
||||
@@ -39,6 +41,7 @@ interface AdminSettingsProps {
|
||||
}
|
||||
|
||||
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
@@ -52,11 +55,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
scopes: 'openid email profile',
|
||||
userinfo_url: ''
|
||||
});
|
||||
const [oidcLoading, setOidcLoading] = React.useState(false);
|
||||
const [oidcError, setOidcError] = React.useState<string | null>(null);
|
||||
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
|
||||
|
||||
const [users, setUsers] = React.useState<Array<{
|
||||
id: string;
|
||||
@@ -68,7 +71,6 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const jwt = getCookie("jwt");
|
||||
@@ -120,12 +122,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
e.preventDefault();
|
||||
setOidcLoading(true);
|
||||
setOidcError(null);
|
||||
setOidcSuccess(null);
|
||||
|
||||
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(`Missing required fields: ${missing.join(', ')}`);
|
||||
setOidcError(t('admin.missingRequiredFields', { fields: missing.join(', ') }));
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -133,9 +134,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
setOidcSuccess("OIDC configuration updated successfully!");
|
||||
toast.success(t('admin.oidcConfigurationUpdated'));
|
||||
} catch (err: any) {
|
||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||
setOidcError(err?.response?.data?.error || t('admin.failedToUpdateOidcConfig'));
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
@@ -145,42 +146,47 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
setOidcConfig(prev => ({...prev, [field]: value}));
|
||||
};
|
||||
|
||||
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||
const handleMakeUserAdmin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newAdminUsername.trim()) return;
|
||||
setMakeAdminLoading(true);
|
||||
setMakeAdminError(null);
|
||||
setMakeAdminSuccess(null);
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||
toast.success(t('admin.userIsNowAdmin', { username: newAdminUsername }));
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
|
||||
setMakeAdminError(err?.response?.data?.error || t('admin.failedToMakeUserAdmin'));
|
||||
} finally {
|
||||
setMakeAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdminStatus = async (username: string) => {
|
||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||
const handleRemoveAdminStatus = async (username: string) => {
|
||||
if (!confirm(t('admin.removeAdminStatus', { username }))) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
toast.success(t('admin.adminStatusRemoved', { username }));
|
||||
fetchUsers();
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove admin status:', err);
|
||||
toast.error(t('admin.failedToRemoveAdminStatus'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (username: string) => {
|
||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
if (!confirm(t('admin.deleteUser', { username }))) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
||||
fetchUsers();
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete user:', err);
|
||||
toast.error(t('admin.failedToDeleteUser'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,7 +206,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">Admin Settings</h1>
|
||||
<h1 className="font-bold text-lg">{t('admin.title')}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full"/>
|
||||
|
||||
@@ -209,7 +215,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
||||
<TabsTrigger value="registration" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
General
|
||||
{t('admin.general')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
@@ -217,91 +223,96 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
Users
|
||||
{t('admin.users')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Admins
|
||||
{t('admin.adminManagement')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="registration" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||
<h3 className="text-lg font-semibold">{t('admin.userRegistration')}</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
{t('admin.allowNewAccountRegistration')}
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<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>
|
||||
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Label htmlFor="client_id">{t('admin.clientId')}</Label>
|
||||
<Input id="client_id" value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder="your-client-id" required/>
|
||||
placeholder={t('placeholders.clientId')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
|
||||
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder="your-client-secret" required/>
|
||||
placeholder={t('placeholders.clientSecret')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
|
||||
<Input id="authorization_url" value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
placeholder={t('placeholders.authUrl')}
|
||||
required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label>
|
||||
<Input id="issuer_url" value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/termix/" required/>
|
||||
placeholder={t('placeholders.redirectUrl')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Label htmlFor="token_url">{t('admin.tokenUrl')}</Label>
|
||||
<Input id="token_url" value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/token/" required/>
|
||||
placeholder={t('placeholders.tokenUrl')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||
<Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label>
|
||||
<Input id="identifier_path" value={oidcConfig.identifier_path}
|
||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||
placeholder="sub" required/>
|
||||
placeholder={t('placeholders.userIdField')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">Display Name Path</Label>
|
||||
<Label htmlFor="name_path">{t('admin.displayNamePath')}</Label>
|
||||
<Input id="name_path" value={oidcConfig.name_path}
|
||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||
placeholder="name" required/>
|
||||
placeholder={t('placeholders.usernameField')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Label htmlFor="scopes">{t('admin.scopes')}</Label>
|
||||
<Input id="scopes" value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
|
||||
placeholder="openid email profile" required/>
|
||||
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="https://your-provider.com/application/o/userinfo/"/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1"
|
||||
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
@@ -310,16 +321,10 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
token_url: '',
|
||||
identifier_path: 'sub',
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile'
|
||||
})}>Reset</Button>
|
||||
scopes: 'openid email profile',
|
||||
userinfo_url: ''
|
||||
})}>{t('admin.reset')}</Button>
|
||||
</div>
|
||||
|
||||
{oidcSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{oidcSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -327,20 +332,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">User Management</h3>
|
||||
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
|
||||
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
|
||||
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
||||
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -350,14 +355,14 @@ 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">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>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => deleteUser(user.username)}
|
||||
onClick={() => handleDeleteUser(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}>
|
||||
<Trash2 className="h-4 w-4"/>
|
||||
@@ -374,44 +379,39 @@ 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">Admin Management</h3>
|
||||
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
|
||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
||||
<h4 className="font-medium">Make User Admin</h4>
|
||||
<form onSubmit={makeUserAdmin} className="space-y-4">
|
||||
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
|
||||
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-admin-username">Username</Label>
|
||||
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="new-admin-username" value={newAdminUsername}
|
||||
onChange={(e) => setNewAdminUsername(e.target.value)}
|
||||
placeholder="Enter username to make admin" required/>
|
||||
placeholder={t('admin.enterUsernameToMakeAdmin')} required/>
|
||||
<Button type="submit"
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{makeAdminError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{makeAdminError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{makeAdminSuccess && (
|
||||
<Alert>
|
||||
<AlertTitle>Success</AlertTitle>
|
||||
<AlertDescription>{makeAdminSuccess}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Current Admins</h4>
|
||||
<h4 className="font-medium">{t('admin.currentAdmins')}</h4>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -420,16 +420,16 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<TableCell className="px-4 font-medium">
|
||||
{admin.username}
|
||||
<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>
|
||||
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 ? "External" : "Local"}</TableCell>
|
||||
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => removeAdminStatus(admin.username)}
|
||||
onClick={() => handleRemoveAdminStatus(admin.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Remove Admin
|
||||
{t('admin.removeAdminButton')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {cn} from '@/lib/utils.ts';
|
||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
getFileManagerRecent,
|
||||
getFileManagerPinned,
|
||||
@@ -66,6 +67,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
embedded?: boolean,
|
||||
initialHost?: SSHHost | null
|
||||
}): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>('home');
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
@@ -134,7 +136,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
]);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
|
||||
setTimeout(() => reject(new Error(t('fileManager.fetchHomeDataTimeout'))), 15000)
|
||||
);
|
||||
|
||||
const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any];
|
||||
@@ -166,20 +168,20 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||
const axiosErr = err as any;
|
||||
if (axiosErr.response?.status === 403) {
|
||||
return `Permission denied. ${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
return `${t('fileManager.permissionDenied')}. ${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`;
|
||||
} else if (axiosErr.response?.status === 500) {
|
||||
const backendError = axiosErr.response?.data?.error || 'Internal server error occurred';
|
||||
return `Server Error (500): ${backendError}. Check the Docker logs for detailed error information.`;
|
||||
const backendError = axiosErr.response?.data?.error || t('fileManager.internalServerError');
|
||||
return `${t('fileManager.serverError')} (500): ${backendError}. ${t('fileManager.checkDockerLogs')}.`;
|
||||
} else if (axiosErr.response?.data?.error) {
|
||||
const backendError = axiosErr.response.data.error;
|
||||
return `${axiosErr.response?.status ? `Error ${axiosErr.response.status}: ` : ''}${backendError}. Check the Docker logs for detailed error information.`;
|
||||
return `${axiosErr.response?.status ? `${t('fileManager.error')} ${axiosErr.response.status}: ` : ''}${backendError}. ${t('fileManager.checkDockerLogs')}.`;
|
||||
} else {
|
||||
return `Request failed with status code ${axiosErr.response?.status || 'unknown'}. Check the Docker logs for detailed error information.`;
|
||||
return `${t('fileManager.requestFailed')} ${axiosErr.response?.status || t('fileManager.unknown')}. ${t('fileManager.checkDockerLogs')}.`;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
return `${err.message}. Check the Docker logs for detailed error information.`;
|
||||
return `${err.message}. ${t('fileManager.checkDockerLogs')}.`;
|
||||
} else {
|
||||
return `${defaultMessage}. Check the Docker logs for detailed error information.`;
|
||||
return `${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,7 +218,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err: any) {
|
||||
const errorMessage = formatErrorMessage(err, 'Cannot read file');
|
||||
const errorMessage = formatErrorMessage(err, t('fileManager.cannotReadFile'));
|
||||
toast.error(errorMessage);
|
||||
setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t));
|
||||
}
|
||||
@@ -355,21 +357,21 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
|
||||
try {
|
||||
if (!tab.sshSessionId) {
|
||||
throw new Error('No SSH session ID available');
|
||||
throw new Error(t('fileManager.noSshSessionId'));
|
||||
}
|
||||
|
||||
if (!tab.filePath) {
|
||||
throw new Error('No file path available');
|
||||
throw new Error(t('fileManager.noFilePath'));
|
||||
}
|
||||
|
||||
if (!currentHost?.id) {
|
||||
throw new Error('No current host available');
|
||||
throw new Error(t('fileManager.noCurrentHost'));
|
||||
}
|
||||
|
||||
try {
|
||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
|
||||
setTimeout(() => reject(new Error(t('fileManager.sshStatusCheckTimeout'))), 10000)
|
||||
);
|
||||
|
||||
const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean };
|
||||
@@ -384,7 +386,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
keyPassword: currentHost.keyPassword
|
||||
});
|
||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000)
|
||||
setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000)
|
||||
);
|
||||
|
||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
||||
@@ -395,7 +397,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => {
|
||||
reject(new Error('Save operation timed out'));
|
||||
reject(new Error(t('fileManager.saveOperationTimeout')));
|
||||
}, 30000)
|
||||
);
|
||||
|
||||
@@ -405,7 +407,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
loading: false
|
||||
} : t));
|
||||
|
||||
toast.success('File saved successfully');
|
||||
toast.success(t('fileManager.fileSavedSuccessfully'));
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
@@ -430,13 +432,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
let errorMessage = formatErrorMessage(err, 'Cannot save file');
|
||||
let errorMessage = formatErrorMessage(err, t('fileManager.cannotSaveFile'));
|
||||
|
||||
if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) {
|
||||
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
|
||||
errorMessage = t('fileManager.saveTimeout');
|
||||
}
|
||||
|
||||
toast.error(`Failed to save file: ${errorMessage}`);
|
||||
toast.error(`${t('fileManager.failedToSaveFile')}: ${errorMessage}`);
|
||||
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
|
||||
...t,
|
||||
loading: false
|
||||
@@ -480,11 +482,11 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
try {
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
|
||||
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
|
||||
toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`);
|
||||
setDeletingItem(null);
|
||||
handleOperationComplete();
|
||||
} catch (error: any) {
|
||||
handleError(error?.response?.data?.error || 'Failed to delete item');
|
||||
handleError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -517,8 +519,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
background: '#09090b'
|
||||
}}>
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Connect to a Server</h2>
|
||||
<p className="text-muted-foreground">Select a server from the sidebar to start editing files</p>
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{t('fileManager.connectToServer')}</h2>
|
||||
<p className="text-muted-foreground">{t('fileManager.selectServerToEdit')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,7 +569,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
'w-[30px] h-[30px]',
|
||||
showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
|
||||
)}
|
||||
title="File Operations"
|
||||
title={t('fileManager.fileOperations')}
|
||||
>
|
||||
<Settings className="h-4 w-4"/>
|
||||
</Button>
|
||||
@@ -656,14 +658,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 max-w-md mx-4 shadow-2xl">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-red-400"/>
|
||||
Confirm Delete
|
||||
{t('fileManager.confirmDelete')}
|
||||
</h3>
|
||||
<p className="text-white mb-4">
|
||||
Are you sure you want to delete <strong>{deletingItem.name}</strong>?
|
||||
{deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'}
|
||||
{t('fileManager.confirmDeleteMessage', { name: deletingItem.name })}
|
||||
{deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`}
|
||||
</p>
|
||||
<p className="text-red-400 text-sm mb-6">
|
||||
This action cannot be undone.
|
||||
{t('fileManager.actionCannotBeUndone')}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
@@ -671,14 +673,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
||||
onClick={() => performDelete(deletingItem)}
|
||||
className="flex-1"
|
||||
>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeletingItem(null)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -43,6 +44,7 @@ export function FileManagerHomeView({
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: FileManagerHomeViewProps) {
|
||||
const {t} = useTranslation();
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
@@ -121,10 +123,9 @@ export function FileManagerHomeView({
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
||||
Shortcuts</TabsTrigger>
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">{t('fileManager.recent')}</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">{t('fileManager.pinned')}</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">{t('fileManager.folderShortcuts')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
@@ -132,7 +133,7 @@ export function FileManagerHomeView({
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noRecentFiles')}</span>
|
||||
</div>
|
||||
) : recent.map((file) =>
|
||||
renderFileCard(
|
||||
@@ -150,7 +151,7 @@ export function FileManagerHomeView({
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noPinnedFiles')}</span>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
@@ -166,7 +167,7 @@ export function FileManagerHomeView({
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
|
||||
<Input
|
||||
placeholder="Enter folder path"
|
||||
placeholder={t('fileManager.enterFolderPath')}
|
||||
value={newShortcut}
|
||||
onChange={e => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
|
||||
@@ -189,14 +190,14 @@ export function FileManagerHomeView({
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
||||
Add
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noShortcuts')}</span>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {cn} from '@/lib/utils.ts';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
listSSHFiles,
|
||||
renameSSHItem,
|
||||
@@ -56,6 +57,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const {t} = useTranslation();
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -126,7 +128,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
|
||||
try {
|
||||
if (!server.password && !server.key) {
|
||||
toast.error('No authentication credentials available for this SSH host');
|
||||
toast.error(t('common.noAuthCredentials'));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -150,7 +152,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
|
||||
return sessionId;
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || 'Failed to connect to SSH');
|
||||
toast.error(err?.response?.data?.error || t('fileManager.failedToConnectSSH'));
|
||||
setSshSessionId(null);
|
||||
return null;
|
||||
} finally {
|
||||
@@ -187,7 +189,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw new Error('Failed to reconnect SSH session');
|
||||
throw new Error(t('fileManager.failedToReconnectSSH'));
|
||||
}
|
||||
} else {
|
||||
res = await listSSHFiles(sshSessionId, currentPath);
|
||||
@@ -218,7 +220,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFiles([]);
|
||||
toast.error(err?.response?.data?.error || err?.message || 'Failed to list files');
|
||||
toast.error(err?.response?.data?.error || err?.message || t('fileManager.failedToListFiles'));
|
||||
} finally {
|
||||
setFilesLoading(false);
|
||||
setFetchingFiles(false);
|
||||
@@ -324,7 +326,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
|
||||
try {
|
||||
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
||||
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} renamed successfully`);
|
||||
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.renamedSuccessfully')}`);
|
||||
setRenamingItem(null);
|
||||
if (onOperationComplete) {
|
||||
onOperationComplete();
|
||||
@@ -332,7 +334,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
fetchFiles();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || 'Failed to rename item');
|
||||
toast.error(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -341,14 +343,14 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
|
||||
try {
|
||||
await deleteSSHItem(sshSessionId, item.path, item.type === 'directory');
|
||||
toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
|
||||
toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.deletedSuccessfully')}`);
|
||||
if (onOperationComplete) {
|
||||
onOperationComplete();
|
||||
} else {
|
||||
fetchFiles();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || 'Failed to delete item');
|
||||
toast.error(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -408,7 +410,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
</div>
|
||||
<div className="px-2 py-2 border-b-1 border-[#303032] bg-[#18181b]">
|
||||
<Input
|
||||
placeholder="Search files and folders..."
|
||||
placeholder={t('fileManager.searchFilesAndFolders')}
|
||||
className="w-full h-7 text-sm bg-[#23232a] border-2 border-[#434345] text-white placeholder:text-muted-foreground rounded-md"
|
||||
autoComplete="off"
|
||||
value={fileSearch}
|
||||
@@ -419,9 +421,9 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
<ScrollArea className="h-full w-full bg-[#09090b]">
|
||||
<div className="p-2 pb-0">
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">No files or folders found.</div>
|
||||
<div className="text-xs text-muted-foreground">{t('fileManager.noFilesOrFoldersFound')}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Button} from '@/components/ui/button.tsx';
|
||||
import {Card} from '@/components/ui/card.tsx';
|
||||
import {Separator} from '@/components/ui/separator.tsx';
|
||||
import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
@@ -61,16 +62,18 @@ export function FileManagerLeftSidebarFileViewer({
|
||||
onSwitchToSSH,
|
||||
currentSSH,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
const {t} = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-[#09090b] p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? 'SSH Path' : 'Local Path'}</span>
|
||||
className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')}</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">Loading...</div>
|
||||
<div className="text-xs text-muted-foreground">{t('common.loading')}</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Folder
|
||||
} from 'lucide-react';
|
||||
import {cn} from '@/lib/utils.ts';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface FileManagerOperationsProps {
|
||||
currentPath: string;
|
||||
@@ -32,6 +33,7 @@ export function FileManagerOperations({
|
||||
onError,
|
||||
onSuccess
|
||||
}: FileManagerOperationsProps) {
|
||||
const {t} = useTranslation();
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||
@@ -81,12 +83,12 @@ export function FileManagerOperations({
|
||||
const {uploadSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
|
||||
onSuccess(`File "${uploadFile.name}" uploaded successfully`);
|
||||
onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name }));
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
onError(error?.response?.data?.error || 'Failed to upload file');
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToUploadFile'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -100,12 +102,12 @@ export function FileManagerOperations({
|
||||
const {createSSHFile} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await createSSHFile(sshSessionId, currentPath, newFileName.trim());
|
||||
onSuccess(`File "${newFileName.trim()}" created successfully`);
|
||||
onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() }));
|
||||
setShowCreateFile(false);
|
||||
setNewFileName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
onError(error?.response?.data?.error || 'Failed to create file');
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFile'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -119,12 +121,12 @@ export function FileManagerOperations({
|
||||
const {createSSHFolder} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
|
||||
onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
|
||||
onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() }));
|
||||
setShowCreateFolder(false);
|
||||
setNewFolderName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
onError(error?.response?.data?.error || 'Failed to create folder');
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -138,13 +140,13 @@ export function FileManagerOperations({
|
||||
const {deleteSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
|
||||
onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
|
||||
onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
setShowDelete(false);
|
||||
setDeletePath('');
|
||||
setDeleteIsDirectory(false);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
onError(error?.response?.data?.error || 'Failed to delete item');
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -158,14 +160,14 @@ export function FileManagerOperations({
|
||||
const {renameSSHItem} = await import('@/ui/main-axios.ts');
|
||||
|
||||
await renameSSHItem(sshSessionId, renamePath, newName.trim());
|
||||
onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
|
||||
onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') }));
|
||||
setShowRename(false);
|
||||
setRenamePath('');
|
||||
setRenameIsDirectory(false);
|
||||
setNewName('');
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
onError(error?.response?.data?.error || 'Failed to rename item');
|
||||
onError(error?.response?.data?.error || t('fileManager.failedToRenameItem'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -202,7 +204,7 @@ export function FileManagerOperations({
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2"/>
|
||||
<p className="text-sm text-muted-foreground">Connect to SSH to use file operations</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fileManager.connectToSsh')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,50 +217,50 @@ export function FileManagerOperations({
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="Upload File"
|
||||
title={t('fileManager.uploadFile')}
|
||||
>
|
||||
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">Upload File</span>}
|
||||
{showTextLabels && <span className="truncate">{t('fileManager.uploadFile')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFile(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="New File"
|
||||
title={t('fileManager.newFile')}
|
||||
>
|
||||
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">New File</span>}
|
||||
{showTextLabels && <span className="truncate">{t('fileManager.newFile')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFolder(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="New Folder"
|
||||
title={t('fileManager.newFolder')}
|
||||
>
|
||||
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">New Folder</span>}
|
||||
{showTextLabels && <span className="truncate">{t('fileManager.newFolder')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRename(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||
title="Rename"
|
||||
title={t('fileManager.rename')}
|
||||
>
|
||||
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">Rename</span>}
|
||||
{showTextLabels && <span className="truncate">{t('fileManager.rename')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
||||
title="Delete Item"
|
||||
title={t('fileManager.deleteItem')}
|
||||
>
|
||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||
{showTextLabels && <span className="truncate">Delete Item</span>}
|
||||
{showTextLabels && <span className="truncate">{t('fileManager.deleteItem')}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -266,7 +268,7 @@ export function FileManagerOperations({
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-muted-foreground block mb-1">Current Path:</span>
|
||||
<span className="text-muted-foreground block mb-1">{t('fileManager.currentPath')}:</span>
|
||||
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,10 +282,10 @@ export function FileManagerOperations({
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Upload File</span>
|
||||
<span className="break-words">{t('fileManager.uploadFileTitle')}</span>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
Max: 100MB (JSON) / 200MB (Binary)
|
||||
{t('fileManager.maxFileSize')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -311,20 +313,20 @@ export function FileManagerOperations({
|
||||
onClick={() => setUploadFile(null)}
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
Remove File
|
||||
{t('fileManager.removeFile')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-12 h-12 text-muted-foreground mx-auto"/>
|
||||
<p className="text-white text-sm break-words px-2">Click to select a file</p>
|
||||
<p className="text-white text-sm break-words px-2">{t('fileManager.clickToSelectFile')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
Choose File
|
||||
{t('fileManager.chooseFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -344,7 +346,7 @@ export function FileManagerOperations({
|
||||
disabled={!uploadFile || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Uploading...' : 'Upload File'}
|
||||
{isLoading ? t('fileManager.uploading') : t('fileManager.uploadFile')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -352,7 +354,7 @@ export function FileManagerOperations({
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,7 +367,7 @@ export function FileManagerOperations({
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Create New File</span>
|
||||
<span className="break-words">{t('fileManager.createNewFile')}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
@@ -381,12 +383,12 @@ export function FileManagerOperations({
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
File Name
|
||||
{t('fileManager.fileName')}
|
||||
</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder="Enter file name (e.g., example.txt)"
|
||||
placeholder={t('placeholders.fileName')}
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
||||
/>
|
||||
@@ -398,7 +400,7 @@ export function FileManagerOperations({
|
||||
disabled={!newFileName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create File'}
|
||||
{isLoading ? t('fileManager.creating') : t('fileManager.createFile')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -406,7 +408,7 @@ export function FileManagerOperations({
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,7 +421,7 @@ export function FileManagerOperations({
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Create New Folder</span>
|
||||
<span className="break-words">{t('fileManager.createNewFolder')}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
@@ -435,12 +437,12 @@ export function FileManagerOperations({
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
Folder Name
|
||||
{t('fileManager.folderName')}
|
||||
</label>
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder="Enter folder name"
|
||||
placeholder={t('placeholders.folderName')}
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||
/>
|
||||
@@ -452,7 +454,7 @@ export function FileManagerOperations({
|
||||
disabled={!newFolderName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Folder'}
|
||||
{isLoading ? t('fileManager.creating') : t('fileManager.createFolder')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -460,7 +462,7 @@ export function FileManagerOperations({
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -473,7 +475,7 @@ export function FileManagerOperations({
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0"/>
|
||||
<span className="break-words">Delete Item</span>
|
||||
<span className="break-words">{t('fileManager.deleteItem')}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
@@ -490,18 +492,18 @@ export function FileManagerOperations({
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
|
||||
<span className="text-sm font-medium break-words">Warning: This action cannot be undone</span>
|
||||
<span className="text-sm font-medium break-words">{t('fileManager.warningCannotUndo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
Item Path
|
||||
{t('fileManager.itemPath')}
|
||||
</label>
|
||||
<Input
|
||||
value={deletePath}
|
||||
onChange={(e) => setDeletePath(e.target.value)}
|
||||
placeholder="Enter full path to item"
|
||||
placeholder={t('placeholders.fullPath')}
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -515,7 +517,7 @@ export function FileManagerOperations({
|
||||
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
|
||||
This is a directory (will delete recursively)
|
||||
{t('fileManager.thisIsDirectory')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -526,7 +528,7 @@ export function FileManagerOperations({
|
||||
variant="destructive"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Item'}
|
||||
{isLoading ? t('fileManager.deleting') : t('fileManager.deleteItem')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -534,7 +536,7 @@ export function FileManagerOperations({
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -547,7 +549,7 @@ export function FileManagerOperations({
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Edit3 className="w-6 h-6 flex-shrink-0"/>
|
||||
<span className="break-words">Rename Item</span>
|
||||
<span className="break-words">{t('fileManager.renameItem')}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
@@ -563,24 +565,24 @@ export function FileManagerOperations({
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
Current Path
|
||||
{t('fileManager.currentPathLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={renamePath}
|
||||
onChange={(e) => setRenamePath(e.target.value)}
|
||||
placeholder="Enter current path to item"
|
||||
placeholder={t('placeholders.currentPath')}
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
New Name
|
||||
{t('fileManager.newName')}
|
||||
</label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Enter new name"
|
||||
placeholder={t('placeholders.newName')}
|
||||
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||
/>
|
||||
@@ -595,7 +597,7 @@ export function FileManagerOperations({
|
||||
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
|
||||
This is a directory
|
||||
{t('fileManager.thisIsDirectoryRename')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -605,7 +607,7 @@ export function FileManagerOperations({
|
||||
disabled={!renamePath || !newName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading ? 'Renaming...' : 'Rename Item'}
|
||||
{isLoading ? t('fileManager.renaming') : t('fileManager.renameItem')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -613,7 +615,7 @@ export function FileManagerOperations({
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface HostManagerProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -34,6 +35,7 @@ interface SSHHost {
|
||||
}
|
||||
|
||||
export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
const {state: sidebarState} = useSidebar();
|
||||
@@ -75,9 +77,9 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0">
|
||||
<TabsList className="bg-[#18181b] border-2 border-[#303032] mt-1.5">
|
||||
<TabsTrigger value="host_viewer">Host Viewer</TabsTrigger>
|
||||
<TabsTrigger value="host_viewer">{t('hosts.hostViewer')}</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? "Edit Host" : "Add Host"}
|
||||
{editingHost ? t('hosts.editHost') : t('hosts.addHost')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="host_viewer" className="flex-1 flex flex-col h-full min-h-0">
|
||||
|
||||
@@ -19,7 +19,9 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {Switch} from "@/components/ui/switch.tsx";
|
||||
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";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -50,6 +52,7 @@ interface SSHManagerHostEditorProps {
|
||||
}
|
||||
|
||||
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
|
||||
const {t} = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||
@@ -127,7 +130,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
if (!data.password || data.password.trim() === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password is required when using password authentication",
|
||||
message: t('hosts.passwordRequired'),
|
||||
path: ['password']
|
||||
});
|
||||
}
|
||||
@@ -135,14 +138,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
if (!data.key) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "SSH Private Key is required when using key authentication",
|
||||
message: t('hosts.sshKeyRequired'),
|
||||
path: ['key']
|
||||
});
|
||||
}
|
||||
if (!data.keyType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Key Type is required when using key authentication",
|
||||
message: t('hosts.keyTypeRequired'),
|
||||
path: ['keyType']
|
||||
});
|
||||
}
|
||||
@@ -152,7 +155,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
if (connection.endpointHost && !sshConfigurations.includes(connection.endpointHost)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Must select a valid SSH configuration from the list",
|
||||
message: t('hosts.mustSelectValidSshConfig'),
|
||||
path: ['tunnelConnections', index, 'endpointHost']
|
||||
});
|
||||
}
|
||||
@@ -244,8 +247,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
|
||||
if (editingHost) {
|
||||
await updateSSHHost(editingHost.id, formData);
|
||||
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name }));
|
||||
} else {
|
||||
await createSSHHost(formData);
|
||||
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
@@ -254,7 +259,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (error) {
|
||||
alert('Failed to save host. Please try again.');
|
||||
toast.error(t('hosts.failedToSaveHost'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -299,15 +304,15 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
}, [folderDropdownOpen]);
|
||||
|
||||
const keyTypeOptions = [
|
||||
{value: 'auto', label: 'Auto-detect'},
|
||||
{value: 'ssh-rsa', label: 'RSA'},
|
||||
{value: 'ssh-ed25519', label: 'ED25519'},
|
||||
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'},
|
||||
{value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'},
|
||||
{value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'},
|
||||
{value: 'ssh-dss', label: 'DSA'},
|
||||
{value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'},
|
||||
{value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'},
|
||||
{value: 'auto', label: t('hosts.autoDetect')},
|
||||
{value: 'ssh-rsa', label: t('hosts.rsa')},
|
||||
{value: 'ssh-ed25519', label: t('hosts.ed25519')},
|
||||
{value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256')},
|
||||
{value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384')},
|
||||
{value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521')},
|
||||
{value: 'ssh-dss', label: t('hosts.dsa')},
|
||||
{value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256')},
|
||||
{value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512')},
|
||||
];
|
||||
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
@@ -393,22 +398,22 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="terminal">Terminal</TabsTrigger>
|
||||
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
|
||||
<TabsTrigger value="file_manager">File Manager</TabsTrigger>
|
||||
<TabsTrigger value="general">{t('hosts.general')}</TabsTrigger>
|
||||
<TabsTrigger value="terminal">{t('hosts.terminal')}</TabsTrigger>
|
||||
<TabsTrigger value="tunnel">{t('hosts.tunnel')}</TabsTrigger>
|
||||
<TabsTrigger value="file_manager">{t('hosts.fileManager')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
|
||||
<FormLabel className="mb-3 font-bold">{t('hosts.connectionDetails')}</FormLabel>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ip"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-5">
|
||||
<FormLabel>IP</FormLabel>
|
||||
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="127.0.0.1" {...field} />
|
||||
<Input placeholder={t('placeholders.ipAddress')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -419,9 +424,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="port"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormLabel>{t('hosts.port')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="22" {...field} />
|
||||
<Input placeholder={t('placeholders.port')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -432,24 +437,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="username"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>{t('hosts.username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
<Input placeholder={t('placeholders.username')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">Organization</FormLabel>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.organization')}</FormLabel>
|
||||
<div className="grid grid-cols-26 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('hosts.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="host name" {...field} />
|
||||
<Input placeholder={t('placeholders.hostname')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -460,11 +465,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="folder"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10 relative">
|
||||
<FormLabel>Folder</FormLabel>
|
||||
<FormLabel>{t('hosts.folder')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={folderInputRef}
|
||||
placeholder="folder"
|
||||
placeholder={t('placeholders.folder')}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={field.value}
|
||||
@@ -505,7 +510,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="tags"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10 overflow-visible">
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<FormLabel>{t('hosts.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]">
|
||||
@@ -541,7 +546,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
field.onChange(field.value.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder="add tags (space to add)"
|
||||
placeholder={t('hosts.addTagsSpaceToAdd')}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -554,7 +559,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="pin"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>Pin Connection</FormLabel>
|
||||
<FormLabel>{t('hosts.pin')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -565,7 +570,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">Authentication</FormLabel>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.authentication')}</FormLabel>
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
@@ -575,8 +580,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
<TabsTrigger value="key">Key</TabsTrigger>
|
||||
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
|
||||
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="password">
|
||||
<FormField
|
||||
@@ -584,9 +589,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t('hosts.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="password" {...field} />
|
||||
<Input type="password" placeholder={t('placeholders.password')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -599,7 +604,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="key"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-4 overflow-hidden min-w-0">
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormLabel>{t('hosts.sshPrivateKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative min-w-0">
|
||||
<input
|
||||
@@ -618,8 +623,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
|
||||
>
|
||||
<span className="block w-full truncate"
|
||||
title={field.value?.name || 'Upload'}>
|
||||
{field.value ? (editingHost ? 'Update Key' : field.value.name) : 'Upload'}
|
||||
title={field.value?.name || t('hosts.upload')}>
|
||||
{field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -632,10 +637,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="keyPassword"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>Key Password</FormLabel>
|
||||
<FormLabel>{t('hosts.keyPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="key password"
|
||||
placeholder={t('placeholders.keyPassword')}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -648,7 +653,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="keyType"
|
||||
render={({field}) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>Key Type</FormLabel>
|
||||
<FormLabel>{t('hosts.keyType')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
@@ -658,7 +663,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
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 || "Auto-detect"}
|
||||
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('hosts.autoDetect')}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
@@ -699,7 +704,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="enableTerminal"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Terminal</FormLabel>
|
||||
<FormLabel>{t('hosts.enableTerminal')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -707,7 +712,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable/disable host visibility in Terminal tab.
|
||||
{t('hosts.enableTerminalDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -719,7 +724,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="enableTunnel"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Tunnel</FormLabel>
|
||||
<FormLabel>{t('hosts.enableTunnel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -727,7 +732,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable/disable host visibility in Tunnel tab.
|
||||
{t('hosts.enableTunnelDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -737,44 +742,40 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<>
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>Sshpass Required For Password Authentication</strong>
|
||||
<strong>{t('hosts.sshpassRequired')}</strong>
|
||||
<div>
|
||||
For password-based SSH authentication, sshpass must be installed on
|
||||
both the local and remote servers. Install with: <code
|
||||
{t('hosts.sshpassRequiredDesc')} <code
|
||||
className="bg-muted px-1 rounded inline">sudo apt install
|
||||
sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong>Other installation methods:</strong>
|
||||
<div>• CentOS/RHEL/Fedora: <code
|
||||
<strong>{t('hosts.otherInstallMethods')}</strong>
|
||||
<div>• {t('hosts.centosRhelFedora')} <code
|
||||
className="bg-muted px-1 rounded inline">sudo yum install
|
||||
sshpass</code> or <code
|
||||
className="bg-muted px-1 rounded inline">sudo dnf install
|
||||
sshpass</code></div>
|
||||
<div>• macOS: <code className="bg-muted px-1 rounded inline">brew
|
||||
<div>• {t('hosts.macos')} <code className="bg-muted px-1 rounded inline">brew
|
||||
install hudochenkov/sshpass/sshpass</code></div>
|
||||
<div>• Windows: Use WSL or consider SSH key authentication</div>
|
||||
<div>• {t('hosts.windows')}</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>SSH Server Configuration Required</strong>
|
||||
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
|
||||
<strong>{t('hosts.sshServerConfigRequired')}</strong>
|
||||
<div>{t('hosts.sshServerConfigDesc')}</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">GatewayPorts
|
||||
yes</code> (bind remote ports)
|
||||
yes</code> {t('hosts.gatewayPortsYes')}
|
||||
</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">AllowTcpForwarding
|
||||
yes</code> (port forwarding)
|
||||
yes</code> {t('hosts.allowTcpForwardingYes')}
|
||||
</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">PermitRootLogin
|
||||
yes</code> (if using root)
|
||||
yes</code> {t('hosts.permitRootLoginYes')}
|
||||
</div>
|
||||
<div className="mt-2">Edit <code
|
||||
className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and
|
||||
restart SSH: <code className="bg-muted px-1 rounded inline">sudo
|
||||
systemctl restart sshd</code></div>
|
||||
<div className="mt-2">{t('hosts.editSshConfig')}</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -783,7 +784,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="tunnelConnections"
|
||||
render={({field}) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Tunnel Connections</FormLabel>
|
||||
<FormLabel>{t('hosts.tunnelConnections')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{field.value.map((connection, index) => (
|
||||
@@ -791,7 +792,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
className="p-4 border rounded-lg bg-muted/50">
|
||||
<div
|
||||
className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold">Connection {index + 1}</h4>
|
||||
<h4 className="text-sm font-bold">{t('hosts.connection')} {index + 1}</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -801,7 +802,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
field.onChange(newConnections);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
{t('hosts.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
@@ -810,10 +811,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.sourcePort`}
|
||||
render={({field: sourcePortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Source Port
|
||||
(Source refers to the Current
|
||||
Connection Details in the
|
||||
General tab)</FormLabel>
|
||||
<FormLabel>{t('hosts.sourcePort')}
|
||||
{t('hosts.sourcePortDesc')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22" {...sourcePortField} />
|
||||
@@ -826,8 +825,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.endpointPort`}
|
||||
render={({field: endpointPortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Endpoint Port
|
||||
(Remote)</FormLabel>
|
||||
<FormLabel>{t('hosts.endpointPort')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="224" {...endpointPortField} />
|
||||
@@ -841,14 +839,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
render={({field: endpointHostField}) => (
|
||||
<FormItem
|
||||
className="col-span-4 relative">
|
||||
<FormLabel>Endpoint SSH
|
||||
Configuration</FormLabel>
|
||||
<FormLabel>{t('hosts.endpointSshConfig')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
sshConfigInputRefs.current[index] = el;
|
||||
}}
|
||||
placeholder="endpoint ssh configuration"
|
||||
placeholder={t('placeholders.sshConfig')}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={endpointHostField.value}
|
||||
@@ -895,12 +892,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This tunnel will forward traffic from
|
||||
port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on
|
||||
the source machine (current connection details
|
||||
in general tab) to
|
||||
port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on
|
||||
the endpoint machine.
|
||||
{t('hosts.tunnelForwardDescription', {
|
||||
sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22',
|
||||
endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224'
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 mt-4">
|
||||
@@ -909,14 +904,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.maxRetries`}
|
||||
render={({field: maxRetriesField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Max Retries</FormLabel>
|
||||
<FormLabel>{t('hosts.maxRetries')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3" {...maxRetriesField} />
|
||||
placeholder={t('placeholders.maxRetries')} {...maxRetriesField} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of retry attempts
|
||||
for tunnel connection.
|
||||
{t('hosts.maxRetriesDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -926,15 +920,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.retryInterval`}
|
||||
render={({field: retryIntervalField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Retry Interval
|
||||
(seconds)</FormLabel>
|
||||
<FormLabel>{t('hosts.retryInterval')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="10" {...retryIntervalField} />
|
||||
placeholder={t('placeholders.retryInterval')} {...retryIntervalField} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time to wait between retry
|
||||
attempts.
|
||||
{t('hosts.retryIntervalDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -944,8 +936,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.autoStart`}
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Auto Start on Container
|
||||
Launch</FormLabel>
|
||||
<FormLabel>{t('hosts.autoStartContainer')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -953,8 +944,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Automatically start this tunnel
|
||||
when the container launches.
|
||||
{t('hosts.autoStartDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -976,7 +966,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
}]);
|
||||
}}
|
||||
>
|
||||
Add Tunnel Connection
|
||||
{t('hosts.addConnection')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -994,7 +984,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="enableFileManager"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable File Manager</FormLabel>
|
||||
<FormLabel>{t('hosts.enableFileManager')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -1002,7 +992,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable/disable host visibility in File Manager tab.
|
||||
{t('hosts.enableFileManagerDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1015,12 +1005,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="defaultPath"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default Path</FormLabel>
|
||||
<FormLabel>{t('hosts.defaultPath')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/home" {...field} />
|
||||
<Input placeholder={t('placeholders.homePath')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Set default directory shown when connected via
|
||||
File Manager</FormDescription>
|
||||
<FormDescription>{t('hosts.defaultPathDesc')}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -1039,7 +1028,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
transform: 'translateY(8px)'
|
||||
}}
|
||||
>
|
||||
{editingHost ? "Update Host" : "Add Host"}
|
||||
{editingHost ? t('hosts.updateHost') : t('hosts.addHost')}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -47,6 +49,7 @@ interface SSHManagerHostViewerProps {
|
||||
}
|
||||
|
||||
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const {t} = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -64,20 +67,21 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
setHosts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load hosts');
|
||||
setError(t('hosts.failedToLoadHosts'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
||||
if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
alert('Failed to delete host');
|
||||
toast.error(t('hosts.failedToDeleteHost'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -98,32 +102,35 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
|
||||
throw new Error('JSON must contain a "hosts" array or be an array of hosts');
|
||||
throw new Error(t('hosts.jsonMustContainHosts'));
|
||||
}
|
||||
|
||||
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
|
||||
|
||||
if (hostsArray.length === 0) {
|
||||
throw new Error('No hosts found in JSON file');
|
||||
throw new Error(t('hosts.noHostsInJson'));
|
||||
}
|
||||
|
||||
if (hostsArray.length > 100) {
|
||||
throw new Error('Maximum 100 hosts allowed per import');
|
||||
throw new Error(t('hosts.maxHostsAllowed'));
|
||||
}
|
||||
|
||||
const result = await bulkImportSSHHosts(hostsArray);
|
||||
|
||||
if (result.success > 0) {
|
||||
alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`);
|
||||
toast.success(t('hosts.importCompleted', { success: result.success, failed: result.failed }));
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(`Import errors: ${result.errors.join(', ')}`);
|
||||
}
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} else {
|
||||
alert(`Import failed: ${result.errors.join('\n')}`);
|
||||
toast.error(t('hosts.importFailed') + `: ${result.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
|
||||
alert(`Import error: ${errorMessage}`);
|
||||
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson');
|
||||
toast.error(t('hosts.importError') + `: ${errorMessage}`);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
event.target.value = '';
|
||||
@@ -163,7 +170,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const grouped: { [key: string]: SSHHost[] } = {};
|
||||
|
||||
filteredAndSortedHosts.forEach(host => {
|
||||
const folder = host.folder || 'Uncategorized';
|
||||
const folder = host.folder || t('hosts.uncategorized');
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
@@ -171,8 +178,8 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
if (a === t('hosts.uncategorized')) return -1;
|
||||
if (b === t('hosts.uncategorized')) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
@@ -189,7 +196,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<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 hosts...</p>
|
||||
<p className="text-muted-foreground">{t('hosts.loadingHosts')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -201,7 +208,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchHosts} variant="outline">
|
||||
Retry
|
||||
{t('hosts.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,9 +220,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<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">No SSH Hosts</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
||||
{t('hosts.noHostsMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,9 +233,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<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">SSH Hosts</h2>
|
||||
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredAndSortedHosts.length} hosts
|
||||
{t('hosts.hostsCount', { count: filteredAndSortedHosts.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -242,15 +249,15 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
onClick={() => document.getElementById('json-import-input')?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import JSON'}
|
||||
{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">Import SSH Hosts from JSON</p>
|
||||
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload a JSON file to bulk import multiple SSH hosts (max 100).
|
||||
{t('hosts.importJsonDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@@ -318,7 +325,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Download Sample
|
||||
{t('hosts.downloadSample')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -328,13 +335,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
window.open('https://docs.termix.site/json-import', '_blank');
|
||||
}}
|
||||
>
|
||||
Format Guide
|
||||
{t('hosts.formatGuide')}
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2"/>
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
{t('hosts.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +357,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<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 hosts by name, username, IP, folder, tags..."
|
||||
placeholder={t('placeholders.searchHosts')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -446,13 +453,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||
Terminal
|
||||
{t('hosts.terminalBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5"/>
|
||||
Tunnel
|
||||
{t('hosts.tunnelBadge')}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span
|
||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
@@ -462,7 +469,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{host.enableFileManager && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
File Manager
|
||||
{t('hosts.fileManagerBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||
import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
@@ -24,6 +25,7 @@ export function Server({
|
||||
isTopbarOpen = true,
|
||||
embedded = false
|
||||
}: ServerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const {state: sidebarState} = useSidebar();
|
||||
const {addTab, tabs} = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||
@@ -168,16 +170,16 @@ export function Server({
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Refresh status and metrics"
|
||||
title={t('serverStats.refreshStatusAndMetrics')}
|
||||
>
|
||||
Refresh Status
|
||||
{t('serverStats.refreshStatus')}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={isFileManagerAlreadyOpen ? "File Manager already open for this host" : "Open File Manager"}
|
||||
title={isFileManagerAlreadyOpen ? t('serverStats.fileManagerAlreadyOpen') : t('serverStats.openFileManager')}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||
@@ -190,7 +192,7 @@ export function Server({
|
||||
});
|
||||
}}
|
||||
>
|
||||
File Manager
|
||||
{t('nav.fileManager')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -208,11 +210,11 @@ export function Server({
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const la = metrics?.cpu?.load;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
||||
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
|
||||
const laText = (la && la.length === 3)
|
||||
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
||||
: 'Avg: N/A';
|
||||
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
||||
? t('serverStats.loadAverage', {avg1: la[0].toFixed(2), avg5: la[1].toFixed(2), avg15: la[2].toFixed(2)})
|
||||
: t('serverStats.loadAverageNA');
|
||||
return `${t('serverStats.cpuUsage')} - ${pctText} ${t('serverStats.of')} ${coresText} (${laText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
@@ -232,7 +234,7 @@ export function Server({
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
||||
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
||||
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
||||
return `${t('serverStats.memoryUsage')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
@@ -252,7 +254,7 @@ export function Server({
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = used ?? 'N/A';
|
||||
const totalText = total ?? 'N/A';
|
||||
return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`;
|
||||
return `${t('serverStats.rootStorageSpace')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
@@ -270,7 +272,7 @@ export function Server({
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
Have ideas for what should come next for server management? Share them on{" "}
|
||||
{t('serverStats.feedbackMessage')}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -17,6 +18,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
const {t} = useTranslation();
|
||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
@@ -139,11 +141,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
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 || 'Disconnected'}]`);
|
||||
terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
@@ -151,12 +153,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln('\r\n[Connection closed]');
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
@@ -87,6 +88,7 @@ export function TunnelObject({
|
||||
compact = false,
|
||||
bare = false
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
@@ -97,7 +99,7 @@ export function TunnelObject({
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status) return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: 'Unknown',
|
||||
text: t('tunnels.unknown'),
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/50',
|
||||
borderColor: 'border-border'
|
||||
@@ -109,7 +111,7 @@ export function TunnelObject({
|
||||
case 'CONNECTED':
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4"/>,
|
||||
text: 'Connected',
|
||||
text: t('tunnels.connected'),
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
bgColor: 'bg-green-500/10 dark:bg-green-400/10',
|
||||
borderColor: 'border-green-500/20 dark:border-green-400/20'
|
||||
@@ -117,7 +119,7 @@ export function TunnelObject({
|
||||
case 'CONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: 'Connecting...',
|
||||
text: t('tunnels.connecting'),
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-500/10 dark:bg-blue-400/10',
|
||||
borderColor: 'border-blue-500/20 dark:border-blue-400/20'
|
||||
@@ -125,7 +127,7 @@ export function TunnelObject({
|
||||
case 'DISCONNECTING':
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin"/>,
|
||||
text: 'Disconnecting...',
|
||||
text: t('tunnels.disconnecting'),
|
||||
color: 'text-orange-600 dark:text-orange-400',
|
||||
bgColor: 'bg-orange-500/10 dark:bg-orange-400/10',
|
||||
borderColor: 'border-orange-500/20 dark:border-orange-400/20'
|
||||
@@ -133,7 +135,7 @@ export function TunnelObject({
|
||||
case 'DISCONNECTED':
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4"/>,
|
||||
text: 'Disconnected',
|
||||
text: t('tunnels.disconnected'),
|
||||
color: 'text-muted-foreground',
|
||||
bgColor: 'bg-muted/30',
|
||||
borderColor: 'border-border'
|
||||
@@ -149,7 +151,7 @@ export function TunnelObject({
|
||||
case 'FAILED':
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4"/>,
|
||||
text: status.reason || 'Error',
|
||||
text: status.reason || t('tunnels.error'),
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
bgColor: 'bg-red-500/10 dark:bg-red-400/10',
|
||||
borderColor: 'border-red-500/20 dark:border-red-400/20'
|
||||
@@ -193,7 +195,7 @@ export function TunnelObject({
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
{t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
||||
{statusDisplay.text}
|
||||
@@ -212,7 +214,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1"/>
|
||||
Disconnect
|
||||
{t('tunnels.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
@@ -223,7 +225,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1"/>
|
||||
Cancel
|
||||
{t('tunnels.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -234,7 +236,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1"/>
|
||||
Connect
|
||||
{t('tunnels.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,7 +248,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
||||
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
||||
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -255,13 +257,13 @@ export function TunnelObject({
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">Error:</div>
|
||||
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
|
||||
{status.reason}
|
||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<div
|
||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
Check your Docker logs for the error reason, join the <a
|
||||
{t('tunnels.checkDockerLogs')} <a
|
||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
||||
@@ -280,12 +282,12 @@ export function TunnelObject({
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
|
||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
||||
</div>
|
||||
<div>
|
||||
Attempt {status.retryCount} of {status.maxRetries}
|
||||
{t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })}
|
||||
{status.nextRetryIn && (
|
||||
<span> • Next retry in {status.nextRetryIn} seconds</span>
|
||||
<span> • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,7 +299,7 @@ export function TunnelObject({
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
||||
<p className="text-sm">No tunnel connections configured</p>
|
||||
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -346,7 +348,7 @@ export function TunnelObject({
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4"/>
|
||||
Tunnel Connections ({host.tunnelConnections.length})
|
||||
{t('tunnels.tunnelConnections')} ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
@@ -373,7 +375,7 @@ export function TunnelObject({
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
{t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div className={`text-xs ${statusDisplay.color} font-medium`}>
|
||||
{statusDisplay.text}
|
||||
@@ -392,7 +394,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1"/>
|
||||
Disconnect
|
||||
{t('tunnels.disconnect')}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
@@ -403,7 +405,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1"/>
|
||||
Cancel
|
||||
{t('tunnels.cancel')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -414,7 +416,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1"/>
|
||||
Connect
|
||||
{t('tunnels.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -427,7 +429,7 @@ export function TunnelObject({
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin"/>
|
||||
{isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'}
|
||||
{isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -436,13 +438,13 @@ export function TunnelObject({
|
||||
{(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && (
|
||||
<div
|
||||
className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">Error:</div>
|
||||
<div className="font-medium mb-1">{t('tunnels.error')}:</div>
|
||||
{status.reason}
|
||||
{status.reason && status.reason.includes('Max retries exhausted') && (
|
||||
<>
|
||||
<div
|
||||
className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
Check your Docker logs for the error reason, join the <a
|
||||
{t('tunnels.checkDockerLogs')} <a
|
||||
href="https://discord.com/invite/jVQGdvHDrf" target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400">Discord</a> or
|
||||
@@ -461,12 +463,12 @@ export function TunnelObject({
|
||||
<div
|
||||
className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'}
|
||||
{statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')}
|
||||
</div>
|
||||
<div>
|
||||
Attempt {status.retryCount} of {status.maxRetries}
|
||||
{t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })}
|
||||
{status.nextRetryIn && (
|
||||
<span> • Next retry in {status.nextRetryIn} seconds</span>
|
||||
<span> • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,7 +480,7 @@ export function TunnelObject({
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50"/>
|
||||
<p className="text-sm">No tunnel connections configured</p>
|
||||
<p className="text-sm">{t('tunnels.noTunnelConnections')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import {TunnelObject} from "./TunnelObject.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
@@ -52,15 +53,15 @@ export function TunnelViewer({
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('tunnels.noSshTunnels')}</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
|
||||
connections.
|
||||
{t('tunnels.createFirstTunnelMessage')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -69,7 +70,7 @@ export function TunnelViewer({
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="w-full flex-shrink-0 mb-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('tunnels.title')}</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,7 @@ import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
|
||||
import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface HomepageProps {
|
||||
onSelectView: (view: string) => void;
|
||||
@@ -32,6 +33,7 @@ export function Homepage({
|
||||
onAuthSuccess,
|
||||
isTopbarOpen = true
|
||||
}: HomepageProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
@@ -70,13 +72,20 @@ export function Homepage({
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const topOffset = isTopbarOpen ? 66 : 0;
|
||||
const topPadding = isTopbarOpen ? 66 : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full min-h-svh relative transition-[padding-top] duration-200 ease-linear ${
|
||||
isTopbarOpen ? 'pt-[66px]' : 'pt-2'
|
||||
}`}>
|
||||
className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out"
|
||||
style={{ paddingTop: `${topPadding}px` }}>
|
||||
{!loggedIn ? (
|
||||
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||
<div
|
||||
className="absolute left-0 w-full flex items-center justify-center transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
top: `${topOffset}px`,
|
||||
height: `calc(100% - ${topOffset}px)`
|
||||
}}>
|
||||
<HomepageAuth
|
||||
setLoggedIn={setLoggedIn}
|
||||
setIsAdmin={setIsAdmin}
|
||||
@@ -90,16 +99,19 @@ export function Homepage({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute top-[66px] left-0 w-full h-[calc(100%-66px)] flex items-center justify-center">
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-[10000]">
|
||||
<div
|
||||
className="absolute left-0 w-full flex items-center justify-center transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
top: `${topOffset}px`,
|
||||
height: `calc(100% - ${topOffset}px)`
|
||||
}}>
|
||||
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
|
||||
<div className="flex flex-col items-center gap-6 w-[400px]">
|
||||
<div
|
||||
className="text-center bg-[#18181b] border-2 border-[#303032] rounded-lg p-6 w-full shadow-lg">
|
||||
<h3 className="text-xl font-bold mb-3 text-white">Logged in!</h3>
|
||||
<h3 className="text-xl font-bold mb-3 text-white">{t('homepage.loggedInTitle')}</h3>
|
||||
<p className="text-gray-300 leading-relaxed">
|
||||
You are logged in! Use the sidebar to access all available tools. To get started,
|
||||
create an SSH Host in the SSH Manager tab. Once created, you can connect to that
|
||||
host using the other apps in the sidebar.
|
||||
{t('homepage.loggedInMessage')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Badge} from "@/components/ui/badge.tsx";
|
||||
import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
@@ -64,6 +65,8 @@ const getTypeBadgeVariant = (type?: string) => {
|
||||
};
|
||||
|
||||
export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
|
||||
if (!alert) {
|
||||
return null;
|
||||
}
|
||||
@@ -79,10 +82,10 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps):
|
||||
const diffTime = expiryDate.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 0) return 'Expired';
|
||||
if (diffDays === 0) return 'Expires today';
|
||||
if (diffDays === 1) return 'Expires tomorrow';
|
||||
return `Expires in ${diffDays} days`;
|
||||
if (diffDays < 0) return t('common.expired');
|
||||
if (diffDays === 0) return t('common.expiresToday');
|
||||
if (diffDays === 1) return t('common.expiresTomorrow');
|
||||
return t('common.expiresInDays', {days: diffDays});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, {useEffect, useState} from "react";
|
||||
import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface TermixAlert {
|
||||
id: string;
|
||||
@@ -20,6 +21,7 @@ interface AlertManagerProps {
|
||||
}
|
||||
|
||||
export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
|
||||
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -57,7 +59,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
setAlerts(sortedAlerts);
|
||||
setCurrentAlertIndex(0);
|
||||
} catch (err) {
|
||||
setError('Failed to load alerts');
|
||||
setError(t('homepage.failedToLoadAlerts'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -81,7 +83,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea
|
||||
return prevIndex;
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Failed to dismiss alert');
|
||||
setError(t('homepage.failedToDismissAlert'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -55,6 +57,7 @@ export function HomepageAuth({
|
||||
onAuthSuccess,
|
||||
...props
|
||||
}: HomepageAuthProps) {
|
||||
const {t} = useTranslation();
|
||||
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -116,7 +119,7 @@ export function HomepageAuth({
|
||||
}
|
||||
setDbError(null);
|
||||
}).catch(() => {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
setDbError(t('errors.databaseConnection'));
|
||||
});
|
||||
}, [setDbError]);
|
||||
|
||||
@@ -126,7 +129,7 @@ export function HomepageAuth({
|
||||
setLoading(true);
|
||||
|
||||
if (!localUsername.trim()) {
|
||||
setError("Username is required");
|
||||
setError(t('errors.requiredField'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -137,12 +140,12 @@ export function HomepageAuth({
|
||||
res = await loginUser(localUsername, password);
|
||||
} else {
|
||||
if (password !== signupConfirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setError(t('errors.passwordMismatch'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setError(t('errors.minLength', {min: 6}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +162,7 @@ export function HomepageAuth({
|
||||
}
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error('No token received from login');
|
||||
throw new Error(t('errors.noTokenReceived'));
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
@@ -186,7 +189,7 @@ export function HomepageAuth({
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Unknown error");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.unknownError'));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
@@ -194,7 +197,7 @@ export function HomepageAuth({
|
||||
setUserId(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
setDbError(t('errors.databaseConnection'));
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
@@ -211,7 +214,7 @@ export function HomepageAuth({
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -226,7 +229,7 @@ export function HomepageAuth({
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to verify reset code");
|
||||
setError(err?.response?.data?.error || t('errors.failedVerifyCode'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -237,13 +240,13 @@ export function HomepageAuth({
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setError(t('errors.passwordMismatch'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setError(t('errors.minLength', {min: 6}));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -260,7 +263,7 @@ export function HomepageAuth({
|
||||
|
||||
setResetSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to complete password reset");
|
||||
setError(err?.response?.data?.error || t('errors.failedCompleteReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -285,7 +288,7 @@ export function HomepageAuth({
|
||||
|
||||
async function handleTOTPVerification() {
|
||||
if (totpCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
setError(t('auth.enterCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -296,7 +299,7 @@ export function HomepageAuth({
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error('No token received from TOTP verification');
|
||||
throw new Error(t('errors.noTokenReceived'));
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
@@ -318,7 +321,7 @@ export function HomepageAuth({
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Invalid TOTP code");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.invalidTotpCode'));
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
}
|
||||
@@ -332,12 +335,12 @@ export function HomepageAuth({
|
||||
const {auth_url: authUrl} = authResponse;
|
||||
|
||||
if (!authUrl || authUrl === 'undefined') {
|
||||
throw new Error('Invalid authorization URL received from backend');
|
||||
throw new Error(t('errors.invalidAuthUrl'));
|
||||
}
|
||||
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.failedOidcLogin'));
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -349,7 +352,7 @@ export function HomepageAuth({
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
setError(`OIDC authentication failed: ${error}`);
|
||||
setError(`${t('errors.oidcAuthFailed')}: ${error}`);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
@@ -377,7 +380,7 @@ export function HomepageAuth({
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
})
|
||||
.catch(err => {
|
||||
setError("Failed to get user info after OIDC login");
|
||||
setError(t('errors.failedUserInfo'));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
@@ -412,39 +415,37 @@ export function HomepageAuth({
|
||||
)}
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>First User</AlertTitle>
|
||||
<AlertTitle>{t('auth.firstUser')}</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
||||
{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
|
||||
GitHub Issue
|
||||
</a>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Registration Disabled</AlertTitle>
|
||||
<AlertTitle>{t('auth.registerTitle')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
New account registration is currently disabled by an admin. Please log in or contact an
|
||||
administrator.
|
||||
{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">Two-Factor Authentication</h2>
|
||||
<p className="text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
|
||||
<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">Authentication Code</Label>
|
||||
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
@@ -457,7 +458,7 @@ export function HomepageAuth({
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Or enter a backup code if you don't have access to your authenticator
|
||||
{t('auth.backupCode')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -467,7 +468,7 @@ export function HomepageAuth({
|
||||
disabled={totpLoading || totpCode.length < 6}
|
||||
onClick={handleTOTPVerification}
|
||||
>
|
||||
{totpLoading ? Spinner : "Verify"}
|
||||
{totpLoading ? Spinner : t('auth.verifyCode')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -482,7 +483,7 @@ export function HomepageAuth({
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,7 +507,7 @@ export function HomepageAuth({
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
Login
|
||||
{t('common.login')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -524,7 +525,7 @@ export function HomepageAuth({
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
Sign Up
|
||||
{t('common.register')}
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
@@ -543,16 +544,16 @@ export function HomepageAuth({
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
External
|
||||
{t('auth.external')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" :
|
||||
tab === "signup" ? "Create a new account" :
|
||||
tab === "external" ? "Login with external provider" :
|
||||
"Reset your password"}
|
||||
{tab === "login" ? t('auth.loginTitle') :
|
||||
tab === "signup" ? t('auth.registerTitle') :
|
||||
tab === "external" ? t('auth.loginWithExternal') :
|
||||
t('auth.forgotPassword')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -561,7 +562,7 @@ export function HomepageAuth({
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Login using your configured external identity provider</p>
|
||||
<p>{t('auth.loginWithExternalDesc')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -569,7 +570,7 @@ export function HomepageAuth({
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||
{oidcLoading ? Spinner : t('auth.loginWithExternal')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -578,12 +579,11 @@ export function HomepageAuth({
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your username to receive a password reset code. The code
|
||||
will be logged in the docker container logs.</p>
|
||||
<p>{t('auth.resetCodeDesc')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">Username</Label>
|
||||
<Label htmlFor="reset-username">{t('common.username')}</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
@@ -600,7 +600,7 @@ export function HomepageAuth({
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
{resetLoading ? Spinner : t('auth.sendResetCode')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -609,12 +609,11 @@ export function HomepageAuth({
|
||||
{resetStep === "verify" && (
|
||||
<>o
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
<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">Reset Code</Label>
|
||||
<Label htmlFor="reset-code">{t('auth.resetCode')}</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
@@ -633,7 +632,7 @@ export function HomepageAuth({
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
{resetLoading ? Spinner : t('auth.verifyCodeButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -645,7 +644,7 @@ export function HomepageAuth({
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -654,10 +653,9 @@ export function HomepageAuth({
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
{t('auth.passwordResetSuccessDesc')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
@@ -668,7 +666,7 @@ export function HomepageAuth({
|
||||
resetPasswordState();
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
{t('auth.goToLogin')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -676,12 +674,11 @@ export function HomepageAuth({
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
<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">New Password</Label>
|
||||
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
@@ -694,7 +691,7 @@ export function HomepageAuth({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Label htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
@@ -712,7 +709,7 @@ export function HomepageAuth({
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
{resetLoading ? Spinner : t('auth.resetPasswordButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -725,7 +722,7 @@ export function HomepageAuth({
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -736,7 +733,7 @@ export function HomepageAuth({
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">{t('common.username')}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
@@ -748,14 +745,14 @@ export function HomepageAuth({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<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">Confirm Password</Label>
|
||||
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
|
||||
<Input id="signup-confirm-password" type="password" required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
@@ -765,7 +762,7 @@ export function HomepageAuth({
|
||||
)}
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
{loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button type="button" variant="outline"
|
||||
@@ -777,11 +774,20 @@ export function HomepageAuth({
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
{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 && (
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Separator} from "@/components/ui/separator.tsx";
|
||||
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
|
||||
loggedIn: boolean;
|
||||
@@ -51,6 +52,7 @@ interface VersionResponse {
|
||||
}
|
||||
|
||||
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
const {t} = useTranslation();
|
||||
const [releases, setReleases] = useState<RSSResponse | null>(null);
|
||||
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -69,7 +71,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Failed to fetch update information');
|
||||
setError(t('common.failedToFetchUpdateInfo'));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
@@ -90,15 +92,15 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
return (
|
||||
<div className="w-[400px] h-[600px] flex flex-col border-2 border-[#303032] rounded-lg bg-[#18181b] p-4 shadow-lg">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-3 text-white">Updates & Releases</h3>
|
||||
<h3 className="text-lg font-bold mb-3 text-white">{t('common.updatesAndReleases')}</h3>
|
||||
|
||||
<Separator className="p-0.25 mt-3 mb-3 bg-[#303032]"/>
|
||||
|
||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
|
||||
<AlertTitle className="text-white">Update Available</AlertTitle>
|
||||
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle>
|
||||
<AlertDescription className="text-gray-300">
|
||||
A new version ({versionInfo.version}) is available.
|
||||
{t('common.newVersionAvailable', { version: versionInfo.version })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -117,7 +119,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
|
||||
<AlertTitle className="text-red-300">Error</AlertTitle>
|
||||
<AlertTitle className="text-red-300">{t('common.error')}</AlertTitle>
|
||||
<AlertDescription className="text-red-300">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -135,7 +137,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
{release.isPrerelease && (
|
||||
<span
|
||||
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||
Pre-release
|
||||
{t('common.preRelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -158,9 +160,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
|
||||
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
|
||||
<AlertTitle className="text-gray-300">{t('common.noReleases')}</AlertTitle>
|
||||
<AlertDescription className="text-gray-400">
|
||||
No releases found.
|
||||
{t('common.noReleasesFound')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
File,
|
||||
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -49,14 +50,7 @@ import {Card} from "@/components/ui/card.tsx";
|
||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getUserList,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser,
|
||||
deleteAccount
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { deleteAccount } from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -112,26 +106,12 @@ export function LeftSidebar({
|
||||
username,
|
||||
children,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const [adminCount, setAdminCount] = React.useState(0);
|
||||
|
||||
const [users, setUsers] = React.useState<Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
}>>([]);
|
||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
||||
|
||||
@@ -140,7 +120,7 @@ export function LeftSidebar({
|
||||
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
|
||||
const openSshManagerTab = () => {
|
||||
if (sshManagerTab || isSplitScreenActive) return;
|
||||
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
|
||||
const id = addTab({type: 'ssh_manager'} as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
const adminTab = tabList.find((t) => t.type === 'admin');
|
||||
@@ -150,7 +130,7 @@ export function LeftSidebar({
|
||||
setCurrentTab(adminTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({type: 'admin', title: 'Admin'} as any);
|
||||
const id = addTab({type: 'admin'} as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
|
||||
@@ -161,33 +141,7 @@ export function LeftSidebar({
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (adminSheetOpen) {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt && isAdmin) {
|
||||
getOIDCConfig().then(res => {
|
||||
if (res) {
|
||||
setOidcConfig(res);
|
||||
}
|
||||
}).catch((error) => {
|
||||
});
|
||||
fetchUsers();
|
||||
}
|
||||
} else {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt && isAdmin) {
|
||||
fetchAdminCount();
|
||||
}
|
||||
}
|
||||
}, [adminSheetOpen, isAdmin]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
setAdminSheetOpen(false);
|
||||
setUsers([]);
|
||||
setAdminCount(0);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
const fetchHosts = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -232,7 +186,7 @@ export function LeftSidebar({
|
||||
}, 50);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setHostsError('Failed to load hosts');
|
||||
setHostsError(t('leftSidebar.failedToLoadHosts'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -275,7 +229,7 @@ export function LeftSidebar({
|
||||
const hostsByFolder = React.useMemo(() => {
|
||||
const map: Record<string, SSHHost[]> = {};
|
||||
filteredHosts.forEach(h => {
|
||||
const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
|
||||
const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder');
|
||||
if (!map[folder]) map[folder] = [];
|
||||
map[folder].push(h);
|
||||
});
|
||||
@@ -285,8 +239,8 @@ export function LeftSidebar({
|
||||
const sortedFolders = React.useMemo(() => {
|
||||
const folders = Object.keys(hostsByFolder);
|
||||
folders.sort((a, b) => {
|
||||
if (a === 'No Folder') return -1;
|
||||
if (b === 'No Folder') return 1;
|
||||
if (a === t('leftSidebar.noFolder')) return -1;
|
||||
if (b === t('leftSidebar.noFolder')) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return folders;
|
||||
@@ -304,7 +258,7 @@ export function LeftSidebar({
|
||||
setDeleteError(null);
|
||||
|
||||
if (!deletePassword.trim()) {
|
||||
setDeleteError("Password is required");
|
||||
setDeleteError(t('leftSidebar.passwordRequired'));
|
||||
setDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -315,101 +269,11 @@ export function LeftSidebar({
|
||||
|
||||
handleLogout();
|
||||
} catch (err: any) {
|
||||
setDeleteError(err?.response?.data?.error || "Failed to delete account");
|
||||
setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount'));
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
|
||||
if (!jwt || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await getUserList();
|
||||
setUsers(response.users);
|
||||
|
||||
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAdminCount = async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
|
||||
if (!jwt || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getUserList();
|
||||
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
}
|
||||
};
|
||||
|
||||
const makeUserAdmin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newAdminUsername.trim()) return;
|
||||
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMakeAdminLoading(true);
|
||||
setMakeAdminError(null);
|
||||
setMakeAdminSuccess(null);
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
|
||||
} finally {
|
||||
setMakeAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAdminStatus = async (username: string) => {
|
||||
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
|
||||
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUser = async (username: string) => {
|
||||
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
|
||||
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
@@ -421,6 +285,7 @@ export function LeftSidebar({
|
||||
variant="outline"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-[28px] h-[28px] absolute right-5"
|
||||
title={t('common.toggleSidebar')}
|
||||
>
|
||||
<Menu className="h-4 w-4"/>
|
||||
</Button>
|
||||
@@ -431,9 +296,9 @@ export function LeftSidebar({
|
||||
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
|
||||
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
||||
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}>
|
||||
<HardDrive strokeWidth="2.5"/>
|
||||
Host Manager
|
||||
{t('nav.hostManager')}
|
||||
</Button>
|
||||
</SidebarGroup>
|
||||
<Separator className="p-0.25"/>
|
||||
@@ -442,7 +307,7 @@ export function LeftSidebar({
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts by any info..."
|
||||
placeholder={t('placeholders.searchHostsAny')}
|
||||
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -452,7 +317,7 @@ export function LeftSidebar({
|
||||
<div className="px-1">
|
||||
<div
|
||||
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
{hostsError}
|
||||
{t('leftSidebar.failedToLoadHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -460,7 +325,7 @@ export function LeftSidebar({
|
||||
{hostsLoading && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Loading hosts...
|
||||
{t('hosts.loadingHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -487,7 +352,7 @@ export function LeftSidebar({
|
||||
style={{width: '100%'}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<User2/> {username ? username : 'Signed out'}
|
||||
<User2/> {username ? username : t('common.logout')}
|
||||
<ChevronUp className="ml-auto"/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -506,10 +371,10 @@ export function LeftSidebar({
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({type: 'profile', title: 'Profile'} as any);
|
||||
const id = addTab({type: 'profile', title: t('profile.title')} as any);
|
||||
setCurrentTab(id);
|
||||
}}>
|
||||
<span>Profile & Security</span>
|
||||
<span>{t('profile.title')}</span>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
@@ -517,23 +382,20 @@ export function LeftSidebar({
|
||||
onClick={() => {
|
||||
if (isAdmin) openAdminTab();
|
||||
}}>
|
||||
<span>Admin Settings</span>
|
||||
<span>{t('admin.title')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<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>Sign out</span>
|
||||
<span>{t('common.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
<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={() => setDeleteAccountOpen(true)}
|
||||
disabled={isAdmin && adminCount <= 1}
|
||||
>
|
||||
<span
|
||||
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
|
||||
Delete Account
|
||||
{isAdmin && adminCount <= 1 && " (Last Admin)"}
|
||||
<span className="text-red-400">
|
||||
{t('leftSidebar.deleteAccount')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -586,7 +448,7 @@ export function LeftSidebar({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('leftSidebar.deleteAccount')}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -596,7 +458,7 @@ export function LeftSidebar({
|
||||
setDeleteError(null);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title="Close Delete Account"
|
||||
title={t('leftSidebar.closeDeleteAccount')}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
@@ -605,48 +467,33 @@ export function LeftSidebar({
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
This action cannot be undone. This will permanently delete your account and all
|
||||
associated data.
|
||||
{t('leftSidebar.deleteAccountWarning')}
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertTitle>{t('common.warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deleting your account will remove all your data including SSH hosts,
|
||||
configurations, and settings.
|
||||
This action is irreversible.
|
||||
{t('leftSidebar.deleteAccountWarningDetails')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
{isAdmin && adminCount <= 1 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Cannot Delete Account</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are the last admin user. You cannot delete your account as this
|
||||
would leave the system without any administrators.
|
||||
Please make another user an admin first, or contact system support.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-password">Confirm Password</Label>
|
||||
<Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder="Enter your password to confirm"
|
||||
placeholder={t('placeholders.confirmPassword')}
|
||||
required
|
||||
disabled={isAdmin && adminCount <= 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -655,9 +502,9 @@ export function LeftSidebar({
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
{deleteLoading ? "Deleting..." : "Delete Account"}
|
||||
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -668,7 +515,7 @@ export function LeftSidebar({
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('leftSidebar.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
Home,
|
||||
SeparatorVertical,
|
||||
@@ -37,6 +38,7 @@ export function Tab({
|
||||
disableSplit = false,
|
||||
disableClose = false
|
||||
}: TabProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
if (tabType === "home") {
|
||||
return (
|
||||
<Button
|
||||
@@ -63,7 +65,7 @@ export function Tab({
|
||||
>
|
||||
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
|
||||
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||
{title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
|
||||
{title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : t('nav.terminal'))}
|
||||
</Button>
|
||||
{canSplit && (
|
||||
<Button
|
||||
@@ -71,7 +73,7 @@ export function Tab({
|
||||
className="!px-2 border-1 border-[#303032]"
|
||||
onClick={onSplit}
|
||||
disabled={disableSplit}
|
||||
title={disableSplit ? 'Cannot split this tab' : 'Split'}
|
||||
title={disableSplit ? t('nav.cannotSplitTab') : t('nav.splitScreen')}
|
||||
>
|
||||
<SeparatorVertical className="w-[28px] h-[28px]"/>
|
||||
</Button>
|
||||
@@ -99,7 +101,7 @@ export function Tab({
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{title || "SSH Manager"}
|
||||
{title || t('nav.sshManager')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -122,7 +124,7 @@ export function Tab({
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{title || "Admin"}
|
||||
{title || t('nav.admin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
export interface Tab {
|
||||
id: number;
|
||||
@@ -34,15 +35,16 @@ interface TabProviderProps {
|
||||
}
|
||||
|
||||
export function TabProvider({children}: TabProviderProps) {
|
||||
const {t} = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{id: 1, type: 'home', title: 'Home'}
|
||||
{id: 1, type: 'home', title: t('nav.home')}
|
||||
]);
|
||||
const [currentTab, setCurrentTab] = useState<number>(1);
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
const nextTabId = useRef(2);
|
||||
|
||||
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
|
||||
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
|
||||
const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal'));
|
||||
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||
const root = match ? match[1] : baseTitle;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
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";
|
||||
|
||||
interface TopNavbarProps {
|
||||
isTopbarOpen: boolean;
|
||||
@@ -23,6 +24,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
const {state} = useSidebar();
|
||||
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
|
||||
const leftPosition = state === "collapsed" ? "26px" : "264px";
|
||||
const {t} = useTranslation();
|
||||
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
@@ -267,7 +269,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px]"
|
||||
title="SSH Tools"
|
||||
title={t('nav.tools')}
|
||||
onClick={() => setToolsSheetOpen(true)}
|
||||
>
|
||||
<Hammer className="h-4 w-4"/>
|
||||
@@ -325,13 +327,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||
<h2 className="text-lg font-semibold text-white">SSH Tools</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('sshTools.title')}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setToolsSheetOpen(false)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title="Close SSH Tools"
|
||||
title={t('sshTools.closeTools')}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
@@ -340,7 +342,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-semibold">
|
||||
Key Recording
|
||||
{t('sshTools.keyRecording')}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
@@ -352,7 +354,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
>
|
||||
Start Key Recording
|
||||
{t('sshTools.startKeyRecording')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -360,7 +362,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
>
|
||||
Stop Key Recording
|
||||
{t('sshTools.stopKeyRecording')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -368,8 +370,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Select
|
||||
terminals:</label>
|
||||
<label className="text-sm font-medium text-white">{t('sshTools.selectTerminals')}</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
||||
{terminalTabs.map(tab => (
|
||||
<Button
|
||||
@@ -391,11 +392,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">Type commands (all
|
||||
keys supported):</label>
|
||||
<label className="text-sm font-medium text-white">{t('sshTools.typeCommands')}</label>
|
||||
<Input
|
||||
id="ssh-tools-input"
|
||||
placeholder="Type here"
|
||||
placeholder={t('placeholders.typeHere')}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="font-mono mt-2"
|
||||
@@ -403,8 +403,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Commands will be sent to {selectedTabIds.length} selected
|
||||
terminal(s).
|
||||
{t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -415,7 +414,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
<Separator className="my-4"/>
|
||||
|
||||
<h1 className="font-semibold">
|
||||
Settings
|
||||
{t('sshTools.settings')}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -428,14 +427,14 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
|
||||
htmlFor="enable-copy-paste"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
|
||||
>
|
||||
Enable right‑click copy/paste
|
||||
{t('sshTools.enableRightClickCopyPaste')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4"/>
|
||||
|
||||
<p className="pt-2 pb-2 text-sm text-gray-500">
|
||||
Have ideas for what should come next for ssh tools? Share them on{" "}
|
||||
{t('sshTools.shareIdeas')}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
|
||||
@@ -6,6 +6,8 @@ import {Label} from "@/components/ui/label.tsx";
|
||||
import {Input} from "@/components/ui/input.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface PasswordResetProps {
|
||||
userInfo: {
|
||||
@@ -17,6 +19,7 @@ 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");
|
||||
@@ -25,7 +28,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [tempToken, setTempToken] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetSuccess, setResetSuccess] = useState(false);
|
||||
|
||||
async function handleInitiatePasswordReset() {
|
||||
setError(null);
|
||||
@@ -35,7 +37,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||
setError(err?.response?.data?.error || err?.message || t('common.failedToInitiatePasswordReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -48,7 +50,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
setResetSuccess(false);
|
||||
}
|
||||
|
||||
async function handleVerifyResetCode() {
|
||||
@@ -60,7 +61,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to verify reset code");
|
||||
setError(err?.response?.data?.error || t('common.failedToVerifyResetCode'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -71,13 +72,13 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setError(t('common.passwordsDoNotMatch'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setError(t('common.passwordMinLength'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -85,16 +86,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
try {
|
||||
await completePasswordReset(userInfo.username, tempToken, newPassword);
|
||||
|
||||
setResetStep("initiate");
|
||||
setResetCode("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setTempToken("");
|
||||
setError(null);
|
||||
|
||||
setResetSuccess(true);
|
||||
toast.success(t('common.passwordResetSuccess'));
|
||||
resetPasswordState();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to complete password reset");
|
||||
setError(err?.response?.data?.error || t('common.failedToCompletePasswordReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -112,15 +107,15 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5"/>
|
||||
Password
|
||||
{t('common.password')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Change your account password
|
||||
{t('common.changeAccountPassword')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<>
|
||||
{resetStep === "initiate" && !resetSuccess && (
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button
|
||||
@@ -129,7 +124,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
disabled={resetLoading || !userInfo.username.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
{resetLoading ? Spinner : t('common.sendResetCode')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -138,12 +133,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{userInfo.username}</strong></p>
|
||||
<p>{t('common.enterSixDigitCode')} <strong>{userInfo.username}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">Reset Code</Label>
|
||||
<Label htmlFor="reset-code">{t('common.resetCode')}</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
@@ -153,7 +147,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
value={resetCode}
|
||||
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
placeholder={t('placeholders.enterCode')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -162,7 +156,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
{resetLoading ? Spinner : t('common.verifyCode')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -174,33 +168,20 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
{resetStep === "newPassword" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{userInfo.username}</strong></p>
|
||||
<p>{t('common.enterNewPassword')} <strong>{userInfo.username}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Label htmlFor="new-password">{t('common.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
@@ -213,7 +194,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Label htmlFor="confirm-password">{t('common.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
@@ -231,7 +212,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
{resetLoading ? Spinner : t('common.resetPassword')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -244,14 +225,14 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.t
|
||||
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TOTPSetupProps {
|
||||
isEnabled: boolean;
|
||||
@@ -15,6 +16,7 @@ interface TOTPSetupProps {
|
||||
}
|
||||
|
||||
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) {
|
||||
const {t} = useTranslation();
|
||||
const [isEnabled, setIsEnabled] = useState(initialEnabled);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init");
|
||||
@@ -55,7 +57,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
const response = await enableTOTP(verificationCode);
|
||||
setBackupCodes(response.backup_codes);
|
||||
setSetupStep("backup");
|
||||
toast.success("Two-factor authentication enabled successfully!");
|
||||
toast.success(t('auth.twoFactorEnabledSuccess'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Invalid verification code");
|
||||
} finally {
|
||||
@@ -74,7 +76,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
setPassword("");
|
||||
setDisableCode("");
|
||||
onStatusChange?.(false);
|
||||
toast.success("Two-factor authentication disabled");
|
||||
toast.success(t('auth.twoFactorDisabled'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to disable TOTP");
|
||||
} finally {
|
||||
@@ -88,7 +90,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
try {
|
||||
const response = await generateBackupCodes(password || undefined, disableCode || undefined);
|
||||
setBackupCodes(response.backup_codes);
|
||||
toast.success("New backup codes generated");
|
||||
toast.success(t('auth.newBackupCodesGenerated'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to generate backup codes");
|
||||
} finally {
|
||||
@@ -98,7 +100,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
toast.success(t('messages.copiedToClipboard', {item: label}));
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
@@ -114,7 +116,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
a.download = 'termix-backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Backup codes downloaded");
|
||||
toast.success(t('auth.backupCodesDownloaded'));
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
@@ -131,50 +133,50 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
{t('auth.twoFactorTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your account is protected with two-factor authentication
|
||||
{t('auth.twoFactorProtected')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>Enabled</AlertTitle>
|
||||
<AlertTitle>{t('common.enabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication is currently active on your account
|
||||
{t('auth.twoFactorActive')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="disable" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="disable">Disable 2FA</TabsTrigger>
|
||||
<TabsTrigger value="backup">Backup Codes</TabsTrigger>
|
||||
<TabsTrigger value="disable">{t('auth.disable2FA')}</TabsTrigger>
|
||||
<TabsTrigger value="backup">{t('auth.backupCodes')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="disable" className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertTitle>{t('common.warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Disabling two-factor authentication will make your account less secure
|
||||
{t('auth.disableTwoFactorWarning')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">Password or TOTP Code</Label>
|
||||
<Label htmlFor="disable-password">{t('auth.passwordOrTotpCode')}</Label>
|
||||
<Input
|
||||
id="disable-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('placeholders.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<p className="text-sm text-muted-foreground">{t('auth.or')}</p>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
placeholder={t('placeholders.totpCode')}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
@@ -186,29 +188,29 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onClick={handleDisable}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Disable Two-Factor Authentication
|
||||
{t('auth.disableTwoFactor')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate new backup codes if you've lost your existing ones
|
||||
{t('auth.generateNewBackupCodesText')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">Password or TOTP Code</Label>
|
||||
<Label htmlFor="backup-password">{t('auth.passwordOrTotpCode')}</Label>
|
||||
<Input
|
||||
id="backup-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('placeholders.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<p className="text-sm text-muted-foreground">{t('auth.or')}</p>
|
||||
<Input
|
||||
id="backup-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
placeholder={t('placeholders.totpCode')}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
@@ -219,20 +221,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onClick={handleGenerateNewBackupCodes}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Generate New Backup Codes
|
||||
{t('auth.generateNewBackupCodes')}
|
||||
</Button>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Label>{t('auth.yourBackupCodes')}</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
{t('auth.download')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
@@ -248,7 +250,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -261,9 +263,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
|
||||
<CardTitle>{t('auth.setupTwoFactorTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
Step 1: Scan the QR code with your authenticator app
|
||||
{t('auth.step1ScanQR')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -272,7 +274,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Manual Entry Code</Label>
|
||||
<Label>{t('auth.manualEntryCode')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={secret}
|
||||
@@ -288,12 +290,12 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If you can't scan the QR code, enter this code manually in your authenticator app
|
||||
{t('auth.cannotScanQRText')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSetupStep("verify")} className="w-full">
|
||||
Next: Verify Code
|
||||
{t('auth.nextVerifyCode')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -304,14 +306,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Your Authenticator</CardTitle>
|
||||
<CardTitle>{t('auth.verifyAuthenticator')}</CardTitle>
|
||||
<CardDescription>
|
||||
Step 2: Enter the 6-digit code from your authenticator app
|
||||
{t('auth.step2EnterCode')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verify-code">Verification Code</Label>
|
||||
<Label htmlFor="verify-code">{t('auth.verificationCode')}</Label>
|
||||
<Input
|
||||
id="verify-code"
|
||||
type="text"
|
||||
@@ -326,7 +328,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -337,14 +339,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onClick={() => setSetupStep("qr")}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
{t('auth.back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading || verificationCode.length !== 6}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? "Verifying..." : "Verify and Enable"}
|
||||
{loading ? t('interface.verifying') : t('auth.verifyAndEnable')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -356,17 +358,17 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Save Your Backup Codes</CardTitle>
|
||||
<CardTitle>{t('auth.saveBackupCodesTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
Step 3: Store these codes in a safe place
|
||||
{t('auth.step3StoreCodesSecurely')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertTitle>{t('common.important')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.
|
||||
{t('auth.importantBackupCodesText')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -393,7 +395,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</div>
|
||||
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
Complete Setup
|
||||
{t('auth.completeSetup')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -405,23 +407,23 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
{t('auth.twoFactorTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account
|
||||
{t('auth.addExtraSecurityLayer')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Not Enabled</AlertTitle>
|
||||
<AlertTitle>{t('common.notEnabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.
|
||||
{t('auth.notEnabledText')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={handleSetupStart} disabled={loading} className="w-full">
|
||||
{loading ? "Setting up..." : "Enable Two-Factor Authentication"}
|
||||
{loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -10,12 +10,15 @@ import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx";
|
||||
import {getUserInfo} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {PasswordReset} from "@/ui/User/PasswordReset.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {LanguageSwitcher} from "@/components/LanguageSwitcher";
|
||||
|
||||
interface UserProfileProps {
|
||||
isTopbarOpen?: boolean;
|
||||
}
|
||||
|
||||
export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
const {t} = useTranslation();
|
||||
const [userInfo, setUserInfo] = useState<{
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
@@ -41,7 +44,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
totp_enabled: info.totp_enabled || false
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to load user information");
|
||||
setError(err?.response?.data?.error || t('errors.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -58,7 +61,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
<div className="container max-w-4xl mx-auto p-6">
|
||||
<Card>
|
||||
<CardContent className="p-12 text-center">
|
||||
<div className="animate-pulse">Loading user profile...</div>
|
||||
<div className="animate-pulse">{t('common.loading')}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -70,8 +73,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
<div className="container max-w-4xl mx-auto p-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4"/>
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error || "Failed to load user profile"}</AlertDescription>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error || t('errors.loadFailed')}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
@@ -84,20 +87,20 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
maxHeight: 'calc(100vh - 60px)'
|
||||
}}>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">User Profile</h1>
|
||||
<p className="text-muted-foreground mt-2">Manage your account settings and security</p>
|
||||
<h1 className="text-3xl font-bold">{t('common.profile')}</h1>
|
||||
<p className="text-muted-foreground mt-2">{t('profile.description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profile" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile" className="flex items-center gap-2">
|
||||
<User className="w-4 h-4"/>
|
||||
Profile
|
||||
{t('common.profile')}
|
||||
</TabsTrigger>
|
||||
{!userInfo.is_oidc && (
|
||||
<TabsTrigger value="security" className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4"/>
|
||||
Security
|
||||
{t('profile.security')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
@@ -105,45 +108,55 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
|
||||
<TabsContent value="profile" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Information</CardTitle>
|
||||
<CardDescription>Your account details and settings</CardDescription>
|
||||
<CardTitle>{t('profile.accountInfo')}</CardTitle>
|
||||
<CardDescription>{t('profile.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<Label>{t('common.username')}</Label>
|
||||
<p className="text-lg font-medium mt-1">{userInfo.username}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Account Type</Label>
|
||||
<Label>{t('profile.role')}</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_admin ? "Administrator" : "User"}
|
||||
{userInfo.is_admin ? t('interface.administrator') : t('interface.user')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Authentication Method</Label>
|
||||
<Label>{t('profile.authMethod')}</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? "External (OIDC)" : "Local"}
|
||||
{userInfo.is_oidc ? t('profile.external') : t('profile.local')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Two-Factor Authentication</Label>
|
||||
<Label>{t('profile.twoFactorAuth')}</Label>
|
||||
<p className="text-lg font-medium mt-1">
|
||||
{userInfo.is_oidc ? (
|
||||
<span className="text-muted-foreground">Locked (OIDC Auth)</span>
|
||||
<span className="text-muted-foreground">{t('auth.lockedOidcAuth')}</span>
|
||||
) : (
|
||||
userInfo.totp_enabled ? (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<Shield className="w-4 h-4"/>
|
||||
Enabled
|
||||
{t('common.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Disabled</span>
|
||||
<span className="text-muted-foreground">{t('common.disabled')}</span>
|
||||
)
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>{t('common.language')}</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t('profile.selectPreferredLanguage')}</p>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -143,6 +143,7 @@ interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
}
|
||||
|
||||
interface UserCount {
|
||||
@@ -897,7 +898,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }>
|
||||
const response = await authApi.post('/users/totp/setup');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError);
|
||||
handleApiError(error as AxiosError, 'setup TOTP');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -907,7 +908,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string;
|
||||
const response = await authApi.post('/users/totp/enable', { totp_code });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError);
|
||||
handleApiError(error as AxiosError, 'enable TOTP');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -917,7 +918,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis
|
||||
const response = await authApi.post('/users/totp/disable', { password, totp_code });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError);
|
||||
handleApiError(error as AxiosError, 'disable TOTP');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -927,7 +928,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr
|
||||
const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError);
|
||||
handleApiError(error as AxiosError, 'verify TOTP login');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -937,7 +938,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string)
|
||||
const response = await authApi.post('/users/totp/backup-codes', { password, totp_code });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error as AxiosError);
|
||||
handleApiError(error as AxiosError, 'generate backup codes');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user