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>
This commit is contained in:
ZacharyZcR
2025-09-02 20:36:48 +08:00
parent 26c1cacc9d
commit 70a26359b6
24 changed files with 1805 additions and 362 deletions

View File

@@ -26,6 +26,7 @@ import {
removeAdminStatus,
deleteUser
} from "@/ui/main-axios.ts";
import {useTranslation} from "react-i18next";
function getCookie(name: string) {
return document.cookie.split('; ').reduce((r, v) => {
@@ -40,6 +41,7 @@ interface AdminSettingsProps {
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {state: sidebarState} = useSidebar();
const {t} = useTranslation();
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
@@ -135,7 +137,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
await updateOIDCConfig(oidcConfig);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
setOidcError(err?.response?.data?.error || t('interface.failedToUpdateOidcConfig'));
} finally {
setOidcLoading(false);
}
@@ -158,7 +160,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
setMakeAdminError(err?.response?.data?.error || t('interface.failedToMakeUserAdmin'));
} finally {
setMakeAdminLoading(false);
}
@@ -200,7 +202,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,99 +211,98 @@ 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('common.settings')}
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
{t('admin.oidcSettings')}
</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('nav.admin')}
</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.userManagement')}</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
Allow new account registration
{t('admin.allowRegistration')}
</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/>
placeholder={t('placeholders.scopes')} required/>
</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: '',
@@ -311,12 +312,12 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
})}>Reset</Button>
})}>{t('admin.reset')}</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertTitle>{t('admin.success')}</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
@@ -327,20 +328,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,11 +351,11 @@ 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)}
@@ -374,29 +375,29 @@ 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>
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
<form onSubmit={makeUserAdmin} 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('placeholders.enterUsername')} 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>
<AlertTitle>{t('admin.success')}</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
@@ -404,14 +405,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
</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>
@@ -423,13 +424,13 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</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)}
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[]>([]);
@@ -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,15 +357,15 @@ 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 {
@@ -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 () => {
@@ -433,10 +435,10 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
let errorMessage = formatErrorMessage(err, 'Cannot save file');
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);
@@ -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

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

@@ -1,6 +1,7 @@
import {zodResolver} from "@hookform/resolvers/zod"
import {Controller, useForm} from "react-hook-form"
import {z} from "zod"
import {useTranslation} from "react-i18next"
import {Button} from "@/components/ui/button.tsx"
import {
@@ -50,6 +51,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[]>([]);
@@ -254,7 +256,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) {
alert('Failed to save host. Please try again.');
alert(t('errors.saveError'));
}
};
@@ -299,7 +301,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
}, [folderDropdownOpen]);
const keyTypeOptions = [
{value: 'auto', label: 'Auto-detect'},
{value: 'auto', label: t('common.autoDetect')},
{value: 'ssh-rsa', label: 'RSA'},
{value: 'ssh-ed25519', label: 'ED25519'},
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'},
@@ -393,20 +395,20 @@ 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('common.settings')}</TabsTrigger>
<TabsTrigger value="terminal">{t('nav.terminal')}</TabsTrigger>
<TabsTrigger value="tunnel">{t('nav.tunnels')}</TabsTrigger>
<TabsTrigger value="file_manager">{t('nav.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} />
</FormControl>
@@ -419,7 +421,7 @@ 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} />
</FormControl>
@@ -432,24 +434,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="username"
render={({field}) => (
<FormItem className="col-span-6">
<FormLabel>Username</FormLabel>
<FormLabel>{t('common.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.hostName')}</FormLabel>
<FormControl>
<Input placeholder="host name" {...field} />
<Input placeholder={t('placeholders.hostname')} {...field} />
</FormControl>
</FormItem>
)}
@@ -460,11 +462,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 +507,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 +543,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
field.onChange(field.value.slice(0, -1));
}
}}
placeholder="add tags (space to add)"
placeholder={t('hosts.addTags')}
/>
</div>
</FormControl>
@@ -586,7 +588,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="password" {...field} />
<Input type="password" placeholder={t('placeholders.password')} {...field} />
</FormControl>
</FormItem>
)}
@@ -635,7 +637,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<FormLabel>Key Password</FormLabel>
<FormControl>
<Input
placeholder="key password"
placeholder={t('placeholders.keyPassword')}
type="password"
{...field}
/>
@@ -848,7 +850,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
ref={(el) => {
sshConfigInputRefs.current[index] = el;
}}
placeholder="endpoint ssh configuration"
placeholder={t('placeholders.sshConfig')}
className="min-h-[40px]"
autoComplete="off"
value={endpointHostField.value}
@@ -1017,7 +1019,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<FormItem>
<FormLabel>Default Path</FormLabel>
<FormControl>
<Input placeholder="/home" {...field} />
<Input placeholder={t('placeholders.homePath')} {...field} />
</FormControl>
<FormDescription>Set default directory shown when connected via
File Manager</FormDescription>

View File

@@ -350,7 +350,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"

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

@@ -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 {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 {
registerUser,
loginUser,
@@ -55,6 +56,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 +118,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 +128,7 @@ export function HomepageAuth({
setLoading(true);
if (!localUsername.trim()) {
setError("Username is required");
setError(t('errors.requiredField'));
setLoading(false);
return;
}
@@ -137,12 +139,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 +161,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 +188,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 +196,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 +213,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 +228,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 +239,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 +262,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 +287,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 +298,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 +320,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 +334,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 +351,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 +379,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 +414,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 +457,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 +467,7 @@ export function HomepageAuth({
disabled={totpLoading || totpCode.length < 6}
onClick={handleTOTPVerification}
>
{totpLoading ? Spinner : "Verify"}
{totpLoading ? Spinner : t('auth.verifyCode')}
</Button>
<Button
@@ -482,7 +482,7 @@ export function HomepageAuth({
setError(null);
}}
>
Cancel
{t('common.cancel')}
</Button>
</div>
)}
@@ -506,7 +506,7 @@ export function HomepageAuth({
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
Login
{t('common.login')}
</button>
<button
type="button"
@@ -524,7 +524,7 @@ export function HomepageAuth({
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
Sign Up
{t('common.register')}
</button>
{oidcConfigured && (
<button
@@ -543,16 +543,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 +561,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 +569,7 @@ export function HomepageAuth({
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
{oidcLoading ? Spinner : t('auth.loginWithExternal')}
</Button>
</>
)}
@@ -578,12 +578,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 +599,7 @@ export function HomepageAuth({
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
{resetLoading ? Spinner : t('auth.sendResetCode')}
</Button>
</div>
</>
@@ -609,12 +608,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 +631,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 +643,7 @@ export function HomepageAuth({
setResetCode("");
}}
>
Back
{t('common.back')}
</Button>
</div>
</>
@@ -654,10 +652,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 +665,7 @@ export function HomepageAuth({
resetPasswordState();
}}
>
Go to Login
{t('auth.goToLogin')}
</Button>
</>
)}
@@ -676,12 +673,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 +690,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 +708,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 +721,7 @@ export function HomepageAuth({
setConfirmPassword("");
}}
>
Back
{t('common.back')}
</Button>
</div>
</>
@@ -736,7 +732,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 +744,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 +761,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,7 +773,7 @@ export function HomepageAuth({
clearFormFields();
}}
>
Reset Password
{t('auth.resetPasswordButton')}
</Button>
)}
</form>

View File

@@ -1,4 +1,5 @@
import React, {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {
Computer,
Server,
@@ -112,6 +113,7 @@ export function LeftSidebar({
username,
children,
}: SidebarProps): React.ReactElement {
const {t} = useTranslation();
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
@@ -140,7 +142,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', title: t('hosts.title')} as any);
setCurrentTab(id);
};
const adminTab = tabList.find((t) => t.type === 'admin');
@@ -150,7 +152,7 @@ export function LeftSidebar({
setCurrentTab(adminTab.id);
return;
}
const id = addTab({type: 'admin', title: 'Admin'} as any);
const id = addTab({type: 'admin', title: t('nav.admin')} as any);
setCurrentTab(id);
};
@@ -232,7 +234,7 @@ export function LeftSidebar({
}, 50);
}
} catch (err: any) {
setHostsError('Failed to load hosts');
setHostsError(t('leftSidebar.failedToLoadHosts'));
}
}, []);
@@ -275,7 +277,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 +287,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 +306,7 @@ export function LeftSidebar({
setDeleteError(null);
if (!deletePassword.trim()) {
setDeleteError("Password is required");
setDeleteError(t('leftSidebar.passwordRequired'));
setDeleteLoading(false);
return;
}
@@ -315,7 +317,7 @@ 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);
}
};
@@ -370,18 +372,18 @@ export function LeftSidebar({
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setMakeAdminSuccess(t('leftSidebar.userIsNowAdmin', {username: newAdminUsername}));
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
setMakeAdminError(err?.response?.data?.error || t('leftSidebar.failedToMakeUserAdmin'));
} finally {
setMakeAdminLoading(false);
}
};
const removeAdminStatus = async (username: string) => {
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
if (!confirm(t('leftSidebar.removeAdminConfirm', {username}))) return;
if (!isAdmin) {
return;
@@ -396,7 +398,7 @@ export function LeftSidebar({
};
const deleteUser = async (username: string) => {
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
if (!confirm(t('leftSidebar.deleteUserConfirm', {username}))) return;
if (!isAdmin) {
return;
@@ -442,7 +444,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"
/>
@@ -460,7 +462,7 @@ export function LeftSidebar({
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
Loading hosts...
{t('common.loading')}
</div>
</div>
)}
@@ -487,7 +489,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 +508,10 @@ export function LeftSidebar({
setCurrentTab(profileTab.id);
return;
}
const id = addTab({type: 'profile', title: 'Profile'} as any);
const id = addTab({type: 'profile', title: t('common.profile')} as any);
setCurrentTab(id);
}}>
<span>Profile & Security</span>
<span>{t('common.profile')}</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
@@ -517,13 +519,13 @@ 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"
@@ -532,7 +534,7 @@ export function LeftSidebar({
>
<span
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
Delete Account
{t('admin.deleteUser')}
{isAdmin && adminCount <= 1 && " (Last Admin)"}
</span>
</DropdownMenuItem>
@@ -586,7 +588,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 +598,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,22 +607,19 @@ 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>
)}
@@ -628,23 +627,21 @@ export function LeftSidebar({
<form onSubmit={handleDeleteAccount} className="space-y-4">
{isAdmin && adminCount <= 1 && (
<Alert variant="destructive">
<AlertTitle>Cannot Delete Account</AlertTitle>
<AlertTitle>{t('leftSidebar.cannotDeleteAccount')}</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.
{t('leftSidebar.lastAdminWarning')}
</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}
/>
@@ -657,7 +654,7 @@ export function LeftSidebar({
className="flex-1"
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
>
{deleteLoading ? "Deleting..." : "Delete Account"}
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')}
</Button>
<Button
type="button"
@@ -668,7 +665,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

@@ -13,6 +13,8 @@ import {
import {Input} from "@/components/ui/input.tsx";
import {Checkbox} from "@/components/ui/checkbox.tsx";
import {Separator} from "@/components/ui/separator.tsx";
import {LanguageSwitcher} from "@/components/LanguageSwitcher";
import {useTranslation} from "react-i18next";
interface TopNavbarProps {
isTopbarOpen: boolean;
@@ -23,6 +25,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);
@@ -264,10 +267,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</div>
<div className="flex items-center justify-center gap-2 flex-1 px-2">
<LanguageSwitcher />
<Button
variant="outline"
className="w-[30px] h-[30px]"
title="SSH Tools"
title={t('nav.tools')}
onClick={() => setToolsSheetOpen(true)}
>
<Hammer className="h-4 w-4"/>
@@ -395,7 +400,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
keys supported):</label>
<Input
id="ssh-tools-input"
placeholder="Type here"
placeholder={t('placeholders.typeHere')}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
className="font-mono mt-2"

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,14 @@ 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";
interface UserProfileProps {
isTopbarOpen?: boolean;
}
export function UserProfile({isTopbarOpen = true}: UserProfileProps) {
const {t} = useTranslation();
const [userInfo, setUserInfo] = useState<{
username: string;
is_admin: boolean;
@@ -41,7 +43,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 +60,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 +72,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 +86,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,40 +107,40 @@ 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>