Translation update

This commit is contained in:
LukeGus
2025-09-02 22:44:19 -05:00
parent df6c79b89b
commit d8f071bb15
9 changed files with 360 additions and 514 deletions

View File

@@ -39,6 +39,7 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English and Chinese
# Planned Features
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc

View File

@@ -1,6 +1,6 @@
import express from 'express';
import {db} from '../db/index.js';
import {users, settings} from '../db/schema.js';
import {users, settings, sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts} from '../db/schema.js';
import {eq, and} from 'drizzle-orm';
import chalk from 'chalk';
import bcrypt from 'bcryptjs';
@@ -377,10 +377,10 @@ router.get('/oidc/callback', async (req, res) => {
let userInfo: any = null;
let userInfoUrls: string[] = [];
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl);
@@ -464,17 +464,17 @@ router.get('/oidc/callback', async (req, res) => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
const identifier = getNestedValue(userInfo, config.identifier_path) ||
userInfo[config.identifier_path] ||
userInfo.sub ||
userInfo.email ||
userInfo.preferred_username;
const name = getNestedValue(userInfo, config.name_path) ||
userInfo[config.name_path] ||
userInfo.name ||
userInfo.given_name ||
identifier;
const identifier = getNestedValue(userInfo, config.identifier_path) ||
userInfo[config.identifier_path] ||
userInfo.sub ||
userInfo.email ||
userInfo.preferred_username;
const name = getNestedValue(userInfo, config.name_path) ||
userInfo[config.name_path] ||
userInfo.name ||
userInfo.given_name ||
identifier;
if (!identifier) {
logger.error(`Identifier not found at path: ${config.identifier_path}`);
@@ -1007,7 +1007,7 @@ router.post('/totp/verify-login', async (req, res) => {
}
const jwtSecret = process.env.JWT_SECRET || 'secret';
try {
const decoded = jwt.verify(temp_token, jwtSecret) as any;
if (!decoded.pending_totp) {
@@ -1020,7 +1020,7 @@ router.post('/totp/verify-login', async (req, res) => {
}
const userRecord = user[0];
if (!userRecord.totp_enabled || !userRecord.totp_secret) {
return res.status(400).json({error: 'TOTP not enabled for this user'});
}
@@ -1035,11 +1035,11 @@ router.post('/totp/verify-login', async (req, res) => {
if (!verified) {
const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : [];
const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) {
return res.status(401).json({error: 'Invalid TOTP code'});
}
backupCodes.splice(backupIndex, 1);
await db.update(users)
.set({totp_backup_codes: JSON.stringify(backupCodes)})
@@ -1066,7 +1066,7 @@ router.post('/totp/verify-login', async (req, res) => {
// POST /users/totp/setup
router.post('/totp/setup', authenticateJWT, async (req, res) => {
const userId = (req as any).userId;
try {
const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) {
@@ -1074,7 +1074,7 @@ router.post('/totp/setup', authenticateJWT, async (req, res) => {
}
const userRecord = user[0];
if (userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is already enabled'});
}
@@ -1118,7 +1118,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
}
const userRecord = user[0];
if (userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is already enabled'});
}
@@ -1138,7 +1138,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
return res.status(401).json({error: 'Invalid TOTP code'});
}
const backupCodes = Array.from({length: 8}, () =>
const backupCodes = Array.from({length: 8}, () =>
Math.random().toString(36).substring(2, 10).toUpperCase()
);
@@ -1177,7 +1177,7 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => {
}
const userRecord = user[0];
if (!userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is not enabled'});
}
@@ -1235,7 +1235,7 @@ router.post('/totp/backup-codes', authenticateJWT, async (req, res) => {
}
const userRecord = user[0];
if (!userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is not enabled'});
}
@@ -1260,7 +1260,7 @@ router.post('/totp/backup-codes', authenticateJWT, async (req, res) => {
return res.status(400).json({error: 'Authentication required'});
}
const backupCodes = Array.from({length: 8}, () =>
const backupCodes = Array.from({length: 8}, () =>
Math.random().toString(36).substring(2, 10).toUpperCase()
);
@@ -1311,10 +1311,15 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => {
const targetUserId = targetUser[0].id;
try {
db.$client.prepare('DELETE FROM file_manager_recent WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM file_manager_pinned WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM file_manager_shortcuts WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM ssh_data WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM config_editor_recent WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM config_editor_pinned WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM config_editor_shortcuts WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM shared_hosts WHERE original_user_id = ? OR shared_with_user_id = ?').run(targetUserId, targetUserId);
await db.delete(fileManagerRecent).where(eq(fileManagerRecent.userId, targetUserId));
await db.delete(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId));
await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId));
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
} catch (cleanupError) {
logger.error(`Cleanup failed for user ${username}:`, cleanupError);
}

View File

@@ -17,15 +17,16 @@ import {
} from "@/components/ui/table.tsx";
import {Shield, Trash2, Users} from "lucide-react";
import {toast} from "sonner";
import {
getOIDCConfig,
getRegistrationAllowed,
getUserList,
updateRegistrationAllowed,
updateOIDCConfig,
makeUserAdmin,
removeAdminStatus,
deleteUser
import {useTranslation} from "react-i18next";
import {
getOIDCConfig,
getRegistrationAllowed,
getUserList,
updateRegistrationAllowed,
updateOIDCConfig,
makeUserAdmin,
removeAdminStatus,
deleteUser
} from "@/ui/main-axios.ts";
function getCookie(name: string) {
@@ -40,6 +41,7 @@ interface AdminSettingsProps {
}
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
const {t} = useTranslation();
const {state: sidebarState} = useSidebar();
const [allowRegistration, setAllowRegistration] = React.useState(true);
@@ -124,7 +126,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
if (missing.length > 0) {
setOidcError(`Missing required fields: ${missing.join(', ')}`);
setOidcError(t('admin.missingRequiredFields', { fields: missing.join(', ') }));
setOidcLoading(false);
return;
}
@@ -132,9 +134,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const jwt = getCookie("jwt");
try {
await updateOIDCConfig(oidcConfig);
toast.success("OIDC configuration updated successfully!");
toast.success(t('admin.oidcConfigurationUpdated'));
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
setOidcError(err?.response?.data?.error || t('admin.failedToUpdateOidcConfig'));
} finally {
setOidcLoading(false);
}
@@ -152,39 +154,39 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
toast.success(`User ${newAdminUsername} is now an admin`);
toast.success(t('admin.userIsNowAdmin', { username: newAdminUsername }));
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
setMakeAdminError(err?.response?.data?.error || t('admin.failedToMakeUserAdmin'));
} finally {
setMakeAdminLoading(false);
}
};
const handleRemoveAdminStatus = async (username: string) => {
if (!confirm(`Remove admin status from ${username}?`)) return;
if (!confirm(t('admin.removeAdminStatus', { username }))) return;
const jwt = getCookie("jwt");
try {
await removeAdminStatus(username);
toast.success(`Admin status removed from ${username}`);
toast.success(t('admin.adminStatusRemoved', { username }));
fetchUsers();
} catch (err: any) {
console.error('Failed to remove admin status:', err);
toast.error('Failed to remove admin status');
toast.error(t('admin.failedToRemoveAdminStatus'));
}
};
const handleDeleteUser = async (username: string) => {
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
if (!confirm(t('admin.deleteUser', { username }))) return;
const jwt = getCookie("jwt");
try {
await deleteUser(username);
toast.success(`User ${username} deleted successfully`);
toast.success(t('admin.userDeletedSuccessfully', { username }));
fetchUsers();
} catch (err: any) {
console.error('Failed to delete user:', err);
toast.error('Failed to delete user');
toast.error(t('admin.failedToDeleteUser'));
}
};
@@ -204,7 +206,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
<div className="h-full w-full flex flex-col">
<div className="flex items-center justify-between px-3 pt-2 pb-2">
<h1 className="font-bold text-lg">Admin Settings</h1>
<h1 className="font-bold text-lg">{t('admin.title')}</h1>
</div>
<Separator className="p-0.25 w-full"/>
@@ -213,7 +215,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
General
{t('admin.general')}
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
@@ -221,97 +223,96 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
{t('admin.users')}
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
{t('admin.adminManagement')}
</TabsTrigger>
</TabsList>
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<h3 className="text-lg font-semibold">{t('admin.userRegistration')}</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
disabled={regLoading}/>
Allow new account registration
{t('admin.allowNewAccountRegistration')}
</label>
</div>
</TabsContent>
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
<p className="text-sm text-muted-foreground">Configure external identity provider for
OIDC/OAuth2 authentication.</p>
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Label htmlFor="client_id">{t('admin.clientId')}</Label>
<Input id="client_id" value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id" required/>
placeholder={t('placeholders.clientId')} required/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret" required/>
placeholder={t('placeholders.clientSecret')} required/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
<Input id="authorization_url" value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
placeholder={t('placeholders.authUrl')}
required/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label>
<Input id="issuer_url" value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/" required/>
placeholder={t('placeholders.redirectUrl')} required/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Label htmlFor="token_url">{t('admin.tokenUrl')}</Label>
<Input id="token_url" value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/" required/>
placeholder={t('placeholders.tokenUrl')} required/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label>
<Input id="identifier_path" value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub" required/>
placeholder={t('placeholders.userIdField')} required/>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Label htmlFor="name_path">{t('admin.displayNamePath')}</Label>
<Input id="name_path" value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name" required/>
placeholder={t('placeholders.usernameField')} required/>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Label htmlFor="scopes">{t('admin.scopes')}</Label>
<Input id="scopes" value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
placeholder="openid email profile" required/>
placeholder={t('placeholders.scopes')} required/>
</div>
<div className="space-y-2">
<Label htmlFor="userinfo_url">Override User Info URL (not required)</Label>
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label>
<Input id="userinfo_url" value={oidcConfig.userinfo_url}
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)}
placeholder="https://your-provider.com/application/o/userinfo/"/>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" className="flex-1"
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
<Button type="button" variant="outline" onClick={() => setOidcConfig({
client_id: '',
client_secret: '',
@@ -322,7 +323,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
name_path: 'name',
scopes: 'openid email profile',
userinfo_url: ''
})}>Reset</Button>
})}>{t('admin.reset')}</Button>
</div>
</form>
</div>
@@ -331,20 +332,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
<TableHead className="px-4">{t('admin.username')}</TableHead>
<TableHead className="px-4">{t('admin.type')}</TableHead>
<TableHead className="px-4">{t('admin.actions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -354,11 +355,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={() => handleDeleteUser(user.username)}
@@ -378,23 +379,23 @@ 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={handleMakeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
<div className="flex gap-2">
<Input id="new-admin-username" value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin" required/>
placeholder={t('admin.enterUsernameToMakeAdmin')} required/>
<Button type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
@@ -403,14 +404,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>
@@ -419,16 +420,16 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
</TableCell>
<TableCell
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
<TableCell className="px-4">
<Button variant="ghost" size="sm"
onClick={() => handleRemoveAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
<Shield className="h-4 w-4"/>
Remove Admin
{t('admin.removeAdminButton')}
</Button>
</TableCell>
</TableRow>

View File

@@ -21,6 +21,7 @@ import {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
import {toast} from "sonner";
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
import {useTranslation} from "react-i18next";
interface SSHHost {
id: number;
@@ -51,6 +52,7 @@ interface SSHManagerHostEditorProps {
}
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
@@ -128,7 +130,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (!data.password || data.password.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password is required when using password authentication",
message: t('hosts.passwordRequired'),
path: ['password']
});
}
@@ -136,14 +138,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (!data.key) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "SSH Private Key is required when using key authentication",
message: t('hosts.sshKeyRequired'),
path: ['key']
});
}
if (!data.keyType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Key Type is required when using key authentication",
message: t('hosts.keyTypeRequired'),
path: ['keyType']
});
}
@@ -153,7 +155,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (connection.endpointHost && !sshConfigurations.includes(connection.endpointHost)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Must select a valid SSH configuration from the list",
message: t('hosts.mustSelectValidSshConfig'),
path: ['tunnelConnections', index, 'endpointHost']
});
}
@@ -245,10 +247,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
if (editingHost) {
await updateSSHHost(editingHost.id, formData);
toast.success(`Host "${formData.name}" updated successfully!`);
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name }));
} else {
await createSSHHost(formData);
toast.success(`Host "${formData.name}" added successfully!`);
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
}
if (onFormSubmit) {
@@ -257,7 +259,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) {
toast.error('Failed to save host. Please try again.');
toast.error(t('hosts.failedToSaveHost'));
}
};
@@ -302,15 +304,15 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
}, [folderDropdownOpen]);
const keyTypeOptions = [
{value: 'auto', label: 'Auto-detect'},
{value: 'ssh-rsa', label: 'RSA'},
{value: 'ssh-ed25519', label: 'ED25519'},
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'},
{value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'},
{value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'},
{value: 'ssh-dss', label: 'DSA'},
{value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'},
{value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'},
{value: 'auto', label: t('hosts.autoDetect')},
{value: 'ssh-rsa', label: t('hosts.rsa')},
{value: 'ssh-ed25519', label: t('hosts.ed25519')},
{value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256')},
{value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384')},
{value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521')},
{value: 'ssh-dss', label: t('hosts.dsa')},
{value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256')},
{value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512')},
];
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
@@ -396,22 +398,22 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
<TabsList>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="terminal">Terminal</TabsTrigger>
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
<TabsTrigger value="file_manager">File Manager</TabsTrigger>
<TabsTrigger value="general">{t('hosts.general')}</TabsTrigger>
<TabsTrigger value="terminal">{t('hosts.terminal')}</TabsTrigger>
<TabsTrigger value="tunnel">{t('hosts.tunnel')}</TabsTrigger>
<TabsTrigger value="file_manager">{t('hosts.fileManager')}</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
<FormLabel className="mb-3 font-bold">{t('hosts.connectionDetails')}</FormLabel>
<div className="grid grid-cols-12 gap-4">
<FormField
control={form.control}
name="ip"
render={({field}) => (
<FormItem className="col-span-5">
<FormLabel>IP</FormLabel>
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
<FormControl>
<Input placeholder="127.0.0.1" {...field} />
<Input placeholder={t('placeholders.ipAddress')} {...field} />
</FormControl>
</FormItem>
)}
@@ -422,9 +424,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="port"
render={({field}) => (
<FormItem className="col-span-1">
<FormLabel>Port</FormLabel>
<FormLabel>{t('hosts.port')}</FormLabel>
<FormControl>
<Input placeholder="22" {...field} />
<Input placeholder={t('placeholders.port')} {...field} />
</FormControl>
</FormItem>
)}
@@ -435,24 +437,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="username"
render={({field}) => (
<FormItem className="col-span-6">
<FormLabel>Username</FormLabel>
<FormLabel>{t('hosts.username')}</FormLabel>
<FormControl>
<Input placeholder="username" {...field} />
<Input placeholder={t('placeholders.username')} {...field} />
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">Organization</FormLabel>
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.organization')}</FormLabel>
<div className="grid grid-cols-26 gap-4">
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem className="col-span-10">
<FormLabel>Name</FormLabel>
<FormLabel>{t('hosts.name')}</FormLabel>
<FormControl>
<Input placeholder="host name" {...field} />
<Input placeholder={t('placeholders.hostname')} {...field} />
</FormControl>
</FormItem>
)}
@@ -463,11 +465,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="folder"
render={({field}) => (
<FormItem className="col-span-10 relative">
<FormLabel>Folder</FormLabel>
<FormLabel>{t('hosts.folder')}</FormLabel>
<FormControl>
<Input
ref={folderInputRef}
placeholder="folder"
placeholder={t('placeholders.folder')}
className="min-h-[40px]"
autoComplete="off"
value={field.value}
@@ -508,7 +510,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="tags"
render={({field}) => (
<FormItem className="col-span-10 overflow-visible">
<FormLabel>Tags</FormLabel>
<FormLabel>{t('hosts.tags')}</FormLabel>
<FormControl>
<div
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
@@ -544,7 +546,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
field.onChange(field.value.slice(0, -1));
}
}}
placeholder="add tags (space to add)"
placeholder={t('hosts.addTagsSpaceToAdd')}
/>
</div>
</FormControl>
@@ -557,7 +559,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="pin"
render={({field}) => (
<FormItem className="col-span-6">
<FormLabel>Pin Connection</FormLabel>
<FormLabel>{t('hosts.pin')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -568,7 +570,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">Authentication</FormLabel>
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.authentication')}</FormLabel>
<Tabs
value={authTab}
onValueChange={(value) => {
@@ -578,8 +580,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList>
<TabsTrigger value="password">Password</TabsTrigger>
<TabsTrigger value="key">Key</TabsTrigger>
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
</TabsList>
<TabsContent value="password">
<FormField
@@ -587,9 +589,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormLabel>{t('hosts.password')}</FormLabel>
<FormControl>
<Input type="password" placeholder="password" {...field} />
<Input type="password" placeholder={t('placeholders.password')} {...field} />
</FormControl>
</FormItem>
)}
@@ -602,7 +604,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="key"
render={({field}) => (
<FormItem className="col-span-4 overflow-hidden min-w-0">
<FormLabel>SSH Private Key</FormLabel>
<FormLabel>{t('hosts.sshPrivateKey')}</FormLabel>
<FormControl>
<div className="relative min-w-0">
<input
@@ -621,8 +623,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
>
<span className="block w-full truncate"
title={field.value?.name || 'Upload'}>
{field.value ? (editingHost ? 'Update Key' : field.value.name) : 'Upload'}
title={field.value?.name || t('hosts.upload')}>
{field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
</span>
</Button>
</div>
@@ -635,10 +637,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="keyPassword"
render={({field}) => (
<FormItem className="col-span-8">
<FormLabel>Key Password</FormLabel>
<FormLabel>{t('hosts.keyPassword')}</FormLabel>
<FormControl>
<Input
placeholder="key password"
placeholder={t('placeholders.keyPassword')}
type="password"
{...field}
/>
@@ -651,7 +653,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="keyType"
render={({field}) => (
<FormItem className="relative col-span-3">
<FormLabel>Key Type</FormLabel>
<FormLabel>{t('hosts.keyType')}</FormLabel>
<FormControl>
<div className="relative">
<Button
@@ -661,7 +663,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
>
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || "Auto-detect"}
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('hosts.autoDetect')}
</Button>
{keyTypeDropdownOpen && (
<div
@@ -702,7 +704,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="enableTerminal"
render={({field}) => (
<FormItem>
<FormLabel>Enable Terminal</FormLabel>
<FormLabel>{t('hosts.enableTerminal')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -710,7 +712,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/>
</FormControl>
<FormDescription>
Enable/disable host visibility in Terminal tab.
{t('hosts.enableTerminalDesc')}
</FormDescription>
</FormItem>
)}
@@ -722,7 +724,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="enableTunnel"
render={({field}) => (
<FormItem>
<FormLabel>Enable Tunnel</FormLabel>
<FormLabel>{t('hosts.enableTunnel')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -730,7 +732,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/>
</FormControl>
<FormDescription>
Enable/disable host visibility in Tunnel tab.
{t('hosts.enableTunnelDesc')}
</FormDescription>
</FormItem>
)}
@@ -740,44 +742,40 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
<>
<Alert className="mt-4">
<AlertDescription>
<strong>Sshpass Required For Password Authentication</strong>
<strong>{t('hosts.sshpassRequired')}</strong>
<div>
For password-based SSH authentication, sshpass must be installed on
both the local and remote servers. Install with: <code
{t('hosts.sshpassRequiredDesc')} <code
className="bg-muted px-1 rounded inline">sudo apt install
sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
</div>
<div className="mt-2">
<strong>Other installation methods:</strong>
<div> CentOS/RHEL/Fedora: <code
<strong>{t('hosts.otherInstallMethods')}</strong>
<div> {t('hosts.centosRhelFedora')} <code
className="bg-muted px-1 rounded inline">sudo yum install
sshpass</code> or <code
className="bg-muted px-1 rounded inline">sudo dnf install
sshpass</code></div>
<div> macOS: <code className="bg-muted px-1 rounded inline">brew
<div> {t('hosts.macos')} <code className="bg-muted px-1 rounded inline">brew
install hudochenkov/sshpass/sshpass</code></div>
<div> Windows: Use WSL or consider SSH key authentication</div>
<div> {t('hosts.windows')}</div>
</div>
</AlertDescription>
</Alert>
<Alert className="mt-4">
<AlertDescription>
<strong>SSH Server Configuration Required</strong>
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
<strong>{t('hosts.sshServerConfigRequired')}</strong>
<div>{t('hosts.sshServerConfigDesc')}</div>
<div> <code className="bg-muted px-1 rounded inline">GatewayPorts
yes</code> (bind remote ports)
yes</code> {t('hosts.gatewayPortsYes')}
</div>
<div> <code className="bg-muted px-1 rounded inline">AllowTcpForwarding
yes</code> (port forwarding)
yes</code> {t('hosts.allowTcpForwardingYes')}
</div>
<div> <code className="bg-muted px-1 rounded inline">PermitRootLogin
yes</code> (if using root)
yes</code> {t('hosts.permitRootLoginYes')}
</div>
<div className="mt-2">Edit <code
className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and
restart SSH: <code className="bg-muted px-1 rounded inline">sudo
systemctl restart sshd</code></div>
<div className="mt-2">{t('hosts.editSshConfig')}</div>
</AlertDescription>
</Alert>
@@ -786,7 +784,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="tunnelConnections"
render={({field}) => (
<FormItem className="mt-4">
<FormLabel>Tunnel Connections</FormLabel>
<FormLabel>{t('hosts.tunnelConnections')}</FormLabel>
<FormControl>
<div className="space-y-4">
{field.value.map((connection, index) => (
@@ -794,7 +792,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
className="p-4 border rounded-lg bg-muted/50">
<div
className="flex items-center justify-between mb-3">
<h4 className="text-sm font-bold">Connection {index + 1}</h4>
<h4 className="text-sm font-bold">{t('hosts.connection')} {index + 1}</h4>
<Button
type="button"
variant="ghost"
@@ -804,7 +802,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
field.onChange(newConnections);
}}
>
Remove
{t('hosts.remove')}
</Button>
</div>
<div className="grid grid-cols-12 gap-4">
@@ -813,10 +811,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.sourcePort`}
render={({field: sourcePortField}) => (
<FormItem className="col-span-4">
<FormLabel>Source Port
(Source refers to the Current
Connection Details in the
General tab)</FormLabel>
<FormLabel>{t('hosts.sourcePort')}
{t('hosts.sourcePortDesc')}</FormLabel>
<FormControl>
<Input
placeholder="22" {...sourcePortField} />
@@ -829,8 +825,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.endpointPort`}
render={({field: endpointPortField}) => (
<FormItem className="col-span-4">
<FormLabel>Endpoint Port
(Remote)</FormLabel>
<FormLabel>{t('hosts.endpointPort')}</FormLabel>
<FormControl>
<Input
placeholder="224" {...endpointPortField} />
@@ -844,14 +839,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
render={({field: endpointHostField}) => (
<FormItem
className="col-span-4 relative">
<FormLabel>Endpoint SSH
Configuration</FormLabel>
<FormLabel>{t('hosts.endpointSshConfig')}</FormLabel>
<FormControl>
<Input
ref={(el) => {
sshConfigInputRefs.current[index] = el;
}}
placeholder="endpoint ssh configuration"
placeholder={t('placeholders.sshConfig')}
className="min-h-[40px]"
autoComplete="off"
value={endpointHostField.value}
@@ -898,12 +892,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
</div>
<p className="text-sm text-muted-foreground mt-2">
This tunnel will forward traffic from
port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on
the source machine (current connection details
in general tab) to
port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on
the endpoint machine.
{t('hosts.tunnelForwardDescription', {
sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22',
endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224'
})}
</p>
<div className="grid grid-cols-12 gap-4 mt-4">
@@ -912,14 +904,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.maxRetries`}
render={({field: maxRetriesField}) => (
<FormItem className="col-span-4">
<FormLabel>Max Retries</FormLabel>
<FormLabel>{t('hosts.maxRetries')}</FormLabel>
<FormControl>
<Input
placeholder="3" {...maxRetriesField} />
placeholder={t('placeholders.maxRetries')} {...maxRetriesField} />
</FormControl>
<FormDescription>
Maximum number of retry attempts
for tunnel connection.
{t('hosts.maxRetriesDescription')}
</FormDescription>
</FormItem>
)}
@@ -929,15 +920,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.retryInterval`}
render={({field: retryIntervalField}) => (
<FormItem className="col-span-4">
<FormLabel>Retry Interval
(seconds)</FormLabel>
<FormLabel>{t('hosts.retryInterval')}</FormLabel>
<FormControl>
<Input
placeholder="10" {...retryIntervalField} />
placeholder={t('placeholders.retryInterval')} {...retryIntervalField} />
</FormControl>
<FormDescription>
Time to wait between retry
attempts.
{t('hosts.retryIntervalDescription')}
</FormDescription>
</FormItem>
)}
@@ -947,8 +936,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name={`tunnelConnections.${index}.autoStart`}
render={({field}) => (
<FormItem className="col-span-4">
<FormLabel>Auto Start on Container
Launch</FormLabel>
<FormLabel>{t('hosts.autoStartContainer')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -956,8 +944,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/>
</FormControl>
<FormDescription>
Automatically start this tunnel
when the container launches.
{t('hosts.autoStartDesc')}
</FormDescription>
</FormItem>
)}
@@ -979,7 +966,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
}]);
}}
>
Add Tunnel Connection
{t('hosts.addConnection')}
</Button>
</div>
</FormControl>
@@ -997,7 +984,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="enableFileManager"
render={({field}) => (
<FormItem>
<FormLabel>Enable File Manager</FormLabel>
<FormLabel>{t('hosts.enableFileManager')}</FormLabel>
<FormControl>
<Switch
checked={field.value}
@@ -1005,7 +992,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
/>
</FormControl>
<FormDescription>
Enable/disable host visibility in File Manager tab.
{t('hosts.enableFileManagerDesc')}
</FormDescription>
</FormItem>
)}
@@ -1018,12 +1005,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
name="defaultPath"
render={({field}) => (
<FormItem>
<FormLabel>Default Path</FormLabel>
<FormLabel>{t('hosts.defaultPath')}</FormLabel>
<FormControl>
<Input placeholder="/home" {...field} />
<Input placeholder={t('placeholders.homePath')} {...field} />
</FormControl>
<FormDescription>Set default directory shown when connected via
File Manager</FormDescription>
<FormDescription>{t('hosts.defaultPathDesc')}</FormDescription>
</FormItem>
)}
/>
@@ -1042,7 +1028,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
transform: 'translateY(8px)'
}}
>
{editingHost ? "Update Host" : "Add Host"}
{editingHost ? t('hosts.updateHost') : t('hosts.addHost')}
</Button>
</footer>
</form>

View File

@@ -8,6 +8,7 @@ import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/co
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
import {toast} from "sonner";
import {useTranslation} from "react-i18next";
import {
Edit,
Trash2,
@@ -48,6 +49,7 @@ interface SSHManagerHostViewerProps {
}
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const {t} = useTranslation();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -65,21 +67,21 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
setHosts(data);
setError(null);
} catch (err) {
setError('Failed to load hosts');
setError(t('hosts.failedToLoadHosts'));
} finally {
setLoading(false);
}
};
const handleDelete = async (hostId: number, hostName: string) => {
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) {
try {
await deleteSSHHost(hostId);
toast.success(`Host "${hostName}" deleted successfully!`);
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (err) {
toast.error('Failed to delete host');
toast.error(t('hosts.failedToDeleteHost'));
}
}
};
@@ -100,35 +102,35 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const data = JSON.parse(text);
if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
throw new Error('JSON must contain a "hosts" array or be an array of hosts');
throw new Error(t('hosts.jsonMustContainHosts'));
}
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
if (hostsArray.length === 0) {
throw new Error('No hosts found in JSON file');
throw new Error(t('hosts.noHostsInJson'));
}
if (hostsArray.length > 100) {
throw new Error('Maximum 100 hosts allowed per import');
throw new Error(t('hosts.maxHostsAllowed'));
}
const result = await bulkImportSSHHosts(hostsArray);
if (result.success > 0) {
toast.success(`Import completed: ${result.success} hosts imported successfully${result.failed > 0 ? `, ${result.failed} failed` : ''}`);
toast.success(t('hosts.importCompleted', { success: result.success, failed: result.failed }));
if (result.errors.length > 0) {
toast.error(`Import errors: ${result.errors.join(', ')}`);
}
await fetchHosts();
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} else {
toast.error(`Import failed: ${result.errors.join(', ')}`);
toast.error(t('hosts.importFailed') + `: ${result.errors.join(', ')}`);
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
toast.error(`Import error: ${errorMessage}`);
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson');
toast.error(t('hosts.importError') + `: ${errorMessage}`);
} finally {
setImporting(false);
event.target.value = '';
@@ -168,7 +170,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
const grouped: { [key: string]: SSHHost[] } = {};
filteredAndSortedHosts.forEach(host => {
const folder = host.folder || 'Uncategorized';
const folder = host.folder || t('hosts.uncategorized');
if (!grouped[folder]) {
grouped[folder] = [];
}
@@ -176,8 +178,8 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
});
const sortedFolders = Object.keys(grouped).sort((a, b) => {
if (a === 'Uncategorized') return -1;
if (b === 'Uncategorized') return 1;
if (a === t('hosts.uncategorized')) return -1;
if (b === t('hosts.uncategorized')) return 1;
return a.localeCompare(b);
});
@@ -194,7 +196,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading hosts...</p>
<p className="text-muted-foreground">{t('hosts.loadingHosts')}</p>
</div>
</div>
);
@@ -206,7 +208,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="text-center">
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={fetchHosts} variant="outline">
Retry
{t('hosts.retry')}
</Button>
</div>
</div>
@@ -218,9 +220,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex items-center justify-center h-full">
<div className="text-center">
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
<p className="text-muted-foreground mb-4">
You haven't added any SSH hosts yet. Click "Add Host" to get started.
{t('hosts.noHostsMessage')}
</p>
</div>
</div>
@@ -231,9 +233,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between mb-2">
<div>
<h2 className="text-xl font-semibold">SSH Hosts</h2>
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
<p className="text-muted-foreground">
{filteredAndSortedHosts.length} hosts
{t('hosts.hostsCount', { count: filteredAndSortedHosts.length })}
</p>
</div>
<div className="flex items-center gap-2">
@@ -247,15 +249,15 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
onClick={() => document.getElementById('json-import-input')?.click()}
disabled={importing}
>
{importing ? 'Importing...' : 'Import JSON'}
{importing ? t('hosts.importing') : t('hosts.importJson')}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom"
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
<div className="space-y-2">
<p className="font-semibold text-sm">Import SSH Hosts from JSON</p>
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p>
<p className="text-xs text-muted-foreground">
Upload a JSON file to bulk import multiple SSH hosts (max 100).
{t('hosts.importJsonDesc')}
</p>
</div>
</TooltipContent>
@@ -323,7 +325,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
URL.revokeObjectURL(url);
}}
>
Download Sample
{t('hosts.downloadSample')}
</Button>
<Button
@@ -333,13 +335,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
window.open('https://docs.termix.site/json-import', '_blank');
}}
>
Format Guide
{t('hosts.formatGuide')}
</Button>
<div className="w-px h-6 bg-border mx-2"/>
<Button onClick={fetchHosts} variant="outline" size="sm">
Refresh
{t('hosts.refresh')}
</Button>
</div>
</div>
@@ -355,7 +357,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
<Input
placeholder="Search hosts by name, username, IP, folder, tags..."
placeholder={t('placeholders.searchHosts')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
@@ -451,13 +453,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
{host.enableTerminal && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Terminal className="h-2 w-2 mr-0.5"/>
Terminal
{t('hosts.terminalBadge')}
</Badge>
)}
{host.enableTunnel && (
<Badge variant="outline" className="text-xs px-1 py-0">
<Network className="h-2 w-2 mr-0.5"/>
Tunnel
{t('hosts.tunnelBadge')}
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
<span
className="ml-0.5">({host.tunnelConnections.length})</span>
@@ -467,7 +469,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
{host.enableFileManager && (
<Badge variant="outline" className="text-xs px-1 py-0">
<FileEdit className="h-2 w-2 mr-0.5"/>
File Manager
{t('hosts.fileManagerBadge')}
</Badge>
)}
</div>

View File

@@ -4,6 +4,8 @@ import {Button} from "../../components/ui/button.tsx";
import {Input} from "../../components/ui/input.tsx";
import {Label} from "../../components/ui/label.tsx";
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
import {useTranslation} from "react-i18next";
import {LanguageSwitcher} from "../../components/LanguageSwitcher";
import {
registerUser,
loginUser,
@@ -55,6 +57,7 @@ export function HomepageAuth({
onAuthSuccess,
...props
}: HomepageAuthProps) {
const {t} = useTranslation();
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
const [localUsername, setLocalUsername] = useState("");
const [password, setPassword] = useState("");
@@ -116,7 +119,7 @@ export function HomepageAuth({
}
setDbError(null);
}).catch(() => {
setDbError("Could not connect to the database. Please try again later.");
setDbError(t('errors.databaseConnection'));
});
}, [setDbError]);
@@ -126,7 +129,7 @@ export function HomepageAuth({
setLoading(true);
if (!localUsername.trim()) {
setError("Username is required");
setError(t('errors.requiredField'));
setLoading(false);
return;
}
@@ -137,12 +140,12 @@ export function HomepageAuth({
res = await loginUser(localUsername, password);
} else {
if (password !== signupConfirmPassword) {
setError("Passwords do not match");
setError(t('errors.passwordMismatch'));
setLoading(false);
return;
}
if (password.length < 6) {
setError("Password must be at least 6 characters long");
setError(t('errors.minLength', {min: 6}));
setLoading(false);
return;
}
@@ -159,7 +162,7 @@ export function HomepageAuth({
}
if (!res || !res.token) {
throw new Error('No token received from login');
throw new Error(t('errors.noTokenReceived'));
}
setCookie("jwt", res.token);
@@ -186,7 +189,7 @@ export function HomepageAuth({
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Unknown error");
setError(err?.response?.data?.error || err?.message || t('errors.unknownError'));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
@@ -194,7 +197,7 @@ export function HomepageAuth({
setUserId(null);
setCookie("jwt", "", -1);
if (err?.response?.data?.error?.includes("Database")) {
setDbError("Could not connect to the database. Please try again later.");
setDbError(t('errors.databaseConnection'));
} else {
setDbError(null);
}
@@ -211,7 +214,7 @@ export function HomepageAuth({
setResetStep("verify");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset'));
} finally {
setResetLoading(false);
}
@@ -226,7 +229,7 @@ export function HomepageAuth({
setResetStep("newPassword");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to verify reset code");
setError(err?.response?.data?.error || t('errors.failedVerifyCode'));
} finally {
setResetLoading(false);
}
@@ -237,13 +240,13 @@ export function HomepageAuth({
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
setError(t('errors.passwordMismatch'));
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError("Password must be at least 6 characters long");
setError(t('errors.minLength', {min: 6}));
setResetLoading(false);
return;
}
@@ -260,7 +263,7 @@ export function HomepageAuth({
setResetSuccess(true);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to complete password reset");
setError(err?.response?.data?.error || t('errors.failedCompleteReset'));
} finally {
setResetLoading(false);
}
@@ -285,7 +288,7 @@ export function HomepageAuth({
async function handleTOTPVerification() {
if (totpCode.length !== 6) {
setError("Please enter a 6-digit code");
setError(t('auth.enterCode'));
return;
}
@@ -296,7 +299,7 @@ export function HomepageAuth({
const res = await verifyTOTPLogin(totpTempToken, totpCode);
if (!res || !res.token) {
throw new Error('No token received from TOTP verification');
throw new Error(t('errors.noTokenReceived'));
}
setCookie("jwt", res.token);
@@ -318,7 +321,7 @@ export function HomepageAuth({
setTotpCode("");
setTotpTempToken("");
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Invalid TOTP code");
setError(err?.response?.data?.error || err?.message || t('errors.invalidTotpCode'));
} finally {
setTotpLoading(false);
}
@@ -332,12 +335,12 @@ export function HomepageAuth({
const {auth_url: authUrl} = authResponse;
if (!authUrl || authUrl === 'undefined') {
throw new Error('Invalid authorization URL received from backend');
throw new Error(t('errors.invalidAuthUrl'));
}
window.location.replace(authUrl);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
setError(err?.response?.data?.error || err?.message || t('errors.failedOidcLogin'));
setOidcLoading(false);
}
}
@@ -349,7 +352,7 @@ export function HomepageAuth({
const error = urlParams.get('error');
if (error) {
setError(`OIDC authentication failed: ${error}`);
setError(`${t('errors.oidcAuthFailed')}: ${error}`);
setOidcLoading(false);
window.history.replaceState({}, document.title, window.location.pathname);
return;
@@ -377,7 +380,7 @@ export function HomepageAuth({
window.history.replaceState({}, document.title, window.location.pathname);
})
.catch(err => {
setError("Failed to get user info after OIDC login");
setError(t('errors.failedUserInfo'));
setInternalLoggedIn(false);
setLoggedIn(false);
setIsAdmin(false);
@@ -412,39 +415,37 @@ export function HomepageAuth({
)}
{firstUser && !dbError && !internalLoggedIn && (
<Alert variant="default" className="mb-4">
<AlertTitle>First User</AlertTitle>
<AlertTitle>{t('auth.firstUser')}</AlertTitle>
<AlertDescription className="inline">
You are the first user and will be made an admin. You can view admin settings in the sidebar
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
{t('auth.firstUserMessage')}{" "}
<a
href="https://github.com/LukeGus/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800 inline"
>
GitHub issue
GitHub Issue
</a>.
</AlertDescription>
</Alert>
)}
{!registrationAllowed && !internalLoggedIn && (
<Alert variant="destructive" className="mb-4">
<AlertTitle>Registration Disabled</AlertTitle>
<AlertTitle>{t('auth.registerTitle')}</AlertTitle>
<AlertDescription>
New account registration is currently disabled by an admin. Please log in or contact an
administrator.
{t('messages.registrationDisabled')}
</AlertDescription>
</Alert>
)}
{totpRequired && (
<div className="flex flex-col gap-5">
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">Two-Factor Authentication</h2>
<p className="text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
<h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2>
<p className="text-muted-foreground">{t('auth.enterCode')}</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="totp-code">Authentication Code</Label>
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label>
<Input
id="totp-code"
type="text"
@@ -457,7 +458,7 @@ export function HomepageAuth({
autoComplete="one-time-code"
/>
<p className="text-xs text-muted-foreground text-center">
Or enter a backup code if you don't have access to your authenticator
{t('auth.backupCode')}
</p>
</div>
@@ -467,7 +468,7 @@ export function HomepageAuth({
disabled={totpLoading || totpCode.length < 6}
onClick={handleTOTPVerification}
>
{totpLoading ? Spinner : "Verify"}
{totpLoading ? Spinner : t('auth.verifyCode')}
</Button>
<Button
@@ -482,7 +483,7 @@ export function HomepageAuth({
setError(null);
}}
>
Cancel
{t('common.cancel')}
</Button>
</div>
)}
@@ -506,7 +507,7 @@ export function HomepageAuth({
aria-selected={tab === "login"}
disabled={loading || firstUser}
>
Login
{t('common.login')}
</button>
<button
type="button"
@@ -524,7 +525,7 @@ export function HomepageAuth({
aria-selected={tab === "signup"}
disabled={loading || !registrationAllowed}
>
Sign Up
{t('common.register')}
</button>
{oidcConfigured && (
<button
@@ -543,16 +544,16 @@ export function HomepageAuth({
aria-selected={tab === "external"}
disabled={oidcLoading}
>
External
{t('auth.external')}
</button>
)}
</div>
<div className="mb-6 text-center">
<h2 className="text-xl font-bold mb-1">
{tab === "login" ? "Login to your account" :
tab === "signup" ? "Create a new account" :
tab === "external" ? "Login with external provider" :
"Reset your password"}
{tab === "login" ? t('auth.loginTitle') :
tab === "signup" ? t('auth.registerTitle') :
tab === "external" ? t('auth.loginWithExternal') :
t('auth.forgotPassword')}
</h2>
</div>
@@ -561,7 +562,7 @@ export function HomepageAuth({
{tab === "external" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Login using your configured external identity provider</p>
<p>{t('auth.loginWithExternalDesc')}</p>
</div>
<Button
type="button"
@@ -569,7 +570,7 @@ export function HomepageAuth({
disabled={oidcLoading}
onClick={handleOIDCLogin}
>
{oidcLoading ? Spinner : "Login with External Provider"}
{oidcLoading ? Spinner : t('auth.loginWithExternal')}
</Button>
</>
)}
@@ -578,12 +579,11 @@ export function HomepageAuth({
{resetStep === "initiate" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your username to receive a password reset code. The code
will be logged in the docker container logs.</p>
<p>{t('auth.resetCodeDesc')}</p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-username">Username</Label>
<Label htmlFor="reset-username">{t('common.username')}</Label>
<Input
id="reset-username"
type="text"
@@ -600,7 +600,7 @@ export function HomepageAuth({
disabled={resetLoading || !localUsername.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
{resetLoading ? Spinner : t('auth.sendResetCode')}
</Button>
</div>
</>
@@ -609,12 +609,11 @@ export function HomepageAuth({
{resetStep === "verify" && (
<>o
<div className="text-center text-muted-foreground mb-4">
<p>Enter the 6-digit code from the docker container logs for
user: <strong>{localUsername}</strong></p>
<p>{t('auth.enterResetCode')} <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">Reset Code</Label>
<Label htmlFor="reset-code">{t('auth.resetCode')}</Label>
<Input
id="reset-code"
type="text"
@@ -633,7 +632,7 @@ export function HomepageAuth({
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : "Verify Code"}
{resetLoading ? Spinner : t('auth.verifyCodeButton')}
</Button>
<Button
type="button"
@@ -645,7 +644,7 @@ export function HomepageAuth({
setResetCode("");
}}
>
Back
{t('common.back')}
</Button>
</div>
</>
@@ -654,10 +653,9 @@ export function HomepageAuth({
{resetSuccess && (
<>
<Alert className="mb-4">
<AlertTitle>Success!</AlertTitle>
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle>
<AlertDescription>
Your password has been successfully reset! You can now log in
with your new password.
{t('auth.passwordResetSuccessDesc')}
</AlertDescription>
</Alert>
<Button
@@ -668,7 +666,7 @@ export function HomepageAuth({
resetPasswordState();
}}
>
Go to Login
{t('auth.goToLogin')}
</Button>
</>
)}
@@ -676,12 +674,11 @@ export function HomepageAuth({
{resetStep === "newPassword" && !resetSuccess && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for
user: <strong>{localUsername}</strong></p>
<p>{t('auth.enterNewPassword')} <strong>{localUsername}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label>
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
<Input
id="new-password"
type="password"
@@ -694,7 +691,7 @@ export function HomepageAuth({
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Label htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
<Input
id="confirm-password"
type="password"
@@ -712,7 +709,7 @@ export function HomepageAuth({
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : "Reset Password"}
{resetLoading ? Spinner : t('auth.resetPasswordButton')}
</Button>
<Button
type="button"
@@ -725,7 +722,7 @@ export function HomepageAuth({
setConfirmPassword("");
}}
>
Back
{t('common.back')}
</Button>
</div>
</>
@@ -736,7 +733,7 @@ export function HomepageAuth({
) : (
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="username">Username</Label>
<Label htmlFor="username">{t('common.username')}</Label>
<Input
id="username"
type="text"
@@ -748,14 +745,14 @@ export function HomepageAuth({
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t('common.password')}</Label>
<Input id="password" type="password" required className="h-11 text-base"
value={password} onChange={e => setPassword(e.target.value)}
disabled={loading || internalLoggedIn}/>
</div>
{tab === "signup" && (
<div className="flex flex-col gap-2">
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
<Input id="signup-confirm-password" type="password" required
className="h-11 text-base"
value={signupConfirmPassword}
@@ -765,7 +762,7 @@ export function HomepageAuth({
)}
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
disabled={loading || internalLoggedIn}>
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
{loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))}
</Button>
{tab === "login" && (
<Button type="button" variant="outline"
@@ -777,11 +774,20 @@ export function HomepageAuth({
clearFormFields();
}}
>
Reset Password
{t('auth.resetPasswordButton')}
</Button>
)}
</form>
)}
<div className="mt-6 pt-4 border-t border-[#303032]">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label>
</div>
<LanguageSwitcher />
</div>
</div>
</>
)}
{error && (

View File

@@ -69,7 +69,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
setError(null);
})
.catch(err => {
setError('Failed to fetch update information');
setError(t('common.failedToFetchUpdateInfo'));
})
.finally(() => setLoading(false));
}
@@ -96,9 +96,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
{versionInfo && versionInfo.status === 'requires_update' && (
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
<AlertTitle className="text-white">Update Available</AlertTitle>
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle>
<AlertDescription className="text-gray-300">
A new version ({versionInfo.version}) is available.
{t('common.newVersionAvailable', { version: versionInfo.version })}
</AlertDescription>
</Alert>
)}
@@ -117,7 +117,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
{error && (
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
<AlertTitle className="text-red-300">Error</AlertTitle>
<AlertTitle className="text-red-300">{t('common.error')}</AlertTitle>
<AlertDescription className="text-red-300">{error}</AlertDescription>
</Alert>
)}
@@ -135,7 +135,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
{release.isPrerelease && (
<span
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
Pre-release
{t('common.preRelease')}
</span>
)}
</div>
@@ -158,9 +158,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
<AlertTitle className="text-gray-300">{t('common.noReleases')}</AlertTitle>
<AlertDescription className="text-gray-400">
No releases found.
{t('common.noReleasesFound')}
</AlertDescription>
</Alert>
)}

View File

@@ -5,6 +5,7 @@ import {
File,
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
} from "lucide-react";
import { useTranslation } from 'react-i18next';
import {
Sidebar,
@@ -49,14 +50,7 @@ import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {
getOIDCConfig,
getUserList,
makeUserAdmin,
removeAdminStatus,
deleteUser,
deleteAccount
} from "@/ui/main-axios.ts";
import { deleteAccount } from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
@@ -112,26 +106,12 @@ export function LeftSidebar({
username,
children,
}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const { t } = useTranslation();
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState("");
const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null);
const [adminCount, setAdminCount] = React.useState(0);
const [users, setUsers] = React.useState<Array<{
id: string;
username: string;
is_admin: boolean;
is_oidc: boolean;
}>>([]);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [usersLoading, setUsersLoading] = React.useState(false);
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
@@ -161,33 +141,7 @@ export function LeftSidebar({
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
React.useEffect(() => {
if (adminSheetOpen) {
const jwt = getCookie("jwt");
if (jwt && isAdmin) {
getOIDCConfig().then(res => {
if (res) {
setOidcConfig(res);
}
}).catch((error) => {
});
fetchUsers();
}
} else {
const jwt = getCookie("jwt");
if (jwt && isAdmin) {
fetchAdminCount();
}
}
}, [adminSheetOpen, isAdmin]);
React.useEffect(() => {
if (!isAdmin) {
setAdminSheetOpen(false);
setUsers([]);
setAdminCount(0);
}
}, [isAdmin]);
const fetchHosts = React.useCallback(async () => {
try {
@@ -304,7 +258,7 @@ export function LeftSidebar({
setDeleteError(null);
if (!deletePassword.trim()) {
setDeleteError("Password is required");
setDeleteError(t('leftSidebar.passwordRequired'));
setDeleteLoading(false);
return;
}
@@ -315,103 +269,11 @@ export function LeftSidebar({
handleLogout();
} catch (err: any) {
setDeleteError(err?.response?.data?.error || "Failed to delete account");
setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount'));
setDeleteLoading(false);
}
};
const fetchUsers = async () => {
const jwt = getCookie("jwt");
if (!jwt || !isAdmin) {
return;
}
setUsersLoading(true);
try {
const response = await getUserList();
setUsers(response.users);
const adminUsers = response.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
} finally {
setUsersLoading(false);
}
};
const fetchAdminCount = async () => {
const jwt = getCookie("jwt");
if (!jwt || !isAdmin) {
return;
}
try {
const response = await getUserList();
const adminUsers = response.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
}
};
const handleMakeUserAdmin = async (e: React.FormEvent) => {
e.preventDefault();
if (!newAdminUsername.trim()) return;
if (!isAdmin) {
return;
}
setMakeAdminLoading(true);
setMakeAdminError(null);
setMakeAdminSuccess(null);
const jwt = getCookie("jwt");
try {
await makeUserAdmin(newAdminUsername.trim());
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
setNewAdminUsername("");
fetchUsers();
} catch (err: any) {
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
} finally {
setMakeAdminLoading(false);
}
};
const handleRemoveAdminStatus = async (username: string) => {
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
if (!isAdmin) {
return;
}
const jwt = getCookie("jwt");
try {
await removeAdminStatus(username);
fetchUsers();
} catch (err: any) {
console.error('Failed to remove admin status:', err);
}
};
const handleDeleteUser = async (username: string) => {
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
if (!isAdmin) {
return;
}
const jwt = getCookie("jwt");
try {
await deleteUser(username);
fetchUsers();
} catch (err: any) {
console.error('Failed to delete user:', err);
}
};
return (
<div className="min-h-svh">
<SidebarProvider open={isSidebarOpen}>
@@ -423,6 +285,7 @@ export function LeftSidebar({
variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px] absolute right-5"
title={t('common.toggleSidebar')}
>
<Menu className="h-4 w-4"/>
</Button>
@@ -433,9 +296,9 @@ export function LeftSidebar({
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}>
<HardDrive strokeWidth="2.5"/>
Host Manager
{t('nav.hostManager')}
</Button>
</SidebarGroup>
<Separator className="p-0.25"/>
@@ -444,7 +307,7 @@ export function LeftSidebar({
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by any info..."
placeholder={t('placeholders.searchHostsAny')}
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
autoComplete="off"
/>
@@ -454,7 +317,7 @@ export function LeftSidebar({
<div className="px-1">
<div
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{hostsError}
{t('leftSidebar.failedToLoadHosts')}
</div>
</div>
)}
@@ -462,7 +325,7 @@ export function LeftSidebar({
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
Loading hosts...
{t('hosts.loadingHosts')}
</div>
</div>
)}
@@ -489,7 +352,7 @@ export function LeftSidebar({
style={{width: '100%'}}
disabled={disabled}
>
<User2/> {username ? username : 'Signed out'}
<User2/> {username ? username : t('common.logout')}
<ChevronUp className="ml-auto"/>
</SidebarMenuButton>
</DropdownMenuTrigger>
@@ -508,10 +371,10 @@ export function LeftSidebar({
setCurrentTab(profileTab.id);
return;
}
const id = addTab({type: 'profile', title: 'Profile'} as any);
const id = addTab({type: 'profile', title: t('profile.title')} as any);
setCurrentTab(id);
}}>
<span>Profile & Security</span>
<span>{t('profile.title')}</span>
</DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
@@ -519,23 +382,20 @@ export function LeftSidebar({
onClick={() => {
if (isAdmin) openAdminTab();
}}>
<span>Admin Settings</span>
<span>{t('admin.title')}</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={handleLogout}>
<span>Sign out</span>
<span>{t('common.logout')}</span>
</DropdownMenuItem>
<DropdownMenuItem
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
onClick={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1}
>
<span
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
Delete Account
{isAdmin && adminCount <= 1 && " (Last Admin)"}
<span className="text-red-400">
{t('leftSidebar.deleteAccount')}
</span>
</DropdownMenuItem>
</DropdownMenuContent>
@@ -588,7 +448,7 @@ export function LeftSidebar({
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
<h2 className="text-lg font-semibold text-white">{t('leftSidebar.deleteAccount')}</h2>
<Button
variant="outline"
size="sm"
@@ -598,7 +458,7 @@ export function LeftSidebar({
setDeleteError(null);
}}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title="Close Delete Account"
title={t('leftSidebar.closeDeleteAccount')}
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
@@ -607,48 +467,33 @@ export function LeftSidebar({
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
This action cannot be undone. This will permanently delete your account and all
associated data.
{t('leftSidebar.deleteAccountWarning')}
</div>
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertTitle>{t('common.warning')}</AlertTitle>
<AlertDescription>
Deleting your account will remove all your data including SSH hosts,
configurations, and settings.
This action is irreversible.
{t('leftSidebar.deleteAccountWarningDetails')}
</AlertDescription>
</Alert>
{deleteError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription>
</Alert>
)}
<form onSubmit={handleDeleteAccount} className="space-y-4">
{isAdmin && adminCount <= 1 && (
<Alert variant="destructive">
<AlertTitle>Cannot Delete Account</AlertTitle>
<AlertDescription>
You are the last admin user. You cannot delete your account as this
would leave the system without any administrators.
Please make another user an admin first, or contact system support.
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="delete-password">Confirm Password</Label>
<Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label>
<Input
id="delete-password"
type="password"
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password to confirm"
placeholder={t('placeholders.confirmPassword')}
required
disabled={isAdmin && adminCount <= 1}
/>
</div>
@@ -657,9 +502,9 @@ export function LeftSidebar({
type="submit"
variant="destructive"
className="flex-1"
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
disabled={deleteLoading || !deletePassword.trim()}
>
{deleteLoading ? "Deleting..." : "Delete Account"}
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')}
</Button>
<Button
type="button"
@@ -670,7 +515,7 @@ export function LeftSidebar({
setDeleteError(null);
}}
>
Cancel
{t('leftSidebar.cancel')}
</Button>
</div>
</form>

View File

@@ -7,6 +7,7 @@ import {Input} from "@/components/ui/input.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {toast} from "sonner";
import {useTranslation} from "react-i18next";
interface PasswordResetProps {
userInfo: {
@@ -18,6 +19,7 @@ interface PasswordResetProps {
}
export function PasswordReset({userInfo}: PasswordResetProps) {
const {t} = useTranslation();
const [error, setError] = useState<string | null>(null);
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
@@ -35,7 +37,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetStep("verify");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
setError(err?.response?.data?.error || err?.message || t('common.failedToInitiatePasswordReset'));
} finally {
setResetLoading(false);
}
@@ -59,7 +61,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetStep("newPassword");
setError(null);
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to verify reset code");
setError(err?.response?.data?.error || t('common.failedToVerifyResetCode'));
} finally {
setResetLoading(false);
}
@@ -70,13 +72,13 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetLoading(true);
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
setError(t('common.passwordsDoNotMatch'));
setResetLoading(false);
return;
}
if (newPassword.length < 6) {
setError("Password must be at least 6 characters long");
setError(t('common.passwordMinLength'));
setResetLoading(false);
return;
}
@@ -84,10 +86,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
try {
await completePasswordReset(userInfo.username, tempToken, newPassword);
toast.success("Password reset successfully! You can now log in with your new password.");
toast.success(t('common.passwordResetSuccess'));
resetPasswordState();
} catch (err: any) {
setError(err?.response?.data?.error || "Failed to complete password reset");
setError(err?.response?.data?.error || t('common.failedToCompletePasswordReset'));
} finally {
setResetLoading(false);
}
@@ -105,10 +107,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Key className="w-5 h-5"/>
Password
{t('common.password')}
</CardTitle>
<CardDescription>
Change your account password
{t('common.changeAccountPassword')}
</CardDescription>
</CardHeader>
<CardContent>
@@ -122,7 +124,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
disabled={resetLoading || !userInfo.username.trim()}
onClick={handleInitiatePasswordReset}
>
{resetLoading ? Spinner : "Send Reset Code"}
{resetLoading ? Spinner : t('common.sendResetCode')}
</Button>
</div>
</>
@@ -131,12 +133,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
{resetStep === "verify" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter the 6-digit code from the docker container logs for
user: <strong>{userInfo.username}</strong></p>
<p>{t('common.enterSixDigitCode')} <strong>{userInfo.username}</strong></p>
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="reset-code">Reset Code</Label>
<Label htmlFor="reset-code">{t('common.resetCode')}</Label>
<Input
id="reset-code"
type="text"
@@ -146,7 +147,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
value={resetCode}
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
disabled={resetLoading}
placeholder="000000"
placeholder={t('placeholders.enterCode')}
/>
</div>
<Button
@@ -155,7 +156,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
disabled={resetLoading || resetCode.length !== 6}
onClick={handleVerifyResetCode}
>
{resetLoading ? Spinner : "Verify Code"}
{resetLoading ? Spinner : t('common.verifyCode')}
</Button>
<Button
type="button"
@@ -167,7 +168,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setResetCode("");
}}
>
Back
{t('common.back')}
</Button>
</div>
</>
@@ -176,12 +177,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
{resetStep === "newPassword" && (
<>
<div className="text-center text-muted-foreground mb-4">
<p>Enter your new password for
user: <strong>{userInfo.username}</strong></p>
<p>{t('common.enterNewPassword')} <strong>{userInfo.username}</strong></p>
</div>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<Label htmlFor="new-password">New Password</Label>
<Label htmlFor="new-password">{t('common.newPassword')}</Label>
<Input
id="new-password"
type="password"
@@ -194,7 +194,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirm-password">Confirm Password</Label>
<Label htmlFor="confirm-password">{t('common.confirmPassword')}</Label>
<Input
id="confirm-password"
type="password"
@@ -212,7 +212,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
disabled={resetLoading || !newPassword || !confirmPassword}
onClick={handleCompletePasswordReset}
>
{resetLoading ? Spinner : "Reset Password"}
{resetLoading ? Spinner : t('common.resetPassword')}
</Button>
<Button
type="button"
@@ -225,14 +225,14 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
setConfirmPassword("");
}}
>
Back
{t('common.back')}
</Button>
</div>
</>
)}
{error && (
<Alert variant="destructive" className="mt-4">
<AlertTitle>Error</AlertTitle>
<AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}