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 - **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 - **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 - **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English and Chinese
# Planned Features # Planned Features
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc - **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 express from 'express';
import {db} from '../db/index.js'; 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 {eq, and} from 'drizzle-orm';
import chalk from 'chalk'; import chalk from 'chalk';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
@@ -377,10 +377,10 @@ router.get('/oidc/callback', async (req, res) => {
let userInfo: any = null; let userInfo: any = null;
let userInfoUrls: string[] = []; let userInfoUrls: string[] = [];
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
try { try {
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
const discoveryResponse = await fetch(discoveryUrl); 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); return path.split('.').reduce((current, key) => current?.[key], obj);
}; };
const identifier = getNestedValue(userInfo, config.identifier_path) || const identifier = getNestedValue(userInfo, config.identifier_path) ||
userInfo[config.identifier_path] || userInfo[config.identifier_path] ||
userInfo.sub || userInfo.sub ||
userInfo.email || userInfo.email ||
userInfo.preferred_username; userInfo.preferred_username;
const name = getNestedValue(userInfo, config.name_path) || const name = getNestedValue(userInfo, config.name_path) ||
userInfo[config.name_path] || userInfo[config.name_path] ||
userInfo.name || userInfo.name ||
userInfo.given_name || userInfo.given_name ||
identifier; identifier;
if (!identifier) { if (!identifier) {
logger.error(`Identifier not found at path: ${config.identifier_path}`); 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'; const jwtSecret = process.env.JWT_SECRET || 'secret';
try { try {
const decoded = jwt.verify(temp_token, jwtSecret) as any; const decoded = jwt.verify(temp_token, jwtSecret) as any;
if (!decoded.pending_totp) { if (!decoded.pending_totp) {
@@ -1020,7 +1020,7 @@ router.post('/totp/verify-login', async (req, res) => {
} }
const userRecord = user[0]; const userRecord = user[0];
if (!userRecord.totp_enabled || !userRecord.totp_secret) { if (!userRecord.totp_enabled || !userRecord.totp_secret) {
return res.status(400).json({error: 'TOTP not enabled for this user'}); 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) { if (!verified) {
const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : []; const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : [];
const backupIndex = backupCodes.indexOf(totp_code); const backupIndex = backupCodes.indexOf(totp_code);
if (backupIndex === -1) { if (backupIndex === -1) {
return res.status(401).json({error: 'Invalid TOTP code'}); return res.status(401).json({error: 'Invalid TOTP code'});
} }
backupCodes.splice(backupIndex, 1); backupCodes.splice(backupIndex, 1);
await db.update(users) await db.update(users)
.set({totp_backup_codes: JSON.stringify(backupCodes)}) .set({totp_backup_codes: JSON.stringify(backupCodes)})
@@ -1066,7 +1066,7 @@ router.post('/totp/verify-login', async (req, res) => {
// POST /users/totp/setup // POST /users/totp/setup
router.post('/totp/setup', authenticateJWT, async (req, res) => { router.post('/totp/setup', authenticateJWT, async (req, res) => {
const userId = (req as any).userId; const userId = (req as any).userId;
try { try {
const user = await db.select().from(users).where(eq(users.id, userId)); const user = await db.select().from(users).where(eq(users.id, userId));
if (!user || user.length === 0) { if (!user || user.length === 0) {
@@ -1074,7 +1074,7 @@ router.post('/totp/setup', authenticateJWT, async (req, res) => {
} }
const userRecord = user[0]; const userRecord = user[0];
if (userRecord.totp_enabled) { if (userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is already 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]; const userRecord = user[0];
if (userRecord.totp_enabled) { if (userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is already 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'}); 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() Math.random().toString(36).substring(2, 10).toUpperCase()
); );
@@ -1177,7 +1177,7 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => {
} }
const userRecord = user[0]; const userRecord = user[0];
if (!userRecord.totp_enabled) { if (!userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is not 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]; const userRecord = user[0];
if (!userRecord.totp_enabled) { if (!userRecord.totp_enabled) {
return res.status(400).json({error: 'TOTP is not 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'}); 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() 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; const targetUserId = targetUser[0].id;
try { try {
db.$client.prepare('DELETE FROM file_manager_recent WHERE user_id = ?').run(targetUserId); db.$client.prepare('DELETE FROM config_editor_recent WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM file_manager_pinned WHERE user_id = ?').run(targetUserId); db.$client.prepare('DELETE FROM config_editor_pinned WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM file_manager_shortcuts WHERE user_id = ?').run(targetUserId); db.$client.prepare('DELETE FROM config_editor_shortcuts WHERE user_id = ?').run(targetUserId);
db.$client.prepare('DELETE FROM ssh_data 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) { } catch (cleanupError) {
logger.error(`Cleanup failed for user ${username}:`, cleanupError); logger.error(`Cleanup failed for user ${username}:`, cleanupError);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
File, File,
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from 'react-i18next';
import { import {
Sidebar, Sidebar,
@@ -49,14 +50,7 @@ import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx"; import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts"; import {getSSHHosts} from "@/ui/main-axios.ts";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import { import { deleteAccount } from "@/ui/main-axios.ts";
getOIDCConfig,
getUserList,
makeUserAdmin,
removeAdminStatus,
deleteUser,
deleteAccount
} from "@/ui/main-axios.ts";
interface SSHHost { interface SSHHost {
id: number; id: number;
@@ -112,26 +106,12 @@ export function LeftSidebar({
username, username,
children, children,
}: SidebarProps): React.ReactElement { }: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false); const { t } = useTranslation();
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false); const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState(""); const [deletePassword, setDeletePassword] = React.useState("");
const [deleteLoading, setDeleteLoading] = React.useState(false); const [deleteLoading, setDeleteLoading] = React.useState(false);
const [deleteError, setDeleteError] = React.useState<string | null>(null); 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); const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
@@ -161,33 +141,7 @@ export function LeftSidebar({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = 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 () => { const fetchHosts = React.useCallback(async () => {
try { try {
@@ -304,7 +258,7 @@ export function LeftSidebar({
setDeleteError(null); setDeleteError(null);
if (!deletePassword.trim()) { if (!deletePassword.trim()) {
setDeleteError("Password is required"); setDeleteError(t('leftSidebar.passwordRequired'));
setDeleteLoading(false); setDeleteLoading(false);
return; return;
} }
@@ -315,103 +269,11 @@ export function LeftSidebar({
handleLogout(); handleLogout();
} catch (err: any) { } catch (err: any) {
setDeleteError(err?.response?.data?.error || "Failed to delete account"); setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount'));
setDeleteLoading(false); 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 ( return (
<div className="min-h-svh"> <div className="min-h-svh">
<SidebarProvider open={isSidebarOpen}> <SidebarProvider open={isSidebarOpen}>
@@ -423,6 +285,7 @@ export function LeftSidebar({
variant="outline" variant="outline"
onClick={() => setIsSidebarOpen(!isSidebarOpen)} onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="w-[28px] h-[28px] absolute right-5" className="w-[28px] h-[28px] absolute right-5"
title={t('common.toggleSidebar')}
> >
<Menu className="h-4 w-4"/> <Menu className="h-4 w-4"/>
</Button> </Button>
@@ -433,9 +296,9 @@ export function LeftSidebar({
<SidebarGroup className="!m-0 !p-0 !-mb-2"> <SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline" <Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} 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"/> <HardDrive strokeWidth="2.5"/>
Host Manager {t('nav.hostManager')}
</Button> </Button>
</SidebarGroup> </SidebarGroup>
<Separator className="p-0.25"/> <Separator className="p-0.25"/>
@@ -444,7 +307,7 @@ export function LeftSidebar({
<Input <Input
value={search} value={search}
onChange={e => setSearch(e.target.value)} 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" className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
autoComplete="off" autoComplete="off"
/> />
@@ -454,7 +317,7 @@ export function LeftSidebar({
<div className="px-1"> <div className="px-1">
<div <div
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full"> 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>
</div> </div>
)} )}
@@ -462,7 +325,7 @@ export function LeftSidebar({
{hostsLoading && ( {hostsLoading && (
<div className="px-4 pb-2"> <div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
Loading hosts... {t('hosts.loadingHosts')}
</div> </div>
</div> </div>
)} )}
@@ -489,7 +352,7 @@ export function LeftSidebar({
style={{width: '100%'}} style={{width: '100%'}}
disabled={disabled} disabled={disabled}
> >
<User2/> {username ? username : 'Signed out'} <User2/> {username ? username : t('common.logout')}
<ChevronUp className="ml-auto"/> <ChevronUp className="ml-auto"/>
</SidebarMenuButton> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -508,10 +371,10 @@ export function LeftSidebar({
setCurrentTab(profileTab.id); setCurrentTab(profileTab.id);
return; return;
} }
const id = addTab({type: 'profile', title: 'Profile'} as any); const id = addTab({type: 'profile', title: t('profile.title')} as any);
setCurrentTab(id); setCurrentTab(id);
}}> }}>
<span>Profile & Security</span> <span>{t('profile.title')}</span>
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && ( {isAdmin && (
<DropdownMenuItem <DropdownMenuItem
@@ -519,23 +382,20 @@ export function LeftSidebar({
onClick={() => { onClick={() => {
if (isAdmin) openAdminTab(); if (isAdmin) openAdminTab();
}}> }}>
<span>Admin Settings</span> <span>{t('admin.title')}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<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" 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}> onClick={handleLogout}>
<span>Sign out</span> <span>{t('common.logout')}</span>
</DropdownMenuItem> </DropdownMenuItem>
<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" 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)} onClick={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1}
> >
<span <span className="text-red-400">
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}> {t('leftSidebar.deleteAccount')}
Delete Account
{isAdmin && adminCount <= 1 && " (Last Admin)"}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@@ -588,7 +448,7 @@ export function LeftSidebar({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center justify-between p-4 border-b border-[#303032]"> <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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -598,7 +458,7 @@ export function LeftSidebar({
setDeleteError(null); setDeleteError(null);
}} }}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center" 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> <span className="text-lg font-bold leading-none">×</span>
</Button> </Button>
@@ -607,48 +467,33 @@ export function LeftSidebar({
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="text-sm text-gray-300"> <div className="text-sm text-gray-300">
This action cannot be undone. This will permanently delete your account and all {t('leftSidebar.deleteAccountWarning')}
associated data.
</div> </div>
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>Warning</AlertTitle> <AlertTitle>{t('common.warning')}</AlertTitle>
<AlertDescription> <AlertDescription>
Deleting your account will remove all your data including SSH hosts, {t('leftSidebar.deleteAccountWarningDetails')}
configurations, and settings.
This action is irreversible.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{deleteError && ( {deleteError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle>Error</AlertTitle> <AlertTitle>{t('common.error')}</AlertTitle>
<AlertDescription>{deleteError}</AlertDescription> <AlertDescription>{deleteError}</AlertDescription>
</Alert> </Alert>
)} )}
<form onSubmit={handleDeleteAccount} className="space-y-4"> <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"> <div className="space-y-2">
<Label htmlFor="delete-password">Confirm Password</Label> <Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label>
<Input <Input
id="delete-password" id="delete-password"
type="password" type="password"
value={deletePassword} value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)} onChange={(e) => setDeletePassword(e.target.value)}
placeholder="Enter your password to confirm" placeholder={t('placeholders.confirmPassword')}
required required
disabled={isAdmin && adminCount <= 1}
/> />
</div> </div>
@@ -657,9 +502,9 @@ export function LeftSidebar({
type="submit" type="submit"
variant="destructive" variant="destructive"
className="flex-1" 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>
<Button <Button
type="button" type="button"
@@ -670,7 +515,7 @@ export function LeftSidebar({
setDeleteError(null); setDeleteError(null);
}} }}
> >
Cancel {t('leftSidebar.cancel')}
</Button> </Button>
</div> </div>
</form> </form>

View File

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