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:
Karmaa
2025-09-03 00:14:49 -05:00
committed by GitHub
parent 26c1cacc9d
commit 61db35daad
41 changed files with 2781 additions and 924 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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) => {

View File

@@ -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>
) : (

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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')}]`);
});
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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 (

View File

@@ -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'));
}
};

View File

@@ -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 && (

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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;

View File

@@ -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 rightclick 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"

View File

@@ -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>
)}

View File

@@ -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 && (

View File

@@ -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>

View File

@@ -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;
}
}