diff --git a/README.md b/README.md
index 72d47b80..64fcca64 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts
index e976da89..f0a892e8 100644
--- a/src/backend/database/routes/users.ts
+++ b/src/backend/database/routes/users.ts
@@ -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);
}
diff --git a/src/ui/Admin/AdminSettings.tsx b/src/ui/Admin/AdminSettings.tsx
index dcd1270f..ca1ab4f0 100644
--- a/src/ui/Admin/AdminSettings.tsx
+++ b/src/ui/Admin/AdminSettings.tsx
@@ -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">
-
Admin Settings
+ {t('admin.title')}
@@ -213,7 +215,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
- General
+ {t('admin.general')}
@@ -221,97 +223,96 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
- Users
+ {t('admin.users')}
- Admins
+ {t('admin.adminManagement')}
-
User Registration
+ {t('admin.userRegistration')}
-
External Authentication (OIDC)
-
Configure external identity provider for
- OIDC/OAuth2 authentication.
+
{t('admin.externalAuthentication')}
+
{t('admin.configureExternalProvider')}
{oidcError && (
- Error
+ {t('common.error')}
{oidcError}
)}
@@ -331,20 +332,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
-
User Management
+ {t('admin.userManagement')}
+ size="sm">{usersLoading ? t('admin.loading') : t('admin.refresh')}
{usersLoading ? (
-
Loading users...
+
{t('admin.loadingUsers')}
) : (
- Username
- Type
- Actions
+ {t('admin.username')}
+ {t('admin.type')}
+ {t('admin.actions')}
@@ -354,11 +355,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.
{user.username}
{user.is_admin && (
Admin
+ 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')}
)}
{user.is_oidc ? "External" : "Local"}
+ className="px-4">{user.is_oidc ? t('admin.external') : t('admin.local')}