Translation update
This commit is contained in:
@@ -39,6 +39,7 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management
|
||||
- **Server Stats** - View CPU, memory, and HDD usage on any SSH server
|
||||
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support
|
||||
- **Modern UI** - Clean interface built with React, Tailwind CSS, and Shadcn
|
||||
- **Languages** - Built-in support for English and Chinese
|
||||
|
||||
# Planned Features
|
||||
- **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import express from 'express';
|
||||
import {db} from '../db/index.js';
|
||||
import {users, settings} from '../db/schema.js';
|
||||
import {users, settings, sshData, fileManagerRecent, fileManagerPinned, fileManagerShortcuts, dismissedAlerts} from '../db/schema.js';
|
||||
import {eq, and} from 'drizzle-orm';
|
||||
import chalk from 'chalk';
|
||||
import bcrypt from 'bcryptjs';
|
||||
@@ -377,10 +377,10 @@ router.get('/oidc/callback', async (req, res) => {
|
||||
|
||||
let userInfo: any = null;
|
||||
let userInfoUrls: string[] = [];
|
||||
|
||||
|
||||
const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url;
|
||||
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '');
|
||||
|
||||
|
||||
try {
|
||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||
const discoveryResponse = await fetch(discoveryUrl);
|
||||
@@ -464,17 +464,17 @@ router.get('/oidc/callback', async (req, res) => {
|
||||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||||
};
|
||||
|
||||
const identifier = getNestedValue(userInfo, config.identifier_path) ||
|
||||
userInfo[config.identifier_path] ||
|
||||
userInfo.sub ||
|
||||
userInfo.email ||
|
||||
userInfo.preferred_username;
|
||||
|
||||
const name = getNestedValue(userInfo, config.name_path) ||
|
||||
userInfo[config.name_path] ||
|
||||
userInfo.name ||
|
||||
userInfo.given_name ||
|
||||
identifier;
|
||||
const identifier = getNestedValue(userInfo, config.identifier_path) ||
|
||||
userInfo[config.identifier_path] ||
|
||||
userInfo.sub ||
|
||||
userInfo.email ||
|
||||
userInfo.preferred_username;
|
||||
|
||||
const name = getNestedValue(userInfo, config.name_path) ||
|
||||
userInfo[config.name_path] ||
|
||||
userInfo.name ||
|
||||
userInfo.given_name ||
|
||||
identifier;
|
||||
|
||||
if (!identifier) {
|
||||
logger.error(`Identifier not found at path: ${config.identifier_path}`);
|
||||
@@ -1007,7 +1007,7 @@ router.post('/totp/verify-login', async (req, res) => {
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET || 'secret';
|
||||
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(temp_token, jwtSecret) as any;
|
||||
if (!decoded.pending_totp) {
|
||||
@@ -1020,7 +1020,7 @@ router.post('/totp/verify-login', async (req, res) => {
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
|
||||
if (!userRecord.totp_enabled || !userRecord.totp_secret) {
|
||||
return res.status(400).json({error: 'TOTP not enabled for this user'});
|
||||
}
|
||||
@@ -1035,11 +1035,11 @@ router.post('/totp/verify-login', async (req, res) => {
|
||||
if (!verified) {
|
||||
const backupCodes = userRecord.totp_backup_codes ? JSON.parse(userRecord.totp_backup_codes) : [];
|
||||
const backupIndex = backupCodes.indexOf(totp_code);
|
||||
|
||||
|
||||
if (backupIndex === -1) {
|
||||
return res.status(401).json({error: 'Invalid TOTP code'});
|
||||
}
|
||||
|
||||
|
||||
backupCodes.splice(backupIndex, 1);
|
||||
await db.update(users)
|
||||
.set({totp_backup_codes: JSON.stringify(backupCodes)})
|
||||
@@ -1066,7 +1066,7 @@ router.post('/totp/verify-login', async (req, res) => {
|
||||
// POST /users/totp/setup
|
||||
router.post('/totp/setup', authenticateJWT, async (req, res) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0) {
|
||||
@@ -1074,7 +1074,7 @@ router.post('/totp/setup', authenticateJWT, async (req, res) => {
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is already enabled'});
|
||||
}
|
||||
@@ -1118,7 +1118,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
|
||||
if (userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is already enabled'});
|
||||
}
|
||||
@@ -1138,7 +1138,7 @@ router.post('/totp/enable', authenticateJWT, async (req, res) => {
|
||||
return res.status(401).json({error: 'Invalid TOTP code'});
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({length: 8}, () =>
|
||||
const backupCodes = Array.from({length: 8}, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||
);
|
||||
|
||||
@@ -1177,7 +1177,7 @@ router.post('/totp/disable', authenticateJWT, async (req, res) => {
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
|
||||
if (!userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is not enabled'});
|
||||
}
|
||||
@@ -1235,7 +1235,7 @@ router.post('/totp/backup-codes', authenticateJWT, async (req, res) => {
|
||||
}
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
|
||||
if (!userRecord.totp_enabled) {
|
||||
return res.status(400).json({error: 'TOTP is not enabled'});
|
||||
}
|
||||
@@ -1260,7 +1260,7 @@ router.post('/totp/backup-codes', authenticateJWT, async (req, res) => {
|
||||
return res.status(400).json({error: 'Authentication required'});
|
||||
}
|
||||
|
||||
const backupCodes = Array.from({length: 8}, () =>
|
||||
const backupCodes = Array.from({length: 8}, () =>
|
||||
Math.random().toString(36).substring(2, 10).toUpperCase()
|
||||
);
|
||||
|
||||
@@ -1311,10 +1311,15 @@ router.delete('/delete-user', authenticateJWT, async (req, res) => {
|
||||
const targetUserId = targetUser[0].id;
|
||||
|
||||
try {
|
||||
db.$client.prepare('DELETE FROM file_manager_recent WHERE user_id = ?').run(targetUserId);
|
||||
db.$client.prepare('DELETE FROM file_manager_pinned WHERE user_id = ?').run(targetUserId);
|
||||
db.$client.prepare('DELETE FROM file_manager_shortcuts WHERE user_id = ?').run(targetUserId);
|
||||
db.$client.prepare('DELETE FROM ssh_data WHERE user_id = ?').run(targetUserId);
|
||||
db.$client.prepare('DELETE FROM config_editor_recent WHERE user_id = ?').run(targetUserId);
|
||||
db.$client.prepare('DELETE FROM config_editor_pinned WHERE user_id = ?').run(targetUserId);
|
||||
db.$client.prepare('DELETE FROM config_editor_shortcuts WHERE user_id = ?').run(targetUserId);
|
||||
db.$client.prepare('DELETE FROM shared_hosts WHERE original_user_id = ? OR shared_with_user_id = ?').run(targetUserId, targetUserId);
|
||||
await db.delete(fileManagerRecent).where(eq(fileManagerRecent.userId, targetUserId));
|
||||
await db.delete(fileManagerPinned).where(eq(fileManagerPinned.userId, targetUserId));
|
||||
await db.delete(fileManagerShortcuts).where(eq(fileManagerShortcuts.userId, targetUserId));
|
||||
await db.delete(dismissedAlerts).where(eq(dismissedAlerts.userId, targetUserId));
|
||||
await db.delete(sshData).where(eq(sshData.userId, targetUserId));
|
||||
} catch (cleanupError) {
|
||||
logger.error(`Cleanup failed for user ${username}:`, cleanupError);
|
||||
}
|
||||
|
||||
@@ -17,15 +17,16 @@ import {
|
||||
} from "@/components/ui/table.tsx";
|
||||
import {Shield, Trash2, Users} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getUserList,
|
||||
updateRegistrationAllowed,
|
||||
updateOIDCConfig,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getRegistrationAllowed,
|
||||
getUserList,
|
||||
updateRegistrationAllowed,
|
||||
updateOIDCConfig,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
function getCookie(name: string) {
|
||||
@@ -40,6 +41,7 @@ interface AdminSettingsProps {
|
||||
}
|
||||
|
||||
export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
|
||||
const {t} = useTranslation();
|
||||
const {state: sidebarState} = useSidebar();
|
||||
|
||||
const [allowRegistration, setAllowRegistration] = React.useState(true);
|
||||
@@ -124,7 +126,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
|
||||
const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
|
||||
if (missing.length > 0) {
|
||||
setOidcError(`Missing required fields: ${missing.join(', ')}`);
|
||||
setOidcError(t('admin.missingRequiredFields', { fields: missing.join(', ') }));
|
||||
setOidcLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -132,9 +134,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await updateOIDCConfig(oidcConfig);
|
||||
toast.success("OIDC configuration updated successfully!");
|
||||
toast.success(t('admin.oidcConfigurationUpdated'));
|
||||
} catch (err: any) {
|
||||
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
|
||||
setOidcError(err?.response?.data?.error || t('admin.failedToUpdateOidcConfig'));
|
||||
} finally {
|
||||
setOidcLoading(false);
|
||||
}
|
||||
@@ -152,39 +154,39 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
toast.success(`User ${newAdminUsername} is now an admin`);
|
||||
toast.success(t('admin.userIsNowAdmin', { username: newAdminUsername }));
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
|
||||
setMakeAdminError(err?.response?.data?.error || t('admin.failedToMakeUserAdmin'));
|
||||
} finally {
|
||||
setMakeAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdminStatus = async (username: string) => {
|
||||
if (!confirm(`Remove admin status from ${username}?`)) return;
|
||||
if (!confirm(t('admin.removeAdminStatus', { username }))) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
toast.success(`Admin status removed from ${username}`);
|
||||
toast.success(t('admin.adminStatusRemoved', { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove admin status:', err);
|
||||
toast.error('Failed to remove admin status');
|
||||
toast.error(t('admin.failedToRemoveAdminStatus'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
|
||||
if (!confirm(t('admin.deleteUser', { username }))) return;
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
toast.success(`User ${username} deleted successfully`);
|
||||
toast.success(t('admin.userDeletedSuccessfully', { username }));
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete user:', err);
|
||||
toast.error('Failed to delete user');
|
||||
toast.error(t('admin.failedToDeleteUser'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,7 +206,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
className="bg-[#18181b] text-white rounded-lg border-2 border-[#303032] overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 pt-2 pb-2">
|
||||
<h1 className="font-bold text-lg">Admin Settings</h1>
|
||||
<h1 className="font-bold text-lg">{t('admin.title')}</h1>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full"/>
|
||||
|
||||
@@ -213,7 +215,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
|
||||
<TabsTrigger value="registration" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
General
|
||||
{t('admin.general')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="oidc" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
@@ -221,97 +223,96 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4"/>
|
||||
Users
|
||||
{t('admin.users')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="admins" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Admins
|
||||
{t('admin.adminManagement')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="registration" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Registration</h3>
|
||||
<h3 className="text-lg font-semibold">{t('admin.userRegistration')}</h3>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={allowRegistration} onCheckedChange={handleToggleRegistration}
|
||||
disabled={regLoading}/>
|
||||
Allow new account registration
|
||||
{t('admin.allowNewAccountRegistration')}
|
||||
</label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="oidc" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">External Authentication (OIDC)</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure external identity provider for
|
||||
OIDC/OAuth2 authentication.</p>
|
||||
<h3 className="text-lg font-semibold">{t('admin.externalAuthentication')}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.configureExternalProvider')}</p>
|
||||
|
||||
{oidcError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{oidcError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_id">Client ID</Label>
|
||||
<Label htmlFor="client_id">{t('admin.clientId')}</Label>
|
||||
<Input id="client_id" value={oidcConfig.client_id}
|
||||
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
|
||||
placeholder="your-client-id" required/>
|
||||
placeholder={t('placeholders.clientId')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="client_secret">Client Secret</Label>
|
||||
<Label htmlFor="client_secret">{t('admin.clientSecret')}</Label>
|
||||
<Input id="client_secret" type="password" value={oidcConfig.client_secret}
|
||||
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
|
||||
placeholder="your-client-secret" required/>
|
||||
placeholder={t('placeholders.clientSecret')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorization_url">Authorization URL</Label>
|
||||
<Label htmlFor="authorization_url">{t('admin.authorizationUrl')}</Label>
|
||||
<Input id="authorization_url" value={oidcConfig.authorization_url}
|
||||
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/authorize/"
|
||||
placeholder={t('placeholders.authUrl')}
|
||||
required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issuer_url">Issuer URL</Label>
|
||||
<Label htmlFor="issuer_url">{t('admin.issuerUrl')}</Label>
|
||||
<Input id="issuer_url" value={oidcConfig.issuer_url}
|
||||
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/termix/" required/>
|
||||
placeholder={t('placeholders.redirectUrl')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="token_url">Token URL</Label>
|
||||
<Label htmlFor="token_url">{t('admin.tokenUrl')}</Label>
|
||||
<Input id="token_url" value={oidcConfig.token_url}
|
||||
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/token/" required/>
|
||||
placeholder={t('placeholders.tokenUrl')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="identifier_path">User Identifier Path</Label>
|
||||
<Label htmlFor="identifier_path">{t('admin.userIdentifierPath')}</Label>
|
||||
<Input id="identifier_path" value={oidcConfig.identifier_path}
|
||||
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
|
||||
placeholder="sub" required/>
|
||||
placeholder={t('placeholders.userIdField')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name_path">Display Name Path</Label>
|
||||
<Label htmlFor="name_path">{t('admin.displayNamePath')}</Label>
|
||||
<Input id="name_path" value={oidcConfig.name_path}
|
||||
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
|
||||
placeholder="name" required/>
|
||||
placeholder={t('placeholders.usernameField')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scopes">Scopes</Label>
|
||||
<Label htmlFor="scopes">{t('admin.scopes')}</Label>
|
||||
<Input id="scopes" value={oidcConfig.scopes}
|
||||
onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)}
|
||||
placeholder="openid email profile" required/>
|
||||
placeholder={t('placeholders.scopes')} required/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userinfo_url">Override User Info URL (not required)</Label>
|
||||
<Label htmlFor="userinfo_url">{t('admin.overrideUserInfoUrl')}</Label>
|
||||
<Input id="userinfo_url" value={oidcConfig.userinfo_url}
|
||||
onChange={(e) => handleOIDCConfigChange('userinfo_url', e.target.value)}
|
||||
placeholder="https://your-provider.com/application/o/userinfo/"/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" className="flex-1"
|
||||
disabled={oidcLoading}>{oidcLoading ? "Saving..." : "Save Configuration"}</Button>
|
||||
disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setOidcConfig({
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
@@ -322,7 +323,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
name_path: 'name',
|
||||
scopes: 'openid email profile',
|
||||
userinfo_url: ''
|
||||
})}>Reset</Button>
|
||||
})}>{t('admin.reset')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -331,20 +332,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<TabsContent value="users" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">User Management</h3>
|
||||
<h3 className="text-lg font-semibold">{t('admin.userManagement')}</h3>
|
||||
<Button onClick={fetchUsers} disabled={usersLoading} variant="outline"
|
||||
size="sm">{usersLoading ? "Loading..." : "Refresh"}</Button>
|
||||
size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}</Button>
|
||||
</div>
|
||||
{usersLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Loading users...</div>
|
||||
<div className="text-center py-8 text-muted-foreground">{t('admin.loadingUsers')}</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -354,11 +355,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="px-4">{user.is_oidc ? "External" : "Local"}</TableCell>
|
||||
className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => handleDeleteUser(user.username)}
|
||||
@@ -378,23 +379,23 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
|
||||
<TabsContent value="admins" className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">Admin Management</h3>
|
||||
<h3 className="text-lg font-semibold">{t('admin.adminManagement')}</h3>
|
||||
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
|
||||
<h4 className="font-medium">Make User Admin</h4>
|
||||
<h4 className="font-medium">{t('admin.makeUserAdmin')}</h4>
|
||||
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-admin-username">Username</Label>
|
||||
<Label htmlFor="new-admin-username">{t('admin.username')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="new-admin-username" value={newAdminUsername}
|
||||
onChange={(e) => setNewAdminUsername(e.target.value)}
|
||||
placeholder="Enter username to make admin" required/>
|
||||
placeholder={t('admin.enterUsernameToMakeAdmin')} required/>
|
||||
<Button type="submit"
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? "Adding..." : "Make Admin"}</Button>
|
||||
disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{makeAdminError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{makeAdminError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -403,14 +404,14 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">Current Admins</h4>
|
||||
<h4 className="font-medium">{t('admin.currentAdmins')}</h4>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Username</TableHead>
|
||||
<TableHead className="px-4">Type</TableHead>
|
||||
<TableHead className="px-4">Actions</TableHead>
|
||||
<TableHead className="px-4">{t('admin.username')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.type')}</TableHead>
|
||||
<TableHead className="px-4">{t('admin.actions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -419,16 +420,16 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
|
||||
<TableCell className="px-4 font-medium">
|
||||
{admin.username}
|
||||
<span
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">Admin</span>
|
||||
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">{t('admin.adminBadge')}</span>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="px-4">{admin.is_oidc ? "External" : "Local"}</TableCell>
|
||||
className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')}</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => handleRemoveAdminStatus(admin.username)}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
|
||||
<Shield className="h-4 w-4"/>
|
||||
Remove Admin
|
||||
{t('admin.removeAdminButton')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {Switch} from "@/components/ui/switch.tsx";
|
||||
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
|
||||
import {toast} from "sonner";
|
||||
import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -51,6 +52,7 @@ interface SSHManagerHostEditorProps {
|
||||
}
|
||||
|
||||
export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
|
||||
const {t} = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
|
||||
@@ -128,7 +130,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
if (!data.password || data.password.trim() === '') {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Password is required when using password authentication",
|
||||
message: t('hosts.passwordRequired'),
|
||||
path: ['password']
|
||||
});
|
||||
}
|
||||
@@ -136,14 +138,14 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
if (!data.key) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "SSH Private Key is required when using key authentication",
|
||||
message: t('hosts.sshKeyRequired'),
|
||||
path: ['key']
|
||||
});
|
||||
}
|
||||
if (!data.keyType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Key Type is required when using key authentication",
|
||||
message: t('hosts.keyTypeRequired'),
|
||||
path: ['keyType']
|
||||
});
|
||||
}
|
||||
@@ -153,7 +155,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
if (connection.endpointHost && !sshConfigurations.includes(connection.endpointHost)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Must select a valid SSH configuration from the list",
|
||||
message: t('hosts.mustSelectValidSshConfig'),
|
||||
path: ['tunnelConnections', index, 'endpointHost']
|
||||
});
|
||||
}
|
||||
@@ -245,10 +247,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
|
||||
if (editingHost) {
|
||||
await updateSSHHost(editingHost.id, formData);
|
||||
toast.success(`Host "${formData.name}" updated successfully!`);
|
||||
toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name }));
|
||||
} else {
|
||||
await createSSHHost(formData);
|
||||
toast.success(`Host "${formData.name}" added successfully!`);
|
||||
toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name }));
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
@@ -257,7 +259,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (error) {
|
||||
toast.error('Failed to save host. Please try again.');
|
||||
toast.error(t('hosts.failedToSaveHost'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,15 +304,15 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
}, [folderDropdownOpen]);
|
||||
|
||||
const keyTypeOptions = [
|
||||
{value: 'auto', label: 'Auto-detect'},
|
||||
{value: 'ssh-rsa', label: 'RSA'},
|
||||
{value: 'ssh-ed25519', label: 'ED25519'},
|
||||
{value: 'ecdsa-sha2-nistp256', label: 'ECDSA NIST P-256'},
|
||||
{value: 'ecdsa-sha2-nistp384', label: 'ECDSA NIST P-384'},
|
||||
{value: 'ecdsa-sha2-nistp521', label: 'ECDSA NIST P-521'},
|
||||
{value: 'ssh-dss', label: 'DSA'},
|
||||
{value: 'ssh-rsa-sha2-256', label: 'RSA SHA2-256'},
|
||||
{value: 'ssh-rsa-sha2-512', label: 'RSA SHA2-512'},
|
||||
{value: 'auto', label: t('hosts.autoDetect')},
|
||||
{value: 'ssh-rsa', label: t('hosts.rsa')},
|
||||
{value: 'ssh-ed25519', label: t('hosts.ed25519')},
|
||||
{value: 'ecdsa-sha2-nistp256', label: t('hosts.ecdsaNistP256')},
|
||||
{value: 'ecdsa-sha2-nistp384', label: t('hosts.ecdsaNistP384')},
|
||||
{value: 'ecdsa-sha2-nistp521', label: t('hosts.ecdsaNistP521')},
|
||||
{value: 'ssh-dss', label: t('hosts.dsa')},
|
||||
{value: 'ssh-rsa-sha2-256', label: t('hosts.rsaSha2256')},
|
||||
{value: 'ssh-rsa-sha2-512', label: t('hosts.rsaSha2512')},
|
||||
];
|
||||
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
@@ -396,22 +398,22 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="terminal">Terminal</TabsTrigger>
|
||||
<TabsTrigger value="tunnel">Tunnel</TabsTrigger>
|
||||
<TabsTrigger value="file_manager">File Manager</TabsTrigger>
|
||||
<TabsTrigger value="general">{t('hosts.general')}</TabsTrigger>
|
||||
<TabsTrigger value="terminal">{t('hosts.terminal')}</TabsTrigger>
|
||||
<TabsTrigger value="tunnel">{t('hosts.tunnel')}</TabsTrigger>
|
||||
<TabsTrigger value="file_manager">{t('hosts.fileManager')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">Connection Details</FormLabel>
|
||||
<FormLabel className="mb-3 font-bold">{t('hosts.connectionDetails')}</FormLabel>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ip"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-5">
|
||||
<FormLabel>IP</FormLabel>
|
||||
<FormLabel>{t('hosts.ipAddress')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="127.0.0.1" {...field} />
|
||||
<Input placeholder={t('placeholders.ipAddress')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -422,9 +424,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="port"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormLabel>{t('hosts.port')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="22" {...field} />
|
||||
<Input placeholder={t('placeholders.port')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -435,24 +437,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="username"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormLabel>{t('hosts.username')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="username" {...field} />
|
||||
<Input placeholder={t('placeholders.username')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">Organization</FormLabel>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.organization')}</FormLabel>
|
||||
<div className="grid grid-cols-26 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10">
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormLabel>{t('hosts.name')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="host name" {...field} />
|
||||
<Input placeholder={t('placeholders.hostname')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -463,11 +465,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="folder"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10 relative">
|
||||
<FormLabel>Folder</FormLabel>
|
||||
<FormLabel>{t('hosts.folder')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={folderInputRef}
|
||||
placeholder="folder"
|
||||
placeholder={t('placeholders.folder')}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={field.value}
|
||||
@@ -508,7 +510,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="tags"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-10 overflow-visible">
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<FormLabel>{t('hosts.tags')}</FormLabel>
|
||||
<FormControl>
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-[#222225] focus-within:ring-2 ring-ring min-h-[40px]">
|
||||
@@ -544,7 +546,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
field.onChange(field.value.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder="add tags (space to add)"
|
||||
placeholder={t('hosts.addTagsSpaceToAdd')}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -557,7 +559,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="pin"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>Pin Connection</FormLabel>
|
||||
<FormLabel>{t('hosts.pin')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -568,7 +570,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">Authentication</FormLabel>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">{t('hosts.authentication')}</FormLabel>
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
@@ -578,8 +580,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">Password</TabsTrigger>
|
||||
<TabsTrigger value="key">Key</TabsTrigger>
|
||||
<TabsTrigger value="password">{t('hosts.password')}</TabsTrigger>
|
||||
<TabsTrigger value="key">{t('hosts.key')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="password">
|
||||
<FormField
|
||||
@@ -587,9 +589,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormLabel>{t('hosts.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="password" {...field} />
|
||||
<Input type="password" placeholder={t('placeholders.password')} {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -602,7 +604,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="key"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-4 overflow-hidden min-w-0">
|
||||
<FormLabel>SSH Private Key</FormLabel>
|
||||
<FormLabel>{t('hosts.sshPrivateKey')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative min-w-0">
|
||||
<input
|
||||
@@ -621,8 +623,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
className="w-full min-w-0 overflow-hidden px-3 py-2 text-left"
|
||||
>
|
||||
<span className="block w-full truncate"
|
||||
title={field.value?.name || 'Upload'}>
|
||||
{field.value ? (editingHost ? 'Update Key' : field.value.name) : 'Upload'}
|
||||
title={field.value?.name || t('hosts.upload')}>
|
||||
{field.value ? (editingHost ? t('hosts.updateKey') : field.value.name) : t('hosts.upload')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -635,10 +637,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="keyPassword"
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>Key Password</FormLabel>
|
||||
<FormLabel>{t('hosts.keyPassword')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="key password"
|
||||
placeholder={t('placeholders.keyPassword')}
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
@@ -651,7 +653,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="keyType"
|
||||
render={({field}) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>Key Type</FormLabel>
|
||||
<FormLabel>{t('hosts.keyType')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
@@ -661,7 +663,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-[#18181b] border border-input text-foreground"
|
||||
onClick={() => setKeyTypeDropdownOpen((open) => !open)}
|
||||
>
|
||||
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || "Auto-detect"}
|
||||
{keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('hosts.autoDetect')}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
@@ -702,7 +704,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="enableTerminal"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Terminal</FormLabel>
|
||||
<FormLabel>{t('hosts.enableTerminal')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -710,7 +712,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable/disable host visibility in Terminal tab.
|
||||
{t('hosts.enableTerminalDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -722,7 +724,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="enableTunnel"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Tunnel</FormLabel>
|
||||
<FormLabel>{t('hosts.enableTunnel')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -730,7 +732,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable/disable host visibility in Tunnel tab.
|
||||
{t('hosts.enableTunnelDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -740,44 +742,40 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
<>
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>Sshpass Required For Password Authentication</strong>
|
||||
<strong>{t('hosts.sshpassRequired')}</strong>
|
||||
<div>
|
||||
For password-based SSH authentication, sshpass must be installed on
|
||||
both the local and remote servers. Install with: <code
|
||||
{t('hosts.sshpassRequiredDesc')} <code
|
||||
className="bg-muted px-1 rounded inline">sudo apt install
|
||||
sshpass</code> (Debian/Ubuntu) or the equivalent for your OS.
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong>Other installation methods:</strong>
|
||||
<div>• CentOS/RHEL/Fedora: <code
|
||||
<strong>{t('hosts.otherInstallMethods')}</strong>
|
||||
<div>• {t('hosts.centosRhelFedora')} <code
|
||||
className="bg-muted px-1 rounded inline">sudo yum install
|
||||
sshpass</code> or <code
|
||||
className="bg-muted px-1 rounded inline">sudo dnf install
|
||||
sshpass</code></div>
|
||||
<div>• macOS: <code className="bg-muted px-1 rounded inline">brew
|
||||
<div>• {t('hosts.macos')} <code className="bg-muted px-1 rounded inline">brew
|
||||
install hudochenkov/sshpass/sshpass</code></div>
|
||||
<div>• Windows: Use WSL or consider SSH key authentication</div>
|
||||
<div>• {t('hosts.windows')}</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>SSH Server Configuration Required</strong>
|
||||
<div>For reverse SSH tunnels, the endpoint SSH server must allow:</div>
|
||||
<strong>{t('hosts.sshServerConfigRequired')}</strong>
|
||||
<div>{t('hosts.sshServerConfigDesc')}</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">GatewayPorts
|
||||
yes</code> (bind remote ports)
|
||||
yes</code> {t('hosts.gatewayPortsYes')}
|
||||
</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">AllowTcpForwarding
|
||||
yes</code> (port forwarding)
|
||||
yes</code> {t('hosts.allowTcpForwardingYes')}
|
||||
</div>
|
||||
<div>• <code className="bg-muted px-1 rounded inline">PermitRootLogin
|
||||
yes</code> (if using root)
|
||||
yes</code> {t('hosts.permitRootLoginYes')}
|
||||
</div>
|
||||
<div className="mt-2">Edit <code
|
||||
className="bg-muted px-1 rounded inline">/etc/ssh/sshd_config</code> and
|
||||
restart SSH: <code className="bg-muted px-1 rounded inline">sudo
|
||||
systemctl restart sshd</code></div>
|
||||
<div className="mt-2">{t('hosts.editSshConfig')}</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -786,7 +784,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="tunnelConnections"
|
||||
render={({field}) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>Tunnel Connections</FormLabel>
|
||||
<FormLabel>{t('hosts.tunnelConnections')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{field.value.map((connection, index) => (
|
||||
@@ -794,7 +792,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
className="p-4 border rounded-lg bg-muted/50">
|
||||
<div
|
||||
className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold">Connection {index + 1}</h4>
|
||||
<h4 className="text-sm font-bold">{t('hosts.connection')} {index + 1}</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -804,7 +802,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
field.onChange(newConnections);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
{t('hosts.remove')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
@@ -813,10 +811,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.sourcePort`}
|
||||
render={({field: sourcePortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Source Port
|
||||
(Source refers to the Current
|
||||
Connection Details in the
|
||||
General tab)</FormLabel>
|
||||
<FormLabel>{t('hosts.sourcePort')}
|
||||
{t('hosts.sourcePortDesc')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22" {...sourcePortField} />
|
||||
@@ -829,8 +825,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.endpointPort`}
|
||||
render={({field: endpointPortField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Endpoint Port
|
||||
(Remote)</FormLabel>
|
||||
<FormLabel>{t('hosts.endpointPort')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="224" {...endpointPortField} />
|
||||
@@ -844,14 +839,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
render={({field: endpointHostField}) => (
|
||||
<FormItem
|
||||
className="col-span-4 relative">
|
||||
<FormLabel>Endpoint SSH
|
||||
Configuration</FormLabel>
|
||||
<FormLabel>{t('hosts.endpointSshConfig')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
sshConfigInputRefs.current[index] = el;
|
||||
}}
|
||||
placeholder="endpoint ssh configuration"
|
||||
placeholder={t('placeholders.sshConfig')}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={endpointHostField.value}
|
||||
@@ -898,12 +892,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This tunnel will forward traffic from
|
||||
port {form.watch(`tunnelConnections.${index}.sourcePort`) || '22'} on
|
||||
the source machine (current connection details
|
||||
in general tab) to
|
||||
port {form.watch(`tunnelConnections.${index}.endpointPort`) || '224'} on
|
||||
the endpoint machine.
|
||||
{t('hosts.tunnelForwardDescription', {
|
||||
sourcePort: form.watch(`tunnelConnections.${index}.sourcePort`) || '22',
|
||||
endpointPort: form.watch(`tunnelConnections.${index}.endpointPort`) || '224'
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 mt-4">
|
||||
@@ -912,14 +904,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.maxRetries`}
|
||||
render={({field: maxRetriesField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Max Retries</FormLabel>
|
||||
<FormLabel>{t('hosts.maxRetries')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="3" {...maxRetriesField} />
|
||||
placeholder={t('placeholders.maxRetries')} {...maxRetriesField} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Maximum number of retry attempts
|
||||
for tunnel connection.
|
||||
{t('hosts.maxRetriesDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -929,15 +920,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.retryInterval`}
|
||||
render={({field: retryIntervalField}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Retry Interval
|
||||
(seconds)</FormLabel>
|
||||
<FormLabel>{t('hosts.retryInterval')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="10" {...retryIntervalField} />
|
||||
placeholder={t('placeholders.retryInterval')} {...retryIntervalField} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Time to wait between retry
|
||||
attempts.
|
||||
{t('hosts.retryIntervalDescription')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -947,8 +936,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name={`tunnelConnections.${index}.autoStart`}
|
||||
render={({field}) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>Auto Start on Container
|
||||
Launch</FormLabel>
|
||||
<FormLabel>{t('hosts.autoStartContainer')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -956,8 +944,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Automatically start this tunnel
|
||||
when the container launches.
|
||||
{t('hosts.autoStartDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -979,7 +966,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
}]);
|
||||
}}
|
||||
>
|
||||
Add Tunnel Connection
|
||||
{t('hosts.addConnection')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
@@ -997,7 +984,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="enableFileManager"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable File Manager</FormLabel>
|
||||
<FormLabel>{t('hosts.enableFileManager')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
@@ -1005,7 +992,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enable/disable host visibility in File Manager tab.
|
||||
{t('hosts.enableFileManagerDesc')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -1018,12 +1005,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
name="defaultPath"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Default Path</FormLabel>
|
||||
<FormLabel>{t('hosts.defaultPath')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="/home" {...field} />
|
||||
<Input placeholder={t('placeholders.homePath')} {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Set default directory shown when connected via
|
||||
File Manager</FormDescription>
|
||||
<FormDescription>{t('hosts.defaultPathDesc')}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
@@ -1042,7 +1028,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos
|
||||
transform: 'translateY(8px)'
|
||||
}}
|
||||
>
|
||||
{editingHost ? "Update Host" : "Add Host"}
|
||||
{editingHost ? t('hosts.updateHost') : t('hosts.addHost')}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/co
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
|
||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -48,6 +49,7 @@ interface SSHManagerHostViewerProps {
|
||||
}
|
||||
|
||||
export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const {t} = useTranslation();
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -65,21 +67,21 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
setHosts(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Failed to load hosts');
|
||||
setError(t('hosts.failedToLoadHosts'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete "${hostName}"?`)) {
|
||||
if (window.confirm(t('hosts.confirmDelete', { name: hostName }))) {
|
||||
try {
|
||||
await deleteSSHHost(hostId);
|
||||
toast.success(`Host "${hostName}" deleted successfully!`);
|
||||
toast.success(t('hosts.hostDeletedSuccessfully', { name: hostName }));
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} catch (err) {
|
||||
toast.error('Failed to delete host');
|
||||
toast.error(t('hosts.failedToDeleteHost'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -100,35 +102,35 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const data = JSON.parse(text);
|
||||
|
||||
if (!Array.isArray(data.hosts) && !Array.isArray(data)) {
|
||||
throw new Error('JSON must contain a "hosts" array or be an array of hosts');
|
||||
throw new Error(t('hosts.jsonMustContainHosts'));
|
||||
}
|
||||
|
||||
const hostsArray = Array.isArray(data.hosts) ? data.hosts : data;
|
||||
|
||||
if (hostsArray.length === 0) {
|
||||
throw new Error('No hosts found in JSON file');
|
||||
throw new Error(t('hosts.noHostsInJson'));
|
||||
}
|
||||
|
||||
if (hostsArray.length > 100) {
|
||||
throw new Error('Maximum 100 hosts allowed per import');
|
||||
throw new Error(t('hosts.maxHostsAllowed'));
|
||||
}
|
||||
|
||||
const result = await bulkImportSSHHosts(hostsArray);
|
||||
|
||||
if (result.success > 0) {
|
||||
toast.success(`Import completed: ${result.success} hosts imported successfully${result.failed > 0 ? `, ${result.failed} failed` : ''}`);
|
||||
toast.success(t('hosts.importCompleted', { success: result.success, failed: result.failed }));
|
||||
if (result.errors.length > 0) {
|
||||
toast.error(`Import errors: ${result.errors.join(', ')}`);
|
||||
}
|
||||
await fetchHosts();
|
||||
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||
} else {
|
||||
toast.error(`Import failed: ${result.errors.join(', ')}`);
|
||||
toast.error(t('hosts.importFailed') + `: ${result.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file';
|
||||
toast.error(`Import error: ${errorMessage}`);
|
||||
const errorMessage = err instanceof Error ? err.message : t('hosts.failedToImportJson');
|
||||
toast.error(t('hosts.importError') + `: ${errorMessage}`);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
event.target.value = '';
|
||||
@@ -168,7 +170,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
const grouped: { [key: string]: SSHHost[] } = {};
|
||||
|
||||
filteredAndSortedHosts.forEach(host => {
|
||||
const folder = host.folder || 'Uncategorized';
|
||||
const folder = host.folder || t('hosts.uncategorized');
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
@@ -176,8 +178,8 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === 'Uncategorized') return -1;
|
||||
if (b === 'Uncategorized') return 1;
|
||||
if (a === t('hosts.uncategorized')) return -1;
|
||||
if (b === t('hosts.uncategorized')) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
@@ -194,7 +196,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">Loading hosts...</p>
|
||||
<p className="text-muted-foreground">{t('hosts.loadingHosts')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -206,7 +208,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchHosts} variant="outline">
|
||||
Retry
|
||||
{t('hosts.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,9 +220,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<Server className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||
<h3 className="text-lg font-semibold mb-2">No SSH Hosts</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">{t('hosts.noHosts')}</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
You haven't added any SSH hosts yet. Click "Add Host" to get started.
|
||||
{t('hosts.noHostsMessage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,9 +233,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">SSH Hosts</h2>
|
||||
<h2 className="text-xl font-semibold">{t('hosts.sshHosts')}</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{filteredAndSortedHosts.length} hosts
|
||||
{t('hosts.hostsCount', { count: filteredAndSortedHosts.length })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -247,15 +249,15 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
onClick={() => document.getElementById('json-import-input')?.click()}
|
||||
disabled={importing}
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import JSON'}
|
||||
{importing ? t('hosts.importing') : t('hosts.importJson')}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom"
|
||||
className="max-w-sm bg-popover text-popover-foreground border border-border shadow-lg">
|
||||
<div className="space-y-2">
|
||||
<p className="font-semibold text-sm">Import SSH Hosts from JSON</p>
|
||||
<p className="font-semibold text-sm">{t('hosts.importJsonTitle')}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload a JSON file to bulk import multiple SSH hosts (max 100).
|
||||
{t('hosts.importJsonDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@@ -323,7 +325,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
Download Sample
|
||||
{t('hosts.downloadSample')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -333,13 +335,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
window.open('https://docs.termix.site/json-import', '_blank');
|
||||
}}
|
||||
>
|
||||
Format Guide
|
||||
{t('hosts.formatGuide')}
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-2"/>
|
||||
|
||||
<Button onClick={fetchHosts} variant="outline" size="sm">
|
||||
Refresh
|
||||
{t('hosts.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -355,7 +357,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||
<Input
|
||||
placeholder="Search hosts by name, username, IP, folder, tags..."
|
||||
placeholder={t('placeholders.searchHosts')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
@@ -451,13 +453,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{host.enableTerminal && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Terminal className="h-2 w-2 mr-0.5"/>
|
||||
Terminal
|
||||
{t('hosts.terminalBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
{host.enableTunnel && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<Network className="h-2 w-2 mr-0.5"/>
|
||||
Tunnel
|
||||
{t('hosts.tunnelBadge')}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 && (
|
||||
<span
|
||||
className="ml-0.5">({host.tunnelConnections.length})</span>
|
||||
@@ -467,7 +469,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
||||
{host.enableFileManager && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
<FileEdit className="h-2 w-2 mr-0.5"/>
|
||||
File Manager
|
||||
{t('hosts.fileManagerBadge')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@ import {Button} from "../../components/ui/button.tsx";
|
||||
import {Input} from "../../components/ui/input.tsx";
|
||||
import {Label} from "../../components/ui/label.tsx";
|
||||
import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {LanguageSwitcher} from "../../components/LanguageSwitcher";
|
||||
import {
|
||||
registerUser,
|
||||
loginUser,
|
||||
@@ -55,6 +57,7 @@ export function HomepageAuth({
|
||||
onAuthSuccess,
|
||||
...props
|
||||
}: HomepageAuthProps) {
|
||||
const {t} = useTranslation();
|
||||
const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
|
||||
const [localUsername, setLocalUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
@@ -116,7 +119,7 @@ export function HomepageAuth({
|
||||
}
|
||||
setDbError(null);
|
||||
}).catch(() => {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
setDbError(t('errors.databaseConnection'));
|
||||
});
|
||||
}, [setDbError]);
|
||||
|
||||
@@ -126,7 +129,7 @@ export function HomepageAuth({
|
||||
setLoading(true);
|
||||
|
||||
if (!localUsername.trim()) {
|
||||
setError("Username is required");
|
||||
setError(t('errors.requiredField'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -137,12 +140,12 @@ export function HomepageAuth({
|
||||
res = await loginUser(localUsername, password);
|
||||
} else {
|
||||
if (password !== signupConfirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setError(t('errors.passwordMismatch'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setError(t('errors.minLength', {min: 6}));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -159,7 +162,7 @@ export function HomepageAuth({
|
||||
}
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error('No token received from login');
|
||||
throw new Error(t('errors.noTokenReceived'));
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
@@ -186,7 +189,7 @@ export function HomepageAuth({
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Unknown error");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.unknownError'));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
@@ -194,7 +197,7 @@ export function HomepageAuth({
|
||||
setUserId(null);
|
||||
setCookie("jwt", "", -1);
|
||||
if (err?.response?.data?.error?.includes("Database")) {
|
||||
setDbError("Could not connect to the database. Please try again later.");
|
||||
setDbError(t('errors.databaseConnection'));
|
||||
} else {
|
||||
setDbError(null);
|
||||
}
|
||||
@@ -211,7 +214,7 @@ export function HomepageAuth({
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.failedPasswordReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -226,7 +229,7 @@ export function HomepageAuth({
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to verify reset code");
|
||||
setError(err?.response?.data?.error || t('errors.failedVerifyCode'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -237,13 +240,13 @@ export function HomepageAuth({
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setError(t('errors.passwordMismatch'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setError(t('errors.minLength', {min: 6}));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -260,7 +263,7 @@ export function HomepageAuth({
|
||||
|
||||
setResetSuccess(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to complete password reset");
|
||||
setError(err?.response?.data?.error || t('errors.failedCompleteReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -285,7 +288,7 @@ export function HomepageAuth({
|
||||
|
||||
async function handleTOTPVerification() {
|
||||
if (totpCode.length !== 6) {
|
||||
setError("Please enter a 6-digit code");
|
||||
setError(t('auth.enterCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -296,7 +299,7 @@ export function HomepageAuth({
|
||||
const res = await verifyTOTPLogin(totpTempToken, totpCode);
|
||||
|
||||
if (!res || !res.token) {
|
||||
throw new Error('No token received from TOTP verification');
|
||||
throw new Error(t('errors.noTokenReceived'));
|
||||
}
|
||||
|
||||
setCookie("jwt", res.token);
|
||||
@@ -318,7 +321,7 @@ export function HomepageAuth({
|
||||
setTotpCode("");
|
||||
setTotpTempToken("");
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Invalid TOTP code");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.invalidTotpCode'));
|
||||
} finally {
|
||||
setTotpLoading(false);
|
||||
}
|
||||
@@ -332,12 +335,12 @@ export function HomepageAuth({
|
||||
const {auth_url: authUrl} = authResponse;
|
||||
|
||||
if (!authUrl || authUrl === 'undefined') {
|
||||
throw new Error('Invalid authorization URL received from backend');
|
||||
throw new Error(t('errors.invalidAuthUrl'));
|
||||
}
|
||||
|
||||
window.location.replace(authUrl);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
|
||||
setError(err?.response?.data?.error || err?.message || t('errors.failedOidcLogin'));
|
||||
setOidcLoading(false);
|
||||
}
|
||||
}
|
||||
@@ -349,7 +352,7 @@ export function HomepageAuth({
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
setError(`OIDC authentication failed: ${error}`);
|
||||
setError(`${t('errors.oidcAuthFailed')}: ${error}`);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
@@ -377,7 +380,7 @@ export function HomepageAuth({
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
})
|
||||
.catch(err => {
|
||||
setError("Failed to get user info after OIDC login");
|
||||
setError(t('errors.failedUserInfo'));
|
||||
setInternalLoggedIn(false);
|
||||
setLoggedIn(false);
|
||||
setIsAdmin(false);
|
||||
@@ -412,39 +415,37 @@ export function HomepageAuth({
|
||||
)}
|
||||
{firstUser && !dbError && !internalLoggedIn && (
|
||||
<Alert variant="default" className="mb-4">
|
||||
<AlertTitle>First User</AlertTitle>
|
||||
<AlertTitle>{t('auth.firstUser')}</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
You are the first user and will be made an admin. You can view admin settings in the sidebar
|
||||
user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
|
||||
{t('auth.firstUserMessage')}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline hover:text-blue-800 inline"
|
||||
>
|
||||
GitHub issue
|
||||
GitHub Issue
|
||||
</a>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{!registrationAllowed && !internalLoggedIn && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertTitle>Registration Disabled</AlertTitle>
|
||||
<AlertTitle>{t('auth.registerTitle')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
New account registration is currently disabled by an admin. Please log in or contact an
|
||||
administrator.
|
||||
{t('messages.registrationDisabled')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{totpRequired && (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">Two-Factor Authentication</h2>
|
||||
<p className="text-muted-foreground">Enter the 6-digit code from your authenticator app</p>
|
||||
<h2 className="text-xl font-bold mb-1">{t('auth.twoFactorAuth')}</h2>
|
||||
<p className="text-muted-foreground">{t('auth.enterCode')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="totp-code">Authentication Code</Label>
|
||||
<Label htmlFor="totp-code">{t('auth.verifyCode')}</Label>
|
||||
<Input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
@@ -457,7 +458,7 @@ export function HomepageAuth({
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Or enter a backup code if you don't have access to your authenticator
|
||||
{t('auth.backupCode')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -467,7 +468,7 @@ export function HomepageAuth({
|
||||
disabled={totpLoading || totpCode.length < 6}
|
||||
onClick={handleTOTPVerification}
|
||||
>
|
||||
{totpLoading ? Spinner : "Verify"}
|
||||
{totpLoading ? Spinner : t('auth.verifyCode')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -482,7 +483,7 @@ export function HomepageAuth({
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -506,7 +507,7 @@ export function HomepageAuth({
|
||||
aria-selected={tab === "login"}
|
||||
disabled={loading || firstUser}
|
||||
>
|
||||
Login
|
||||
{t('common.login')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -524,7 +525,7 @@ export function HomepageAuth({
|
||||
aria-selected={tab === "signup"}
|
||||
disabled={loading || !registrationAllowed}
|
||||
>
|
||||
Sign Up
|
||||
{t('common.register')}
|
||||
</button>
|
||||
{oidcConfigured && (
|
||||
<button
|
||||
@@ -543,16 +544,16 @@ export function HomepageAuth({
|
||||
aria-selected={tab === "external"}
|
||||
disabled={oidcLoading}
|
||||
>
|
||||
External
|
||||
{t('auth.external')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-6 text-center">
|
||||
<h2 className="text-xl font-bold mb-1">
|
||||
{tab === "login" ? "Login to your account" :
|
||||
tab === "signup" ? "Create a new account" :
|
||||
tab === "external" ? "Login with external provider" :
|
||||
"Reset your password"}
|
||||
{tab === "login" ? t('auth.loginTitle') :
|
||||
tab === "signup" ? t('auth.registerTitle') :
|
||||
tab === "external" ? t('auth.loginWithExternal') :
|
||||
t('auth.forgotPassword')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -561,7 +562,7 @@ export function HomepageAuth({
|
||||
{tab === "external" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Login using your configured external identity provider</p>
|
||||
<p>{t('auth.loginWithExternalDesc')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -569,7 +570,7 @@ export function HomepageAuth({
|
||||
disabled={oidcLoading}
|
||||
onClick={handleOIDCLogin}
|
||||
>
|
||||
{oidcLoading ? Spinner : "Login with External Provider"}
|
||||
{oidcLoading ? Spinner : t('auth.loginWithExternal')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -578,12 +579,11 @@ export function HomepageAuth({
|
||||
{resetStep === "initiate" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your username to receive a password reset code. The code
|
||||
will be logged in the docker container logs.</p>
|
||||
<p>{t('auth.resetCodeDesc')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-username">Username</Label>
|
||||
<Label htmlFor="reset-username">{t('common.username')}</Label>
|
||||
<Input
|
||||
id="reset-username"
|
||||
type="text"
|
||||
@@ -600,7 +600,7 @@ export function HomepageAuth({
|
||||
disabled={resetLoading || !localUsername.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
{resetLoading ? Spinner : t('auth.sendResetCode')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -609,12 +609,11 @@ export function HomepageAuth({
|
||||
{resetStep === "verify" && (
|
||||
<>o
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
<p>{t('auth.enterResetCode')} <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">Reset Code</Label>
|
||||
<Label htmlFor="reset-code">{t('auth.resetCode')}</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
@@ -633,7 +632,7 @@ export function HomepageAuth({
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
{resetLoading ? Spinner : t('auth.verifyCodeButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -645,7 +644,7 @@ export function HomepageAuth({
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -654,10 +653,9 @@ export function HomepageAuth({
|
||||
{resetSuccess && (
|
||||
<>
|
||||
<Alert className="mb-4">
|
||||
<AlertTitle>Success!</AlertTitle>
|
||||
<AlertTitle>{t('auth.passwordResetSuccess')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your password has been successfully reset! You can now log in
|
||||
with your new password.
|
||||
{t('auth.passwordResetSuccessDesc')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button
|
||||
@@ -668,7 +666,7 @@ export function HomepageAuth({
|
||||
resetPasswordState();
|
||||
}}
|
||||
>
|
||||
Go to Login
|
||||
{t('auth.goToLogin')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -676,12 +674,11 @@ export function HomepageAuth({
|
||||
{resetStep === "newPassword" && !resetSuccess && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{localUsername}</strong></p>
|
||||
<p>{t('auth.enterNewPassword')} <strong>{localUsername}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
@@ -694,7 +691,7 @@ export function HomepageAuth({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Label htmlFor="confirm-password">{t('auth.confirmNewPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
@@ -712,7 +709,7 @@ export function HomepageAuth({
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
{resetLoading ? Spinner : t('auth.resetPasswordButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -725,7 +722,7 @@ export function HomepageAuth({
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -736,7 +733,7 @@ export function HomepageAuth({
|
||||
) : (
|
||||
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Label htmlFor="username">{t('common.username')}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
@@ -748,14 +745,14 @@ export function HomepageAuth({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t('common.password')}</Label>
|
||||
<Input id="password" type="password" required className="h-11 text-base"
|
||||
value={password} onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading || internalLoggedIn}/>
|
||||
</div>
|
||||
{tab === "signup" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="signup-confirm-password">Confirm Password</Label>
|
||||
<Label htmlFor="signup-confirm-password">{t('common.confirmPassword')}</Label>
|
||||
<Input id="signup-confirm-password" type="password" required
|
||||
className="h-11 text-base"
|
||||
value={signupConfirmPassword}
|
||||
@@ -765,7 +762,7 @@ export function HomepageAuth({
|
||||
)}
|
||||
<Button type="submit" className="w-full h-11 mt-2 text-base font-semibold"
|
||||
disabled={loading || internalLoggedIn}>
|
||||
{loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")}
|
||||
{loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))}
|
||||
</Button>
|
||||
{tab === "login" && (
|
||||
<Button type="button" variant="outline"
|
||||
@@ -777,11 +774,20 @@ export function HomepageAuth({
|
||||
clearFormFields();
|
||||
}}
|
||||
>
|
||||
Reset Password
|
||||
{t('auth.resetPasswordButton')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-[#303032]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-sm text-muted-foreground">{t('common.language')}</Label>
|
||||
</div>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
|
||||
@@ -69,7 +69,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
setError(null);
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Failed to fetch update information');
|
||||
setError(t('common.failedToFetchUpdateInfo'));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
@@ -96,9 +96,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
|
||||
{versionInfo && versionInfo.status === 'requires_update' && (
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-white">
|
||||
<AlertTitle className="text-white">Update Available</AlertTitle>
|
||||
<AlertTitle className="text-white">{t('common.updateAvailable')}</AlertTitle>
|
||||
<AlertDescription className="text-gray-300">
|
||||
A new version ({versionInfo.version}) is available.
|
||||
{t('common.newVersionAvailable', { version: versionInfo.version })}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -117,7 +117,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive" className="bg-red-900/20 border-red-500 text-red-300">
|
||||
<AlertTitle className="text-red-300">Error</AlertTitle>
|
||||
<AlertTitle className="text-red-300">{t('common.error')}</AlertTitle>
|
||||
<AlertDescription className="text-red-300">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -135,7 +135,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
{release.isPrerelease && (
|
||||
<span
|
||||
className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
|
||||
Pre-release
|
||||
{t('common.preRelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -158,9 +158,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
|
||||
|
||||
{releases && releases.items.length === 0 && !loading && (
|
||||
<Alert className="bg-[#0e0e10] border-[#303032] text-gray-300">
|
||||
<AlertTitle className="text-gray-300">No Releases</AlertTitle>
|
||||
<AlertTitle className="text-gray-300">{t('common.noReleases')}</AlertTitle>
|
||||
<AlertDescription className="text-gray-400">
|
||||
No releases found.
|
||||
{t('common.noReleasesFound')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
File,
|
||||
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -49,14 +50,7 @@ import {Card} from "@/components/ui/card.tsx";
|
||||
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
|
||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||
import {
|
||||
getOIDCConfig,
|
||||
getUserList,
|
||||
makeUserAdmin,
|
||||
removeAdminStatus,
|
||||
deleteUser,
|
||||
deleteAccount
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { deleteAccount } from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
@@ -112,26 +106,12 @@ export function LeftSidebar({
|
||||
username,
|
||||
children,
|
||||
}: SidebarProps): React.ReactElement {
|
||||
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const [adminCount, setAdminCount] = React.useState(0);
|
||||
|
||||
const [users, setUsers] = React.useState<Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
}>>([]);
|
||||
const [newAdminUsername, setNewAdminUsername] = React.useState("");
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
|
||||
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
|
||||
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
|
||||
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
|
||||
|
||||
@@ -161,33 +141,7 @@ export function LeftSidebar({
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (adminSheetOpen) {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt && isAdmin) {
|
||||
getOIDCConfig().then(res => {
|
||||
if (res) {
|
||||
setOidcConfig(res);
|
||||
}
|
||||
}).catch((error) => {
|
||||
});
|
||||
fetchUsers();
|
||||
}
|
||||
} else {
|
||||
const jwt = getCookie("jwt");
|
||||
if (jwt && isAdmin) {
|
||||
fetchAdminCount();
|
||||
}
|
||||
}
|
||||
}, [adminSheetOpen, isAdmin]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isAdmin) {
|
||||
setAdminSheetOpen(false);
|
||||
setUsers([]);
|
||||
setAdminCount(0);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
const fetchHosts = React.useCallback(async () => {
|
||||
try {
|
||||
@@ -304,7 +258,7 @@ export function LeftSidebar({
|
||||
setDeleteError(null);
|
||||
|
||||
if (!deletePassword.trim()) {
|
||||
setDeleteError("Password is required");
|
||||
setDeleteError(t('leftSidebar.passwordRequired'));
|
||||
setDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -315,103 +269,11 @@ export function LeftSidebar({
|
||||
|
||||
handleLogout();
|
||||
} catch (err: any) {
|
||||
setDeleteError(err?.response?.data?.error || "Failed to delete account");
|
||||
setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount'));
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
|
||||
if (!jwt || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUsersLoading(true);
|
||||
try {
|
||||
const response = await getUserList();
|
||||
setUsers(response.users);
|
||||
|
||||
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
} finally {
|
||||
setUsersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAdminCount = async () => {
|
||||
const jwt = getCookie("jwt");
|
||||
|
||||
if (!jwt || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getUserList();
|
||||
const adminUsers = response.users.filter((user: any) => user.is_admin);
|
||||
setAdminCount(adminUsers.length);
|
||||
} catch (err: any) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMakeUserAdmin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newAdminUsername.trim()) return;
|
||||
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMakeAdminLoading(true);
|
||||
setMakeAdminError(null);
|
||||
setMakeAdminSuccess(null);
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await makeUserAdmin(newAdminUsername.trim());
|
||||
setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
|
||||
setNewAdminUsername("");
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
|
||||
} finally {
|
||||
setMakeAdminLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdminStatus = async (username: string) => {
|
||||
if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
|
||||
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await removeAdminStatus(username);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to remove admin status:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (username: string) => {
|
||||
if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
|
||||
|
||||
if (!isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteUser(username);
|
||||
fetchUsers();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete user:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
@@ -423,6 +285,7 @@ export function LeftSidebar({
|
||||
variant="outline"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-[28px] h-[28px] absolute right-5"
|
||||
title={t('common.toggleSidebar')}
|
||||
>
|
||||
<Menu className="h-4 w-4"/>
|
||||
</Button>
|
||||
@@ -433,9 +296,9 @@ export function LeftSidebar({
|
||||
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
||||
<Button className="m-2 flex flex-row font-semibold border-2 !border-[#303032]" variant="outline"
|
||||
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
|
||||
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}>
|
||||
<HardDrive strokeWidth="2.5"/>
|
||||
Host Manager
|
||||
{t('nav.hostManager')}
|
||||
</Button>
|
||||
</SidebarGroup>
|
||||
<Separator className="p-0.25"/>
|
||||
@@ -444,7 +307,7 @@ export function LeftSidebar({
|
||||
<Input
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search hosts by any info..."
|
||||
placeholder={t('placeholders.searchHostsAny')}
|
||||
className="w-full h-8 text-sm border-2 !bg-[#222225] border-[#303032] rounded-md"
|
||||
autoComplete="off"
|
||||
/>
|
||||
@@ -454,7 +317,7 @@ export function LeftSidebar({
|
||||
<div className="px-1">
|
||||
<div
|
||||
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
{hostsError}
|
||||
{t('leftSidebar.failedToLoadHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -462,7 +325,7 @@ export function LeftSidebar({
|
||||
{hostsLoading && (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
Loading hosts...
|
||||
{t('hosts.loadingHosts')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -489,7 +352,7 @@ export function LeftSidebar({
|
||||
style={{width: '100%'}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<User2/> {username ? username : 'Signed out'}
|
||||
<User2/> {username ? username : t('common.logout')}
|
||||
<ChevronUp className="ml-auto"/>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -508,10 +371,10 @@ export function LeftSidebar({
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({type: 'profile', title: 'Profile'} as any);
|
||||
const id = addTab({type: 'profile', title: t('profile.title')} as any);
|
||||
setCurrentTab(id);
|
||||
}}>
|
||||
<span>Profile & Security</span>
|
||||
<span>{t('profile.title')}</span>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
@@ -519,23 +382,20 @@ export function LeftSidebar({
|
||||
onClick={() => {
|
||||
if (isAdmin) openAdminTab();
|
||||
}}>
|
||||
<span>Admin Settings</span>
|
||||
<span>{t('admin.title')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={handleLogout}>
|
||||
<span>Sign out</span>
|
||||
<span>{t('common.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
disabled={isAdmin && adminCount <= 1}
|
||||
>
|
||||
<span
|
||||
className={isAdmin && adminCount <= 1 ? "text-muted-foreground" : "text-red-400"}>
|
||||
Delete Account
|
||||
{isAdmin && adminCount <= 1 && " (Last Admin)"}
|
||||
<span className="text-red-400">
|
||||
{t('leftSidebar.deleteAccount')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -588,7 +448,7 @@ export function LeftSidebar({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||
<h2 className="text-lg font-semibold text-white">Delete Account</h2>
|
||||
<h2 className="text-lg font-semibold text-white">{t('leftSidebar.deleteAccount')}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -598,7 +458,7 @@ export function LeftSidebar({
|
||||
setDeleteError(null);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title="Close Delete Account"
|
||||
title={t('leftSidebar.closeDeleteAccount')}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
@@ -607,48 +467,33 @@ export function LeftSidebar({
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
This action cannot be undone. This will permanently delete your account and all
|
||||
associated data.
|
||||
{t('leftSidebar.deleteAccountWarning')}
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertTitle>{t('common.warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
Deleting your account will remove all your data including SSH hosts,
|
||||
configurations, and settings.
|
||||
This action is irreversible.
|
||||
{t('leftSidebar.deleteAccountWarningDetails')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
{isAdmin && adminCount <= 1 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Cannot Delete Account</AlertTitle>
|
||||
<AlertDescription>
|
||||
You are the last admin user. You cannot delete your account as this
|
||||
would leave the system without any administrators.
|
||||
Please make another user an admin first, or contact system support.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-password">Confirm Password</Label>
|
||||
<Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder="Enter your password to confirm"
|
||||
placeholder={t('placeholders.confirmPassword')}
|
||||
required
|
||||
disabled={isAdmin && adminCount <= 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -657,9 +502,9 @@ export function LeftSidebar({
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={deleteLoading || !deletePassword.trim() || (isAdmin && adminCount <= 1)}
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
{deleteLoading ? "Deleting..." : "Delete Account"}
|
||||
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -670,7 +515,7 @@ export function LeftSidebar({
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
{t('leftSidebar.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {Input} from "@/components/ui/input.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
|
||||
import {toast} from "sonner";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
interface PasswordResetProps {
|
||||
userInfo: {
|
||||
@@ -18,6 +19,7 @@ interface PasswordResetProps {
|
||||
}
|
||||
|
||||
export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
const {t} = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
|
||||
@@ -35,7 +37,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetStep("verify");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
|
||||
setError(err?.response?.data?.error || err?.message || t('common.failedToInitiatePasswordReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -59,7 +61,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetStep("newPassword");
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to verify reset code");
|
||||
setError(err?.response?.data?.error || t('common.failedToVerifyResetCode'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -70,13 +72,13 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetLoading(true);
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setError(t('common.passwordsDoNotMatch'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError("Password must be at least 6 characters long");
|
||||
setError(t('common.passwordMinLength'));
|
||||
setResetLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -84,10 +86,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
try {
|
||||
await completePasswordReset(userInfo.username, tempToken, newPassword);
|
||||
|
||||
toast.success("Password reset successfully! You can now log in with your new password.");
|
||||
toast.success(t('common.passwordResetSuccess'));
|
||||
resetPasswordState();
|
||||
} catch (err: any) {
|
||||
setError(err?.response?.data?.error || "Failed to complete password reset");
|
||||
setError(err?.response?.data?.error || t('common.failedToCompletePasswordReset'));
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
@@ -105,10 +107,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5"/>
|
||||
Password
|
||||
{t('common.password')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Change your account password
|
||||
{t('common.changeAccountPassword')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -122,7 +124,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
disabled={resetLoading || !userInfo.username.trim()}
|
||||
onClick={handleInitiatePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Send Reset Code"}
|
||||
{resetLoading ? Spinner : t('common.sendResetCode')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -131,12 +133,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
{resetStep === "verify" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter the 6-digit code from the docker container logs for
|
||||
user: <strong>{userInfo.username}</strong></p>
|
||||
<p>{t('common.enterSixDigitCode')} <strong>{userInfo.username}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="reset-code">Reset Code</Label>
|
||||
<Label htmlFor="reset-code">{t('common.resetCode')}</Label>
|
||||
<Input
|
||||
id="reset-code"
|
||||
type="text"
|
||||
@@ -146,7 +147,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
value={resetCode}
|
||||
onChange={e => setResetCode(e.target.value.replace(/\D/g, ''))}
|
||||
disabled={resetLoading}
|
||||
placeholder="000000"
|
||||
placeholder={t('placeholders.enterCode')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -155,7 +156,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
disabled={resetLoading || resetCode.length !== 6}
|
||||
onClick={handleVerifyResetCode}
|
||||
>
|
||||
{resetLoading ? Spinner : "Verify Code"}
|
||||
{resetLoading ? Spinner : t('common.verifyCode')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -167,7 +168,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setResetCode("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -176,12 +177,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
{resetStep === "newPassword" && (
|
||||
<>
|
||||
<div className="text-center text-muted-foreground mb-4">
|
||||
<p>Enter your new password for
|
||||
user: <strong>{userInfo.username}</strong></p>
|
||||
<p>{t('common.enterNewPassword')} <strong>{userInfo.username}</strong></p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="new-password">New Password</Label>
|
||||
<Label htmlFor="new-password">{t('common.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
@@ -194,7 +194,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirm-password">Confirm Password</Label>
|
||||
<Label htmlFor="confirm-password">{t('common.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
@@ -212,7 +212,7 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
disabled={resetLoading || !newPassword || !confirmPassword}
|
||||
onClick={handleCompletePasswordReset}
|
||||
>
|
||||
{resetLoading ? Spinner : "Reset Password"}
|
||||
{resetLoading ? Spinner : t('common.resetPassword')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -225,14 +225,14 @@ export function PasswordReset({userInfo}: PasswordResetProps) {
|
||||
setConfirmPassword("");
|
||||
}}
|
||||
>
|
||||
Back
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mt-4">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user