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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Trash2, Folder, File, Plus, Pin} from 'lucide-react';
|
||||
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -43,6 +44,7 @@ export function FileManagerHomeView({
|
||||
onRemoveShortcut,
|
||||
onAddShortcut
|
||||
}: FileManagerHomeViewProps) {
|
||||
const {t} = useTranslation();
|
||||
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
|
||||
const [newShortcut, setNewShortcut] = useState('');
|
||||
|
||||
@@ -121,10 +123,9 @@ export function FileManagerHomeView({
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
|
||||
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
|
||||
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
|
||||
Shortcuts</TabsTrigger>
|
||||
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">{t('fileManager.recent')}</TabsTrigger>
|
||||
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">{t('fileManager.pinned')}</TabsTrigger>
|
||||
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">{t('fileManager.folderShortcuts')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
@@ -132,7 +133,7 @@ export function FileManagerHomeView({
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No recent files.</span>
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noRecentFiles')}</span>
|
||||
</div>
|
||||
) : recent.map((file) =>
|
||||
renderFileCard(
|
||||
@@ -150,7 +151,7 @@ export function FileManagerHomeView({
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No pinned files.</span>
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noPinnedFiles')}</span>
|
||||
</div>
|
||||
) : pinned.map((file) =>
|
||||
renderFileCard(
|
||||
@@ -166,7 +167,7 @@ export function FileManagerHomeView({
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
|
||||
<Input
|
||||
placeholder="Enter folder path"
|
||||
placeholder={t('fileManager.enterFolderPath')}
|
||||
value={newShortcut}
|
||||
onChange={e => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
|
||||
@@ -189,14 +190,14 @@ export function FileManagerHomeView({
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1"/>
|
||||
Add
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">No shortcuts.</span>
|
||||
<span className="text-sm text-muted-foreground">{t('fileManager.noShortcuts')}</span>
|
||||
</div>
|
||||
) : shortcuts.map((shortcut) =>
|
||||
renderShortcutCard(shortcut)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {cn} from '@/lib/utils.ts';
|
||||
import {Input} from '@/components/ui/input.tsx';
|
||||
import {Button} from '@/components/ui/button.tsx';
|
||||
import {toast} from 'sonner';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {
|
||||
listSSHFiles,
|
||||
renameSSHItem,
|
||||
@@ -56,6 +57,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const {t} = useTranslation();
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {Cpu, HardDrive, MemoryStick} from "lucide-react";
|
||||
import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx";
|
||||
import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
@@ -24,6 +25,7 @@ export function Server({
|
||||
isTopbarOpen = true,
|
||||
embedded = false
|
||||
}: ServerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const {state: sidebarState} = useSidebar();
|
||||
const {addTab, tabs} = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||
@@ -168,16 +170,16 @@ export function Server({
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Refresh status and metrics"
|
||||
title={t('serverStats.refreshStatusAndMetrics')}
|
||||
>
|
||||
Refresh Status
|
||||
{t('serverStats.refreshStatus')}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={isFileManagerAlreadyOpen ? "File Manager already open for this host" : "Open File Manager"}
|
||||
title={isFileManagerAlreadyOpen ? t('serverStats.fileManagerAlreadyOpen') : t('serverStats.openFileManager')}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||
@@ -190,7 +192,7 @@ export function Server({
|
||||
});
|
||||
}}
|
||||
>
|
||||
File Manager
|
||||
{t('nav.fileManager')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -208,11 +210,11 @@ export function Server({
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const la = metrics?.cpu?.load;
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)';
|
||||
const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus');
|
||||
const laText = (la && la.length === 3)
|
||||
? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}`
|
||||
: 'Avg: N/A';
|
||||
return `CPU Usage - ${pctText} of ${coresText} (${laText})`;
|
||||
? t('serverStats.loadAverage', {avg1: la[0].toFixed(2), avg5: la[1].toFixed(2), avg15: la[2].toFixed(2)})
|
||||
: t('serverStats.loadAverageNA');
|
||||
return `${t('serverStats.cpuUsage')} - ${pctText} ${t('serverStats.of')} ${coresText} (${laText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
@@ -232,7 +234,7 @@ export function Server({
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A';
|
||||
const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A';
|
||||
return `Memory Usage - ${pctText} (${usedText} of ${totalText})`;
|
||||
return `${t('serverStats.memoryUsage')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
@@ -252,7 +254,7 @@ export function Server({
|
||||
const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A';
|
||||
const usedText = used ?? 'N/A';
|
||||
const totalText = total ?? 'N/A';
|
||||
return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`;
|
||||
return `${t('serverStats.rootStorageSpace')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`;
|
||||
})()}
|
||||
</h1>
|
||||
|
||||
@@ -270,7 +272,7 @@ export function Server({
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
Have ideas for what should come next for server management? Share them on{" "}
|
||||
{t('serverStats.feedbackMessage')}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {FitAddon} from '@xterm/addon-fit';
|
||||
import {ClipboardAddon} from '@xterm/addon-clipboard';
|
||||
import {Unicode11Addon} from '@xterm/addon-unicode11';
|
||||
import {WebLinksAddon} from '@xterm/addon-web-links';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
@@ -17,6 +18,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{hostConfig, isVisible, splitScreen = false},
|
||||
ref
|
||||
) {
|
||||
const {t} = useTranslation();
|
||||
const {instance: terminal, ref: xtermRef} = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
@@ -139,11 +141,11 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === 'data') terminal.write(msg.data);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`);
|
||||
else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`);
|
||||
else if (msg.type === 'connected') {
|
||||
} else if (msg.type === 'disconnected') {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`);
|
||||
terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
@@ -151,12 +153,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
if (!wasDisconnectedBySSH.current) {
|
||||
terminal.writeln('\r\n[Connection closed]');
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`);
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
terminal.writeln('\r\n[Connection error]');
|
||||
terminal.writeln(`\r\n[${t('terminal.connectionError')}]`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import {TunnelObject} from "./TunnelObject.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
interface TunnelConnection {
|
||||
sourcePort: number;
|
||||
@@ -52,15 +53,15 @@ export function TunnelViewer({
|
||||
tunnelActions = {},
|
||||
onTunnelAction
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">No SSH Tunnels</h3>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">{t('tunnels.noSshTunnels')}</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel
|
||||
connections.
|
||||
{t('tunnels.createFirstTunnelMessage')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -69,7 +70,7 @@ export function TunnelViewer({
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="w-full flex-shrink-0 mb-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">SSH Tunnels</h1>
|
||||
<h1 className="text-xl font-semibold text-foreground">{t('tunnels.title')}</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div
|
||||
|
||||
@@ -4,6 +4,7 @@ import {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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.t
|
||||
import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TOTPSetupProps {
|
||||
isEnabled: boolean;
|
||||
@@ -15,6 +16,7 @@ interface TOTPSetupProps {
|
||||
}
|
||||
|
||||
export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) {
|
||||
const {t} = useTranslation();
|
||||
const [isEnabled, setIsEnabled] = useState(initialEnabled);
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init");
|
||||
@@ -55,7 +57,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
const response = await enableTOTP(verificationCode);
|
||||
setBackupCodes(response.backup_codes);
|
||||
setSetupStep("backup");
|
||||
toast.success("Two-factor authentication enabled successfully!");
|
||||
toast.success(t('auth.twoFactorEnabledSuccess'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Invalid verification code");
|
||||
} finally {
|
||||
@@ -74,7 +76,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
setPassword("");
|
||||
setDisableCode("");
|
||||
onStatusChange?.(false);
|
||||
toast.success("Two-factor authentication disabled");
|
||||
toast.success(t('auth.twoFactorDisabled'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to disable TOTP");
|
||||
} finally {
|
||||
@@ -88,7 +90,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
try {
|
||||
const response = await generateBackupCodes(password || undefined, disableCode || undefined);
|
||||
setBackupCodes(response.backup_codes);
|
||||
toast.success("New backup codes generated");
|
||||
toast.success(t('auth.newBackupCodesGenerated'));
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to generate backup codes");
|
||||
} finally {
|
||||
@@ -98,7 +100,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
toast.success(t('messages.copiedToClipboard', {item: label}));
|
||||
};
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
@@ -114,7 +116,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
a.download = 'termix-backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Backup codes downloaded");
|
||||
toast.success(t('auth.backupCodesDownloaded'));
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
@@ -131,50 +133,50 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
{t('auth.twoFactorTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Your account is protected with two-factor authentication
|
||||
{t('auth.twoFactorProtected')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertTitle>Enabled</AlertTitle>
|
||||
<AlertTitle>{t('common.enabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication is currently active on your account
|
||||
{t('auth.twoFactorActive')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Tabs defaultValue="disable" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="disable">Disable 2FA</TabsTrigger>
|
||||
<TabsTrigger value="backup">Backup Codes</TabsTrigger>
|
||||
<TabsTrigger value="disable">{t('auth.disable2FA')}</TabsTrigger>
|
||||
<TabsTrigger value="backup">{t('auth.backupCodes')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="disable" className="space-y-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertTitle>{t('common.warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Disabling two-factor authentication will make your account less secure
|
||||
{t('auth.disableTwoFactorWarning')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disable-password">Password or TOTP Code</Label>
|
||||
<Label htmlFor="disable-password">{t('auth.passwordOrTotpCode')}</Label>
|
||||
<Input
|
||||
id="disable-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('placeholders.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<p className="text-sm text-muted-foreground">{t('auth.or')}</p>
|
||||
<Input
|
||||
id="disable-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
placeholder={t('placeholders.totpCode')}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
@@ -186,29 +188,29 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onClick={handleDisable}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Disable Two-Factor Authentication
|
||||
{t('auth.disableTwoFactor')}
|
||||
</Button>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="backup" className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate new backup codes if you've lost your existing ones
|
||||
{t('auth.generateNewBackupCodesText')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="backup-password">Password or TOTP Code</Label>
|
||||
<Label htmlFor="backup-password">{t('auth.passwordOrTotpCode')}</Label>
|
||||
<Input
|
||||
id="backup-password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
placeholder={t('placeholders.enterPassword')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">Or</p>
|
||||
<p className="text-sm text-muted-foreground">{t('auth.or')}</p>
|
||||
<Input
|
||||
id="backup-code"
|
||||
type="text"
|
||||
placeholder="6-digit TOTP code"
|
||||
placeholder={t('placeholders.totpCode')}
|
||||
maxLength={6}
|
||||
value={disableCode}
|
||||
onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))}
|
||||
@@ -219,20 +221,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onClick={handleGenerateNewBackupCodes}
|
||||
disabled={loading || (!password && !disableCode)}
|
||||
>
|
||||
Generate New Backup Codes
|
||||
{t('auth.generateNewBackupCodes')}
|
||||
</Button>
|
||||
|
||||
{backupCodes.length > 0 && (
|
||||
<div className="space-y-2 mt-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Your Backup Codes</Label>
|
||||
<Label>{t('auth.yourBackupCodes')}</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={downloadBackupCodes}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download
|
||||
{t('auth.download')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 bg-muted rounded-lg font-mono text-sm">
|
||||
@@ -248,7 +250,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -261,9 +263,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Set Up Two-Factor Authentication</CardTitle>
|
||||
<CardTitle>{t('auth.setupTwoFactorTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
Step 1: Scan the QR code with your authenticator app
|
||||
{t('auth.step1ScanQR')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -272,7 +274,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Manual Entry Code</Label>
|
||||
<Label>{t('auth.manualEntryCode')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={secret}
|
||||
@@ -288,12 +290,12 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If you can't scan the QR code, enter this code manually in your authenticator app
|
||||
{t('auth.cannotScanQRText')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={() => setSetupStep("verify")} className="w-full">
|
||||
Next: Verify Code
|
||||
{t('auth.nextVerifyCode')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -304,14 +306,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Verify Your Authenticator</CardTitle>
|
||||
<CardTitle>{t('auth.verifyAuthenticator')}</CardTitle>
|
||||
<CardDescription>
|
||||
Step 2: Enter the 6-digit code from your authenticator app
|
||||
{t('auth.step2EnterCode')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="verify-code">Verification Code</Label>
|
||||
<Label htmlFor="verify-code">{t('auth.verificationCode')}</Label>
|
||||
<Input
|
||||
id="verify-code"
|
||||
type="text"
|
||||
@@ -326,7 +328,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -337,14 +339,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
onClick={() => setSetupStep("qr")}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
{t('auth.back')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleVerifyCode}
|
||||
disabled={loading || verificationCode.length !== 6}
|
||||
className="flex-1"
|
||||
>
|
||||
{loading ? "Verifying..." : "Verify and Enable"}
|
||||
{loading ? t('interface.verifying') : t('auth.verifyAndEnable')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -356,17 +358,17 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Save Your Backup Codes</CardTitle>
|
||||
<CardTitle>{t('auth.saveBackupCodesTitle')}</CardTitle>
|
||||
<CardDescription>
|
||||
Step 3: Store these codes in a safe place
|
||||
{t('auth.step3StoreCodesSecurely')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertTitle>{t('common.important')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.
|
||||
{t('auth.importantBackupCodesText')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -393,7 +395,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
</div>
|
||||
|
||||
<Button onClick={handleComplete} className="w-full">
|
||||
Complete Setup
|
||||
{t('auth.completeSetup')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -405,23 +407,23 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
Two-Factor Authentication
|
||||
{t('auth.twoFactorTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account
|
||||
{t('auth.addExtraSecurityLayer')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Not Enabled</AlertTitle>
|
||||
<AlertTitle>{t('common.notEnabled')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.
|
||||
{t('auth.notEnabledText')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Button onClick={handleSetupStart} disabled={loading} className="w-full">
|
||||
{loading ? "Setting up..." : "Enable Two-Factor Authentication"}
|
||||
{loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -10,12 +10,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>
|
||||
|
||||
Reference in New Issue
Block a user