diff --git a/.env b/.env index c22d1734..c1e19f61 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.4.0 \ No newline at end of file +VERSION=1.5.0 \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4a7cecd9..b2bf6f80 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -73,7 +73,7 @@ jobs: elif [ "${{ github.ref }}" == "refs/heads/development" ]; then IMAGE_TAG="development-latest" else - IMAGE_TAG="${{ github.ref_name }}-development-latest" + IMAGE_TAG="${{ github.ref_name }}" fi echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV 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/docker/Dockerfile b/docker/Dockerfile index 15f3d81f..7a95cabb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -57,6 +57,7 @@ RUN apk add --no-cache nginx gettext su-exec && \ COPY docker/nginx.conf /etc/nginx/nginx.conf COPY --from=frontend-builder /app/dist /usr/share/nginx/html +COPY --from=frontend-builder /app/public/locales /usr/share/nginx/html/locales RUN chown -R nginx:nginx /usr/share/nginx/html WORKDIR /app diff --git a/package-lock.json b/package-lock.json index 1ec9298d..a3195012 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,9 @@ "dotenv": "^17.2.0", "drizzle-orm": "^0.44.3", "express": "^5.1.0", + "i18next": "^25.4.2", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "jose": "^5.2.3", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.525.0", @@ -64,6 +67,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", + "react-i18next": "^15.7.3", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", @@ -5071,6 +5075,35 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6232,6 +6265,15 @@ "node": ">= 0.4" } }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -6257,6 +6299,55 @@ "node": ">= 0.8" } }, + "node_modules/i18next": { + "version": "25.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.4.2.tgz", + "integrity": "sha512-gD4T25a6ovNXsfXY1TwHXXXLnD/K2t99jyYMCSimSCBnBRJVQr5j+VAaU83RJCPzrTGhVQ6dqIga66xO2rtd5g==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz", + "integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -7644,6 +7735,32 @@ "react": "^16.8.0 || ^17 || ^18 || ^19" } }, + "node_modules/react-i18next": { + "version": "15.7.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.3.tgz", + "integrity": "sha512-AANws4tOE+QSq/IeMF/ncoHlMNZaVLxpa5uUGW1wjike68elVYr0018L9xYoqBr1OFO7G7boDPrbn0HpMCJxTw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 25.4.1", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", @@ -8365,6 +8482,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -8493,7 +8616,7 @@ "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8769,6 +8892,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -8784,6 +8916,22 @@ "node": ">= 8" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index bfb57933..81530bff 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,9 @@ "dotenv": "^17.2.0", "drizzle-orm": "^0.44.3", "express": "^5.1.0", + "i18next": "^25.4.2", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-backend": "^3.0.2", "jose": "^5.2.3", "jsonwebtoken": "^9.0.2", "lucide-react": "^0.525.0", @@ -68,6 +71,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", + "react-i18next": "^15.7.3", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 00000000..d1d55c02 --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,814 @@ +{ + "sshTools": { + "title": "SSH Tools", + "closeTools": "Close SSH Tools", + "keyRecording": "Key Recording", + "startKeyRecording": "Start Key Recording", + "stopKeyRecording": "Stop Key Recording", + "selectTerminals": "Select terminals:", + "typeCommands": "Type commands (all keys supported):", + "commandsWillBeSent": "Commands will be sent to {{count}} selected terminal(s).", + "settings": "Settings", + "enableRightClickCopyPaste": "Enable right‑click copy/paste", + "shareIdeas": "Have ideas for what should come next for ssh tools? Share them on" + }, + "homepage": { + "loggedInTitle": "Logged in!", + "loggedInMessage": "You are logged in! Use the sidebar to access all available tools. To get started, create an SSH Host in the SSH Manager tab. Once created, you can connect to that host using the other apps in the sidebar.", + "failedToLoadAlerts": "Failed to load alerts", + "failedToDismissAlert": "Failed to dismiss alert" + }, + "common": { + "close": "Close", + "online": "Online", + "offline": "Offline", + "maintenance": "Maintenance", + "degraded": "Degraded", + "discord": "Discord", + "error": "Error", + "warning": "Warning", + "info": "Info", + "success": "Success", + "loading": "Loading", + "required": "Required", + "optional": "Optional", + "toggleSidebar": "Toggle Sidebar", + "sidebar": "Sidebar", + "home": "Home", + "expired": "Expired", + "expiresToday": "Expires today", + "expiresTomorrow": "Expires tomorrow", + "expiresInDays": "Expires in {{days}} days", + "updateAvailable": "Update Available", + "sshPath": "SSH Path", + "localPath": "Local Path", + "loading": "Loading...", + "noAuthCredentials": "No authentication credentials available for this SSH host", + "noReleases": "No Releases", + "updatesAndReleases": "Updates & Releases", + "newVersionAvailable": "A new version ({{version}}) is available.", + "failedToFetchUpdateInfo": "Failed to fetch update information", + "preRelease": "Pre-release", + "noReleasesFound": "No releases found.", + "yourBackupCodes": "Your Backup Codes", + "sendResetCode": "Send Reset Code", + "verifyCode": "Verify Code", + "resetPassword": "Reset Password", + "resetCode": "Reset Code", + "newPassword": "New Password", + "sshPath": "SSH Path", + "localPath": "Local Path", + "folder": "Folder", + "file": "File", + "renamedSuccessfully": "renamed successfully", + "deletedSuccessfully": "deleted successfully", + "noAuthCredentials": "No authentication credentials available for this SSH host", + "noTunnelConnections": "No tunnel connections configured", + "sshTools": "SSH Tools", + "english": "English", + "chinese": "Chinese", + "login": "Login", + "logout": "Logout", + "register": "Register", + "username": "Username", + "password": "Password", + "confirmPassword": "Confirm Password", + "back": "Back", + "email": "Email", + "submit": "Submit", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "search": "Search", + "loading": "Loading...", + "error": "Error", + "success": "Success", + "warning": "Warning", + "info": "Info", + "confirm": "Confirm", + "yes": "Yes", + "no": "No", + "ok": "OK", + "close": "Close", + "enabled": "Enabled", + "disabled": "Disabled", + "important": "Important", + "notEnabled": "Not Enabled", + "settingUp": "Setting up...", + "back": "Back", + "next": "Next", + "previous": "Previous", + "refresh": "Refresh", + "settings": "Settings", + "profile": "Profile", + "help": "Help", + "about": "About", + "language": "Language", + "autoDetect": "Auto-detect", + "changeAccountPassword": "Change your account password", + "enterSixDigitCode": "Enter the 6-digit code from the docker container logs for user:", + "enterNewPassword": "Enter your new password for user:", + "passwordsDoNotMatch": "Passwords do not match", + "passwordMinLength": "Password must be at least 6 characters long", + "passwordResetSuccess": "Password reset successfully! You can now log in with your new password.", + "failedToInitiatePasswordReset": "Failed to initiate password reset", + "failedToVerifyResetCode": "Failed to verify reset code", + "failedToCompletePasswordReset": "Failed to complete password reset" + }, + "nav": { + "home": "Home", + "hosts": "Hosts", + "terminal": "Terminal", + "tunnels": "Tunnels", + "fileManager": "File Manager", + "serverStats": "Server Stats", + "admin": "Admin", + "tools": "Tools", + "newTab": "New Tab", + "splitScreen": "Split Screen", + "closeTab": "Close Tab", + "sshManager": "SSH Manager", + "hostManager": "Host Manager", + "cannotSplitTab": "Cannot split this tab" + }, + "admin": { + "title": "Admin Settings", + "users": "Users", + "userManagement": "User Management", + "makeAdmin": "Make Admin", + "removeAdmin": "Remove Admin", + "deleteUser": "Delete User", + "allowRegistration": "Allow Registration", + "oidcSettings": "OIDC Settings", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "issuerUrl": "Issuer URL", + "authorizationUrl": "Authorization URL", + "tokenUrl": "Token URL", + "updateSettings": "Update Settings", + "confirmDelete": "Are you sure you want to delete this user?", + "confirmMakeAdmin": "Are you sure you want to make this user an admin?", + "confirmRemoveAdmin": "Are you sure you want to remove admin privileges from this user?", + "externalAuthentication": "External Authentication (OIDC)", + "configureExternalProvider": "Configure external identity provider for OIDC/OAuth2 authentication.", + "userIdentifierPath": "User Identifier Path", + "displayNamePath": "Display Name Path", + "scopes": "Scopes", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "reset": "Reset", + "success": "Success", + "loading": "Loading...", + "refresh": "Refresh", + "loadingUsers": "Loading users...", + "username": "Username", + "type": "Type", + "actions": "Actions", + "external": "External", + "local": "Local", + "adminManagement": "Admin Management", + "makeUserAdmin": "Make User Admin", + "adding": "Adding...", + "currentAdmins": "Current Admins", + "adminBadge": "Admin", + "removeAdminButton": "Remove Admin", + "general": "General", + "userRegistration": "User Registration", + "allowNewAccountRegistration": "Allow new account registration", + "missingRequiredFields": "Missing required fields: {{fields}}", + "oidcConfigurationUpdated": "OIDC configuration updated successfully!", + "failedToUpdateOidcConfig": "Failed to update OIDC configuration", + "enterUsernameToMakeAdmin": "Enter username to make admin", + "userIsNowAdmin": "User {{username}} is now an admin", + "failedToMakeUserAdmin": "Failed to make user admin", + "removeAdminStatus": "Remove admin status from {{username}}?", + "adminStatusRemoved": "Admin status removed from {{username}}", + "failedToRemoveAdminStatus": "Failed to remove admin status", + "deleteUser": "Delete user {{username}}? This cannot be undone.", + "userDeletedSuccessfully": "User {{username}} deleted successfully", + "failedToDeleteUser": "Failed to delete user", + "overrideUserInfoUrl": "Override User Info URL (not required)" + }, + "hosts": { + "title": "Host Manager", + "sshHosts": "SSH Hosts", + "noHosts": "No SSH Hosts", + "noHostsMessage": "You haven't added any SSH hosts yet. Click \"Add Host\" to get started.", + "loadingHosts": "Loading hosts...", + "failedToLoadHosts": "Failed to load hosts", + "retry": "Retry", + "refresh": "Refresh", + "hostsCount": "{{count}} hosts", + "importJson": "Import JSON", + "importing": "Importing...", + "importJsonTitle": "Import SSH Hosts from JSON", + "importJsonDesc": "Upload a JSON file to bulk import multiple SSH hosts (max 100).", + "downloadSample": "Download Sample", + "formatGuide": "Format Guide", + "uncategorized": "Uncategorized", + "confirmDelete": "Are you sure you want to delete \"{{name}}\"?", + "failedToDeleteHost": "Failed to delete host", + "jsonMustContainHosts": "JSON must contain a \"hosts\" array or be an array of hosts", + "noHostsInJson": "No hosts found in JSON file", + "maxHostsAllowed": "Maximum 100 hosts allowed per import", + "importCompleted": "Import completed: {{success}} successful, {{failed}} failed", + "importFailed": "Import failed", + "importError": "Import error", + "failedToImportJson": "Failed to import JSON file", + "connectionDetails": "Connection Details", + "organization": "Organization", + "ipAddress": "IP Address", + "port": "Port", + "name": "Name", + "username": "Username", + "folder": "Folder", + "tags": "Tags", + "pin": "Pin", + "passwordRequired": "Password is required when using password authentication", + "sshKeyRequired": "SSH Private Key is required when using key authentication", + "keyTypeRequired": "Key Type is required when using key authentication", + "mustSelectValidSshConfig": "Must select a valid SSH configuration from the list", + "addHost": "Add Host", + "editHost": "Edit Host", + "updateHost": "Update Host", + "hostUpdatedSuccessfully": "Host \"{{name}}\" updated successfully!", + "hostAddedSuccessfully": "Host \"{{name}}\" added successfully!", + "hostDeletedSuccessfully": "Host \"{{name}}\" deleted successfully!", + "failedToSaveHost": "Failed to save host. Please try again.", + "enableTerminal": "Enable Terminal", + "enableTerminalDesc": "Enable/disable host visibility in Terminal tab", + "enableTunnel": "Enable Tunnel", + "enableTunnelDesc": "Enable/disable host visibility in Tunnel tab", + "enableFileManager": "Enable File Manager", + "enableFileManagerDesc": "Enable/disable host visibility in File Manager tab", + "defaultPath": "Default Path", + "defaultPathDesc": "Default directory when opening file manager for this host", + "tunnelConnections": "Tunnel Connections", + "connection": "Connection", + "remove": "Remove", + "sourcePort": "Source Port", + "sourcePortDesc": " (Source refers to the Current Connection Details in the General tab)", + "endpointPort": "Endpoint Port", + "endpointSshConfig": "Endpoint SSH Configuration", + "tunnelForwardDescription": "This tunnel will forward traffic from port {{sourcePort}} on the source machine (current connection details in general tab) to port {{endpointPort}} on the endpoint machine.", + "maxRetries": "Max Retries", + "maxRetriesDescription": "Maximum number of retry attempts for tunnel connection.", + "retryInterval": "Retry Interval (seconds)", + "retryIntervalDescription": "Time to wait between retry attempts.", + "autoStartContainer": "Auto Start on Container Launch", + "autoStartDesc": "Automatically start this tunnel when the container launches", + "addConnection": "Add Tunnel Connection", + "sshpassRequired": "Sshpass Required For Password Authentication", + "sshpassRequiredDesc": "For password authentication in tunnels, sshpass must be installed on the system.", + "otherInstallMethods": "Other installation methods:", + "centosRhelFedora": "CentOS/RHEL/Fedora", + "macos": "macOS", + "windows": "Windows", + "sshServerConfigRequired": "SSH Server Configuration Required", + "sshServerConfigDesc": "For tunnel connections, the SSH server must be configured to allow port forwarding:", + "gatewayPortsYes": "to bind remote ports to all interfaces", + "allowTcpForwardingYes": "to enable port forwarding", + "permitRootLoginYes": "if using root user for tunneling", + "editSshConfig": "Edit /etc/ssh/sshd_config and restart SSH: sudo systemctl restart sshd", + "upload": "Upload", + "authentication": "Authentication", + "password": "Password", + "key": "Key", + "sshPrivateKey": "SSH Private Key", + "keyPassword": "Key Password", + "keyType": "Key Type", + "autoDetect": "Auto-detect", + "rsa": "RSA", + "ed25519": "ED25519", + "ecdsaNistP256": "ECDSA NIST P-256", + "ecdsaNistP384": "ECDSA NIST P-384", + "ecdsaNistP521": "ECDSA NIST P-521", + "dsa": "DSA", + "rsaSha2256": "RSA SHA2-256", + "rsaSha2512": "RSA SHA2-512", + "updateKey": "Update Key", + "addTagsSpaceToAdd": "add tags (space to add)", + "terminalBadge": "Terminal", + "tunnelBadge": "Tunnel", + "fileManagerBadge": "File Manager", + "general": "General", + "terminal": "Terminal", + "tunnel": "Tunnel", + "fileManager": "File Manager", + "hostViewer": "Host Viewer" + }, + "terminal": { + "title": "Terminal", + "connect": "Connect to Host", + "disconnect": "Disconnect", + "clear": "Clear", + "copy": "Copy", + "paste": "Paste", + "find": "Find", + "fullscreen": "Fullscreen", + "splitHorizontal": "Split Horizontal", + "splitVertical": "Split Vertical", + "closePanel": "Close Panel", + "reconnect": "Reconnect", + "sessionEnded": "Session Ended", + "connectionLost": "Connection Lost", + "error": "ERROR", + "disconnected": "Disconnected", + "connectionClosed": "Connection closed", + "connectionError": "Connection error" + }, + "fileManager": { + "title": "File Manager", + "file": "File", + "folder": "Folder", + "connectToSsh": "Connect to SSH to use file operations", + "uploadFile": "Upload File", + "newFile": "New File", + "newFolder": "New Folder", + "rename": "Rename", + "renameItem": "Rename Item", + "deleteItem": "Delete Item", + "currentPath": "Current Path", + "uploadFileTitle": "Upload File", + "maxFileSize": "Max: 100MB (JSON) / 200MB (Binary)", + "removeFile": "Remove File", + "clickToSelectFile": "Click to select a file", + "chooseFile": "Choose File", + "uploading": "Uploading...", + "createNewFile": "Create New File", + "fileName": "File Name", + "creating": "Creating...", + "createFile": "Create File", + "createNewFolder": "Create New Folder", + "folderName": "Folder Name", + "createFolder": "Create Folder", + "warningCannotUndo": "Warning: This action cannot be undone", + "itemPath": "Item Path", + "thisIsDirectory": "This is a directory (will delete recursively)", + "deleting": "Deleting...", + "currentPathLabel": "Current Path", + "newName": "New Name", + "thisIsDirectoryRename": "This is a directory", + "renaming": "Renaming...", + "fileUploadedSuccessfully": "File \"{{name}}\" uploaded successfully", + "failedToUploadFile": "Failed to upload file", + "fileCreatedSuccessfully": "File \"{{name}}\" created successfully", + "failedToCreateFile": "Failed to create file", + "folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully", + "failedToCreateFolder": "Failed to create folder", + "itemDeletedSuccessfully": "{{type}} deleted successfully", + "failedToDeleteItem": "Failed to delete item", + "itemRenamedSuccessfully": "{{type}} renamed successfully", + "failedToRenameItem": "Failed to rename item", + "upload": "Upload", + "download": "Download", + "newFile": "New File", + "newFolder": "New Folder", + "rename": "Rename", + "delete": "Delete", + "permissions": "Permissions", + "size": "Size", + "modified": "Modified", + "path": "Path", + "fileName": "File Name", + "folderName": "Folder Name", + "confirmDelete": "Are you sure you want to delete {{name}}?", + "uploadSuccess": "File uploaded successfully", + "uploadFailed": "File upload failed", + "downloadSuccess": "File downloaded successfully", + "downloadFailed": "File download failed", + "permissionDenied": "Permission denied", + "checkDockerLogs": "Check the Docker logs for detailed error information", + "internalServerError": "Internal server error occurred", + "serverError": "Server Error", + "error": "Error", + "requestFailed": "Request failed with status code", + "unknown": "unknown", + "cannotReadFile": "Cannot read file", + "noSshSessionId": "No SSH session ID available", + "noFilePath": "No file path available", + "noCurrentHost": "No current host available", + "fileSavedSuccessfully": "File saved successfully", + "saveTimeout": "Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.", + "failedToSaveFile": "Failed to save file", + "folder": "Folder", + "file": "File", + "deletedSuccessfully": "deleted successfully", + "failedToDeleteItem": "Failed to delete item", + "connectToServer": "Connect to a Server", + "selectServerToEdit": "Select a server from the sidebar to start editing files", + "fileOperations": "File Operations", + "confirmDeleteMessage": "Are you sure you want to delete {{name}}?", + "deleteDirectoryWarning": "This will delete the folder and all its contents.", + "actionCannotBeUndone": "This action cannot be undone.", + "recent": "Recent", + "pinned": "Pinned", + "folderShortcuts": "Folder Shortcuts", + "noRecentFiles": "No recent files.", + "noPinnedFiles": "No pinned files.", + "enterFolderPath": "Enter folder path", + "noShortcuts": "No shortcuts.", + "searchFilesAndFolders": "Search files and folders...", + "noFilesOrFoldersFound": "No files or folders found.", + "failedToConnectSSH": "Failed to connect to SSH", + "failedToReconnectSSH": "Failed to reconnect SSH session", + "failedToListFiles": "Failed to list files", + "fetchHomeDataTimeout": "Fetch home data timed out", + "sshStatusCheckTimeout": "SSH status check timed out", + "sshReconnectionTimeout": "SSH reconnection timed out", + "saveOperationTimeout": "Save operation timed out", + "cannotSaveFile": "Cannot save file" + }, + "tunnels": { + "title": "SSH Tunnels", + "noSshTunnels": "No SSH Tunnels", + "createFirstTunnelMessage": "You haven't created any SSH tunnels yet. Configure tunnel connections in the Host Manager to get started.", + "connected": "Connected", + "disconnected": "Disconnected", + "connecting": "Connecting...", + "disconnecting": "Disconnecting...", + "unknown": "Unknown", + "error": "Error", + "failed": "Failed", + "retrying": "Retrying", + "waiting": "Waiting", + "waitingForRetry": "Waiting for retry", + "retryingConnection": "Retrying connection", + "canceling": "Canceling...", + "connect": "Connect", + "disconnect": "Disconnect", + "cancel": "Cancel", + "port": "Port", + "attempt": "Attempt {{current}} of {{max}}", + "nextRetryIn": "Next retry in {{seconds}} seconds", + "checkDockerLogs": "Check your Docker logs for the error reason, join the", + "noTunnelConnections": "No tunnel connections configured", + "tunnelConnections": "Tunnel Connections", + "addTunnel": "Add Tunnel", + "editTunnel": "Edit Tunnel", + "deleteTunnel": "Delete Tunnel", + "tunnelName": "Tunnel Name", + "localPort": "Local Port", + "remoteHost": "Remote Host", + "remotePort": "Remote Port", + "autoStart": "Auto Start", + "status": "Status", + "active": "Active", + "inactive": "Inactive", + "start": "Start", + "stop": "Stop", + "restart": "Restart", + "connectionType": "Connection Type", + "local": "Local", + "remote": "Remote", + "dynamic": "Dynamic", + "noSshTunnels": "No SSH Tunnels", + "createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.", + "unknown": "Unknown", + "connected": "Connected", + "connecting": "Connecting...", + "disconnecting": "Disconnecting...", + "disconnected": "Disconnected", + "portMapping": "Port {{sourcePort}} → {{endpointHost}}:{{endpointPort}}", + "disconnect": "Disconnect", + "connect": "Connect", + "canceling": "Canceling...", + "endpointHostNotFound": "Endpoint host not found" + }, + "serverStats": { + "title": "Server Statistics", + "cpu": "CPU", + "memory": "Memory", + "disk": "Disk", + "network": "Network", + "uptime": "Uptime", + "loadAverage": "Load Average", + "processes": "Processes", + "connections": "Connections", + "usage": "Usage", + "available": "Available", + "total": "Total", + "free": "Free", + "used": "Used", + "percentage": "Percentage", + "refreshStatusAndMetrics": "Refresh status and metrics", + "refreshStatus": "Refresh Status", + "fileManagerAlreadyOpen": "File Manager already open for this host", + "openFileManager": "Open File Manager", + "cpuCores_one": "{{count}} CPU", + "cpuCores_other": "{{count}} CPUs", + "naCpus": "N/A CPU(s)", + "loadAverage": "Avg: {{avg1}}, {{avg5}}, {{avg15}}", + "loadAverageNA": "Avg: N/A", + "cpuUsage": "CPU Usage", + "memoryUsage": "Memory Usage", + "rootStorageSpace": "Root Storage Space", + "of": "of", + "feedbackMessage": "Have ideas for what should come next for server management? Share them on" + }, + "auth": { + "loginTitle": "Login to Termix", + "registerTitle": "Create Account", + "loginButton": "Login", + "registerButton": "Register", + "forgotPassword": "Forgot Password?", + "rememberMe": "Remember Me", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "loginSuccess": "Login successful", + "loginFailed": "Login failed", + "registerSuccess": "Registration successful", + "registerFailed": "Registration failed", + "logoutSuccess": "Logged out successfully", + "invalidCredentials": "Invalid username or password", + "accountCreated": "Account created successfully", + "passwordReset": "Password reset link sent", + "twoFactorAuth": "Two-Factor Authentication", + "enterCode": "Enter verification code", + "backupCode": "Or use backup code", + "verifyCode": "Verify Code", + "enableTwoFactor": "Enable Two-Factor Authentication", + "disableTwoFactor": "Disable Two-Factor Authentication", + "scanQRCode": "Scan this QR code with your authenticator app", + "backupCodes": "Backup Codes", + "saveBackupCodes": "Save these backup codes in a safe place", + "twoFactorEnabledSuccess": "Two-factor authentication enabled successfully!", + "twoFactorDisabled": "Two-factor authentication disabled", + "newBackupCodesGenerated": "New backup codes generated", + "backupCodesDownloaded": "Backup codes downloaded", + "pleaseEnterSixDigitCode": "Please enter a 6-digit code", + "invalidVerificationCode": "Invalid verification code", + "failedToDisableTotp": "Failed to disable TOTP", + "failedToGenerateBackupCodes": "Failed to generate backup codes", + "enterPassword": "Enter your password", + "lockedOidcAuth": "Locked (OIDC Auth)", + "twoFactorTitle": "Two-Factor Authentication", + "twoFactorProtected": "Your account is protected with two-factor authentication", + "twoFactorActive": "Two-factor authentication is currently active on your account", + "disable2FA": "Disable 2FA", + "disableTwoFactorWarning": "Disabling two-factor authentication will make your account less secure", + "passwordOrTotpCode": "Password or TOTP Code", + "or": "Or", + "generateNewBackupCodesText": "Generate new backup codes if you've lost your existing ones", + "generateNewBackupCodes": "Generate New Backup Codes", + "yourBackupCodes": "Your Backup Codes", + "download": "Download", + "setupTwoFactorTitle": "Set Up Two-Factor Authentication", + "step1ScanQR": "Step 1: Scan the QR code with your authenticator app", + "manualEntryCode": "Manual Entry Code", + "cannotScanQRText": "If you can't scan the QR code, enter this code manually in your authenticator app", + "nextVerifyCode": "Next: Verify Code", + "verifyAuthenticator": "Verify Your Authenticator", + "step2EnterCode": "Step 2: Enter the 6-digit code from your authenticator app", + "verificationCode": "Verification Code", + "back": "Back", + "verifyAndEnable": "Verify and Enable", + "saveBackupCodesTitle": "Save Your Backup Codes", + "step3StoreCodesSecurely": "Step 3: Store these codes in a safe place", + "importantBackupCodesText": "Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device.", + "completeSetup": "Complete Setup", + "notEnabledText": "Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in.", + "enableTwoFactorButton": "Enable Two-Factor Authentication", + "addExtraSecurityLayer": "Add an extra layer of security to your account", + "firstUser": "First User", + "firstUserMessage": "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", + "external": "External", + "loginWithExternal": "Login with External Provider", + "loginWithExternalDesc": "Login using your configured external identity provider", + "resetPasswordButton": "Reset Password", + "sendResetCode": "Send Reset Code", + "resetCodeDesc": "Enter your username to receive a password reset code. The code will be logged in the docker container logs.", + "resetCode": "Reset Code", + "verifyCodeButton": "Verify Code", + "enterResetCode": "Enter the 6-digit code from the docker container logs for user:", + "goToLogin": "Go to Login", + "newPassword": "New Password", + "confirmNewPassword": "Confirm Password", + "enterNewPassword": "Enter your new password for user:", + "passwordResetSuccess": "Success!", + "passwordResetSuccessDesc": "Your password has been successfully reset! You can now log in with your new password.", + "signUp": "Sign Up" + }, + "errors": { + "notFound": "Page not found", + "unauthorized": "Unauthorized access", + "forbidden": "Access forbidden", + "serverError": "Server error", + "networkError": "Network error", + "databaseConnection": "Could not connect to the database. Please try again later.", + "unknownError": "Unknown error", + "failedPasswordReset": "Failed to initiate password reset", + "failedVerifyCode": "Failed to verify reset code", + "failedCompleteReset": "Failed to complete password reset", + "invalidTotpCode": "Invalid TOTP code", + "failedOidcLogin": "Failed to start OIDC login", + "failedUserInfo": "Failed to get user info after OIDC login", + "oidcAuthFailed": "OIDC authentication failed", + "noTokenReceived": "No token received from login", + "invalidAuthUrl": "Invalid authorization URL received from backend", + "connectionTimeout": "Connection timeout", + "invalidInput": "Invalid input", + "requiredField": "This field is required", + "minLength": "Minimum length is {{min}}", + "maxLength": "Maximum length is {{max}}", + "invalidEmail": "Invalid email address", + "passwordMismatch": "Passwords do not match", + "weakPassword": "Password is too weak", + "usernameExists": "Username already exists", + "emailExists": "Email already exists", + "loadFailed": "Failed to load data", + "saveError": "Failed to save" + }, + "messages": { + "saveSuccess": "Saved successfully", + "saveError": "Failed to save", + "deleteSuccess": "Deleted successfully", + "deleteError": "Failed to delete", + "updateSuccess": "Updated successfully", + "updateError": "Failed to update", + "copySuccess": "Copied to clipboard", + "copyError": "Failed to copy", + "copiedToClipboard": "{{item}} copied to clipboard", + "connectionEstablished": "Connection established", + "connectionClosed": "Connection closed", + "reconnecting": "Reconnecting...", + "processing": "Processing...", + "pleaseWait": "Please wait...", + "registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator." + }, + "profile": { + "title": "User Profile", + "description": "Manage your account settings and security", + "security": "Security", + "changePassword": "Change Password", + "twoFactorAuth": "Two-Factor Authentication", + "accountInfo": "Account Information", + "role": "Role", + "admin": "Administrator", + "user": "User", + "authMethod": "Authentication Method", + "local": "Local", + "external": "External (OIDC)", + "selectPreferredLanguage": "Select your preferred language for the interface" + }, + "placeholders": { + "enterCode": "000000", + "ipAddress": "127.0.0.1", + "port": "22", + "maxRetries": "3", + "retryInterval": "10", + "language": "Language", + "username": "username", + "hostname": "host name", + "folder": "folder", + "password": "password", + "keyPassword": "key password", + "sshConfig": "endpoint ssh configuration", + "homePath": "/home", + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "authUrl": "https://your-provider.com/application/o/authorize/", + "redirectUrl": "https://your-provider.com/application/o/termix/", + "tokenUrl": "https://your-provider.com/application/o/token/", + "userIdField": "sub", + "usernameField": "name", + "scopes": "openid email profile", + "enterUsername": "Enter username to make admin", + "searchHosts": "Search hosts by name, username, IP, folder, tags...", + "enterPassword": "Enter your password", + "totpCode": "6-digit TOTP code", + "searchHostsAny": "Search hosts by any info...", + "confirmPassword": "Enter your password to confirm", + "typeHere": "Type here", + "fileName": "Enter file name (e.g., example.txt)", + "folderName": "Enter folder name", + "fullPath": "Enter full path to item", + "currentPath": "Enter current path to item", + "newName": "Enter new name" + }, + "leftSidebar": { + "failedToLoadHosts": "Failed to load hosts", + "noFolder": "No Folder", + "passwordRequired": "Password is required", + "failedToDeleteAccount": "Failed to delete account", + "failedToMakeUserAdmin": "Failed to make user admin", + "userIsNowAdmin": "User {{username}} is now an admin", + "removeAdminConfirm": "Are you sure you want to remove admin status from {{username}}?", + "deleteUserConfirm": "Are you sure you want to delete user {{username}}? This action cannot be undone.", + "deleteAccount": "Delete Account", + "closeDeleteAccount": "Close Delete Account", + "deleteAccountWarning": "This action cannot be undone. This will permanently delete your account and all associated data.", + "deleteAccountWarningDetails": "Deleting your account will remove all your data including SSH hosts, configurations, and settings. This action is irreversible.", + "cannotDeleteAccount": "Cannot Delete Account", + "lastAdminWarning": "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.", + "confirmPassword": "Confirm Password", + "deleting": "Deleting...", + "cancel": "Cancel" + }, + "interface": { + "sidebar": "Sidebar", + "toggleSidebar": "Toggle Sidebar", + "close": "Close", + "online": "Online", + "offline": "Offline", + "maintenance": "Maintenance", + "degraded": "Degraded", + "noTunnelConnections": "No tunnel connections configured", + "discord": "Discord", + "connectToSshForOperations": "Connect to SSH to use file operations", + "uploadFile": "Upload File", + "newFile": "New File", + "newFolder": "New Folder", + "rename": "Rename", + "deleteItem": "Delete Item", + "createNewFile": "Create New File", + "createNewFolder": "Create New Folder", + "deleteItem": "Delete Item", + "renameItem": "Rename Item", + "clickToSelectFile": "Click to select a file", + "noSshHosts": "No SSH Hosts", + "sshHosts": "SSH Hosts", + "importSshHosts": "Import SSH Hosts from JSON", + "clientId": "Client ID", + "clientSecret": "Client Secret", + "error": "Error", + "warning": "Warning", + "deleteAccount": "Delete Account", + "closeDeleteAccount": "Close Delete Account", + "cannotDeleteAccount": "Cannot Delete Account", + "confirmPassword": "Confirm Password", + "deleting": "Deleting...", + "externalAuth": "External Authentication (OIDC)", + "configureExternalProvider": "Configure external identity provider for", + "waitingForRetry": "Waiting for retry", + "retryingConnection": "Retrying connection", + "resetSplitSizes": "Reset split sizes", + "sshManagerAlreadyOpen": "SSH Manager already open", + "disabledDuringSplitScreen": "Disabled during split screen", + "unknown": "Unknown", + "connected": "Connected", + "disconnected": "Disconnected", + "maxRetriesExhausted": "Max retries exhausted", + "endpointHostNotFound": "Endpoint host not found", + "administrator": "Administrator", + "user": "User", + "external": "External", + "local": "Local", + "saving": "Saving...", + "saveConfiguration": "Save Configuration", + "loading": "Loading...", + "refresh": "Refresh", + "adding": "Adding...", + "makeAdmin": "Make Admin", + "verifying": "Verifying...", + "verifyAndEnable": "Verify and Enable", + "secretKey": "Secret key", + "totpQrCode": "TOTP QR Code", + "passwordRequired": "Password is required when using password authentication", + "sshKeyRequired": "SSH Private Key is required when using key authentication", + "keyTypeRequired": "Key Type is required when using key authentication", + "validSshConfigRequired": "Must select a valid SSH configuration from the list", + "updateHost": "Update Host", + "addHost": "Add Host", + "editHost": "Edit Host", + "pinConnection": "Pin Connection", + "authentication": "Authentication", + "password": "Password", + "key": "Key", + "sshPrivateKey": "SSH Private Key", + "keyPassword": "Key Password", + "keyType": "Key Type", + "enableTerminal": "Enable Terminal", + "enableTunnel": "Enable Tunnel", + "enableFileManager": "Enable File Manager", + "defaultPath": "Default Path", + "tunnelConnections": "Tunnel Connections", + "maxRetries": "Max Retries", + "upload": "Upload", + "updateKey": "Update Key", + "productionFolder": "Production", + "databaseServer": "Database Server", + "unknownError": "Unknown error", + "failedToInitiatePasswordReset": "Failed to initiate password reset", + "failedToVerifyResetCode": "Failed to verify reset code", + "failedToCompletePasswordReset": "Failed to complete password reset", + "invalidTotpCode": "Invalid TOTP code", + "failedToStartOidcLogin": "Failed to start OIDC login", + "failedToGetUserInfoAfterOidc": "Failed to get user info after OIDC login", + "loginWithExternalProvider": "Login with external provider", + "loginWithExternal": "Login with External Provider", + "sendResetCode": "Send Reset Code", + "verifyCode": "Verify Code", + "resetPassword": "Reset Password", + "login": "Login", + "signUp": "Sign Up", + "failedToUpdateOidcConfig": "Failed to update OIDC configuration", + "failedToMakeUserAdmin": "Failed to make user admin", + "failedToStartTotpSetup": "Failed to start TOTP setup", + "invalidVerificationCode": "Invalid verification code", + "failedToDisableTotp": "Failed to disable TOTP", + "failedToGenerateBackupCodes": "Failed to generate backup codes" + } +} \ No newline at end of file diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json new file mode 100644 index 00000000..c8f299dc --- /dev/null +++ b/public/locales/zh/translation.json @@ -0,0 +1,855 @@ +{ + "sshTools": { + "title": "SSH 工具", + "closeTools": "关闭 SSH 工具", + "keyRecording": "按键录制", + "startKeyRecording": "开始按键录制", + "stopKeyRecording": "停止按键录制", + "selectTerminals": "选择终端:", + "typeCommands": "输入命令(支持所有按键):", + "commandsWillBeSent": "命令将发送到 {{count}} 个选中的终端。", + "settings": "设置", + "enableRightClickCopyPaste": "启用右键复制/粘贴", + "shareIdeas": "对 SSH 工具有什么想法?在此分享" + }, + "homepage": { + "loggedInTitle": "登录成功!", + "loggedInMessage": "您已登录!使用侧边栏访问所有可用工具。要开始使用,请在 SSH 管理器选项卡中创建 SSH 主机。创建后,您可以使用侧边栏中的其他应用程序连接到该主机。", + "failedToLoadAlerts": "加载警报失败", + "failedToDismissAlert": "关闭警报失败" + }, + "common": { + "close": "关闭", + "online": "在线", + "offline": "离线", + "maintenance": "维护中", + "degraded": "降级", + "discord": "Discord", + "error": "错误", + "warning": "警告", + "info": "信息", + "success": "成功", + "loading": "加载中", + "required": "必填", + "optional": "可选", + "toggleSidebar": "切换侧边栏", + "sidebar": "侧边栏", + "home": "首页", + "expired": "已过期", + "expiresToday": "今天过期", + "expiresTomorrow": "明天过期", + "expiresInDays": "{{days}} 天后过期", + "updateAvailable": "有可用更新", + "sshPath": "SSH 路径", + "localPath": "本地路径", + "loading": "加载中...", + "noAuthCredentials": "此 SSH 主机没有可用的身份验证凭据", + "noReleases": "没有发布版本", + "updatesAndReleases": "更新与发布", + "newVersionAvailable": "有新版本 ({{version}}) 可用。", + "failedToFetchUpdateInfo": "获取更新信息失败", + "preRelease": "预发布版本", + "noReleasesFound": "未找到发布版本。", + "yourBackupCodes": "您的备份代码", + "sendResetCode": "发送重置代码", + "verifyCode": "验证代码", + "resetPassword": "重置密码", + "resetCode": "重置代码", + "newPassword": "新密码", + "sshPath": "SSH 路径", + "localPath": "本地路径", + "folder": "文件夹", + "file": "文件", + "renamedSuccessfully": "重命名成功", + "deletedSuccessfully": "删除成功", + "noAuthCredentials": "此 SSH 主机没有可用的认证凭据", + "noTunnelConnections": "没有配置隧道连接", + "sshTools": "SSH 工具", + "english": "英语", + "chinese": "中文", + "login": "登录", + "logout": "登出", + "register": "注册", + "username": "用户名", + "password": "密码", + "confirmPassword": "确认密码", + "back": "返回", + "email": "邮箱", + "submit": "提交", + "cancel": "取消", + "save": "保存", + "delete": "删除", + "edit": "编辑", + "add": "添加", + "search": "搜索", + "loading": "加载中...", + "error": "错误", + "success": "成功", + "warning": "警告", + "info": "信息", + "confirm": "确认", + "yes": "是", + "no": "否", + "ok": "确定", + "close": "关闭", + "enabled": "已启用", + "disabled": "已禁用", + "important": "重要", + "notEnabled": "未启用", + "settingUp": "设置中...", + "back": "返回", + "next": "下一步", + "previous": "上一步", + "refresh": "刷新", + "settings": "设置", + "profile": "个人资料", + "help": "帮助", + "about": "关于", + "language": "语言", + "autoDetect": "自动检测", + "changeAccountPassword": "修改您的账户密码", + "enterSixDigitCode": "输入来自 docker 容器日志中用户的 6 位数代码:", + "enterNewPassword": "为用户输入新密码:", + "passwordsDoNotMatch": "密码不匹配", + "passwordMinLength": "密码长度至少为 6 个字符", + "passwordResetSuccess": "密码重置成功!您现在可以使用新密码登录。", + "failedToInitiatePasswordReset": "启动密码重置失败", + "failedToVerifyResetCode": "验证重置代码失败", + "failedToCompletePasswordReset": "完成密码重置失败" + }, + "nav": { + "home": "首页", + "hosts": "主机", + "terminal": "终端", + "tunnels": "隧道", + "fileManager": "文件管理器", + "serverStats": "服务器统计", + "admin": "管理员", + "tools": "工具", + "newTab": "新标签页", + "splitScreen": "分屏", + "closeTab": "关闭标签页", + "sshManager": "SSH 管理器", + "hostManager": "主机管理器", + "cannotSplitTab": "无法分割此标签页" + }, + "admin": { + "title": "管理员设置", + "users": "用户", + "userManagement": "用户管理", + "makeAdmin": "设为管理员", + "removeAdmin": "移除管理员", + "deleteUser": "删除用户", + "allowRegistration": "允许注册", + "oidcSettings": "OIDC 设置", + "clientId": "客户端 ID", + "clientSecret": "客户端密钥", + "issuerUrl": "颁发者 URL", + "authorizationUrl": "授权 URL", + "tokenUrl": "令牌 URL", + "updateSettings": "更新设置", + "confirmDelete": "确定要删除此用户吗?", + "confirmMakeAdmin": "确定要将此用户设为管理员吗?", + "confirmRemoveAdmin": "确定要移除此用户的管理员权限吗?", + "externalAuthentication": "外部认证 (OIDC)", + "configureExternalProvider": "配置 OIDC/OAuth2 认证的外部身份提供者。", + "userIdentifierPath": "用户标识符路径", + "displayNamePath": "显示名称路径", + "scopes": "作用域", + "saving": "保存中...", + "saveConfiguration": "保存配置", + "reset": "重置", + "success": "成功", + "loading": "加载中...", + "refresh": "刷新", + "loadingUsers": "加载用户中...", + "username": "用户名", + "type": "类型", + "actions": "操作", + "external": "外部", + "local": "本地", + "adminManagement": "管理员管理", + "makeUserAdmin": "设置用户为管理员", + "adding": "添加中...", + "currentAdmins": "当前管理员", + "adminBadge": "管理员", + "removeAdminButton": "移除管理员", + "general": "常规", + "userRegistration": "用户注册", + "allowNewAccountRegistration": "允许新账户注册", + "missingRequiredFields": "缺少必填字段:{{fields}}", + "oidcConfigurationUpdated": "OIDC 配置更新成功!", + "failedToUpdateOidcConfig": "更新 OIDC 配置失败", + "enterUsernameToMakeAdmin": "输入用户名以设为管理员", + "userIsNowAdmin": "用户 {{username}} 现在是管理员", + "failedToMakeUserAdmin": "设为管理员失败", + "removeAdminStatus": "移除 {{username}} 的管理员权限吗?", + "adminStatusRemoved": "已移除 {{username}} 的管理员权限", + "failedToRemoveAdminStatus": "移除管理员权限失败", + "deleteUser": "删除用户 {{username}} 吗?此操作无法撤销。", + "userDeletedSuccessfully": "用户 {{username}} 删除成功", + "failedToDeleteUser": "删除用户失败", + "overrideUserInfoUrl": "覆盖用户信息 URL(非必填)" + }, + "hosts": { + "title": "主机管理", + "sshHosts": "SSH 主机", + "noHosts": "没有 SSH 主机", + "noHostsMessage": "您还没有添加任何 SSH 主机。点击\"添加主机\"开始使用。", + "loadingHosts": "加载主机中...", + "failedToLoadHosts": "加载主机失败", + "retry": "重试", + "refresh": "刷新", + "hostsCount": "{{count}} 个主机", + "importJson": "导入 JSON", + "importing": "导入中...", + "importJsonTitle": "从 JSON 导入 SSH 主机", + "importJsonDesc": "上传 JSON 文件以批量导入多个 SSH 主机(最多 100 个)。", + "downloadSample": "下载示例", + "formatGuide": "格式指南", + "uncategorized": "未分类", + "confirmDelete": "确定要删除 \"{{name}}\" 吗?", + "failedToDeleteHost": "删除主机失败", + "jsonMustContainHosts": "JSON 必须包含 \"hosts\" 数组或是一个主机数组", + "noHostsInJson": "JSON 文件中未找到主机", + "maxHostsAllowed": "每次导入最多允许 100 个主机", + "importCompleted": "导入完成:{{success}} 个成功,{{failed}} 个失败", + "importFailed": "导入失败", + "importError": "导入错误", + "failedToImportJson": "导入 JSON 文件失败", + "connectionDetails": "连接详情", + "organization": "组织", + "ipAddress": "IP 地址", + "port": "端口", + "name": "名称", + "username": "用户名", + "folder": "文件夹", + "tags": "标签", + "passwordRequired": "使用密码认证时需要密码", + "sshKeyRequired": "使用密钥认证时需要 SSH 私钥", + "keyTypeRequired": "使用密钥认证时需要密钥类型", + "mustSelectValidSshConfig": "必须从列表中选择有效的 SSH 配置", + "addHost": "添加主机", + "editHost": "编辑主机", + "deleteHost": "删除主机", + "hostName": "主机名", + "ipAddress": "IP 地址", + "port": "端口", + "authType": "认证类型", + "passwordAuth": "密码", + "keyAuth": "SSH 密钥", + "keyPassword": "密钥密码", + "keyType": "密钥类型", + "folder": "文件夹", + "tags": "标签", + "pin": "固定", + "enableTerminal": "启用终端", + "enableTunnel": "启用隧道", + "enableFileManager": "启用文件管理器", + "defaultPath": "默认路径", + "testConnection": "测试连接", + "connect": "连接", + "disconnect": "断开连接", + "connected": "已连接", + "disconnected": "已断开", + "connecting": "连接中...", + "connectionFailed": "连接失败", + "connectionSuccess": "连接成功", + "connectionDetails": "连接详情", + "organization": "组织管理", + "addTags": "添加标签(空格添加)", + "sourcePort": "源端口", + "sourcePortDesc": "(源指通用标签页中的当前连接详情)", + "endpointPort": "目标端口", + "endpointSshConfig": "目标 SSH 配置", + "retryInterval": "重试间隔(秒)", + "connection": "连接", + "remove": "移除", + "addConnection": "添加连接", + "sshpassRequired": "密码认证需要安装 Sshpass", + "sshpassInstallCommand": "安装命令:sudo apt install sshpass", + "sshServerConfig": "需要配置 SSH 服务器", + "sshServerConfigInstructions": "运行以下命令以允许密码认证:", + "sshConfigCommand1": "sudo sed -i 's/#PasswordAuthentication yes/PasswordAuthentication yes/' /etc/ssh/sshd_config", + "sshConfigCommand2": "sudo systemctl restart sshd", + "localPortForwarding": "本地端口转发", + "localPortForwardingDesc": "通过 SSH 连接将本地端口转发到远程服务器", + "remotePortForwarding": "远程端口转发", + "remotePortForwardingDesc": "通过 SSH 连接将远程端口转发到本地服务器", + "dynamicPortForwarding": "动态端口转发(SOCKS 代理)", + "dynamicPortForwardingDesc": "在本地计算机上创建 SOCKS 代理,通过 SSH 连接路由流量", + "bindAddress": "绑定地址", + "hostViewer": "主机查看器", + "configuration": "配置", + "maxRetries": "最大重试次数", + "tunnelConnections": "隧道连接", + "enableTerminalDesc": "启用/禁用在终端选项卡中显示此主机", + "enableTunnelDesc": "启用/禁用在隧道选项卡中显示此主机", + "enableFileManagerDesc": "启用/禁用在文件管理器选项卡中显示此主机", + "autoStartDesc": "容器启动时自动启动此隧道", + "defaultPathDesc": "打开此主机文件管理器时的默认目录", + "tunnelForwardDescription": "此隧道将从源计算机(常规选项卡中的当前连接详情)的端口 {{sourcePort}} 转发流量到端点计算机的端口 {{endpointPort}}。", + "endpointSshConfiguration": "端点 SSH 配置", + "sourcePortDescription": "(源指的是常规选项卡中的当前连接详情)", + "autoStartContainer": "容器启动时自动启动", + "upload": "上传", + "authentication": "认证方式", + "password": "密码", + "key": "密钥", + "sshPrivateKey": "SSH 私钥", + "keyPassword": "密钥密码", + "keyType": "密钥类型", + "maxRetriesDescription": "隧道连接的最大重试次数。", + "retryIntervalDescription": "重试尝试之间的等待时间。", + "otherInstallMethods": "其他安装方法:", + "sshpassOSInstructions": { + "centos": "CentOS/RHEL/Fedora: sudo yum install sshpass 或 sudo dnf install sshpass", + "macos": "macOS: brew install hudochenkov/sshpass/sshpass", + "windows": "Windows: 使用 WSL 或考虑使用 SSH 密钥认证" + }, + "sshServerConfigReverse": "对于反向 SSH 隧道,端点 SSH 服务器必须允许:", + "gatewayPorts": "GatewayPorts yes(绑定远程端口)", + "allowTcpForwarding": "AllowTcpForwarding yes(端口转发)", + "permitRootLogin": "PermitRootLogin yes(如果使用 root)", + "editSshConfig": "编辑 /etc/ssh/sshd_config 并重启 SSH: sudo systemctl restart sshd", + "updateHost": "更新主机", + "hostUpdatedSuccessfully": "主机 \"{{name}}\" 更新成功!", + "hostAddedSuccessfully": "主机 \"{{name}}\" 添加成功!", + "hostDeletedSuccessfully": "主机 \"{{name}}\" 删除成功!", + "failedToSaveHost": "保存主机失败,请重试。", + "autoDetect": "自动检测", + "rsa": "RSA", + "ed25519": "ED25519", + "ecdsaNistP256": "ECDSA NIST P-256", + "ecdsaNistP384": "ECDSA NIST P-384", + "ecdsaNistP521": "ECDSA NIST P-521", + "dsa": "DSA", + "rsaSha2256": "RSA SHA2-256", + "rsaSha2512": "RSA SHA2-512", + "updateKey": "更新密钥", + "addTagsSpaceToAdd": "添加标签(空格添加)", + "terminalBadge": "终端", + "tunnelBadge": "隧道", + "fileManagerBadge": "文件管理器", + "general": "常规", + "terminal": "终端", + "tunnel": "隧道", + "fileManager": "文件管理器" + }, + "terminal": { + "title": "终端", + "connect": "连接主机", + "disconnect": "断开连接", + "clear": "清屏", + "copy": "复制", + "paste": "粘贴", + "find": "查找", + "fullscreen": "全屏", + "splitHorizontal": "水平分屏", + "splitVertical": "垂直分屏", + "closePanel": "关闭面板", + "reconnect": "重新连接", + "sessionEnded": "会话已结束", + "connectionLost": "连接已断开", + "error": "错误", + "disconnected": "已断开连接", + "connectionClosed": "连接已关闭", + "connectionError": "连接错误" + }, + "fileManager": { + "title": "文件管理器", + "file": "文件", + "folder": "文件夹", + "connectToSsh": "连接 SSH 以使用文件操作", + "uploadFile": "上传文件", + "newFile": "新建文件", + "newFolder": "新建文件夹", + "rename": "重命名", + "renameItem": "重命名项目", + "deleteItem": "删除项目", + "currentPath": "当前路径", + "uploadFileTitle": "上传文件", + "maxFileSize": "最大:100MB(JSON)/ 200MB(二进制)", + "removeFile": "移除文件", + "clickToSelectFile": "点击选择文件", + "chooseFile": "选择文件", + "uploading": "上传中...", + "createNewFile": "创建新文件", + "fileName": "文件名", + "creating": "创建中...", + "createFile": "创建文件", + "createNewFolder": "创建新文件夹", + "folderName": "文件夹名", + "createFolder": "创建文件夹", + "warningCannotUndo": "警告:此操作无法撤销", + "itemPath": "项目路径", + "thisIsDirectory": "这是一个目录(将递归删除)", + "deleting": "删除中...", + "currentPathLabel": "当前路径", + "newName": "新名称", + "thisIsDirectoryRename": "这是一个目录", + "renaming": "重命名中...", + "fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功", + "failedToUploadFile": "上传文件失败", + "fileCreatedSuccessfully": "文件 \"{{name}}\" 创建成功", + "failedToCreateFile": "创建文件失败", + "folderCreatedSuccessfully": "文件夹 \"{{name}}\" 创建成功", + "failedToCreateFolder": "创建文件夹失败", + "itemDeletedSuccessfully": "{{type}}删除成功", + "failedToDeleteItem": "删除项目失败", + "itemRenamedSuccessfully": "{{type}}重命名成功", + "failedToRenameItem": "重命名项目失败", + "upload": "上传", + "download": "下载", + "newFile": "新建文件", + "newFolder": "新建文件夹", + "rename": "重命名", + "delete": "删除", + "permissions": "权限", + "size": "大小", + "modified": "修改时间", + "path": "路径", + "fileName": "文件名", + "folderName": "文件夹名", + "confirmDelete": "确定要删除 {{name}} 吗?", + "uploadSuccess": "文件上传成功", + "uploadFailed": "文件上传失败", + "downloadSuccess": "文件下载成功", + "downloadFailed": "文件下载失败", + "permissionDenied": "权限被拒绝", + "checkDockerLogs": "请检查 Docker 日志以获取详细的错误信息", + "internalServerError": "内部服务器错误发生", + "serverError": "服务器错误", + "error": "错误", + "requestFailed": "请求失败,状态码", + "unknown": "未知", + "cannotReadFile": "无法读取文件", + "noSshSessionId": "没有可用的 SSH 会话 ID", + "noFilePath": "没有可用的文件路径", + "noCurrentHost": "没有可用的当前主机", + "fileSavedSuccessfully": "文件保存成功", + "saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。", + "failedToSaveFile": "保存文件失败", + "folder": "文件夹", + "file": "文件", + "deletedSuccessfully": "删除成功", + "failedToDeleteItem": "删除项目失败", + "connectToServer": "连接到服务器", + "selectServerToEdit": "从侧边栏选择服务器以开始编辑文件", + "fileOperations": "文件操作", + "confirmDeleteMessage": "确定要删除 {{name}} 吗?", + "deleteDirectoryWarning": "这将删除文件夹及其所有内容。", + "actionCannotBeUndone": "此操作无法撤销。", + "recent": "最近的", + "pinned": "固定的", + "folderShortcuts": "文件夹快捷方式", + "noRecentFiles": "没有最近的文件。", + "noPinnedFiles": "没有固定的文件。", + "enterFolderPath": "输入文件夹路径", + "noShortcuts": "没有快捷方式。", + "searchFilesAndFolders": "搜索文件和文件夹...", + "noFilesOrFoldersFound": "没有找到文件或文件夹。", + "failedToConnectSSH": "连接 SSH 失败", + "failedToReconnectSSH": "重新连接 SSH 会话失败", + "failedToListFiles": "列出文件失败", + "fetchHomeDataTimeout": "获取主页数据超时", + "sshStatusCheckTimeout": "SSH 状态检查超时", + "sshReconnectionTimeout": "SSH 重新连接超时", + "saveOperationTimeout": "保存操作超时", + "cannotSaveFile": "无法保存文件" + }, + "tunnels": { + "title": "SSH 隧道", + "noSshTunnels": "没有 SSH 隧道", + "createFirstTunnelMessage": "您还没有创建任何 SSH 隧道。在主机管理器中配置隧道连接以开始使用。", + "connected": "已连接", + "disconnected": "已断开", + "connecting": "连接中...", + "disconnecting": "断开中...", + "unknown": "未知", + "error": "错误", + "failed": "失败", + "retrying": "重试中", + "waiting": "等待中", + "waitingForRetry": "等待重试", + "retryingConnection": "重试连接", + "canceling": "取消中...", + "connect": "连接", + "disconnect": "断开连接", + "cancel": "取消", + "port": "端口", + "attempt": "第 {{current}} 次尝试,共 {{max}} 次", + "nextRetryIn": "{{seconds}} 秒后重试", + "checkDockerLogs": "查看 Docker 日志以了解错误原因,加入", + "noTunnelConnections": "未配置隧道连接", + "tunnelConnections": "隧道连接", + "addTunnel": "添加隧道", + "editTunnel": "编辑隧道", + "deleteTunnel": "删除隧道", + "tunnelName": "隧道名称", + "localPort": "本地端口", + "remoteHost": "远程主机", + "remotePort": "远程端口", + "autoStart": "自动启动", + "status": "状态", + "active": "活动", + "inactive": "未激活", + "start": "启动", + "stop": "停止", + "restart": "重启", + "connectionType": "连接类型", + "local": "本地", + "remote": "远程", + "dynamic": "动态", + "noSshTunnels": "没有 SSH 隧道", + "createFirstTunnelMessage": "创建您的第一个 SSH 隧道以开始使用。使用 SSH 管理器添加具有隧道连接的主机。", + "unknown": "未知", + "connected": "已连接", + "connecting": "连接中...", + "disconnecting": "断开连接中...", + "disconnected": "已断开连接", + "portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}", + "disconnect": "断开连接", + "connect": "连接", + "canceling": "取消中...", + "endpointHostNotFound": "未找到端点主机" + }, + "serverStats": { + "title": "服务器统计", + "cpu": "CPU", + "memory": "内存", + "disk": "磁盘", + "network": "网络", + "uptime": "运行时间", + "loadAverage": "平均负载", + "processes": "进程", + "connections": "连接", + "usage": "使用率", + "available": "可用", + "total": "总计", + "free": "空闲", + "used": "已用", + "percentage": "百分比", + "refreshStatusAndMetrics": "刷新状态和指标", + "refreshStatus": "刷新状态", + "fileManagerAlreadyOpen": "此主机的文件管理器已打开", + "openFileManager": "打开文件管理器", + "cpuCores_one": "{{count}} 个 CPU", + "cpuCores_other": "{{count}} 个 CPU", + "naCpus": "N/A CPU", + "loadAverage": "平均: {{avg1}}, {{avg5}}, {{avg15}}", + "loadAverageNA": "平均: N/A", + "cpuUsage": "CPU 使用率", + "memoryUsage": "内存使用率", + "rootStorageSpace": "根目录存储空间", + "of": "的", + "feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧" + }, + "auth": { + "loginTitle": "登录 Termix", + "registerTitle": "创建账户", + "loginButton": "登录", + "registerButton": "注册", + "forgotPassword": "忘记密码?", + "rememberMe": "记住我", + "noAccount": "还没有账户?", + "hasAccount": "已有账户?", + "loginSuccess": "登录成功", + "loginFailed": "登录失败", + "registerSuccess": "注册成功", + "registerFailed": "注册失败", + "logoutSuccess": "登出成功", + "invalidCredentials": "用户名或密码错误", + "accountCreated": "账户创建成功", + "passwordReset": "密码重置链接已发送", + "twoFactorAuth": "双因素认证", + "enterCode": "输入验证码", + "backupCode": "使用备用码", + "verifyCode": "验证码", + "enableTwoFactor": "启用双因素认证", + "disableTwoFactor": "禁用双因素认证", + "scanQRCode": "使用您的身份验证器应用扫描此二维码", + "backupCodes": "备用码", + "saveBackupCodes": "请将这些备用码保存在安全的地方", + "twoFactorEnabledSuccess": "双因素认证启用成功!", + "twoFactorDisabled": "双因素认证已禁用", + "newBackupCodesGenerated": "新备用码已生成", + "backupCodesDownloaded": "备用码已下载", + "pleaseEnterSixDigitCode": "请输入 6 位验证码", + "invalidVerificationCode": "无效的验证码", + "failedToDisableTotp": "禁用 TOTP 失败", + "failedToGenerateBackupCodes": "生成备用码失败", + "enterPassword": "输入您的密码", + "lockedOidcAuth": "已锁定 (OIDC 认证)", + "twoFactorTitle": "双因素认证", + "twoFactorProtected": "您的账户已启用双因素认证保护", + "twoFactorActive": "双因素认证当前在您的账户上处于活动状态", + "disable2FA": "禁用 2FA", + "disableTwoFactorWarning": "禁用双因素认证将降低您账户的安全性", + "passwordOrTotpCode": "密码或 TOTP 验证码", + "or": "或", + "generateNewBackupCodesText": "如果您丢失了现有的备用码,请生成新的备用码", + "generateNewBackupCodes": "生成新的备用码", + "yourBackupCodes": "您的备用码", + "download": "下载", + "setupTwoFactorTitle": "设置双因素认证", + "step1ScanQR": "步骤 1:使用您的身份验证器应用扫描二维码", + "manualEntryCode": "手动输入代码", + "cannotScanQRText": "如果无法扫描二维码,请在身份验证器应用中手动输入此代码", + "nextVerifyCode": "下一步:验证代码", + "verifyAuthenticator": "验证您的身份验证器", + "step2EnterCode": "步骤 2:输入身份验证器应用中的6位数代码", + "verificationCode": "验证码", + "back": "返回", + "verifyAndEnable": "验证并启用", + "saveBackupCodesTitle": "保存您的备用码", + "step3StoreCodesSecurely": "步骤 3:将这些代码保存在安全的地方", + "importantBackupCodesText": "请将这些备用码保存在安全的地方。如果您丢失了身份验证器设备,可以使用它们访问您的账户。", + "completeSetup": "完成设置", + "notEnabledText": "双因素认证通过在登录时要求来自身份验证器应用的代码,为您的账户增加额外的安全层。", + "enableTwoFactorButton": "启用双因素认证", + "addExtraSecurityLayer": "为您的账户添加额外的安全层", + "firstUser": "首位用户", + "firstUserMessage": "您是第一个用户,将被设为管理员。您可以在侧边栏用户下拉菜单中查看管理员设置。如果您认为这是错误,请检查 docker 日志,或创建", + "external": "外部", + "loginWithExternal": "使用外部提供商登录", + "loginWithExternalDesc": "使用您配置的外部身份提供商登录", + "resetPasswordButton": "重置密码", + "sendResetCode": "发送重置代码", + "resetCodeDesc": "输入您的用户名以接收密码重置代码。代码将记录在 docker 容器日志中。", + "resetCode": "重置代码", + "verifyCodeButton": "验证代码", + "enterResetCode": "输入来自 docker 容器日志中用户的 6 位数代码:", + "goToLogin": "转到登录", + "newPassword": "新密码", + "confirmNewPassword": "确认密码", + "enterNewPassword": "为用户输入新密码:", + "passwordResetSuccess": "成功!", + "passwordResetSuccessDesc": "您的密码已成功重置!您现在可以使用新密码登录。", + "signUp": "注册" + }, + "errors": { + "notFound": "页面未找到", + "unauthorized": "未授权访问", + "forbidden": "访问被禁止", + "serverError": "服务器错误", + "networkError": "网络错误", + "databaseConnection": "无法连接到数据库。请稍后再试。", + "unknownError": "未知错误", + "failedPasswordReset": "无法启动密码重置", + "failedVerifyCode": "验证重置代码失败", + "failedCompleteReset": "无法完成密码重置", + "invalidTotpCode": "无效的 TOTP 代码", + "failedOidcLogin": "无法启动 OIDC 登录", + "failedUserInfo": "OIDC 登录后无法获取用户信息", + "oidcAuthFailed": "OIDC 认证失败", + "noTokenReceived": "登录未收到令牌", + "invalidAuthUrl": "从后端收到无效的授权 URL", + "connectionTimeout": "连接超时", + "invalidInput": "输入无效", + "requiredField": "此字段为必填项", + "minLength": "最小长度为 {{min}}", + "maxLength": "最大长度为 {{max}}", + "invalidEmail": "邮箱地址无效", + "passwordMismatch": "密码不匹配", + "weakPassword": "密码强度太弱", + "usernameExists": "用户名已存在", + "emailExists": "邮箱已存在", + "loadFailed": "加载数据失败", + "saveError": "保存失败" + }, + "messages": { + "saveSuccess": "保存成功", + "saveError": "保存失败", + "deleteSuccess": "删除成功", + "deleteError": "删除失败", + "updateSuccess": "更新成功", + "updateError": "更新失败", + "copySuccess": "已复制到剪贴板", + "copyError": "复制失败", + "copiedToClipboard": "{{item}} 已复制到剪贴板", + "connectionEstablished": "连接已建立", + "connectionClosed": "连接已关闭", + "reconnecting": "重新连接中...", + "processing": "处理中...", + "pleaseWait": "请稍候...", + "registrationDisabled": "新用户注册已被管理员禁用。请登录或联系管理员。" + }, + "profile": { + "title": "用户资料", + "description": "管理您的账户设置和安全", + "security": "安全", + "changePassword": "修改密码", + "twoFactorAuth": "双因素认证", + "accountInfo": "账户信息", + "role": "角色", + "admin": "管理员", + "user": "用户", + "authMethod": "认证方式", + "local": "本地", + "external": "外部 (OIDC)", + "selectPreferredLanguage": "选择您的界面首选语言" + }, + "placeholders": { + "enterCode": "000000", + "ipAddress": "127.0.0.1", + "port": "22", + "maxRetries": "3", + "retryInterval": "10", + "language": "语言", + "username": "用户名", + "hostname": "主机名", + "folder": "文件夹", + "password": "密码", + "keyPassword": "密钥密码", + "sshConfig": "端点 SSH 配置", + "homePath": "/home", + "clientId": "您的客户端 ID", + "clientSecret": "您的客户端密钥", + "authUrl": "https://your-provider.com/application/o/authorize/", + "redirectUrl": "https://your-provider.com/application/o/termix/", + "tokenUrl": "https://your-provider.com/application/o/token/", + "userIdField": "sub", + "usernameField": "name", + "scopes": "openid email profile", + "enterUsername": "输入用户名以设为管理员", + "searchHosts": "按名称、用户名、IP、文件夹、标签搜索主机...", + "enterPassword": "输入您的密码", + "totpCode": "6 位 TOTP 验证码", + "searchHostsAny": "按任意信息搜索主机...", + "confirmPassword": "输入您的密码以确认", + "typeHere": "在此输入", + "fileName": "输入文件名(例如:example.txt)", + "folderName": "输入文件夹名", + "fullPath": "输入项目的完整路径", + "currentPath": "输入项目的当前路径", + "newName": "输入新名称" + }, + "leftSidebar": { + "failedToLoadHosts": "加载主机失败", + "noFolder": "无文件夹", + "passwordRequired": "需要输入密码", + "failedToDeleteAccount": "删除账户失败", + "failedToMakeUserAdmin": "设为管理员失败", + "userIsNowAdmin": "用户 {{username}} 现在是管理员", + "removeAdminConfirm": "确定要移除 {{username}} 的管理员权限吗?", + "deleteUserConfirm": "确定要删除用户 {{username}} 吗?此操作无法撤销。", + "deleteAccount": "删除账户", + "closeDeleteAccount": "关闭删除账户", + "deleteAccountWarning": "此操作无法撤销。这将永久删除您的账户和所有相关数据。", + "deleteAccountWarningDetails": "删除您的账户将删除所有数据,包括 SSH 主机、配置和设置。此操作不可逆。", + "cannotDeleteAccount": "无法删除账户", + "lastAdminWarning": "您是最后一个管理员用户。您不能删除自己的账户,否则系统将没有任何管理员。请先将其他用户设为管理员,或联系系统支持。", + "confirmPassword": "确认密码", + "deleting": "删除中...", + "cancel": "取消" + }, + "interface": { + "sidebar": "侧边栏", + "toggleSidebar": "切换侧边栏", + "close": "关闭", + "online": "在线", + "offline": "离线", + "maintenance": "维护中", + "degraded": "降级", + "noTunnelConnections": "未配置隧道连接", + "discord": "Discord", + "connectToSshForOperations": "连接 SSH 以使用文件操作", + "uploadFile": "上传文件", + "newFile": "新建文件", + "newFolder": "新建文件夹", + "rename": "重命名", + "deleteItem": "删除项目", + "createNewFile": "创建新文件", + "createNewFolder": "创建新文件夹", + "deleteItem": "删除项目", + "renameItem": "重命名项目", + "clickToSelectFile": "点击选择文件", + "noSshHosts": "没有 SSH 主机", + "sshHosts": "SSH 主机", + "importSshHosts": "从 JSON 导入 SSH 主机", + "clientId": "客户端 ID", + "clientSecret": "客户端密钥", + "error": "错误", + "warning": "警告", + "deleteAccount": "删除账户", + "closeDeleteAccount": "关闭删除账户", + "cannotDeleteAccount": "无法删除账户", + "confirmPassword": "确认密码", + "deleting": "删除中...", + "externalAuth": "外部认证 (OIDC)", + "configureExternalProvider": "配置外部身份提供者", + "waitingForRetry": "等待重试", + "retryingConnection": "重试连接中", + "resetSplitSizes": "重置分屏大小", + "sshManagerAlreadyOpen": "SSH 管理器已打开", + "disabledDuringSplitScreen": "分屏期间禁用", + "unknown": "未知", + "connected": "已连接", + "disconnected": "已断开连接", + "maxRetriesExhausted": "已达到最大重试次数", + "endpointHostNotFound": "未找到端点主机", + "administrator": "管理员", + "user": "用户", + "external": "外部", + "local": "本地", + "saving": "保存中...", + "saveConfiguration": "保存配置", + "loading": "加载中...", + "refresh": "刷新", + "adding": "添加中...", + "makeAdmin": "设为管理员", + "verifying": "验证中...", + "verifyAndEnable": "验证并启用", + "secretKey": "密钥", + "totpQrCode": "TOTP 二维码", + "passwordRequired": "使用密码认证时需要密码", + "sshKeyRequired": "使用密钥认证时需要 SSH 私钥", + "keyTypeRequired": "使用密钥认证时需要密钥类型", + "validSshConfigRequired": "必须从列表中选择有效的 SSH 配置", + "updateHost": "更新主机", + "addHost": "添加主机", + "editHost": "编辑主机", + "pinConnection": "固定连接", + "authentication": "认证", + "password": "密码", + "key": "密钥", + "sshPrivateKey": "SSH 私钥", + "keyPassword": "密钥密码", + "keyType": "密钥类型", + "enableTerminal": "启用终端", + "enableTunnel": "启用隧道", + "enableFileManager": "启用文件管理器", + "defaultPath": "默认路径", + "tunnelConnections": "隧道连接", + "maxRetries": "最大重试次数", + "upload": "上传", + "updateKey": "更新密钥", + "sshpassRequired": "密码认证需要 Sshpass", + "sshServerConfigRequired": "需要 SSH 服务器配置", + "sshManagerAlreadyOpen": "SSH 管理器已打开", + "disabledDuringSplitScreen": "分屏期间禁用", + "productionFolder": "生产环境", + "databaseServer": "数据库服务器", + "unknownError": "未知错误", + "failedToInitiatePasswordReset": "启动密码重置失败", + "failedToVerifyResetCode": "验证重置代码失败", + "failedToCompletePasswordReset": "完成密码重置失败", + "invalidTotpCode": "无效的 TOTP 代码", + "failedToStartOidcLogin": "启动 OIDC 登录失败", + "failedToGetUserInfoAfterOidc": "OIDC 登录后获取用户信息失败", + "loginWithExternalProvider": "使用外部提供者登录", + "loginWithExternal": "使用外部提供者登录", + "sendResetCode": "发送重置代码", + "verifyCode": "验证代码", + "resetPassword": "重置密码", + "login": "登录", + "signUp": "注册", + "failedToUpdateOidcConfig": "更新 OIDC 配置失败", + "failedToMakeUserAdmin": "设为管理员失败", + "failedToStartTotpSetup": "启动 TOTP 设置失败", + "invalidVerificationCode": "无效的验证码", + "failedToDisableTotp": "禁用 TOTP 失败", + "failedToGenerateBackupCodes": "生成备用码失败" + } +} \ No newline at end of file diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 71ed1e22..2dd60b79 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -411,8 +411,7 @@ const migrateSchema = () => { addColumnIfNotExists('users', 'identifier_path', 'TEXT'); addColumnIfNotExists('users', 'name_path', 'TEXT'); addColumnIfNotExists('users', 'scopes', 'TEXT'); - - // Add TOTP columns + addColumnIfNotExists('users', 'totp_secret', 'TEXT'); addColumnIfNotExists('users', 'totp_enabled', 'INTEGER NOT NULL DEFAULT 0'); addColumnIfNotExists('users', 'totp_backup_codes', 'TEXT'); diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 065ebc0b..11dc46d0 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'; @@ -12,9 +12,19 @@ import type {Request, Response, NextFunction} from 'express'; async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise { try { - let jwksUrl: string | null = null; - const normalizedIssuerUrl = issuerUrl.endsWith('/') ? issuerUrl.slice(0, -1) : issuerUrl; + const possibleIssuers = [ + issuerUrl, + normalizedIssuerUrl, + issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''), + normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '') + ]; + + const jwksUrls = [ + `${normalizedIssuerUrl}/.well-known/jwks.json`, + `${normalizedIssuerUrl}/jwks/`, + `${normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '')}/.well-known/jwks.json` + ]; try { const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`; @@ -22,71 +32,58 @@ async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: str if (discoveryResponse.ok) { const discovery = await discoveryResponse.json() as any; if (discovery.jwks_uri) { - jwksUrl = discovery.jwks_uri; - } else { - logger.warn('OIDC discovery document does not contain jwks_uri'); + jwksUrls.unshift(discovery.jwks_uri); } - } else { - logger.warn(`OIDC discovery failed with status: ${discoveryResponse.status}`); } } catch (discoveryError) { - logger.warn(`OIDC discovery failed: ${discoveryError}`); + logger.error(`OIDC discovery failed: ${discoveryError}`); } - if (!jwksUrl) { - jwksUrl = `${normalizedIssuerUrl}/.well-known/jwks.json`; - } + let jwks: any = null; + let jwksUrl: string | null = null; - if (!jwksUrl) { - const authentikJwksUrl = `${normalizedIssuerUrl}/jwks/`; + for (const url of jwksUrls) { try { - const jwksTestResponse = await fetch(authentikJwksUrl); - if (jwksTestResponse.ok) { - jwksUrl = authentikJwksUrl; + const response = await fetch(url); + if (response.ok) { + const jwksData = await response.json() as any; + if (jwksData && jwksData.keys && Array.isArray(jwksData.keys)) { + jwks = jwksData; + jwksUrl = url; + break; + } else { + logger.error(`Invalid JWKS structure from ${url}: ${JSON.stringify(jwksData)}`); + } + } else { + logger.error(`JWKS fetch failed from ${url}: ${response.status} ${response.statusText}`); } } catch (error) { - logger.warn(`Authentik JWKS URL also failed: ${error}`); + logger.error(`JWKS fetch error from ${url}:`, error); + continue; } } - if (!jwksUrl) { - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - const rootJwksUrl = `${baseUrl}/.well-known/jwks.json`; - try { - const jwksTestResponse = await fetch(rootJwksUrl); - if (jwksTestResponse.ok) { - jwksUrl = rootJwksUrl; - } - } catch (error) { - logger.warn(`Authentik root JWKS URL also failed: ${error}`); - } + if (!jwks) { + throw new Error('Failed to fetch JWKS from any URL'); } - const jwksResponse = await fetch(jwksUrl); - if (!jwksResponse.ok) { - throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${jwksResponse.status}`); + if (!jwks.keys || !Array.isArray(jwks.keys)) { + throw new Error(`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`); } - const jwks = await jwksResponse.json() as any; - const header = JSON.parse(Buffer.from(idToken.split('.')[0], 'base64').toString()); const keyId = header.kid; const publicKey = jwks.keys.find((key: any) => key.kid === keyId); if (!publicKey) { - throw new Error(`No matching public key found for key ID: ${keyId}`); + throw new Error(`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: any) => k.kid).join(', ')}`); } const {importJWK, jwtVerify} = await import('jose'); const key = await importJWK(publicKey); const {payload} = await jwtVerify(idToken, key, { - issuer: [ - issuerUrl, - normalizedIssuerUrl, - issuerUrl.replace(/\/application\/o\/[^\/]+$/, ''), - normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, '') - ], + issuer: possibleIssuers, audience: clientId, }); @@ -237,6 +234,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { issuer_url, authorization_url, token_url, + userinfo_url, identifier_path, name_path, scopes @@ -255,6 +253,7 @@ router.post('/oidc-config', authenticateJWT, async (req, res) => { issuer_url, authorization_url, token_url, + userinfo_url: userinfo_url || '', identifier_path, name_path, scopes: scopes || 'openid email profile' @@ -376,54 +375,106 @@ router.get('/oidc/callback', async (req, res) => { const tokenData = await tokenResponse.json() as any; - let userInfo; + 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); + if (discoveryResponse.ok) { + const discovery = await discoveryResponse.json() as any; + if (discovery.userinfo_endpoint) { + userInfoUrls.push(discovery.userinfo_endpoint); + } + } + } catch (discoveryError) { + logger.error(`OIDC discovery failed: ${discoveryError}`); + } + + if (config.userinfo_url) { + userInfoUrls.unshift(config.userinfo_url); + } + + userInfoUrls.push( + `${baseUrl}/userinfo/`, + `${baseUrl}/userinfo`, + `${normalizedIssuerUrl}/userinfo/`, + `${normalizedIssuerUrl}/userinfo`, + `${baseUrl}/oauth2/userinfo/`, + `${baseUrl}/oauth2/userinfo`, + `${normalizedIssuerUrl}/oauth2/userinfo/`, + `${normalizedIssuerUrl}/oauth2/userinfo` + ); + if (tokenData.id_token) { try { userInfo = await verifyOIDCToken(tokenData.id_token, config.issuer_url, config.client_id); + logger.info('Successfully verified ID token and extracted user info'); } catch (error) { - logger.error('OIDC token verification failed, falling back to userinfo endpoint', error); - if (tokenData.access_token) { - const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - const userInfoUrl = `${baseUrl}/userinfo/`; + logger.error('OIDC token verification failed, trying userinfo endpoints', error); + try { + const parts = tokenData.id_token.split('.'); + if (parts.length === 3) { + const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString()); + userInfo = payload; + logger.info('Successfully decoded ID token payload without verification'); + } + } catch (decodeError) { + logger.error('Failed to decode ID token payload:', decodeError); + } + } + } + if (!userInfo && tokenData.access_token) { + for (const userInfoUrl of userInfoUrls) { + try { const userInfoResponse = await fetch(userInfoUrl, { headers: { 'Authorization': `Bearer ${tokenData.access_token}`, - }, + } }); if (userInfoResponse.ok) { userInfo = await userInfoResponse.json(); + break; } else { - logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`); + logger.error(`Userinfo endpoint ${userInfoUrl} failed with status: ${userInfoResponse.status}`); } + } catch (error) { + logger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error); + continue; } } - } else if (tokenData.access_token) { - const normalizedIssuerUrl = config.issuer_url.endsWith('/') ? config.issuer_url.slice(0, -1) : config.issuer_url; - const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^\/]+$/, ''); - const userInfoUrl = `${baseUrl}/userinfo/`; - - const userInfoResponse = await fetch(userInfoUrl, { - headers: { - 'Authorization': `Bearer ${tokenData.access_token}`, - }, - }); - - if (userInfoResponse.ok) { - userInfo = await userInfoResponse.json(); - } else { - logger.error(`Userinfo endpoint failed with status: ${userInfoResponse.status}`); - } } if (!userInfo) { + logger.error('Failed to get user information from all sources'); + logger.error(`Tried userinfo URLs: ${userInfoUrls.join(', ')}`); + logger.error(`Token data keys: ${Object.keys(tokenData).join(', ')}`); + logger.error(`Has id_token: ${!!tokenData.id_token}`); + logger.error(`Has access_token: ${!!tokenData.access_token}`); return res.status(400).json({error: 'Failed to get user information'}); } - const identifier = userInfo[config.identifier_path]; - const name = userInfo[config.name_path] || identifier; + const getNestedValue = (obj: any, path: string): any => { + if (!path || !obj) return null; + 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; if (!identifier) { logger.error(`Identifier not found at path: ${config.identifier_path}`); @@ -956,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) { @@ -969,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'}); } @@ -984,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)}) @@ -1015,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) { @@ -1023,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'}); } @@ -1067,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'}); } @@ -1087,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() ); @@ -1126,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'}); } @@ -1184,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'}); } @@ -1209,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() ); @@ -1260,12 +1311,19 @@ 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); + 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)); + + // Note: All user-related data has been deleted above + // The tables config_editor_* and shared_hosts don't exist in the current schema } catch (cleanupError) { logger.error(`Cleanup failed for user ${username}:`, cleanupError); + throw cleanupError; } await db.delete(users).where(eq(users.id, targetUserId)); diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx new file mode 100644 index 00000000..57748fa1 --- /dev/null +++ b/src/components/LanguageSwitcher.tsx @@ -0,0 +1,44 @@ +// Language switcher component for changing UI language +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Globe } from 'lucide-react'; + +const languages = [ + { code: 'en', name: 'English', nativeName: 'English' }, + { code: 'zh', name: 'Chinese', nativeName: '中文' }, +]; + +export function LanguageSwitcher() { + const { i18n, t } = useTranslation(); + + const handleLanguageChange = (value: string) => { + i18n.changeLanguage(value); + // Save to localStorage for persistence + localStorage.setItem('i18nextLng', value); + }; + + return ( + + + + + + + + {languages.map((lang) => ( + + {lang.nativeName} + + ))} + + + + ); +} \ No newline at end of file diff --git a/src/components/ui/shadcn-io/status/index.tsx b/src/components/ui/shadcn-io/status/index.tsx index 37836101..29b6c6ec 100644 --- a/src/components/ui/shadcn-io/status/index.tsx +++ b/src/components/ui/shadcn-io/status/index.tsx @@ -1,6 +1,7 @@ import type { ComponentProps, HTMLAttributes } from 'react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; +import { useTranslation } from 'react-i18next'; export type StatusProps = ComponentProps & { status: 'online' | 'offline' | 'maintenance' | 'degraded'; @@ -48,15 +49,18 @@ export const StatusLabel = ({ className, children, ...props -}: StatusLabelProps) => ( - - {children ?? ( - <> - Online - Offline - Maintenance - Degraded - > - )} - -); +}: StatusLabelProps) => { + const { t } = useTranslation(); + return ( + + {children ?? ( + <> + {t('common.online')} + {t('common.offline')} + {t('common.maintenance')} + {t('common.degraded')} + > + )} + + ); +}; diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index fe6a02e8..291c11b2 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -178,34 +178,35 @@ function Sidebar({ ) } - if (isMobile) { - return ( - - - - Sidebar - Displays the mobile sidebar. - - {children} - - - ) - } + // Commented out mobile behavior to keep sidebar always visible + // if (isMobile) { + // return ( + // + // + // + // Sidebar + // Displays the mobile sidebar. + // + // {children} + // + // + // ) + // } return ( diff --git a/src/ui/Admin/AdminSettings.tsx b/src/ui/Admin/AdminSettings.tsx index bece9c0a..ca1ab4f0 100644 --- a/src/ui/Admin/AdminSettings.tsx +++ b/src/ui/Admin/AdminSettings.tsx @@ -16,15 +16,17 @@ import { TableRow, } from "@/components/ui/table.tsx"; import {Shield, Trash2, Users} from "lucide-react"; -import { - getOIDCConfig, - getRegistrationAllowed, - getUserList, - updateRegistrationAllowed, - updateOIDCConfig, - makeUserAdmin, - removeAdminStatus, - deleteUser +import {toast} from "sonner"; +import {useTranslation} from "react-i18next"; +import { + getOIDCConfig, + getRegistrationAllowed, + getUserList, + updateRegistrationAllowed, + updateOIDCConfig, + makeUserAdmin, + removeAdminStatus, + deleteUser } from "@/ui/main-axios.ts"; function getCookie(name: string) { @@ -39,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); @@ -52,11 +55,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. token_url: '', identifier_path: 'sub', name_path: 'name', - scopes: 'openid email profile' + scopes: 'openid email profile', + userinfo_url: '' }); const [oidcLoading, setOidcLoading] = React.useState(false); const [oidcError, setOidcError] = React.useState(null); - const [oidcSuccess, setOidcSuccess] = React.useState(null); const [users, setUsers] = React.useState(null); - const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); React.useEffect(() => { const jwt = getCookie("jwt"); @@ -120,12 +122,11 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. e.preventDefault(); setOidcLoading(true); setOidcError(null); - setOidcSuccess(null); 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; } @@ -133,9 +134,9 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. const jwt = getCookie("jwt"); try { await updateOIDCConfig(oidcConfig); - setOidcSuccess("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); } @@ -145,42 +146,47 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. setOidcConfig(prev => ({...prev, [field]: value})); }; - const makeUserAdmin = async (e: React.FormEvent) => { + const handleMakeUserAdmin = async (e: React.FormEvent) => { e.preventDefault(); if (!newAdminUsername.trim()) return; setMakeAdminLoading(true); setMakeAdminError(null); - setMakeAdminSuccess(null); const jwt = getCookie("jwt"); try { await makeUserAdmin(newAdminUsername.trim()); - setMakeAdminSuccess(`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 removeAdminStatus = async (username: string) => { - if (!confirm(`Remove admin status from ${username}?`)) return; + const handleRemoveAdminStatus = async (username: string) => { + if (!confirm(t('admin.removeAdminStatus', { username }))) return; const jwt = getCookie("jwt"); try { await removeAdminStatus(username); + toast.success(t('admin.adminStatusRemoved', { username })); fetchUsers(); - } catch { + } catch (err: any) { + console.error('Failed to remove admin status:', err); + toast.error(t('admin.failedToRemoveAdminStatus')); } }; - const deleteUser = async (username: string) => { - if (!confirm(`Delete user ${username}? This cannot be undone.`)) return; + const handleDeleteUser = async (username: string) => { + if (!confirm(t('admin.deleteUser', { username }))) return; const jwt = getCookie("jwt"); try { await deleteUser(username); + toast.success(t('admin.userDeletedSuccessfully', { username })); fetchUsers(); - } catch { + } catch (err: any) { + console.error('Failed to delete user:', err); + toast.error(t('admin.failedToDeleteUser')); } }; @@ -200,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')} @@ -209,7 +215,7 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. - General + {t('admin.general')} @@ -217,91 +223,96 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. - Users + {t('admin.users')} - Admins + {t('admin.adminManagement')} - User Registration + {t('admin.userRegistration')} - Allow new account registration + {t('admin.allowNewAccountRegistration')} - External Authentication (OIDC) - Configure external identity provider for - OIDC/OAuth2 authentication. + {t('admin.externalAuthentication')} + {t('admin.configureExternalProvider')} {oidcError && ( - Error + {t('common.error')} {oidcError} )} - Client ID + {t('admin.clientId')} handleOIDCConfigChange('client_id', e.target.value)} - placeholder="your-client-id" required/> + placeholder={t('placeholders.clientId')} required/> - Client Secret + {t('admin.clientSecret')} handleOIDCConfigChange('client_secret', e.target.value)} - placeholder="your-client-secret" required/> + placeholder={t('placeholders.clientSecret')} required/> - Authorization URL + {t('admin.authorizationUrl')} handleOIDCConfigChange('authorization_url', e.target.value)} - placeholder="https://your-provider.com/application/o/authorize/" + placeholder={t('placeholders.authUrl')} required/> - Issuer URL + {t('admin.issuerUrl')} handleOIDCConfigChange('issuer_url', e.target.value)} - placeholder="https://your-provider.com/application/o/termix/" required/> + placeholder={t('placeholders.redirectUrl')} required/> - Token URL + {t('admin.tokenUrl')} handleOIDCConfigChange('token_url', e.target.value)} - placeholder="https://your-provider.com/application/o/token/" required/> + placeholder={t('placeholders.tokenUrl')} required/> - User Identifier Path + {t('admin.userIdentifierPath')} handleOIDCConfigChange('identifier_path', e.target.value)} - placeholder="sub" required/> + placeholder={t('placeholders.userIdField')} required/> - Display Name Path + {t('admin.displayNamePath')} handleOIDCConfigChange('name_path', e.target.value)} - placeholder="name" required/> + placeholder={t('placeholders.usernameField')} required/> - Scopes + {t('admin.scopes')} handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)} - placeholder="openid email profile" required/> + onChange={(e) => handleOIDCConfigChange('scopes', e.target.value)} + placeholder={t('placeholders.scopes')} required/> + + + {t('admin.overrideUserInfoUrl')} + handleOIDCConfigChange('userinfo_url', e.target.value)} + placeholder="https://your-provider.com/application/o/userinfo/"/> {oidcLoading ? "Saving..." : "Save Configuration"} + disabled={oidcLoading}>{oidcLoading ? t('admin.saving') : t('admin.saveConfiguration')} setOidcConfig({ client_id: '', client_secret: '', @@ -310,16 +321,10 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. token_url: '', identifier_path: 'sub', name_path: 'name', - scopes: 'openid email profile' - })}>Reset + scopes: 'openid email profile', + userinfo_url: '' + })}>{t('admin.reset')} - - {oidcSuccess && ( - - Success - {oidcSuccess} - - )} @@ -327,20 +332,20 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. - User Management + {t('admin.userManagement')} {usersLoading ? "Loading..." : "Refresh"} + 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')} @@ -350,14 +355,14 @@ 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')} deleteUser(user.username)} + onClick={() => handleDeleteUser(user.username)} className="text-red-600 hover:text-red-700 hover:bg-red-50" disabled={user.is_admin}> @@ -374,44 +379,39 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. - Admin Management + {t('admin.adminManagement')} - Make User Admin - + {t('admin.makeUserAdmin')} + - Username + {t('admin.username')} setNewAdminUsername(e.target.value)} - placeholder="Enter username to make admin" required/> + placeholder={t('admin.enterUsernameToMakeAdmin')} required/> {makeAdminLoading ? "Adding..." : "Make Admin"} + disabled={makeAdminLoading || !newAdminUsername.trim()}>{makeAdminLoading ? t('admin.adding') : t('admin.makeAdmin')} {makeAdminError && ( - Error + {t('common.error')} {makeAdminError} )} - {makeAdminSuccess && ( - - Success - {makeAdminSuccess} - - )} + - Current Admins + {t('admin.currentAdmins')} - Username - Type - Actions + {t('admin.username')} + {t('admin.type')} + {t('admin.actions')} @@ -420,16 +420,16 @@ export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React. {admin.username} 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')} {admin.is_oidc ? "External" : "Local"} + className="px-4">{admin.is_oidc ? t('admin.external') : t('admin.local')} removeAdminStatus(admin.username)} + onClick={() => handleRemoveAdminStatus(admin.username)} className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"> - Remove Admin + {t('admin.removeAdminButton')} diff --git a/src/ui/Apps/File Manager/FileManager.tsx b/src/ui/Apps/File Manager/FileManager.tsx index ac2edd2d..85e33c00 100644 --- a/src/ui/Apps/File Manager/FileManager.tsx +++ b/src/ui/Apps/File Manager/FileManager.tsx @@ -10,6 +10,7 @@ import {cn} from '@/lib/utils.ts'; import {Save, RefreshCw, Settings, Trash2} from 'lucide-react'; import {Separator} from '@/components/ui/separator.tsx'; import {toast} from 'sonner'; +import {useTranslation} from 'react-i18next'; import { getFileManagerRecent, getFileManagerPinned, @@ -66,6 +67,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} embedded?: boolean, initialHost?: SSHHost | null }): React.ReactElement { + const {t} = useTranslation(); const [tabs, setTabs] = useState([]); const [activeTab, setActiveTab] = useState('home'); const [recent, setRecent] = useState([]); @@ -134,7 +136,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} ]); const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('Fetch home data timed out')), 15000) + setTimeout(() => reject(new Error(t('fileManager.fetchHomeDataTimeout'))), 15000) ); const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any]; @@ -166,20 +168,20 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} if (typeof err === 'object' && err !== null && 'response' in err) { const axiosErr = err as any; if (axiosErr.response?.status === 403) { - return `Permission denied. ${defaultMessage}. Check the Docker logs for detailed error information.`; + return `${t('fileManager.permissionDenied')}. ${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`; } else if (axiosErr.response?.status === 500) { - const backendError = axiosErr.response?.data?.error || 'Internal server error occurred'; - return `Server Error (500): ${backendError}. Check the Docker logs for detailed error information.`; + const backendError = axiosErr.response?.data?.error || t('fileManager.internalServerError'); + return `${t('fileManager.serverError')} (500): ${backendError}. ${t('fileManager.checkDockerLogs')}.`; } else if (axiosErr.response?.data?.error) { const backendError = axiosErr.response.data.error; - return `${axiosErr.response?.status ? `Error ${axiosErr.response.status}: ` : ''}${backendError}. Check the Docker logs for detailed error information.`; + return `${axiosErr.response?.status ? `${t('fileManager.error')} ${axiosErr.response.status}: ` : ''}${backendError}. ${t('fileManager.checkDockerLogs')}.`; } else { - return `Request failed with status code ${axiosErr.response?.status || 'unknown'}. Check the Docker logs for detailed error information.`; + return `${t('fileManager.requestFailed')} ${axiosErr.response?.status || t('fileManager.unknown')}. ${t('fileManager.checkDockerLogs')}.`; } } else if (err instanceof Error) { - return `${err.message}. Check the Docker logs for detailed error information.`; + return `${err.message}. ${t('fileManager.checkDockerLogs')}.`; } else { - return `${defaultMessage}. Check the Docker logs for detailed error information.`; + return `${defaultMessage}. ${t('fileManager.checkDockerLogs')}.`; } }; @@ -216,7 +218,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} }); fetchHomeData(); } catch (err: any) { - const errorMessage = formatErrorMessage(err, 'Cannot read file'); + const errorMessage = formatErrorMessage(err, t('fileManager.cannotReadFile')); toast.error(errorMessage); setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t)); } @@ -355,21 +357,21 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} try { if (!tab.sshSessionId) { - throw new Error('No SSH session ID available'); + throw new Error(t('fileManager.noSshSessionId')); } if (!tab.filePath) { - throw new Error('No file path available'); + throw new Error(t('fileManager.noFilePath')); } if (!currentHost?.id) { - throw new Error('No current host available'); + throw new Error(t('fileManager.noCurrentHost')); } try { const statusPromise = getSSHStatus(tab.sshSessionId); const statusTimeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('SSH status check timed out')), 10000) + setTimeout(() => reject(new Error(t('fileManager.sshStatusCheckTimeout'))), 10000) ); const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean }; @@ -384,7 +386,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} keyPassword: currentHost.keyPassword }); const connectTimeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('SSH reconnection timed out')), 15000) + setTimeout(() => reject(new Error(t('fileManager.sshReconnectionTimeout'))), 15000) ); await Promise.race([connectPromise, connectTimeoutPromise]); @@ -395,7 +397,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} const savePromise = writeSSHFile(tab.sshSessionId, tab.filePath, tab.content); const timeoutPromise = new Promise((_, reject) => setTimeout(() => { - reject(new Error('Save operation timed out')); + reject(new Error(t('fileManager.saveOperationTimeout'))); }, 30000) ); @@ -405,7 +407,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} loading: false } : t)); - toast.success('File saved successfully'); + toast.success(t('fileManager.fileSavedSuccessfully')); Promise.allSettled([ (async () => { @@ -430,13 +432,13 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} }); } catch (err) { - let errorMessage = formatErrorMessage(err, 'Cannot save file'); + let errorMessage = formatErrorMessage(err, t('fileManager.cannotSaveFile')); if (errorMessage.includes('timed out') || errorMessage.includes('timeout')) { - errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`; + errorMessage = t('fileManager.saveTimeout'); } - toast.error(`Failed to save file: ${errorMessage}`); + toast.error(`${t('fileManager.failedToSaveFile')}: ${errorMessage}`); setTabs(tabs => tabs.map(t => t.id === tab.id ? { ...t, loading: false @@ -480,11 +482,11 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} try { const {deleteSSHItem} = await import('@/ui/main-axios.ts'); await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory'); - toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`); + toast.success(`${item.type === 'directory' ? t('fileManager.folder') : t('fileManager.file')} ${t('fileManager.deletedSuccessfully')}`); setDeletingItem(null); handleOperationComplete(); } catch (error: any) { - handleError(error?.response?.data?.error || 'Failed to delete item'); + handleError(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); } }; @@ -517,8 +519,8 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} background: '#09090b' }}> - Connect to a Server - Select a server from the sidebar to start editing files + {t('fileManager.connectToServer')} + {t('fileManager.selectServerToEdit')} @@ -567,7 +569,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} 'w-[30px] h-[30px]', showOperations ? 'bg-[#2d2d30] border-[#434345]' : '' )} - title="File Operations" + title={t('fileManager.fileOperations')} > @@ -656,14 +658,14 @@ export function FileManager({onSelectView, embedded = false, initialHost = null} - Confirm Delete + {t('fileManager.confirmDelete')} - Are you sure you want to delete {deletingItem.name}? - {deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'} + {t('fileManager.confirmDeleteMessage', { name: deletingItem.name })} + {deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`} - This action cannot be undone. + {t('fileManager.actionCannotBeUndone')} performDelete(deletingItem)} className="flex-1" > - Delete + {t('common.delete')} setDeletingItem(null)} className="flex-1" > - Cancel + {t('common.cancel')} diff --git a/src/ui/Apps/File Manager/FileManagerHomeView.tsx b/src/ui/Apps/File Manager/FileManagerHomeView.tsx index ae75804e..7e735ca2 100644 --- a/src/ui/Apps/File Manager/FileManagerHomeView.tsx +++ b/src/ui/Apps/File Manager/FileManagerHomeView.tsx @@ -4,6 +4,7 @@ import {Trash2, Folder, File, Plus, Pin} from 'lucide-react'; import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs.tsx'; import {Input} from '@/components/ui/input.tsx'; import {useState} from 'react'; +import {useTranslation} from 'react-i18next'; interface FileItem { name: string; @@ -43,6 +44,7 @@ export function FileManagerHomeView({ onRemoveShortcut, onAddShortcut }: FileManagerHomeViewProps) { + const {t} = useTranslation(); const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent'); const [newShortcut, setNewShortcut] = useState(''); @@ -121,10 +123,9 @@ export function FileManagerHomeView({ setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full"> - Recent - Pinned - Folder - Shortcuts + {t('fileManager.recent')} + {t('fileManager.pinned')} + {t('fileManager.folderShortcuts')} @@ -132,7 +133,7 @@ export function FileManagerHomeView({ className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> {recent.length === 0 ? ( - No recent files. + {t('fileManager.noRecentFiles')} ) : recent.map((file) => renderFileCard( @@ -150,7 +151,7 @@ export function FileManagerHomeView({ className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> {pinned.length === 0 ? ( - No pinned files. + {t('fileManager.noPinnedFiles')} ) : pinned.map((file) => renderFileCard( @@ -166,7 +167,7 @@ export function FileManagerHomeView({ setNewShortcut(e.target.value)} className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground" @@ -189,14 +190,14 @@ export function FileManagerHomeView({ }} > - Add + {t('common.add')} {shortcuts.length === 0 ? ( - No shortcuts. + {t('fileManager.noShortcuts')} ) : shortcuts.map((shortcut) => renderShortcutCard(shortcut) diff --git a/src/ui/Apps/File Manager/FileManagerLeftSidebar.tsx b/src/ui/Apps/File Manager/FileManagerLeftSidebar.tsx index eb754f4a..290c4ab7 100644 --- a/src/ui/Apps/File Manager/FileManagerLeftSidebar.tsx +++ b/src/ui/Apps/File Manager/FileManagerLeftSidebar.tsx @@ -6,6 +6,7 @@ import {cn} from '@/lib/utils.ts'; import {Input} from '@/components/ui/input.tsx'; import {Button} from '@/components/ui/button.tsx'; import {toast} from 'sonner'; +import {useTranslation} from 'react-i18next'; import { listSSHFiles, renameSSHItem, @@ -56,6 +57,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( }, ref ) { + const {t} = useTranslation(); const [currentPath, setCurrentPath] = useState('/'); const [files, setFiles] = useState([]); const pathInputRef = useRef(null); @@ -126,7 +128,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( try { if (!server.password && !server.key) { - toast.error('No authentication credentials available for this SSH host'); + toast.error(t('common.noAuthCredentials')); return null; } @@ -150,7 +152,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( return sessionId; } catch (err: any) { - toast.error(err?.response?.data?.error || 'Failed to connect to SSH'); + toast.error(err?.response?.data?.error || t('fileManager.failedToConnectSSH')); setSshSessionId(null); return null; } finally { @@ -187,7 +189,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( setSshSessionId(newSessionId); res = await listSSHFiles(newSessionId, currentPath); } else { - throw new Error('Failed to reconnect SSH session'); + throw new Error(t('fileManager.failedToReconnectSSH')); } } else { res = await listSSHFiles(sshSessionId, currentPath); @@ -218,7 +220,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( } } catch (err: any) { setFiles([]); - toast.error(err?.response?.data?.error || err?.message || 'Failed to list files'); + toast.error(err?.response?.data?.error || err?.message || t('fileManager.failedToListFiles')); } finally { setFilesLoading(false); setFetchingFiles(false); @@ -324,7 +326,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( try { await renameSSHItem(sshSessionId, item.path, newName.trim()); - toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} renamed successfully`); + toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.renamedSuccessfully')}`); setRenamingItem(null); if (onOperationComplete) { onOperationComplete(); @@ -332,7 +334,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( fetchFiles(); } } catch (error: any) { - toast.error(error?.response?.data?.error || 'Failed to rename item'); + toast.error(error?.response?.data?.error || t('fileManager.failedToRenameItem')); } }; @@ -341,14 +343,14 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( try { await deleteSSHItem(sshSessionId, item.path, item.type === 'directory'); - toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`); + toast.success(`${item.type === 'directory' ? t('common.folder') : t('common.file')} ${t('common.deletedSuccessfully')}`); if (onOperationComplete) { onOperationComplete(); } else { fetchFiles(); } } catch (error: any) { - toast.error(error?.response?.data?.error || 'Failed to delete item'); + toast.error(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); } }; @@ -408,7 +410,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( {connectingSSH || filesLoading ? ( - Loading... + {t('common.loading')} ) : filteredFiles.length === 0 ? ( - No files or folders found. + {t('fileManager.noFilesOrFoldersFound')} ) : ( {filteredFiles.map((item: any) => { diff --git a/src/ui/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx b/src/ui/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx index 59bac696..c7392b46 100644 --- a/src/ui/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx +++ b/src/ui/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx @@ -3,6 +3,7 @@ import {Button} from '@/components/ui/button.tsx'; import {Card} from '@/components/ui/card.tsx'; import {Separator} from '@/components/ui/separator.tsx'; import {Plus, Folder, File, Star, Trash2, Edit, Link2, Server, Pin} from 'lucide-react'; +import {useTranslation} from 'react-i18next'; interface SSHConnection { id: string; @@ -61,16 +62,18 @@ export function FileManagerLeftSidebarFileViewer({ onSwitchToSSH, currentSSH, }: FileManagerLeftSidebarVileViewerProps) { + const {t} = useTranslation(); + return ( {isSSHMode ? 'SSH Path' : 'Local Path'} + className="text-xs text-muted-foreground font-semibold">{isSSHMode ? t('common.sshPath') : t('common.localPath')} {currentPath} {isLoading ? ( - Loading... + {t('common.loading')} ) : error ? ( {error} ) : ( diff --git a/src/ui/Apps/File Manager/FileManagerOperations.tsx b/src/ui/Apps/File Manager/FileManagerOperations.tsx index 6a974d91..4d71fc02 100644 --- a/src/ui/Apps/File Manager/FileManagerOperations.tsx +++ b/src/ui/Apps/File Manager/FileManagerOperations.tsx @@ -16,6 +16,7 @@ import { Folder } from 'lucide-react'; import {cn} from '@/lib/utils.ts'; +import {useTranslation} from 'react-i18next'; interface FileManagerOperationsProps { currentPath: string; @@ -32,6 +33,7 @@ export function FileManagerOperations({ onError, onSuccess }: FileManagerOperationsProps) { + const {t} = useTranslation(); const [showUpload, setShowUpload] = useState(false); const [showCreateFile, setShowCreateFile] = useState(false); const [showCreateFolder, setShowCreateFolder] = useState(false); @@ -81,12 +83,12 @@ export function FileManagerOperations({ const {uploadSSHFile} = await import('@/ui/main-axios.ts'); await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content); - onSuccess(`File "${uploadFile.name}" uploaded successfully`); + onSuccess(t('fileManager.fileUploadedSuccessfully', { name: uploadFile.name })); setShowUpload(false); setUploadFile(null); onOperationComplete(); } catch (error: any) { - onError(error?.response?.data?.error || 'Failed to upload file'); + onError(error?.response?.data?.error || t('fileManager.failedToUploadFile')); } finally { setIsLoading(false); } @@ -100,12 +102,12 @@ export function FileManagerOperations({ const {createSSHFile} = await import('@/ui/main-axios.ts'); await createSSHFile(sshSessionId, currentPath, newFileName.trim()); - onSuccess(`File "${newFileName.trim()}" created successfully`); + onSuccess(t('fileManager.fileCreatedSuccessfully', { name: newFileName.trim() })); setShowCreateFile(false); setNewFileName(''); onOperationComplete(); } catch (error: any) { - onError(error?.response?.data?.error || 'Failed to create file'); + onError(error?.response?.data?.error || t('fileManager.failedToCreateFile')); } finally { setIsLoading(false); } @@ -119,12 +121,12 @@ export function FileManagerOperations({ const {createSSHFolder} = await import('@/ui/main-axios.ts'); await createSSHFolder(sshSessionId, currentPath, newFolderName.trim()); - onSuccess(`Folder "${newFolderName.trim()}" created successfully`); + onSuccess(t('fileManager.folderCreatedSuccessfully', { name: newFolderName.trim() })); setShowCreateFolder(false); setNewFolderName(''); onOperationComplete(); } catch (error: any) { - onError(error?.response?.data?.error || 'Failed to create folder'); + onError(error?.response?.data?.error || t('fileManager.failedToCreateFolder')); } finally { setIsLoading(false); } @@ -138,13 +140,13 @@ export function FileManagerOperations({ const {deleteSSHItem} = await import('@/ui/main-axios.ts'); await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory); - onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`); + onSuccess(t('fileManager.itemDeletedSuccessfully', { type: deleteIsDirectory ? t('fileManager.folder') : t('fileManager.file') })); setShowDelete(false); setDeletePath(''); setDeleteIsDirectory(false); onOperationComplete(); } catch (error: any) { - onError(error?.response?.data?.error || 'Failed to delete item'); + onError(error?.response?.data?.error || t('fileManager.failedToDeleteItem')); } finally { setIsLoading(false); } @@ -158,14 +160,14 @@ export function FileManagerOperations({ const {renameSSHItem} = await import('@/ui/main-axios.ts'); await renameSSHItem(sshSessionId, renamePath, newName.trim()); - onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`); + onSuccess(t('fileManager.itemRenamedSuccessfully', { type: renameIsDirectory ? t('fileManager.folder') : t('fileManager.file') })); setShowRename(false); setRenamePath(''); setRenameIsDirectory(false); setNewName(''); onOperationComplete(); } catch (error: any) { - onError(error?.response?.data?.error || 'Failed to rename item'); + onError(error?.response?.data?.error || t('fileManager.failedToRenameItem')); } finally { setIsLoading(false); } @@ -202,7 +204,7 @@ export function FileManagerOperations({ return ( - Connect to SSH to use file operations + {t('fileManager.connectToSsh')} ); } @@ -215,50 +217,50 @@ export function FileManagerOperations({ size="sm" onClick={() => setShowUpload(true)} className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" - title="Upload File" + title={t('fileManager.uploadFile')} > - {showTextLabels && Upload File} + {showTextLabels && {t('fileManager.uploadFile')}} setShowCreateFile(true)} className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" - title="New File" + title={t('fileManager.newFile')} > - {showTextLabels && New File} + {showTextLabels && {t('fileManager.newFile')}} setShowCreateFolder(true)} className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" - title="New Folder" + title={t('fileManager.newFolder')} > - {showTextLabels && New Folder} + {showTextLabels && {t('fileManager.newFolder')}} setShowRename(true)} className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]" - title="Rename" + title={t('fileManager.rename')} > - {showTextLabels && Rename} + {showTextLabels && {t('fileManager.rename')}} setShowDelete(true)} className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2" - title="Delete Item" + title={t('fileManager.deleteItem')} > - {showTextLabels && Delete Item} + {showTextLabels && {t('fileManager.deleteItem')}} @@ -266,7 +268,7 @@ export function FileManagerOperations({ - Current Path: + {t('fileManager.currentPath')}: {currentPath} @@ -280,10 +282,10 @@ export function FileManagerOperations({ - Upload File + {t('fileManager.uploadFileTitle')} - Max: 100MB (JSON) / 200MB (Binary) + {t('fileManager.maxFileSize')} setUploadFile(null)} className="w-full text-sm h-8" > - Remove File + {t('fileManager.removeFile')} ) : ( - Click to select a file + {t('fileManager.clickToSelectFile')} - Choose File + {t('fileManager.chooseFile')} )} @@ -344,7 +346,7 @@ export function FileManagerOperations({ disabled={!uploadFile || isLoading} className="w-full text-sm h-9" > - {isLoading ? 'Uploading...' : 'Upload File'} + {isLoading ? t('fileManager.uploading') : t('fileManager.uploadFile')} - Cancel + {t('common.cancel')} @@ -365,7 +367,7 @@ export function FileManagerOperations({ - Create New File + {t('fileManager.createNewFile')} - File Name + {t('fileManager.fileName')} setNewFileName(e.target.value)} - placeholder="Enter file name (e.g., example.txt)" + placeholder={t('placeholders.fileName')} className="bg-[#23232a] border-2 border-[#434345] text-white text-sm" onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()} /> @@ -398,7 +400,7 @@ export function FileManagerOperations({ disabled={!newFileName.trim() || isLoading} className="w-full text-sm h-9" > - {isLoading ? 'Creating...' : 'Create File'} + {isLoading ? t('fileManager.creating') : t('fileManager.createFile')} - Cancel + {t('common.cancel')} @@ -419,7 +421,7 @@ export function FileManagerOperations({ - Create New Folder + {t('fileManager.createNewFolder')} - Folder Name + {t('fileManager.folderName')} setNewFolderName(e.target.value)} - placeholder="Enter folder name" + placeholder={t('placeholders.folderName')} className="bg-[#23232a] border-2 border-[#434345] text-white text-sm" onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()} /> @@ -452,7 +454,7 @@ export function FileManagerOperations({ disabled={!newFolderName.trim() || isLoading} className="w-full text-sm h-9" > - {isLoading ? 'Creating...' : 'Create Folder'} + {isLoading ? t('fileManager.creating') : t('fileManager.createFolder')} - Cancel + {t('common.cancel')} @@ -473,7 +475,7 @@ export function FileManagerOperations({ - Delete Item + {t('fileManager.deleteItem')} - Warning: This action cannot be undone + {t('fileManager.warningCannotUndo')} - Item Path + {t('fileManager.itemPath')} setDeletePath(e.target.value)} - placeholder="Enter full path to item" + placeholder={t('placeholders.fullPath')} className="bg-[#23232a] border-2 border-[#434345] text-white text-sm" /> @@ -515,7 +517,7 @@ export function FileManagerOperations({ className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0" /> - This is a directory (will delete recursively) + {t('fileManager.thisIsDirectory')} @@ -526,7 +528,7 @@ export function FileManagerOperations({ variant="destructive" className="w-full text-sm h-9" > - {isLoading ? 'Deleting...' : 'Delete Item'} + {isLoading ? t('fileManager.deleting') : t('fileManager.deleteItem')} - Cancel + {t('common.cancel')} @@ -547,7 +549,7 @@ export function FileManagerOperations({ - Rename Item + {t('fileManager.renameItem')} - Current Path + {t('fileManager.currentPathLabel')} setRenamePath(e.target.value)} - placeholder="Enter current path to item" + placeholder={t('placeholders.currentPath')} className="bg-[#23232a] border-2 border-[#434345] text-white text-sm" /> - New Name + {t('fileManager.newName')} setNewName(e.target.value)} - placeholder="Enter new name" + placeholder={t('placeholders.newName')} className="bg-[#23232a] border-2 border-[#434345] text-white text-sm" onKeyDown={(e) => e.key === 'Enter' && handleRename()} /> @@ -595,7 +597,7 @@ export function FileManagerOperations({ className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0" /> - This is a directory + {t('fileManager.thisIsDirectoryRename')} @@ -605,7 +607,7 @@ export function FileManagerOperations({ disabled={!renamePath || !newName.trim() || isLoading} className="w-full text-sm h-9" > - {isLoading ? 'Renaming...' : 'Rename Item'} + {isLoading ? t('fileManager.renaming') : t('fileManager.renameItem')} - Cancel + {t('common.cancel')} diff --git a/src/ui/Apps/Host Manager/HostManager.tsx b/src/ui/Apps/Host Manager/HostManager.tsx index 22a12b76..9a53545e 100644 --- a/src/ui/Apps/Host Manager/HostManager.tsx +++ b/src/ui/Apps/Host Manager/HostManager.tsx @@ -4,6 +4,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx import {Separator} from "@/components/ui/separator.tsx"; import {HostManagerHostEditor} from "@/ui/Apps/Host Manager/HostManagerHostEditor.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx"; +import {useTranslation} from "react-i18next"; interface HostManagerProps { onSelectView: (view: string) => void; @@ -34,6 +35,7 @@ interface SSHHost { } export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement { + const {t} = useTranslation(); const [activeTab, setActiveTab] = useState("host_viewer"); const [editingHost, setEditingHost] = useState(null); const {state: sidebarState} = useSidebar(); @@ -75,9 +77,9 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea - Host Viewer + {t('hosts.hostViewer')} - {editingHost ? "Edit Host" : "Add Host"} + {editingHost ? t('hosts.editHost') : t('hosts.addHost')} diff --git a/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx b/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx index 7dc84072..a00a15f0 100644 --- a/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx @@ -19,7 +19,9 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx import React, {useEffect, useRef, useState} from "react"; 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; @@ -50,6 +52,7 @@ interface SSHManagerHostEditorProps { } export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) { + const {t} = useTranslation(); const [hosts, setHosts] = useState([]); const [folders, setFolders] = useState([]); const [sshConfigurations, setSshConfigurations] = useState([]); @@ -127,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'] }); } @@ -135,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'] }); } @@ -152,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'] }); } @@ -244,8 +247,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos if (editingHost) { await updateSSHHost(editingHost.id, formData); + toast.success(t('hosts.hostUpdatedSuccessfully', { name: formData.name })); } else { await createSSHHost(formData); + toast.success(t('hosts.hostAddedSuccessfully', { name: formData.name })); } if (onFormSubmit) { @@ -254,7 +259,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (error) { - alert('Failed to save host. Please try again.'); + toast.error(t('hosts.failedToSaveHost')); } }; @@ -299,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); @@ -393,22 +398,22 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos - General - Terminal - Tunnel - File Manager + {t('hosts.general')} + {t('hosts.terminal')} + {t('hosts.tunnel')} + {t('hosts.fileManager')} - Connection Details + {t('hosts.connectionDetails')} ( - IP + {t('hosts.ipAddress')} - + )} @@ -419,9 +424,9 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="port" render={({field}) => ( - Port + {t('hosts.port')} - + )} @@ -432,24 +437,24 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="username" render={({field}) => ( - Username + {t('hosts.username')} - + )} /> - Organization + {t('hosts.organization')} ( - Name + {t('hosts.name')} - + )} @@ -460,11 +465,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="folder" render={({field}) => ( - Folder + {t('hosts.folder')} ( - Tags + {t('hosts.tags')} @@ -541,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')} /> @@ -554,7 +559,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="pin" render={({field}) => ( - Pin Connection + {t('hosts.pin')} - Authentication + {t('hosts.authentication')} { @@ -575,8 +580,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos className="flex-1 flex flex-col h-full min-h-0" > - Password - Key + {t('hosts.password')} + {t('hosts.key')} ( - Password + {t('hosts.password')} - + )} @@ -599,7 +604,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="key" render={({field}) => ( - SSH Private Key + {t('hosts.sshPrivateKey')} - {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')} @@ -632,10 +637,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="keyPassword" render={({field}) => ( - Key Password + {t('hosts.keyPassword')} @@ -648,7 +653,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="keyType" render={({field}) => ( - Key Type + {t('hosts.keyType')} setKeyTypeDropdownOpen((open) => !open)} > - {keyTypeOptions.find((opt) => opt.value === field.value)?.label || "Auto-detect"} + {keyTypeOptions.find((opt) => opt.value === field.value)?.label || t('hosts.autoDetect')} {keyTypeDropdownOpen && ( ( - Enable Terminal + {t('hosts.enableTerminal')} - Enable/disable host visibility in Terminal tab. + {t('hosts.enableTerminalDesc')} )} @@ -719,7 +724,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="enableTunnel" render={({field}) => ( - Enable Tunnel + {t('hosts.enableTunnel')} - Enable/disable host visibility in Tunnel tab. + {t('hosts.enableTunnelDesc')} )} @@ -737,44 +742,40 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos <> - Sshpass Required For Password Authentication + {t('hosts.sshpassRequired')} - For password-based SSH authentication, sshpass must be installed on - both the local and remote servers. Install with: sudo apt install sshpass (Debian/Ubuntu) or the equivalent for your OS. - Other installation methods: - • CentOS/RHEL/Fedora: {t('hosts.otherInstallMethods')} + • {t('hosts.centosRhelFedora')} sudo yum install sshpass or sudo dnf install sshpass - • macOS: brew + • {t('hosts.macos')} brew install hudochenkov/sshpass/sshpass - • Windows: Use WSL or consider SSH key authentication + • {t('hosts.windows')} - SSH Server Configuration Required - For reverse SSH tunnels, the endpoint SSH server must allow: + {t('hosts.sshServerConfigRequired')} + {t('hosts.sshServerConfigDesc')} • GatewayPorts - yes (bind remote ports) + yes {t('hosts.gatewayPortsYes')} • AllowTcpForwarding - yes (port forwarding) + yes {t('hosts.allowTcpForwardingYes')} • PermitRootLogin - yes (if using root) + yes {t('hosts.permitRootLoginYes')} - Edit /etc/ssh/sshd_config and - restart SSH: sudo - systemctl restart sshd + {t('hosts.editSshConfig')} @@ -783,7 +784,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="tunnelConnections" render={({field}) => ( - Tunnel Connections + {t('hosts.tunnelConnections')} {field.value.map((connection, index) => ( @@ -791,7 +792,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos className="p-4 border rounded-lg bg-muted/50"> - Connection {index + 1} + {t('hosts.connection')} {index + 1} - Remove + {t('hosts.remove')} @@ -810,10 +811,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.sourcePort`} render={({field: sourcePortField}) => ( - Source Port - (Source refers to the Current - Connection Details in the - General tab) + {t('hosts.sourcePort')} + {t('hosts.sourcePortDesc')} @@ -826,8 +825,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.endpointPort`} render={({field: endpointPortField}) => ( - Endpoint Port - (Remote) + {t('hosts.endpointPort')} @@ -841,14 +839,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos render={({field: endpointHostField}) => ( - Endpoint SSH - Configuration + {t('hosts.endpointSshConfig')} { sshConfigInputRefs.current[index] = el; }} - placeholder="endpoint ssh configuration" + placeholder={t('placeholders.sshConfig')} className="min-h-[40px]" autoComplete="off" value={endpointHostField.value} @@ -895,12 +892,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos - 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' + })} @@ -909,14 +904,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.maxRetries`} render={({field: maxRetriesField}) => ( - Max Retries + {t('hosts.maxRetries')} + placeholder={t('placeholders.maxRetries')} {...maxRetriesField} /> - Maximum number of retry attempts - for tunnel connection. + {t('hosts.maxRetriesDescription')} )} @@ -926,15 +920,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.retryInterval`} render={({field: retryIntervalField}) => ( - Retry Interval - (seconds) + {t('hosts.retryInterval')} + placeholder={t('placeholders.retryInterval')} {...retryIntervalField} /> - Time to wait between retry - attempts. + {t('hosts.retryIntervalDescription')} )} @@ -944,8 +936,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.autoStart`} render={({field}) => ( - Auto Start on Container - Launch + {t('hosts.autoStartContainer')} - Automatically start this tunnel - when the container launches. + {t('hosts.autoStartDesc')} )} @@ -976,7 +966,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos }]); }} > - Add Tunnel Connection + {t('hosts.addConnection')} @@ -994,7 +984,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="enableFileManager" render={({field}) => ( - Enable File Manager + {t('hosts.enableFileManager')} - Enable/disable host visibility in File Manager tab. + {t('hosts.enableFileManagerDesc')} )} @@ -1015,12 +1005,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="defaultPath" render={({field}) => ( - Default Path + {t('hosts.defaultPath')} - + - Set default directory shown when connected via - File Manager + {t('hosts.defaultPathDesc')} )} /> @@ -1039,7 +1028,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos transform: 'translateY(8px)' }} > - {editingHost ? "Update Host" : "Add Host"} + {editingHost ? t('hosts.updateHost') : t('hosts.addHost')} diff --git a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx index 476eb895..33574649 100644 --- a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx @@ -7,6 +7,8 @@ import {Input} from "@/components/ui/input"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; 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, @@ -47,6 +49,7 @@ interface SSHManagerHostViewerProps { } export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { + const {t} = useTranslation(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -64,20 +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(t('hosts.hostDeletedSuccessfully', { name: hostName })); await fetchHosts(); window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (err) { - alert('Failed to delete host'); + toast.error(t('hosts.failedToDeleteHost')); } } }; @@ -98,32 +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) { - alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`); + 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 { - alert(`Import failed: ${result.errors.join('\n')}`); + toast.error(t('hosts.importFailed') + `: ${result.errors.join(', ')}`); } } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file'; - alert(`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 = ''; @@ -163,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] = []; } @@ -171,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); }); @@ -189,7 +196,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - Loading hosts... + {t('hosts.loadingHosts')} ); @@ -201,7 +208,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {error} - Retry + {t('hosts.retry')} @@ -213,9 +220,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - No SSH Hosts + {t('hosts.noHosts')} - You haven't added any SSH hosts yet. Click "Add Host" to get started. + {t('hosts.noHostsMessage')} @@ -226,9 +233,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - SSH Hosts + {t('hosts.sshHosts')} - {filteredAndSortedHosts.length} hosts + {t('hosts.hostsCount', { count: filteredAndSortedHosts.length })} @@ -242,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')} - Import SSH Hosts from JSON + {t('hosts.importJsonTitle')} - Upload a JSON file to bulk import multiple SSH hosts (max 100). + {t('hosts.importJsonDesc')} @@ -318,7 +325,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { URL.revokeObjectURL(url); }} > - Download Sample + {t('hosts.downloadSample')} - Format Guide + {t('hosts.formatGuide')} - Refresh + {t('hosts.refresh')} @@ -350,7 +357,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { setSearchQuery(e.target.value)} className="pl-10" @@ -446,13 +453,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {host.enableTerminal && ( - Terminal + {t('hosts.terminalBadge')} )} {host.enableTunnel && ( - Tunnel + {t('hosts.tunnelBadge')} {host.tunnelConnections && host.tunnelConnections.length > 0 && ( ({host.tunnelConnections.length}) @@ -462,7 +469,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {host.enableFileManager && ( - File Manager + {t('hosts.fileManagerBadge')} )} diff --git a/src/ui/Apps/Server/Server.tsx b/src/ui/Apps/Server/Server.tsx index c97b0346..413c0a78 100644 --- a/src/ui/Apps/Server/Server.tsx +++ b/src/ui/Apps/Server/Server.tsx @@ -8,6 +8,7 @@ import {Cpu, HardDrive, MemoryStick} from "lucide-react"; import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx"; import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"; +import {useTranslation} from 'react-i18next'; interface ServerProps { hostConfig?: any; @@ -24,6 +25,7 @@ export function Server({ isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement { + const {t} = useTranslation(); const {state: sidebarState} = useSidebar(); const {addTab, tabs} = useTabs() as any; const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); @@ -168,16 +170,16 @@ export function Server({ } } }} - title="Refresh status and metrics" + title={t('serverStats.refreshStatusAndMetrics')} > - Refresh Status + {t('serverStats.refreshStatus')} {currentHostConfig?.enableFileManager && ( { if (!currentHostConfig || isFileManagerAlreadyOpen) return; const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== '' @@ -190,7 +192,7 @@ export function Server({ }); }} > - File Manager + {t('nav.fileManager')} )} @@ -208,11 +210,11 @@ export function Server({ const cores = metrics?.cpu?.cores; const la = metrics?.cpu?.load; const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; - const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)'; + const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus'); const laText = (la && la.length === 3) - ? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}` - : 'Avg: N/A'; - return `CPU Usage - ${pctText} of ${coresText} (${laText})`; + ? t('serverStats.loadAverage', {avg1: la[0].toFixed(2), avg5: la[1].toFixed(2), avg15: la[2].toFixed(2)}) + : t('serverStats.loadAverageNA'); + return `${t('serverStats.cpuUsage')} - ${pctText} ${t('serverStats.of')} ${coresText} (${laText})`; })()} @@ -232,7 +234,7 @@ export function Server({ const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A'; const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A'; - return `Memory Usage - ${pctText} (${usedText} of ${totalText})`; + return `${t('serverStats.memoryUsage')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; })()} @@ -252,7 +254,7 @@ export function Server({ const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const usedText = used ?? 'N/A'; const totalText = total ?? 'N/A'; - return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`; + return `${t('serverStats.rootStorageSpace')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; })()} @@ -270,7 +272,7 @@ export function Server({ )} - Have ideas for what should come next for server management? Share them on{" "} + {t('serverStats.feedbackMessage')}{" "} (function SSHTerminal( {hostConfig, isVisible, splitScreen = false}, ref ) { + const {t} = useTranslation(); const {instance: terminal, ref: xtermRef} = useXTerm(); const fitAddonRef = useRef(null); const webSocketRef = useRef(null); @@ -139,11 +141,11 @@ export const Terminal = forwardRef(function SSHTerminal( try { const msg = JSON.parse(event.data); if (msg.type === 'data') terminal.write(msg.data); - else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`); + else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`); else if (msg.type === 'connected') { } else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; - terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); + terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`); } } catch (error) { } @@ -151,12 +153,12 @@ export const Terminal = forwardRef(function SSHTerminal( ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) { - terminal.writeln('\r\n[Connection closed]'); + terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`); } }); ws.addEventListener('error', () => { - terminal.writeln('\r\n[Connection error]'); + terminal.writeln(`\r\n[${t('terminal.connectionError')}]`); }); } diff --git a/src/ui/Apps/Tunnel/TunnelObject.tsx b/src/ui/Apps/Tunnel/TunnelObject.tsx index 8daf97b9..52105a53 100644 --- a/src/ui/Apps/Tunnel/TunnelObject.tsx +++ b/src/ui/Apps/Tunnel/TunnelObject.tsx @@ -2,6 +2,7 @@ import React from "react"; import {Button} from "@/components/ui/button.tsx"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {Separator} from "@/components/ui/separator.tsx"; +import {useTranslation} from 'react-i18next'; import { Loader2, Pin, @@ -87,6 +88,7 @@ export function TunnelObject({ compact = false, bare = false }: SSHTunnelObjectProps): React.ReactElement { + const {t} = useTranslation(); const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { const tunnel = host.tunnelConnections[tunnelIndex]; @@ -97,7 +99,7 @@ export function TunnelObject({ const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => { if (!status) return { icon: , - text: 'Unknown', + text: t('tunnels.unknown'), color: 'text-muted-foreground', bgColor: 'bg-muted/50', borderColor: 'border-border' @@ -109,7 +111,7 @@ export function TunnelObject({ case 'CONNECTED': return { icon: , - text: 'Connected', + text: t('tunnels.connected'), color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-500/10 dark:bg-green-400/10', borderColor: 'border-green-500/20 dark:border-green-400/20' @@ -117,7 +119,7 @@ export function TunnelObject({ case 'CONNECTING': return { icon: , - text: 'Connecting...', + text: t('tunnels.connecting'), color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-500/10 dark:bg-blue-400/10', borderColor: 'border-blue-500/20 dark:border-blue-400/20' @@ -125,7 +127,7 @@ export function TunnelObject({ case 'DISCONNECTING': return { icon: , - text: 'Disconnecting...', + text: t('tunnels.disconnecting'), color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-500/10 dark:bg-orange-400/10', borderColor: 'border-orange-500/20 dark:border-orange-400/20' @@ -133,7 +135,7 @@ export function TunnelObject({ case 'DISCONNECTED': return { icon: , - text: 'Disconnected', + text: t('tunnels.disconnected'), color: 'text-muted-foreground', bgColor: 'bg-muted/30', borderColor: 'border-border' @@ -149,7 +151,7 @@ export function TunnelObject({ case 'FAILED': return { icon: , - text: status.reason || 'Error', + text: status.reason || t('tunnels.error'), color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-500/10 dark:bg-red-400/10', borderColor: 'border-red-500/20 dark:border-red-400/20' @@ -193,7 +195,7 @@ export function TunnelObject({ - Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} + {t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} {statusDisplay.text} @@ -212,7 +214,7 @@ export function TunnelObject({ className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > - Disconnect + {t('tunnels.disconnect')} > ) : isRetrying || isWaiting ? ( @@ -223,7 +225,7 @@ export function TunnelObject({ className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > - Cancel + {t('tunnels.cancel')} ) : ( - Connect + {t('tunnels.connect')} )} @@ -246,7 +248,7 @@ export function TunnelObject({ className="h-7 px-2 text-muted-foreground border-border text-xs" > - {isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'} + {isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} )} @@ -255,13 +257,13 @@ export function TunnelObject({ {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( - Error: + {t('tunnels.error')}: {status.reason} {status.reason && status.reason.includes('Max retries exhausted') && ( <> - Check your Docker logs for the error reason, join the Discord or @@ -280,12 +282,12 @@ export function TunnelObject({ - {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} + {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} - Attempt {status.retryCount} of {status.maxRetries} + {t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {status.nextRetryIn && ( - • Next retry in {status.nextRetryIn} seconds + • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })} )} @@ -297,7 +299,7 @@ export function TunnelObject({ ) : ( - No tunnel connections configured + {t('tunnels.noTunnelConnections')} )} @@ -346,7 +348,7 @@ export function TunnelObject({ {!compact && ( - Tunnel Connections ({host.tunnelConnections.length}) + {t('tunnels.tunnelConnections')} ({host.tunnelConnections.length}) )} {host.tunnelConnections && host.tunnelConnections.length > 0 ? ( @@ -373,7 +375,7 @@ export function TunnelObject({ - Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} + {t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} {statusDisplay.text} @@ -392,7 +394,7 @@ export function TunnelObject({ className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > - Disconnect + {t('tunnels.disconnect')} > ) : isRetrying || isWaiting ? ( @@ -403,7 +405,7 @@ export function TunnelObject({ className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > - Cancel + {t('tunnels.cancel')} ) : ( - Connect + {t('tunnels.connect')} )} @@ -427,7 +429,7 @@ export function TunnelObject({ className="h-7 px-2 text-muted-foreground border-border text-xs" > - {isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'} + {isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} )} @@ -436,13 +438,13 @@ export function TunnelObject({ {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( - Error: + {t('tunnels.error')}: {status.reason} {status.reason && status.reason.includes('Max retries exhausted') && ( <> - Check your Docker logs for the error reason, join the Discord or @@ -461,12 +463,12 @@ export function TunnelObject({ - {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} + {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} - Attempt {status.retryCount} of {status.maxRetries} + {t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {status.nextRetryIn && ( - • Next retry in {status.nextRetryIn} seconds + • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })} )} @@ -478,7 +480,7 @@ export function TunnelObject({ ) : ( - No tunnel connections configured + {t('tunnels.noTunnelConnections')} )} diff --git a/src/ui/Apps/Tunnel/TunnelViewer.tsx b/src/ui/Apps/Tunnel/TunnelViewer.tsx index 337f3b24..dcec9123 100644 --- a/src/ui/Apps/Tunnel/TunnelViewer.tsx +++ b/src/ui/Apps/Tunnel/TunnelViewer.tsx @@ -1,5 +1,6 @@ import React from "react"; import {TunnelObject} from "./TunnelObject.tsx"; +import {useTranslation} from 'react-i18next'; interface TunnelConnection { sourcePort: number; @@ -52,15 +53,15 @@ export function TunnelViewer({ tunnelActions = {}, onTunnelAction }: SSHTunnelViewerProps): React.ReactElement { + const {t} = useTranslation(); const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined; if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) { return ( - No SSH Tunnels + {t('tunnels.noSshTunnels')} - Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel - connections. + {t('tunnels.createFirstTunnelMessage')} ); @@ -69,7 +70,7 @@ export function TunnelViewer({ return ( - SSH Tunnels + {t('tunnels.title')} void; @@ -32,6 +33,7 @@ export function Homepage({ onAuthSuccess, isTopbarOpen = true }: HomepageProps): React.ReactElement { + const {t} = useTranslation(); const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [isAdmin, setIsAdmin] = useState(false); const [username, setUsername] = useState(null); @@ -70,13 +72,20 @@ export function Homepage({ } }, [isAuthenticated]); + const topOffset = isTopbarOpen ? 66 : 0; + const topPadding = isTopbarOpen ? 66 : 0; + return ( + className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out" + style={{ paddingTop: `${topPadding}px` }}> {!loggedIn ? ( - + ) : ( - - + + - Logged in! + {t('homepage.loggedInTitle')} - You are logged in! Use the sidebar to access all available tools. To get started, - create an SSH Host in the SSH Manager tab. Once created, you can connect to that - host using the other apps in the sidebar. + {t('homepage.loggedInMessage')} diff --git a/src/ui/Homepage/HomepageAlertCard.tsx b/src/ui/Homepage/HomepageAlertCard.tsx index d2f34722..e2f774fa 100644 --- a/src/ui/Homepage/HomepageAlertCard.tsx +++ b/src/ui/Homepage/HomepageAlertCard.tsx @@ -3,6 +3,7 @@ import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components import {Button} from "@/components/ui/button.tsx"; import {Badge} from "@/components/ui/badge.tsx"; import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; +import {useTranslation} from "react-i18next"; interface TermixAlert { id: string; @@ -64,6 +65,8 @@ const getTypeBadgeVariant = (type?: string) => { }; export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { + const {t} = useTranslation(); + if (!alert) { return null; } @@ -79,10 +82,10 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): const diffTime = expiryDate.getTime() - now.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - if (diffDays < 0) return 'Expired'; - if (diffDays === 0) return 'Expires today'; - if (diffDays === 1) return 'Expires tomorrow'; - return `Expires in ${diffDays} days`; + if (diffDays < 0) return t('common.expired'); + if (diffDays === 0) return t('common.expiresToday'); + if (diffDays === 1) return t('common.expiresTomorrow'); + return t('common.expiresInDays', {days: diffDays}); }; return ( diff --git a/src/ui/Homepage/HomepageAlertManager.tsx b/src/ui/Homepage/HomepageAlertManager.tsx index 4aa8dd70..6e68b941 100644 --- a/src/ui/Homepage/HomepageAlertManager.tsx +++ b/src/ui/Homepage/HomepageAlertManager.tsx @@ -2,6 +2,7 @@ import React, {useEffect, useState} from "react"; import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; import {Button} from "@/components/ui/button.tsx"; import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts"; +import {useTranslation} from "react-i18next"; interface TermixAlert { id: string; @@ -20,6 +21,7 @@ interface AlertManagerProps { } export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { + const {t} = useTranslation(); const [alerts, setAlerts] = useState([]); const [currentAlertIndex, setCurrentAlertIndex] = useState(0); const [loading, setLoading] = useState(false); @@ -57,7 +59,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea setAlerts(sortedAlerts); setCurrentAlertIndex(0); } catch (err) { - setError('Failed to load alerts'); + setError(t('homepage.failedToLoadAlerts')); } finally { setLoading(false); } @@ -81,7 +83,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea return prevIndex; }); } catch (err) { - setError('Failed to dismiss alert'); + setError(t('homepage.failedToDismissAlert')); } }; diff --git a/src/ui/Homepage/HomepageAuth.tsx b/src/ui/Homepage/HomepageAuth.tsx index 51212b33..5b1ca837 100644 --- a/src/ui/Homepage/HomepageAuth.tsx +++ b/src/ui/Homepage/HomepageAuth.tsx @@ -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 && ( - First User + {t('auth.firstUser')} - 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')}{" "} - GitHub issue + GitHub Issue . )} {!registrationAllowed && !internalLoggedIn && ( - Registration Disabled + {t('auth.registerTitle')} - New account registration is currently disabled by an admin. Please log in or contact an - administrator. + {t('messages.registrationDisabled')} )} {totpRequired && ( - Two-Factor Authentication - Enter the 6-digit code from your authenticator app + {t('auth.twoFactorAuth')} + {t('auth.enterCode')} - Authentication Code + {t('auth.verifyCode')} - Or enter a backup code if you don't have access to your authenticator + {t('auth.backupCode')} @@ -467,7 +468,7 @@ export function HomepageAuth({ disabled={totpLoading || totpCode.length < 6} onClick={handleTOTPVerification} > - {totpLoading ? Spinner : "Verify"} + {totpLoading ? Spinner : t('auth.verifyCode')} - Cancel + {t('common.cancel')} )} @@ -506,7 +507,7 @@ export function HomepageAuth({ aria-selected={tab === "login"} disabled={loading || firstUser} > - Login + {t('common.login')} - Sign Up + {t('common.register')} {oidcConfigured && ( - External + {t('auth.external')} )} - {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')} @@ -561,7 +562,7 @@ export function HomepageAuth({ {tab === "external" && ( <> - Login using your configured external identity provider + {t('auth.loginWithExternalDesc')} - {oidcLoading ? Spinner : "Login with External Provider"} + {oidcLoading ? Spinner : t('auth.loginWithExternal')} > )} @@ -578,12 +579,11 @@ export function HomepageAuth({ {resetStep === "initiate" && ( <> - Enter your username to receive a password reset code. The code - will be logged in the docker container logs. + {t('auth.resetCodeDesc')} - Username + {t('common.username')} - {resetLoading ? Spinner : "Send Reset Code"} + {resetLoading ? Spinner : t('auth.sendResetCode')} > @@ -609,12 +609,11 @@ export function HomepageAuth({ {resetStep === "verify" && ( <>o - Enter the 6-digit code from the docker container logs for - user: {localUsername} + {t('auth.enterResetCode')} {localUsername} - Reset Code + {t('auth.resetCode')} - {resetLoading ? Spinner : "Verify Code"} + {resetLoading ? Spinner : t('auth.verifyCodeButton')} - Back + {t('common.back')} > @@ -654,10 +653,9 @@ export function HomepageAuth({ {resetSuccess && ( <> - Success! + {t('auth.passwordResetSuccess')} - Your password has been successfully reset! You can now log in - with your new password. + {t('auth.passwordResetSuccessDesc')} - Go to Login + {t('auth.goToLogin')} > )} @@ -676,12 +674,11 @@ export function HomepageAuth({ {resetStep === "newPassword" && !resetSuccess && ( <> - Enter your new password for - user: {localUsername} + {t('auth.enterNewPassword')} {localUsername} - New Password + {t('auth.newPassword')} - Confirm Password + {t('auth.confirmNewPassword')} - {resetLoading ? Spinner : "Reset Password"} + {resetLoading ? Spinner : t('auth.resetPasswordButton')} - Back + {t('common.back')} > @@ -736,7 +733,7 @@ export function HomepageAuth({ ) : ( - Username + {t('common.username')} - Password + {t('common.password')} setPassword(e.target.value)} disabled={loading || internalLoggedIn}/> {tab === "signup" && ( - Confirm Password + {t('common.confirmPassword')} - {loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")} + {loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))} {tab === "login" && ( - Reset Password + {t('auth.resetPasswordButton')} )} )} + + + + + {t('common.language')} + + + + > )} {error && ( diff --git a/src/ui/Homepage/HompageUpdateLog.tsx b/src/ui/Homepage/HompageUpdateLog.tsx index c95f5bb0..667eed78 100644 --- a/src/ui/Homepage/HompageUpdateLog.tsx +++ b/src/ui/Homepage/HompageUpdateLog.tsx @@ -3,6 +3,7 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Separator} from "@/components/ui/separator.tsx"; import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts"; +import {useTranslation} from "react-i18next"; interface HomepageUpdateLogProps extends React.ComponentProps<"div"> { loggedIn: boolean; @@ -51,6 +52,7 @@ interface VersionResponse { } export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { + const {t} = useTranslation(); const [releases, setReleases] = useState(null); const [versionInfo, setVersionInfo] = useState(null); const [loading, setLoading] = useState(false); @@ -69,7 +71,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { setError(null); }) .catch(err => { - setError('Failed to fetch update information'); + setError(t('common.failedToFetchUpdateInfo')); }) .finally(() => setLoading(false)); } @@ -90,15 +92,15 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { return ( - Updates & Releases + {t('common.updatesAndReleases')} {versionInfo && versionInfo.status === 'requires_update' && ( - Update Available + {t('common.updateAvailable')} - A new version ({versionInfo.version}) is available. + {t('common.newVersionAvailable', { version: versionInfo.version })} )} @@ -117,7 +119,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {error && ( - Error + {t('common.error')} {error} )} @@ -135,7 +137,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {release.isPrerelease && ( - Pre-release + {t('common.preRelease')} )} @@ -158,9 +160,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {releases && releases.items.length === 0 && !loading && ( - No Releases + {t('common.noReleases')} - No releases found. + {t('common.noReleasesFound')} )} diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx index 9ddcec72..cdc25b26 100644 --- a/src/ui/Navigation/LeftSidebar.tsx +++ b/src/ui/Navigation/LeftSidebar.tsx @@ -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(null); - const [adminCount, setAdminCount] = React.useState(0); - - const [users, setUsers] = React.useState>([]); - const [newAdminUsername, setNewAdminUsername] = React.useState(""); - const [usersLoading, setUsersLoading] = React.useState(false); - const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); - const [makeAdminError, setMakeAdminError] = React.useState(null); - const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); - const [oidcConfig, setOidcConfig] = React.useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(true); @@ -140,7 +120,7 @@ export function LeftSidebar({ const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const openSshManagerTab = () => { if (sshManagerTab || isSplitScreenActive) return; - const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any); + const id = addTab({type: 'ssh_manager'} as any); setCurrentTab(id); }; const adminTab = tabList.find((t) => t.type === 'admin'); @@ -150,7 +130,7 @@ export function LeftSidebar({ setCurrentTab(adminTab.id); return; } - const id = addTab({type: 'admin', title: 'Admin'} as any); + const id = addTab({type: 'admin'} as any); setCurrentTab(id); }; @@ -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 { @@ -232,7 +186,7 @@ export function LeftSidebar({ }, 50); } } catch (err: any) { - setHostsError('Failed to load hosts'); + setHostsError(t('leftSidebar.failedToLoadHosts')); } }, []); @@ -275,7 +229,7 @@ export function LeftSidebar({ const hostsByFolder = React.useMemo(() => { const map: Record = {}; filteredHosts.forEach(h => { - const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; + const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder'); if (!map[folder]) map[folder] = []; map[folder].push(h); }); @@ -285,8 +239,8 @@ export function LeftSidebar({ const sortedFolders = React.useMemo(() => { const folders = Object.keys(hostsByFolder); folders.sort((a, b) => { - if (a === 'No Folder') return -1; - if (b === 'No Folder') return 1; + if (a === t('leftSidebar.noFolder')) return -1; + if (b === t('leftSidebar.noFolder')) return 1; return a.localeCompare(b); }); return folders; @@ -304,7 +258,7 @@ export function LeftSidebar({ setDeleteError(null); if (!deletePassword.trim()) { - setDeleteError("Password is required"); + setDeleteError(t('leftSidebar.passwordRequired')); setDeleteLoading(false); return; } @@ -315,101 +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 makeUserAdmin = 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 removeAdminStatus = 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) { - } - }; - - const deleteUser = 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) { - } - }; - return ( @@ -421,6 +285,7 @@ export function LeftSidebar({ variant="outline" onClick={() => setIsSidebarOpen(!isSidebarOpen)} className="w-[28px] h-[28px] absolute right-5" + title={t('common.toggleSidebar')} > @@ -431,9 +296,9 @@ export function LeftSidebar({ + title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}> - Host Manager + {t('nav.hostManager')} @@ -442,7 +307,7 @@ export function LeftSidebar({ 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" /> @@ -452,7 +317,7 @@ export function LeftSidebar({ - {hostsError} + {t('leftSidebar.failedToLoadHosts')} )} @@ -460,7 +325,7 @@ export function LeftSidebar({ {hostsLoading && ( - Loading hosts... + {t('hosts.loadingHosts')} )} @@ -487,7 +352,7 @@ export function LeftSidebar({ style={{width: '100%'}} disabled={disabled} > - {username ? username : 'Signed out'} + {username ? username : t('common.logout')} @@ -506,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); }}> - Profile & Security + {t('profile.title')} {isAdmin && ( { if (isAdmin) openAdminTab(); }}> - Admin Settings + {t('admin.title')} )} - Sign out + {t('common.logout')} setDeleteAccountOpen(true)} - disabled={isAdmin && adminCount <= 1} > - - Delete Account - {isAdmin && adminCount <= 1 && " (Last Admin)"} + + {t('leftSidebar.deleteAccount')} @@ -586,7 +448,7 @@ export function LeftSidebar({ onClick={(e) => e.stopPropagation()} > - Delete Account + {t('leftSidebar.deleteAccount')} × @@ -605,48 +467,33 @@ export function LeftSidebar({ - This action cannot be undone. This will permanently delete your account and all - associated data. + {t('leftSidebar.deleteAccountWarning')} - Warning + {t('common.warning')} - Deleting your account will remove all your data including SSH hosts, - configurations, and settings. - This action is irreversible. + {t('leftSidebar.deleteAccountWarningDetails')} {deleteError && ( - Error + {t('common.error')} {deleteError} )} - {isAdmin && adminCount <= 1 && ( - - Cannot Delete Account - - 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. - - - )} - - Confirm Password + {t('leftSidebar.confirmPassword')} setDeletePassword(e.target.value)} - placeholder="Enter your password to confirm" + placeholder={t('placeholders.confirmPassword')} required - disabled={isAdmin && adminCount <= 1} /> @@ -655,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')} - Cancel + {t('leftSidebar.cancel')} diff --git a/src/ui/Navigation/Tabs/Tab.tsx b/src/ui/Navigation/Tabs/Tab.tsx index 217aeb33..5b6a9d9d 100644 --- a/src/ui/Navigation/Tabs/Tab.tsx +++ b/src/ui/Navigation/Tabs/Tab.tsx @@ -1,6 +1,7 @@ import React from "react"; import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {Button} from "@/components/ui/button.tsx"; +import {useTranslation} from 'react-i18next'; import { Home, SeparatorVertical, @@ -37,6 +38,7 @@ export function Tab({ disableSplit = false, disableClose = false }: TabProps): React.ReactElement { + const {t} = useTranslation(); if (tabType === "home") { return ( {isServer ? : isFileManager ? : } - {title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')} + {title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : t('nav.terminal'))} {canSplit && ( @@ -99,7 +101,7 @@ export function Tab({ onClick={onActivate} disabled={disableActivate} > - {title || "SSH Manager"} + {title || t('nav.sshManager')} - {title || "Admin"} + {title || t('nav.admin')} ([ - {id: 1, type: 'home', title: 'Home'} + {id: 1, type: 'home', title: t('nav.home')} ]); const [currentTab, setCurrentTab] = useState(1); const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); const nextTabId = useRef(2); function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { - const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal'); + const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal')); const baseTitle = (desiredTitle || defaultTitle).trim(); const match = baseTitle.match(/^(.*) \((\d+)\)$/); const root = match ? match[1] : baseTitle; diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx index d89f6cc2..50904763 100644 --- a/src/ui/Navigation/TopNavbar.tsx +++ b/src/ui/Navigation/TopNavbar.tsx @@ -13,6 +13,7 @@ import { import {Input} from "@/components/ui/input.tsx"; import {Checkbox} from "@/components/ui/checkbox.tsx"; import {Separator} from "@/components/ui/separator.tsx"; +import {useTranslation} from "react-i18next"; interface TopNavbarProps { isTopbarOpen: boolean; @@ -23,6 +24,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac const {state} = useSidebar(); const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; const leftPosition = state === "collapsed" ? "26px" : "264px"; + const {t} = useTranslation(); const [toolsSheetOpen, setToolsSheetOpen] = useState(false); const [isRecording, setIsRecording] = useState(false); @@ -267,7 +269,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac setToolsSheetOpen(true)} > @@ -325,13 +327,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac onClick={(e) => e.stopPropagation()} > - SSH Tools + {t('sshTools.title')} setToolsSheetOpen(false)} className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center" - title="Close SSH Tools" + title={t('sshTools.closeTools')} > × @@ -340,7 +342,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac - Key Recording + {t('sshTools.keyRecording')} @@ -352,7 +354,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac className="flex-1" variant="outline" > - Start Key Recording + {t('sshTools.startKeyRecording')} ) : ( - Stop Key Recording + {t('sshTools.stopKeyRecording')} )} @@ -368,8 +370,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac {isRecording && ( <> - Select - terminals: + {t('sshTools.selectTerminals')} {terminalTabs.map(tab => ( - Type commands (all - keys supported): + {t('sshTools.typeCommands')} - Commands will be sent to {selectedTabIds.length} selected - terminal(s). + {t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })} > @@ -415,7 +414,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac - Settings + {t('sshTools.settings')} @@ -428,14 +427,14 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac htmlFor="enable-copy-paste" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white" > - Enable right‑click copy/paste + {t('sshTools.enableRightClickCopyPaste')} - Have ideas for what should come next for ssh tools? Share them on{" "} + {t('sshTools.shareIdeas')}{" "} (null); const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); @@ -25,7 +28,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [tempToken, setTempToken] = useState(""); const [resetLoading, setResetLoading] = useState(false); - const [resetSuccess, setResetSuccess] = useState(false); async function handleInitiatePasswordReset() { setError(null); @@ -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); } @@ -48,7 +50,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setConfirmPassword(""); setTempToken(""); setError(null); - setResetSuccess(false); } async function handleVerifyResetCode() { @@ -60,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); } @@ -71,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; } @@ -85,16 +86,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) { try { await completePasswordReset(userInfo.username, tempToken, newPassword); - setResetStep("initiate"); - setResetCode(""); - setNewPassword(""); - setConfirmPassword(""); - setTempToken(""); - setError(null); - - setResetSuccess(true); + 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); } @@ -112,15 +107,15 @@ export function PasswordReset({userInfo}: PasswordResetProps) { - Password + {t('common.password')} - Change your account password + {t('common.changeAccountPassword')} <> - {resetStep === "initiate" && !resetSuccess && ( + {resetStep === "initiate" && ( <> - {resetLoading ? Spinner : "Send Reset Code"} + {resetLoading ? Spinner : t('common.sendResetCode')} > @@ -138,12 +133,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) { {resetStep === "verify" && ( <> - Enter the 6-digit code from the docker container logs for - user: {userInfo.username} + {t('common.enterSixDigitCode')} {userInfo.username} - Reset Code + {t('common.resetCode')} setResetCode(e.target.value.replace(/\D/g, ''))} disabled={resetLoading} - placeholder="000000" + placeholder={t('placeholders.enterCode')} /> - {resetLoading ? Spinner : "Verify Code"} + {resetLoading ? Spinner : t('common.verifyCode')} - Back + {t('common.back')} > )} - {resetSuccess && ( - <> - - Success! - - Your password has been successfully reset! You can now log in - with your new password. - - - > - )} - - {resetStep === "newPassword" && !resetSuccess && ( + {resetStep === "newPassword" && ( <> - Enter your new password for - user: {userInfo.username} + {t('common.enterNewPassword')} {userInfo.username} - New Password + {t('common.newPassword')} - Confirm Password + {t('common.confirmPassword')} - {resetLoading ? Spinner : "Reset Password"} + {resetLoading ? Spinner : t('common.resetPassword')} - Back + {t('common.back')} > )} {error && ( - Error + {t('common.error')} {error} )} diff --git a/src/ui/User/TOTPSetup.tsx b/src/ui/User/TOTPSetup.tsx index 4bbd00d8..9239cdab 100644 --- a/src/ui/User/TOTPSetup.tsx +++ b/src/ui/User/TOTPSetup.tsx @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.t import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react"; import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts"; import { toast } from "sonner"; +import { useTranslation } from 'react-i18next'; interface TOTPSetupProps { isEnabled: boolean; @@ -15,6 +16,7 @@ interface TOTPSetupProps { } export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) { + const {t} = useTranslation(); const [isEnabled, setIsEnabled] = useState(initialEnabled); const [isSettingUp, setIsSettingUp] = useState(false); const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init"); @@ -55,7 +57,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const response = await enableTOTP(verificationCode); setBackupCodes(response.backup_codes); setSetupStep("backup"); - toast.success("Two-factor authentication enabled successfully!"); + toast.success(t('auth.twoFactorEnabledSuccess')); } catch (err: any) { setError(err?.response?.data?.error || "Invalid verification code"); } finally { @@ -74,7 +76,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet setPassword(""); setDisableCode(""); onStatusChange?.(false); - toast.success("Two-factor authentication disabled"); + toast.success(t('auth.twoFactorDisabled')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to disable TOTP"); } finally { @@ -88,7 +90,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet try { const response = await generateBackupCodes(password || undefined, disableCode || undefined); setBackupCodes(response.backup_codes); - toast.success("New backup codes generated"); + toast.success(t('auth.newBackupCodesGenerated')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to generate backup codes"); } finally { @@ -98,7 +100,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const copyToClipboard = (text: string, label: string) => { navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard`); + toast.success(t('messages.copiedToClipboard', {item: label})); }; const downloadBackupCodes = () => { @@ -114,7 +116,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet a.download = 'termix-backup-codes.txt'; a.click(); URL.revokeObjectURL(url); - toast.success("Backup codes downloaded"); + toast.success(t('auth.backupCodesDownloaded')); }; const handleComplete = () => { @@ -131,50 +133,50 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Your account is protected with two-factor authentication + {t('auth.twoFactorProtected')} - Enabled + {t('common.enabled')} - Two-factor authentication is currently active on your account + {t('auth.twoFactorActive')} - Disable 2FA - Backup Codes + {t('auth.disable2FA')} + {t('auth.backupCodes')} - Warning + {t('common.warning')} - Disabling two-factor authentication will make your account less secure + {t('auth.disableTwoFactorWarning')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -186,29 +188,29 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleDisable} disabled={loading || (!password && !disableCode)} > - Disable Two-Factor Authentication + {t('auth.disableTwoFactor')} - Generate new backup codes if you've lost your existing ones + {t('auth.generateNewBackupCodesText')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -219,20 +221,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleGenerateNewBackupCodes} disabled={loading || (!password && !disableCode)} > - Generate New Backup Codes + {t('auth.generateNewBackupCodes')} {backupCodes.length > 0 && ( - Your Backup Codes + {t('auth.yourBackupCodes')} - Download + {t('auth.download')} @@ -248,7 +250,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet {error && ( - Error + {t('common.error')} {error} )} @@ -261,9 +263,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Set Up Two-Factor Authentication + {t('auth.setupTwoFactorTitle')} - Step 1: Scan the QR code with your authenticator app + {t('auth.step1ScanQR')} @@ -272,7 +274,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Manual Entry Code + {t('auth.manualEntryCode')} - If you can't scan the QR code, enter this code manually in your authenticator app + {t('auth.cannotScanQRText')} setSetupStep("verify")} className="w-full"> - Next: Verify Code + {t('auth.nextVerifyCode')} @@ -304,14 +306,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Verify Your Authenticator + {t('auth.verifyAuthenticator')} - Step 2: Enter the 6-digit code from your authenticator app + {t('auth.step2EnterCode')} - Verification Code + {t('auth.verificationCode')} - Error + {t('common.error')} {error} )} @@ -337,14 +339,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={() => setSetupStep("qr")} disabled={loading} > - Back + {t('auth.back')} - {loading ? "Verifying..." : "Verify and Enable"} + {loading ? t('interface.verifying') : t('auth.verifyAndEnable')} @@ -356,17 +358,17 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Save Your Backup Codes + {t('auth.saveBackupCodesTitle')} - Step 3: Store these codes in a safe place + {t('auth.step3StoreCodesSecurely')} - Important + {t('common.important')} - Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device. + {t('auth.importantBackupCodesText')} @@ -393,7 +395,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Complete Setup + {t('auth.completeSetup')} @@ -405,23 +407,23 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Add an extra layer of security to your account + {t('auth.addExtraSecurityLayer')} - Not Enabled + {t('common.notEnabled')} - Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in. + {t('auth.notEnabledText')} - {loading ? "Setting up..." : "Enable Two-Factor Authentication"} + {loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')} {error && ( diff --git a/src/ui/User/UserProfile.tsx b/src/ui/User/UserProfile.tsx index 9a4bccc0..2f165472 100644 --- a/src/ui/User/UserProfile.tsx +++ b/src/ui/User/UserProfile.tsx @@ -10,12 +10,15 @@ import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx"; import {getUserInfo} from "@/ui/main-axios.ts"; import {toast} from "sonner"; import {PasswordReset} from "@/ui/User/PasswordReset.tsx"; +import {useTranslation} from "react-i18next"; +import {LanguageSwitcher} from "@/components/LanguageSwitcher"; interface UserProfileProps { isTopbarOpen?: boolean; } export function UserProfile({isTopbarOpen = true}: UserProfileProps) { + const {t} = useTranslation(); const [userInfo, setUserInfo] = useState<{ username: string; is_admin: boolean; @@ -41,7 +44,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { totp_enabled: info.totp_enabled || false }); } catch (err: any) { - setError(err?.response?.data?.error || "Failed to load user information"); + setError(err?.response?.data?.error || t('errors.loadFailed')); } finally { setLoading(false); } @@ -58,7 +61,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Loading user profile... + {t('common.loading')} @@ -70,8 +73,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Error - {error || "Failed to load user profile"} + {t('common.error')} + {error || t('errors.loadFailed')} ); @@ -84,20 +87,20 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { maxHeight: 'calc(100vh - 60px)' }}> - User Profile - Manage your account settings and security + {t('common.profile')} + {t('profile.description')} - Profile + {t('common.profile')} {!userInfo.is_oidc && ( - Security + {t('profile.security')} )} @@ -105,45 +108,55 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Account Information - Your account details and settings + {t('profile.accountInfo')} + {t('profile.description')} - Username + {t('common.username')} {userInfo.username} - Account Type + {t('profile.role')} - {userInfo.is_admin ? "Administrator" : "User"} + {userInfo.is_admin ? t('interface.administrator') : t('interface.user')} - Authentication Method + {t('profile.authMethod')} - {userInfo.is_oidc ? "External (OIDC)" : "Local"} + {userInfo.is_oidc ? t('profile.external') : t('profile.local')} - Two-Factor Authentication + {t('profile.twoFactorAuth')} {userInfo.is_oidc ? ( - Locked (OIDC Auth) + {t('auth.lockedOidcAuth')} ) : ( userInfo.totp_enabled ? ( - Enabled + {t('common.enabled')} ) : ( - Disabled + {t('common.disabled')} ) )} + + + + + {t('common.language')} + {t('profile.selectPreferredLanguage')} + + + + diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 99627071..6fa29322 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -143,6 +143,7 @@ interface UserInfo { id: string; username: string; is_admin: boolean; + is_oidc: boolean; } interface UserCount { @@ -897,7 +898,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> const response = await authApi.post('/users/totp/setup'); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'setup TOTP'); throw error; } } @@ -907,7 +908,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string; const response = await authApi.post('/users/totp/enable', { totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'enable TOTP'); throw error; } } @@ -917,7 +918,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis const response = await authApi.post('/users/totp/disable', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'disable TOTP'); throw error; } } @@ -927,7 +928,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'verify TOTP login'); throw error; } } @@ -937,7 +938,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string) const response = await authApi.post('/users/totp/backup-codes', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'generate backup codes'); throw error; } }
Configure external identity provider for - OIDC/OAuth2 authentication.
{t('admin.configureExternalProvider')}
Select a server from the sidebar to start editing files
{t('fileManager.selectServerToEdit')}
- Are you sure you want to delete {deletingItem.name}? - {deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'} + {t('fileManager.confirmDeleteMessage', { name: deletingItem.name })} + {deletingItem.type === 'directory' && ` ${t('fileManager.deleteDirectoryWarning')}`}
- This action cannot be undone. + {t('fileManager.actionCannotBeUndone')}
Connect to SSH to use file operations
{t('fileManager.connectToSsh')}
- Max: 100MB (JSON) / 200MB (Binary) + {t('fileManager.maxFileSize')}
Click to select a file
{t('fileManager.clickToSelectFile')}
sudo apt install sshpass
{t('hosts.otherInstallMethods')} + • {t('hosts.centosRhelFedora')} sudo yum install sshpass or sudo dnf install sshpass - • macOS: brew + • {t('hosts.macos')} brew install hudochenkov/sshpass/sshpass - • Windows: Use WSL or consider SSH key authentication + • {t('hosts.windows')} - SSH Server Configuration Required - For reverse SSH tunnels, the endpoint SSH server must allow: + {t('hosts.sshServerConfigRequired')} + {t('hosts.sshServerConfigDesc')} • GatewayPorts - yes (bind remote ports) + yes {t('hosts.gatewayPortsYes')} • AllowTcpForwarding - yes (port forwarding) + yes {t('hosts.allowTcpForwardingYes')} • PermitRootLogin - yes (if using root) + yes {t('hosts.permitRootLoginYes')} - Edit /etc/ssh/sshd_config and - restart SSH: sudo - systemctl restart sshd + {t('hosts.editSshConfig')} @@ -783,7 +784,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="tunnelConnections" render={({field}) => ( - Tunnel Connections + {t('hosts.tunnelConnections')} {field.value.map((connection, index) => ( @@ -791,7 +792,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos className="p-4 border rounded-lg bg-muted/50"> - Connection {index + 1} + {t('hosts.connection')} {index + 1} - Remove + {t('hosts.remove')} @@ -810,10 +811,8 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.sourcePort`} render={({field: sourcePortField}) => ( - Source Port - (Source refers to the Current - Connection Details in the - General tab) + {t('hosts.sourcePort')} + {t('hosts.sourcePortDesc')} @@ -826,8 +825,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.endpointPort`} render={({field: endpointPortField}) => ( - Endpoint Port - (Remote) + {t('hosts.endpointPort')} @@ -841,14 +839,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos render={({field: endpointHostField}) => ( - Endpoint SSH - Configuration + {t('hosts.endpointSshConfig')} { sshConfigInputRefs.current[index] = el; }} - placeholder="endpoint ssh configuration" + placeholder={t('placeholders.sshConfig')} className="min-h-[40px]" autoComplete="off" value={endpointHostField.value} @@ -895,12 +892,10 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos - 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' + })} @@ -909,14 +904,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.maxRetries`} render={({field: maxRetriesField}) => ( - Max Retries + {t('hosts.maxRetries')} + placeholder={t('placeholders.maxRetries')} {...maxRetriesField} /> - Maximum number of retry attempts - for tunnel connection. + {t('hosts.maxRetriesDescription')} )} @@ -926,15 +920,13 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.retryInterval`} render={({field: retryIntervalField}) => ( - Retry Interval - (seconds) + {t('hosts.retryInterval')} + placeholder={t('placeholders.retryInterval')} {...retryIntervalField} /> - Time to wait between retry - attempts. + {t('hosts.retryIntervalDescription')} )} @@ -944,8 +936,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name={`tunnelConnections.${index}.autoStart`} render={({field}) => ( - Auto Start on Container - Launch + {t('hosts.autoStartContainer')} - Automatically start this tunnel - when the container launches. + {t('hosts.autoStartDesc')} )} @@ -976,7 +966,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos }]); }} > - Add Tunnel Connection + {t('hosts.addConnection')} @@ -994,7 +984,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="enableFileManager" render={({field}) => ( - Enable File Manager + {t('hosts.enableFileManager')} - Enable/disable host visibility in File Manager tab. + {t('hosts.enableFileManagerDesc')} )} @@ -1015,12 +1005,11 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos name="defaultPath" render={({field}) => ( - Default Path + {t('hosts.defaultPath')} - + - Set default directory shown when connected via - File Manager + {t('hosts.defaultPathDesc')} )} /> @@ -1039,7 +1028,7 @@ export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHos transform: 'translateY(8px)' }} > - {editingHost ? "Update Host" : "Add Host"} + {editingHost ? t('hosts.updateHost') : t('hosts.addHost')} diff --git a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx index 476eb895..33574649 100644 --- a/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx @@ -7,6 +7,8 @@ import {Input} from "@/components/ui/input"; import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion"; 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, @@ -47,6 +49,7 @@ interface SSHManagerHostViewerProps { } export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { + const {t} = useTranslation(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -64,20 +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(t('hosts.hostDeletedSuccessfully', { name: hostName })); await fetchHosts(); window.dispatchEvent(new CustomEvent('ssh-hosts:changed')); } catch (err) { - alert('Failed to delete host'); + toast.error(t('hosts.failedToDeleteHost')); } } }; @@ -98,32 +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) { - alert(`Import completed: ${result.success} successful, ${result.failed} failed${result.errors.length > 0 ? '\n\nErrors:\n' + result.errors.join('\n') : ''}`); + 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 { - alert(`Import failed: ${result.errors.join('\n')}`); + toast.error(t('hosts.importFailed') + `: ${result.errors.join(', ')}`); } } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to import JSON file'; - alert(`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 = ''; @@ -163,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] = []; } @@ -171,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); }); @@ -189,7 +196,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - Loading hosts... + {t('hosts.loadingHosts')} ); @@ -201,7 +208,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {error} - Retry + {t('hosts.retry')} @@ -213,9 +220,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - No SSH Hosts + {t('hosts.noHosts')} - You haven't added any SSH hosts yet. Click "Add Host" to get started. + {t('hosts.noHostsMessage')} @@ -226,9 +233,9 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { - SSH Hosts + {t('hosts.sshHosts')} - {filteredAndSortedHosts.length} hosts + {t('hosts.hostsCount', { count: filteredAndSortedHosts.length })} @@ -242,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')} - Import SSH Hosts from JSON + {t('hosts.importJsonTitle')} - Upload a JSON file to bulk import multiple SSH hosts (max 100). + {t('hosts.importJsonDesc')} @@ -318,7 +325,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { URL.revokeObjectURL(url); }} > - Download Sample + {t('hosts.downloadSample')} - Format Guide + {t('hosts.formatGuide')} - Refresh + {t('hosts.refresh')} @@ -350,7 +357,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { setSearchQuery(e.target.value)} className="pl-10" @@ -446,13 +453,13 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {host.enableTerminal && ( - Terminal + {t('hosts.terminalBadge')} )} {host.enableTunnel && ( - Tunnel + {t('hosts.tunnelBadge')} {host.tunnelConnections && host.tunnelConnections.length > 0 && ( ({host.tunnelConnections.length}) @@ -462,7 +469,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { {host.enableFileManager && ( - File Manager + {t('hosts.fileManagerBadge')} )} diff --git a/src/ui/Apps/Server/Server.tsx b/src/ui/Apps/Server/Server.tsx index c97b0346..413c0a78 100644 --- a/src/ui/Apps/Server/Server.tsx +++ b/src/ui/Apps/Server/Server.tsx @@ -8,6 +8,7 @@ import {Cpu, HardDrive, MemoryStick} from "lucide-react"; import {Tunnel} from "@/ui/Apps/Tunnel/Tunnel.tsx"; import {getServerStatusById, getServerMetricsById, type ServerMetrics} from "@/ui/main-axios.ts"; import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx"; +import {useTranslation} from 'react-i18next'; interface ServerProps { hostConfig?: any; @@ -24,6 +25,7 @@ export function Server({ isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement { + const {t} = useTranslation(); const {state: sidebarState} = useSidebar(); const {addTab, tabs} = useTabs() as any; const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); @@ -168,16 +170,16 @@ export function Server({ } } }} - title="Refresh status and metrics" + title={t('serverStats.refreshStatusAndMetrics')} > - Refresh Status + {t('serverStats.refreshStatus')} {currentHostConfig?.enableFileManager && ( { if (!currentHostConfig || isFileManagerAlreadyOpen) return; const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== '' @@ -190,7 +192,7 @@ export function Server({ }); }} > - File Manager + {t('nav.fileManager')} )} @@ -208,11 +210,11 @@ export function Server({ const cores = metrics?.cpu?.cores; const la = metrics?.cpu?.load; const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; - const coresText = (typeof cores === 'number') ? `${cores} CPU(s)` : 'N/A CPU(s)'; + const coresText = (typeof cores === 'number') ? t('serverStats.cpuCores', {count: cores}) : t('serverStats.naCpus'); const laText = (la && la.length === 3) - ? `Avg: ${la[0].toFixed(2)}, ${la[1].toFixed(2)}, ${la[2].toFixed(2)}` - : 'Avg: N/A'; - return `CPU Usage - ${pctText} of ${coresText} (${laText})`; + ? t('serverStats.loadAverage', {avg1: la[0].toFixed(2), avg5: la[1].toFixed(2), avg15: la[2].toFixed(2)}) + : t('serverStats.loadAverageNA'); + return `${t('serverStats.cpuUsage')} - ${pctText} ${t('serverStats.of')} ${coresText} (${laText})`; })()} @@ -232,7 +234,7 @@ export function Server({ const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const usedText = (typeof used === 'number') ? `${used} GiB` : 'N/A'; const totalText = (typeof total === 'number') ? `${total} GiB` : 'N/A'; - return `Memory Usage - ${pctText} (${usedText} of ${totalText})`; + return `${t('serverStats.memoryUsage')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; })()} @@ -252,7 +254,7 @@ export function Server({ const pctText = (typeof pct === 'number') ? `${pct}%` : 'N/A'; const usedText = used ?? 'N/A'; const totalText = total ?? 'N/A'; - return `Root Storage Space - ${pctText} (${usedText} of ${totalText})`; + return `${t('serverStats.rootStorageSpace')} - ${pctText} (${usedText} ${t('serverStats.of')} ${totalText})`; })()} @@ -270,7 +272,7 @@ export function Server({ )} - Have ideas for what should come next for server management? Share them on{" "} + {t('serverStats.feedbackMessage')}{" "} (function SSHTerminal( {hostConfig, isVisible, splitScreen = false}, ref ) { + const {t} = useTranslation(); const {instance: terminal, ref: xtermRef} = useXTerm(); const fitAddonRef = useRef(null); const webSocketRef = useRef(null); @@ -139,11 +141,11 @@ export const Terminal = forwardRef(function SSHTerminal( try { const msg = JSON.parse(event.data); if (msg.type === 'data') terminal.write(msg.data); - else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`); + else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`); else if (msg.type === 'connected') { } else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; - terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); + terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`); } } catch (error) { } @@ -151,12 +153,12 @@ export const Terminal = forwardRef(function SSHTerminal( ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) { - terminal.writeln('\r\n[Connection closed]'); + terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`); } }); ws.addEventListener('error', () => { - terminal.writeln('\r\n[Connection error]'); + terminal.writeln(`\r\n[${t('terminal.connectionError')}]`); }); } diff --git a/src/ui/Apps/Tunnel/TunnelObject.tsx b/src/ui/Apps/Tunnel/TunnelObject.tsx index 8daf97b9..52105a53 100644 --- a/src/ui/Apps/Tunnel/TunnelObject.tsx +++ b/src/ui/Apps/Tunnel/TunnelObject.tsx @@ -2,6 +2,7 @@ import React from "react"; import {Button} from "@/components/ui/button.tsx"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {Separator} from "@/components/ui/separator.tsx"; +import {useTranslation} from 'react-i18next'; import { Loader2, Pin, @@ -87,6 +88,7 @@ export function TunnelObject({ compact = false, bare = false }: SSHTunnelObjectProps): React.ReactElement { + const {t} = useTranslation(); const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { const tunnel = host.tunnelConnections[tunnelIndex]; @@ -97,7 +99,7 @@ export function TunnelObject({ const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => { if (!status) return { icon: , - text: 'Unknown', + text: t('tunnels.unknown'), color: 'text-muted-foreground', bgColor: 'bg-muted/50', borderColor: 'border-border' @@ -109,7 +111,7 @@ export function TunnelObject({ case 'CONNECTED': return { icon: , - text: 'Connected', + text: t('tunnels.connected'), color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-500/10 dark:bg-green-400/10', borderColor: 'border-green-500/20 dark:border-green-400/20' @@ -117,7 +119,7 @@ export function TunnelObject({ case 'CONNECTING': return { icon: , - text: 'Connecting...', + text: t('tunnels.connecting'), color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-500/10 dark:bg-blue-400/10', borderColor: 'border-blue-500/20 dark:border-blue-400/20' @@ -125,7 +127,7 @@ export function TunnelObject({ case 'DISCONNECTING': return { icon: , - text: 'Disconnecting...', + text: t('tunnels.disconnecting'), color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-500/10 dark:bg-orange-400/10', borderColor: 'border-orange-500/20 dark:border-orange-400/20' @@ -133,7 +135,7 @@ export function TunnelObject({ case 'DISCONNECTED': return { icon: , - text: 'Disconnected', + text: t('tunnels.disconnected'), color: 'text-muted-foreground', bgColor: 'bg-muted/30', borderColor: 'border-border' @@ -149,7 +151,7 @@ export function TunnelObject({ case 'FAILED': return { icon: , - text: status.reason || 'Error', + text: status.reason || t('tunnels.error'), color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-500/10 dark:bg-red-400/10', borderColor: 'border-red-500/20 dark:border-red-400/20' @@ -193,7 +195,7 @@ export function TunnelObject({ - Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} + {t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} {statusDisplay.text} @@ -212,7 +214,7 @@ export function TunnelObject({ className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > - Disconnect + {t('tunnels.disconnect')} > ) : isRetrying || isWaiting ? ( @@ -223,7 +225,7 @@ export function TunnelObject({ className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > - Cancel + {t('tunnels.cancel')} ) : ( - Connect + {t('tunnels.connect')} )} @@ -246,7 +248,7 @@ export function TunnelObject({ className="h-7 px-2 text-muted-foreground border-border text-xs" > - {isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'} + {isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} )} @@ -255,13 +257,13 @@ export function TunnelObject({ {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( - Error: + {t('tunnels.error')}: {status.reason} {status.reason && status.reason.includes('Max retries exhausted') && ( <> - Check your Docker logs for the error reason, join the Discord or @@ -280,12 +282,12 @@ export function TunnelObject({ - {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} + {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} - Attempt {status.retryCount} of {status.maxRetries} + {t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {status.nextRetryIn && ( - • Next retry in {status.nextRetryIn} seconds + • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })} )} @@ -297,7 +299,7 @@ export function TunnelObject({ ) : ( - No tunnel connections configured + {t('tunnels.noTunnelConnections')} )} @@ -346,7 +348,7 @@ export function TunnelObject({ {!compact && ( - Tunnel Connections ({host.tunnelConnections.length}) + {t('tunnels.tunnelConnections')} ({host.tunnelConnections.length}) )} {host.tunnelConnections && host.tunnelConnections.length > 0 ? ( @@ -373,7 +375,7 @@ export function TunnelObject({ - Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} + {t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} {statusDisplay.text} @@ -392,7 +394,7 @@ export function TunnelObject({ className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > - Disconnect + {t('tunnels.disconnect')} > ) : isRetrying || isWaiting ? ( @@ -403,7 +405,7 @@ export function TunnelObject({ className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > - Cancel + {t('tunnels.cancel')} ) : ( - Connect + {t('tunnels.connect')} )} @@ -427,7 +429,7 @@ export function TunnelObject({ className="h-7 px-2 text-muted-foreground border-border text-xs" > - {isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'} + {isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} )} @@ -436,13 +438,13 @@ export function TunnelObject({ {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( - Error: + {t('tunnels.error')}: {status.reason} {status.reason && status.reason.includes('Max retries exhausted') && ( <> - Check your Docker logs for the error reason, join the Discord or @@ -461,12 +463,12 @@ export function TunnelObject({ - {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} + {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} - Attempt {status.retryCount} of {status.maxRetries} + {t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {status.nextRetryIn && ( - • Next retry in {status.nextRetryIn} seconds + • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })} )} @@ -478,7 +480,7 @@ export function TunnelObject({ ) : ( - No tunnel connections configured + {t('tunnels.noTunnelConnections')} )} diff --git a/src/ui/Apps/Tunnel/TunnelViewer.tsx b/src/ui/Apps/Tunnel/TunnelViewer.tsx index 337f3b24..dcec9123 100644 --- a/src/ui/Apps/Tunnel/TunnelViewer.tsx +++ b/src/ui/Apps/Tunnel/TunnelViewer.tsx @@ -1,5 +1,6 @@ import React from "react"; import {TunnelObject} from "./TunnelObject.tsx"; +import {useTranslation} from 'react-i18next'; interface TunnelConnection { sourcePort: number; @@ -52,15 +53,15 @@ export function TunnelViewer({ tunnelActions = {}, onTunnelAction }: SSHTunnelViewerProps): React.ReactElement { + const {t} = useTranslation(); const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined; if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) { return ( - No SSH Tunnels + {t('tunnels.noSshTunnels')} - Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel - connections. + {t('tunnels.createFirstTunnelMessage')} ); @@ -69,7 +70,7 @@ export function TunnelViewer({ return ( - SSH Tunnels + {t('tunnels.title')} void; @@ -32,6 +33,7 @@ export function Homepage({ onAuthSuccess, isTopbarOpen = true }: HomepageProps): React.ReactElement { + const {t} = useTranslation(); const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [isAdmin, setIsAdmin] = useState(false); const [username, setUsername] = useState(null); @@ -70,13 +72,20 @@ export function Homepage({ } }, [isAuthenticated]); + const topOffset = isTopbarOpen ? 66 : 0; + const topPadding = isTopbarOpen ? 66 : 0; + return ( + className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out" + style={{ paddingTop: `${topPadding}px` }}> {!loggedIn ? ( - + ) : ( - - + + - Logged in! + {t('homepage.loggedInTitle')} - You are logged in! Use the sidebar to access all available tools. To get started, - create an SSH Host in the SSH Manager tab. Once created, you can connect to that - host using the other apps in the sidebar. + {t('homepage.loggedInMessage')} diff --git a/src/ui/Homepage/HomepageAlertCard.tsx b/src/ui/Homepage/HomepageAlertCard.tsx index d2f34722..e2f774fa 100644 --- a/src/ui/Homepage/HomepageAlertCard.tsx +++ b/src/ui/Homepage/HomepageAlertCard.tsx @@ -3,6 +3,7 @@ import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components import {Button} from "@/components/ui/button.tsx"; import {Badge} from "@/components/ui/badge.tsx"; import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; +import {useTranslation} from "react-i18next"; interface TermixAlert { id: string; @@ -64,6 +65,8 @@ const getTypeBadgeVariant = (type?: string) => { }; export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { + const {t} = useTranslation(); + if (!alert) { return null; } @@ -79,10 +82,10 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): const diffTime = expiryDate.getTime() - now.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - if (diffDays < 0) return 'Expired'; - if (diffDays === 0) return 'Expires today'; - if (diffDays === 1) return 'Expires tomorrow'; - return `Expires in ${diffDays} days`; + if (diffDays < 0) return t('common.expired'); + if (diffDays === 0) return t('common.expiresToday'); + if (diffDays === 1) return t('common.expiresTomorrow'); + return t('common.expiresInDays', {days: diffDays}); }; return ( diff --git a/src/ui/Homepage/HomepageAlertManager.tsx b/src/ui/Homepage/HomepageAlertManager.tsx index 4aa8dd70..6e68b941 100644 --- a/src/ui/Homepage/HomepageAlertManager.tsx +++ b/src/ui/Homepage/HomepageAlertManager.tsx @@ -2,6 +2,7 @@ import React, {useEffect, useState} from "react"; import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; import {Button} from "@/components/ui/button.tsx"; import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts"; +import {useTranslation} from "react-i18next"; interface TermixAlert { id: string; @@ -20,6 +21,7 @@ interface AlertManagerProps { } export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { + const {t} = useTranslation(); const [alerts, setAlerts] = useState([]); const [currentAlertIndex, setCurrentAlertIndex] = useState(0); const [loading, setLoading] = useState(false); @@ -57,7 +59,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea setAlerts(sortedAlerts); setCurrentAlertIndex(0); } catch (err) { - setError('Failed to load alerts'); + setError(t('homepage.failedToLoadAlerts')); } finally { setLoading(false); } @@ -81,7 +83,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea return prevIndex; }); } catch (err) { - setError('Failed to dismiss alert'); + setError(t('homepage.failedToDismissAlert')); } }; diff --git a/src/ui/Homepage/HomepageAuth.tsx b/src/ui/Homepage/HomepageAuth.tsx index 51212b33..5b1ca837 100644 --- a/src/ui/Homepage/HomepageAuth.tsx +++ b/src/ui/Homepage/HomepageAuth.tsx @@ -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 && ( - First User + {t('auth.firstUser')} - 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')}{" "} - GitHub issue + GitHub Issue . )} {!registrationAllowed && !internalLoggedIn && ( - Registration Disabled + {t('auth.registerTitle')} - New account registration is currently disabled by an admin. Please log in or contact an - administrator. + {t('messages.registrationDisabled')} )} {totpRequired && ( - Two-Factor Authentication - Enter the 6-digit code from your authenticator app + {t('auth.twoFactorAuth')} + {t('auth.enterCode')} - Authentication Code + {t('auth.verifyCode')} - Or enter a backup code if you don't have access to your authenticator + {t('auth.backupCode')} @@ -467,7 +468,7 @@ export function HomepageAuth({ disabled={totpLoading || totpCode.length < 6} onClick={handleTOTPVerification} > - {totpLoading ? Spinner : "Verify"} + {totpLoading ? Spinner : t('auth.verifyCode')} - Cancel + {t('common.cancel')} )} @@ -506,7 +507,7 @@ export function HomepageAuth({ aria-selected={tab === "login"} disabled={loading || firstUser} > - Login + {t('common.login')} - Sign Up + {t('common.register')} {oidcConfigured && ( - External + {t('auth.external')} )} - {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')} @@ -561,7 +562,7 @@ export function HomepageAuth({ {tab === "external" && ( <> - Login using your configured external identity provider + {t('auth.loginWithExternalDesc')} - {oidcLoading ? Spinner : "Login with External Provider"} + {oidcLoading ? Spinner : t('auth.loginWithExternal')} > )} @@ -578,12 +579,11 @@ export function HomepageAuth({ {resetStep === "initiate" && ( <> - Enter your username to receive a password reset code. The code - will be logged in the docker container logs. + {t('auth.resetCodeDesc')} - Username + {t('common.username')} - {resetLoading ? Spinner : "Send Reset Code"} + {resetLoading ? Spinner : t('auth.sendResetCode')} > @@ -609,12 +609,11 @@ export function HomepageAuth({ {resetStep === "verify" && ( <>o - Enter the 6-digit code from the docker container logs for - user: {localUsername} + {t('auth.enterResetCode')} {localUsername} - Reset Code + {t('auth.resetCode')} - {resetLoading ? Spinner : "Verify Code"} + {resetLoading ? Spinner : t('auth.verifyCodeButton')} - Back + {t('common.back')} > @@ -654,10 +653,9 @@ export function HomepageAuth({ {resetSuccess && ( <> - Success! + {t('auth.passwordResetSuccess')} - Your password has been successfully reset! You can now log in - with your new password. + {t('auth.passwordResetSuccessDesc')} - Go to Login + {t('auth.goToLogin')} > )} @@ -676,12 +674,11 @@ export function HomepageAuth({ {resetStep === "newPassword" && !resetSuccess && ( <> - Enter your new password for - user: {localUsername} + {t('auth.enterNewPassword')} {localUsername} - New Password + {t('auth.newPassword')} - Confirm Password + {t('auth.confirmNewPassword')} - {resetLoading ? Spinner : "Reset Password"} + {resetLoading ? Spinner : t('auth.resetPasswordButton')} - Back + {t('common.back')} > @@ -736,7 +733,7 @@ export function HomepageAuth({ ) : ( - Username + {t('common.username')} - Password + {t('common.password')} setPassword(e.target.value)} disabled={loading || internalLoggedIn}/> {tab === "signup" && ( - Confirm Password + {t('common.confirmPassword')} - {loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")} + {loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))} {tab === "login" && ( - Reset Password + {t('auth.resetPasswordButton')} )} )} + + + + + {t('common.language')} + + + + > )} {error && ( diff --git a/src/ui/Homepage/HompageUpdateLog.tsx b/src/ui/Homepage/HompageUpdateLog.tsx index c95f5bb0..667eed78 100644 --- a/src/ui/Homepage/HompageUpdateLog.tsx +++ b/src/ui/Homepage/HompageUpdateLog.tsx @@ -3,6 +3,7 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Separator} from "@/components/ui/separator.tsx"; import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts"; +import {useTranslation} from "react-i18next"; interface HomepageUpdateLogProps extends React.ComponentProps<"div"> { loggedIn: boolean; @@ -51,6 +52,7 @@ interface VersionResponse { } export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { + const {t} = useTranslation(); const [releases, setReleases] = useState(null); const [versionInfo, setVersionInfo] = useState(null); const [loading, setLoading] = useState(false); @@ -69,7 +71,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { setError(null); }) .catch(err => { - setError('Failed to fetch update information'); + setError(t('common.failedToFetchUpdateInfo')); }) .finally(() => setLoading(false)); } @@ -90,15 +92,15 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { return ( - Updates & Releases + {t('common.updatesAndReleases')} {versionInfo && versionInfo.status === 'requires_update' && ( - Update Available + {t('common.updateAvailable')} - A new version ({versionInfo.version}) is available. + {t('common.newVersionAvailable', { version: versionInfo.version })} )} @@ -117,7 +119,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {error && ( - Error + {t('common.error')} {error} )} @@ -135,7 +137,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {release.isPrerelease && ( - Pre-release + {t('common.preRelease')} )} @@ -158,9 +160,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {releases && releases.items.length === 0 && !loading && ( - No Releases + {t('common.noReleases')} - No releases found. + {t('common.noReleasesFound')} )} diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx index 9ddcec72..cdc25b26 100644 --- a/src/ui/Navigation/LeftSidebar.tsx +++ b/src/ui/Navigation/LeftSidebar.tsx @@ -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(null); - const [adminCount, setAdminCount] = React.useState(0); - - const [users, setUsers] = React.useState>([]); - const [newAdminUsername, setNewAdminUsername] = React.useState(""); - const [usersLoading, setUsersLoading] = React.useState(false); - const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); - const [makeAdminError, setMakeAdminError] = React.useState(null); - const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); - const [oidcConfig, setOidcConfig] = React.useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(true); @@ -140,7 +120,7 @@ export function LeftSidebar({ const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const openSshManagerTab = () => { if (sshManagerTab || isSplitScreenActive) return; - const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any); + const id = addTab({type: 'ssh_manager'} as any); setCurrentTab(id); }; const adminTab = tabList.find((t) => t.type === 'admin'); @@ -150,7 +130,7 @@ export function LeftSidebar({ setCurrentTab(adminTab.id); return; } - const id = addTab({type: 'admin', title: 'Admin'} as any); + const id = addTab({type: 'admin'} as any); setCurrentTab(id); }; @@ -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 { @@ -232,7 +186,7 @@ export function LeftSidebar({ }, 50); } } catch (err: any) { - setHostsError('Failed to load hosts'); + setHostsError(t('leftSidebar.failedToLoadHosts')); } }, []); @@ -275,7 +229,7 @@ export function LeftSidebar({ const hostsByFolder = React.useMemo(() => { const map: Record = {}; filteredHosts.forEach(h => { - const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; + const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder'); if (!map[folder]) map[folder] = []; map[folder].push(h); }); @@ -285,8 +239,8 @@ export function LeftSidebar({ const sortedFolders = React.useMemo(() => { const folders = Object.keys(hostsByFolder); folders.sort((a, b) => { - if (a === 'No Folder') return -1; - if (b === 'No Folder') return 1; + if (a === t('leftSidebar.noFolder')) return -1; + if (b === t('leftSidebar.noFolder')) return 1; return a.localeCompare(b); }); return folders; @@ -304,7 +258,7 @@ export function LeftSidebar({ setDeleteError(null); if (!deletePassword.trim()) { - setDeleteError("Password is required"); + setDeleteError(t('leftSidebar.passwordRequired')); setDeleteLoading(false); return; } @@ -315,101 +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 makeUserAdmin = 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 removeAdminStatus = 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) { - } - }; - - const deleteUser = 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) { - } - }; - return ( @@ -421,6 +285,7 @@ export function LeftSidebar({ variant="outline" onClick={() => setIsSidebarOpen(!isSidebarOpen)} className="w-[28px] h-[28px] absolute right-5" + title={t('common.toggleSidebar')} > @@ -431,9 +296,9 @@ export function LeftSidebar({ + title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}> - Host Manager + {t('nav.hostManager')} @@ -442,7 +307,7 @@ export function LeftSidebar({ 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" /> @@ -452,7 +317,7 @@ export function LeftSidebar({ - {hostsError} + {t('leftSidebar.failedToLoadHosts')} )} @@ -460,7 +325,7 @@ export function LeftSidebar({ {hostsLoading && ( - Loading hosts... + {t('hosts.loadingHosts')} )} @@ -487,7 +352,7 @@ export function LeftSidebar({ style={{width: '100%'}} disabled={disabled} > - {username ? username : 'Signed out'} + {username ? username : t('common.logout')} @@ -506,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); }}> - Profile & Security + {t('profile.title')} {isAdmin && ( { if (isAdmin) openAdminTab(); }}> - Admin Settings + {t('admin.title')} )} - Sign out + {t('common.logout')} setDeleteAccountOpen(true)} - disabled={isAdmin && adminCount <= 1} > - - Delete Account - {isAdmin && adminCount <= 1 && " (Last Admin)"} + + {t('leftSidebar.deleteAccount')} @@ -586,7 +448,7 @@ export function LeftSidebar({ onClick={(e) => e.stopPropagation()} > - Delete Account + {t('leftSidebar.deleteAccount')} × @@ -605,48 +467,33 @@ export function LeftSidebar({ - This action cannot be undone. This will permanently delete your account and all - associated data. + {t('leftSidebar.deleteAccountWarning')} - Warning + {t('common.warning')} - Deleting your account will remove all your data including SSH hosts, - configurations, and settings. - This action is irreversible. + {t('leftSidebar.deleteAccountWarningDetails')} {deleteError && ( - Error + {t('common.error')} {deleteError} )} - {isAdmin && adminCount <= 1 && ( - - Cannot Delete Account - - 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. - - - )} - - Confirm Password + {t('leftSidebar.confirmPassword')} setDeletePassword(e.target.value)} - placeholder="Enter your password to confirm" + placeholder={t('placeholders.confirmPassword')} required - disabled={isAdmin && adminCount <= 1} /> @@ -655,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')} - Cancel + {t('leftSidebar.cancel')} diff --git a/src/ui/Navigation/Tabs/Tab.tsx b/src/ui/Navigation/Tabs/Tab.tsx index 217aeb33..5b6a9d9d 100644 --- a/src/ui/Navigation/Tabs/Tab.tsx +++ b/src/ui/Navigation/Tabs/Tab.tsx @@ -1,6 +1,7 @@ import React from "react"; import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {Button} from "@/components/ui/button.tsx"; +import {useTranslation} from 'react-i18next'; import { Home, SeparatorVertical, @@ -37,6 +38,7 @@ export function Tab({ disableSplit = false, disableClose = false }: TabProps): React.ReactElement { + const {t} = useTranslation(); if (tabType === "home") { return ( {isServer ? : isFileManager ? : } - {title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')} + {title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : t('nav.terminal'))} {canSplit && ( @@ -99,7 +101,7 @@ export function Tab({ onClick={onActivate} disabled={disableActivate} > - {title || "SSH Manager"} + {title || t('nav.sshManager')} - {title || "Admin"} + {title || t('nav.admin')} ([ - {id: 1, type: 'home', title: 'Home'} + {id: 1, type: 'home', title: t('nav.home')} ]); const [currentTab, setCurrentTab] = useState(1); const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); const nextTabId = useRef(2); function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { - const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal'); + const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal')); const baseTitle = (desiredTitle || defaultTitle).trim(); const match = baseTitle.match(/^(.*) \((\d+)\)$/); const root = match ? match[1] : baseTitle; diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx index d89f6cc2..50904763 100644 --- a/src/ui/Navigation/TopNavbar.tsx +++ b/src/ui/Navigation/TopNavbar.tsx @@ -13,6 +13,7 @@ import { import {Input} from "@/components/ui/input.tsx"; import {Checkbox} from "@/components/ui/checkbox.tsx"; import {Separator} from "@/components/ui/separator.tsx"; +import {useTranslation} from "react-i18next"; interface TopNavbarProps { isTopbarOpen: boolean; @@ -23,6 +24,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac const {state} = useSidebar(); const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; const leftPosition = state === "collapsed" ? "26px" : "264px"; + const {t} = useTranslation(); const [toolsSheetOpen, setToolsSheetOpen] = useState(false); const [isRecording, setIsRecording] = useState(false); @@ -267,7 +269,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac setToolsSheetOpen(true)} > @@ -325,13 +327,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac onClick={(e) => e.stopPropagation()} > - SSH Tools + {t('sshTools.title')} setToolsSheetOpen(false)} className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center" - title="Close SSH Tools" + title={t('sshTools.closeTools')} > × @@ -340,7 +342,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac - Key Recording + {t('sshTools.keyRecording')} @@ -352,7 +354,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac className="flex-1" variant="outline" > - Start Key Recording + {t('sshTools.startKeyRecording')} ) : ( - Stop Key Recording + {t('sshTools.stopKeyRecording')} )} @@ -368,8 +370,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac {isRecording && ( <> - Select - terminals: + {t('sshTools.selectTerminals')} {terminalTabs.map(tab => ( - Type commands (all - keys supported): + {t('sshTools.typeCommands')} - Commands will be sent to {selectedTabIds.length} selected - terminal(s). + {t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })} > @@ -415,7 +414,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac - Settings + {t('sshTools.settings')} @@ -428,14 +427,14 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac htmlFor="enable-copy-paste" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white" > - Enable right‑click copy/paste + {t('sshTools.enableRightClickCopyPaste')} - Have ideas for what should come next for ssh tools? Share them on{" "} + {t('sshTools.shareIdeas')}{" "} (null); const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); @@ -25,7 +28,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [tempToken, setTempToken] = useState(""); const [resetLoading, setResetLoading] = useState(false); - const [resetSuccess, setResetSuccess] = useState(false); async function handleInitiatePasswordReset() { setError(null); @@ -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); } @@ -48,7 +50,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setConfirmPassword(""); setTempToken(""); setError(null); - setResetSuccess(false); } async function handleVerifyResetCode() { @@ -60,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); } @@ -71,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; } @@ -85,16 +86,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) { try { await completePasswordReset(userInfo.username, tempToken, newPassword); - setResetStep("initiate"); - setResetCode(""); - setNewPassword(""); - setConfirmPassword(""); - setTempToken(""); - setError(null); - - setResetSuccess(true); + 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); } @@ -112,15 +107,15 @@ export function PasswordReset({userInfo}: PasswordResetProps) { - Password + {t('common.password')} - Change your account password + {t('common.changeAccountPassword')} <> - {resetStep === "initiate" && !resetSuccess && ( + {resetStep === "initiate" && ( <> - {resetLoading ? Spinner : "Send Reset Code"} + {resetLoading ? Spinner : t('common.sendResetCode')} > @@ -138,12 +133,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) { {resetStep === "verify" && ( <> - Enter the 6-digit code from the docker container logs for - user: {userInfo.username} + {t('common.enterSixDigitCode')} {userInfo.username} - Reset Code + {t('common.resetCode')} setResetCode(e.target.value.replace(/\D/g, ''))} disabled={resetLoading} - placeholder="000000" + placeholder={t('placeholders.enterCode')} /> - {resetLoading ? Spinner : "Verify Code"} + {resetLoading ? Spinner : t('common.verifyCode')} - Back + {t('common.back')} > )} - {resetSuccess && ( - <> - - Success! - - Your password has been successfully reset! You can now log in - with your new password. - - - > - )} - - {resetStep === "newPassword" && !resetSuccess && ( + {resetStep === "newPassword" && ( <> - Enter your new password for - user: {userInfo.username} + {t('common.enterNewPassword')} {userInfo.username} - New Password + {t('common.newPassword')} - Confirm Password + {t('common.confirmPassword')} - {resetLoading ? Spinner : "Reset Password"} + {resetLoading ? Spinner : t('common.resetPassword')} - Back + {t('common.back')} > )} {error && ( - Error + {t('common.error')} {error} )} diff --git a/src/ui/User/TOTPSetup.tsx b/src/ui/User/TOTPSetup.tsx index 4bbd00d8..9239cdab 100644 --- a/src/ui/User/TOTPSetup.tsx +++ b/src/ui/User/TOTPSetup.tsx @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.t import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react"; import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts"; import { toast } from "sonner"; +import { useTranslation } from 'react-i18next'; interface TOTPSetupProps { isEnabled: boolean; @@ -15,6 +16,7 @@ interface TOTPSetupProps { } export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) { + const {t} = useTranslation(); const [isEnabled, setIsEnabled] = useState(initialEnabled); const [isSettingUp, setIsSettingUp] = useState(false); const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init"); @@ -55,7 +57,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const response = await enableTOTP(verificationCode); setBackupCodes(response.backup_codes); setSetupStep("backup"); - toast.success("Two-factor authentication enabled successfully!"); + toast.success(t('auth.twoFactorEnabledSuccess')); } catch (err: any) { setError(err?.response?.data?.error || "Invalid verification code"); } finally { @@ -74,7 +76,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet setPassword(""); setDisableCode(""); onStatusChange?.(false); - toast.success("Two-factor authentication disabled"); + toast.success(t('auth.twoFactorDisabled')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to disable TOTP"); } finally { @@ -88,7 +90,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet try { const response = await generateBackupCodes(password || undefined, disableCode || undefined); setBackupCodes(response.backup_codes); - toast.success("New backup codes generated"); + toast.success(t('auth.newBackupCodesGenerated')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to generate backup codes"); } finally { @@ -98,7 +100,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const copyToClipboard = (text: string, label: string) => { navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard`); + toast.success(t('messages.copiedToClipboard', {item: label})); }; const downloadBackupCodes = () => { @@ -114,7 +116,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet a.download = 'termix-backup-codes.txt'; a.click(); URL.revokeObjectURL(url); - toast.success("Backup codes downloaded"); + toast.success(t('auth.backupCodesDownloaded')); }; const handleComplete = () => { @@ -131,50 +133,50 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Your account is protected with two-factor authentication + {t('auth.twoFactorProtected')} - Enabled + {t('common.enabled')} - Two-factor authentication is currently active on your account + {t('auth.twoFactorActive')} - Disable 2FA - Backup Codes + {t('auth.disable2FA')} + {t('auth.backupCodes')} - Warning + {t('common.warning')} - Disabling two-factor authentication will make your account less secure + {t('auth.disableTwoFactorWarning')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -186,29 +188,29 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleDisable} disabled={loading || (!password && !disableCode)} > - Disable Two-Factor Authentication + {t('auth.disableTwoFactor')} - Generate new backup codes if you've lost your existing ones + {t('auth.generateNewBackupCodesText')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -219,20 +221,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleGenerateNewBackupCodes} disabled={loading || (!password && !disableCode)} > - Generate New Backup Codes + {t('auth.generateNewBackupCodes')} {backupCodes.length > 0 && ( - Your Backup Codes + {t('auth.yourBackupCodes')} - Download + {t('auth.download')} @@ -248,7 +250,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet {error && ( - Error + {t('common.error')} {error} )} @@ -261,9 +263,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Set Up Two-Factor Authentication + {t('auth.setupTwoFactorTitle')} - Step 1: Scan the QR code with your authenticator app + {t('auth.step1ScanQR')} @@ -272,7 +274,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Manual Entry Code + {t('auth.manualEntryCode')} - If you can't scan the QR code, enter this code manually in your authenticator app + {t('auth.cannotScanQRText')} setSetupStep("verify")} className="w-full"> - Next: Verify Code + {t('auth.nextVerifyCode')} @@ -304,14 +306,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Verify Your Authenticator + {t('auth.verifyAuthenticator')} - Step 2: Enter the 6-digit code from your authenticator app + {t('auth.step2EnterCode')} - Verification Code + {t('auth.verificationCode')} - Error + {t('common.error')} {error} )} @@ -337,14 +339,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={() => setSetupStep("qr")} disabled={loading} > - Back + {t('auth.back')} - {loading ? "Verifying..." : "Verify and Enable"} + {loading ? t('interface.verifying') : t('auth.verifyAndEnable')} @@ -356,17 +358,17 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Save Your Backup Codes + {t('auth.saveBackupCodesTitle')} - Step 3: Store these codes in a safe place + {t('auth.step3StoreCodesSecurely')} - Important + {t('common.important')} - Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device. + {t('auth.importantBackupCodesText')} @@ -393,7 +395,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Complete Setup + {t('auth.completeSetup')} @@ -405,23 +407,23 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Add an extra layer of security to your account + {t('auth.addExtraSecurityLayer')} - Not Enabled + {t('common.notEnabled')} - Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in. + {t('auth.notEnabledText')} - {loading ? "Setting up..." : "Enable Two-Factor Authentication"} + {loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')} {error && ( diff --git a/src/ui/User/UserProfile.tsx b/src/ui/User/UserProfile.tsx index 9a4bccc0..2f165472 100644 --- a/src/ui/User/UserProfile.tsx +++ b/src/ui/User/UserProfile.tsx @@ -10,12 +10,15 @@ import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx"; import {getUserInfo} from "@/ui/main-axios.ts"; import {toast} from "sonner"; import {PasswordReset} from "@/ui/User/PasswordReset.tsx"; +import {useTranslation} from "react-i18next"; +import {LanguageSwitcher} from "@/components/LanguageSwitcher"; interface UserProfileProps { isTopbarOpen?: boolean; } export function UserProfile({isTopbarOpen = true}: UserProfileProps) { + const {t} = useTranslation(); const [userInfo, setUserInfo] = useState<{ username: string; is_admin: boolean; @@ -41,7 +44,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { totp_enabled: info.totp_enabled || false }); } catch (err: any) { - setError(err?.response?.data?.error || "Failed to load user information"); + setError(err?.response?.data?.error || t('errors.loadFailed')); } finally { setLoading(false); } @@ -58,7 +61,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Loading user profile... + {t('common.loading')} @@ -70,8 +73,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Error - {error || "Failed to load user profile"} + {t('common.error')} + {error || t('errors.loadFailed')} ); @@ -84,20 +87,20 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { maxHeight: 'calc(100vh - 60px)' }}> - User Profile - Manage your account settings and security + {t('common.profile')} + {t('profile.description')} - Profile + {t('common.profile')} {!userInfo.is_oidc && ( - Security + {t('profile.security')} )} @@ -105,45 +108,55 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Account Information - Your account details and settings + {t('profile.accountInfo')} + {t('profile.description')} - Username + {t('common.username')} {userInfo.username} - Account Type + {t('profile.role')} - {userInfo.is_admin ? "Administrator" : "User"} + {userInfo.is_admin ? t('interface.administrator') : t('interface.user')} - Authentication Method + {t('profile.authMethod')} - {userInfo.is_oidc ? "External (OIDC)" : "Local"} + {userInfo.is_oidc ? t('profile.external') : t('profile.local')} - Two-Factor Authentication + {t('profile.twoFactorAuth')} {userInfo.is_oidc ? ( - Locked (OIDC Auth) + {t('auth.lockedOidcAuth')} ) : ( userInfo.totp_enabled ? ( - Enabled + {t('common.enabled')} ) : ( - Disabled + {t('common.disabled')} ) )} + + + + + {t('common.language')} + {t('profile.selectPreferredLanguage')} + + + + diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 99627071..6fa29322 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -143,6 +143,7 @@ interface UserInfo { id: string; username: string; is_admin: boolean; + is_oidc: boolean; } interface UserCount { @@ -897,7 +898,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> const response = await authApi.post('/users/totp/setup'); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'setup TOTP'); throw error; } } @@ -907,7 +908,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string; const response = await authApi.post('/users/totp/enable', { totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'enable TOTP'); throw error; } } @@ -917,7 +918,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis const response = await authApi.post('/users/totp/disable', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'disable TOTP'); throw error; } } @@ -927,7 +928,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'verify TOTP login'); throw error; } } @@ -937,7 +938,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string) const response = await authApi.post('/users/totp/backup-codes', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'generate backup codes'); throw error; } }
sudo yum install sshpass
sudo dnf install sshpass
brew + • {t('hosts.macos')} brew install hudochenkov/sshpass/sshpass - • Windows: Use WSL or consider SSH key authentication + • {t('hosts.windows')}
brew install hudochenkov/sshpass/sshpass
GatewayPorts - yes
AllowTcpForwarding - yes
PermitRootLogin - yes
/etc/ssh/sshd_config
sudo - systemctl restart sshd
- 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' + })}
Loading hosts...
{t('hosts.loadingHosts')}
{error}
- You haven't added any SSH hosts yet. Click "Add Host" to get started. + {t('hosts.noHostsMessage')}
- {filteredAndSortedHosts.length} hosts + {t('hosts.hostsCount', { count: filteredAndSortedHosts.length })}
Import SSH Hosts from JSON
{t('hosts.importJsonTitle')}
- Upload a JSON file to bulk import multiple SSH hosts (max 100). + {t('hosts.importJsonDesc')}
- Have ideas for what should come next for server management? Share them on{" "} + {t('serverStats.feedbackMessage')}{" "} (function SSHTerminal( {hostConfig, isVisible, splitScreen = false}, ref ) { + const {t} = useTranslation(); const {instance: terminal, ref: xtermRef} = useXTerm(); const fitAddonRef = useRef(null); const webSocketRef = useRef(null); @@ -139,11 +141,11 @@ export const Terminal = forwardRef(function SSHTerminal( try { const msg = JSON.parse(event.data); if (msg.type === 'data') terminal.write(msg.data); - else if (msg.type === 'error') terminal.writeln(`\r\n[ERROR] ${msg.message}`); + else if (msg.type === 'error') terminal.writeln(`\r\n[${t('terminal.error')}] ${msg.message}`); else if (msg.type === 'connected') { } else if (msg.type === 'disconnected') { wasDisconnectedBySSH.current = true; - terminal.writeln(`\r\n[${msg.message || 'Disconnected'}]`); + terminal.writeln(`\r\n[${msg.message || t('terminal.disconnected')}]`); } } catch (error) { } @@ -151,12 +153,12 @@ export const Terminal = forwardRef(function SSHTerminal( ws.addEventListener('close', () => { if (!wasDisconnectedBySSH.current) { - terminal.writeln('\r\n[Connection closed]'); + terminal.writeln(`\r\n[${t('terminal.connectionClosed')}]`); } }); ws.addEventListener('error', () => { - terminal.writeln('\r\n[Connection error]'); + terminal.writeln(`\r\n[${t('terminal.connectionError')}]`); }); } diff --git a/src/ui/Apps/Tunnel/TunnelObject.tsx b/src/ui/Apps/Tunnel/TunnelObject.tsx index 8daf97b9..52105a53 100644 --- a/src/ui/Apps/Tunnel/TunnelObject.tsx +++ b/src/ui/Apps/Tunnel/TunnelObject.tsx @@ -2,6 +2,7 @@ import React from "react"; import {Button} from "@/components/ui/button.tsx"; import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card.tsx"; import {Separator} from "@/components/ui/separator.tsx"; +import {useTranslation} from 'react-i18next'; import { Loader2, Pin, @@ -87,6 +88,7 @@ export function TunnelObject({ compact = false, bare = false }: SSHTunnelObjectProps): React.ReactElement { + const {t} = useTranslation(); const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => { const tunnel = host.tunnelConnections[tunnelIndex]; @@ -97,7 +99,7 @@ export function TunnelObject({ const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => { if (!status) return { icon: , - text: 'Unknown', + text: t('tunnels.unknown'), color: 'text-muted-foreground', bgColor: 'bg-muted/50', borderColor: 'border-border' @@ -109,7 +111,7 @@ export function TunnelObject({ case 'CONNECTED': return { icon: , - text: 'Connected', + text: t('tunnels.connected'), color: 'text-green-600 dark:text-green-400', bgColor: 'bg-green-500/10 dark:bg-green-400/10', borderColor: 'border-green-500/20 dark:border-green-400/20' @@ -117,7 +119,7 @@ export function TunnelObject({ case 'CONNECTING': return { icon: , - text: 'Connecting...', + text: t('tunnels.connecting'), color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-500/10 dark:bg-blue-400/10', borderColor: 'border-blue-500/20 dark:border-blue-400/20' @@ -125,7 +127,7 @@ export function TunnelObject({ case 'DISCONNECTING': return { icon: , - text: 'Disconnecting...', + text: t('tunnels.disconnecting'), color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-500/10 dark:bg-orange-400/10', borderColor: 'border-orange-500/20 dark:border-orange-400/20' @@ -133,7 +135,7 @@ export function TunnelObject({ case 'DISCONNECTED': return { icon: , - text: 'Disconnected', + text: t('tunnels.disconnected'), color: 'text-muted-foreground', bgColor: 'bg-muted/30', borderColor: 'border-border' @@ -149,7 +151,7 @@ export function TunnelObject({ case 'FAILED': return { icon: , - text: status.reason || 'Error', + text: status.reason || t('tunnels.error'), color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-500/10 dark:bg-red-400/10', borderColor: 'border-red-500/20 dark:border-red-400/20' @@ -193,7 +195,7 @@ export function TunnelObject({ - Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} + {t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} {statusDisplay.text} @@ -212,7 +214,7 @@ export function TunnelObject({ className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > - Disconnect + {t('tunnels.disconnect')} > ) : isRetrying || isWaiting ? ( @@ -223,7 +225,7 @@ export function TunnelObject({ className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > - Cancel + {t('tunnels.cancel')} ) : ( - Connect + {t('tunnels.connect')} )} @@ -246,7 +248,7 @@ export function TunnelObject({ className="h-7 px-2 text-muted-foreground border-border text-xs" > - {isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'} + {isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} )} @@ -255,13 +257,13 @@ export function TunnelObject({ {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( - Error: + {t('tunnels.error')}: {status.reason} {status.reason && status.reason.includes('Max retries exhausted') && ( <> - Check your Docker logs for the error reason, join the Discord or @@ -280,12 +282,12 @@ export function TunnelObject({ - {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} + {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} - Attempt {status.retryCount} of {status.maxRetries} + {t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {status.nextRetryIn && ( - • Next retry in {status.nextRetryIn} seconds + • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })} )} @@ -297,7 +299,7 @@ export function TunnelObject({ ) : ( - No tunnel connections configured + {t('tunnels.noTunnelConnections')} )} @@ -346,7 +348,7 @@ export function TunnelObject({ {!compact && ( - Tunnel Connections ({host.tunnelConnections.length}) + {t('tunnels.tunnelConnections')} ({host.tunnelConnections.length}) )} {host.tunnelConnections && host.tunnelConnections.length > 0 ? ( @@ -373,7 +375,7 @@ export function TunnelObject({ - Port {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} + {t('tunnels.port')} {tunnel.sourcePort} → {tunnel.endpointHost}:{tunnel.endpointPort} {statusDisplay.text} @@ -392,7 +394,7 @@ export function TunnelObject({ className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs" > - Disconnect + {t('tunnels.disconnect')} > ) : isRetrying || isWaiting ? ( @@ -403,7 +405,7 @@ export function TunnelObject({ className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs" > - Cancel + {t('tunnels.cancel')} ) : ( - Connect + {t('tunnels.connect')} )} @@ -427,7 +429,7 @@ export function TunnelObject({ className="h-7 px-2 text-muted-foreground border-border text-xs" > - {isConnected ? 'Disconnecting...' : isRetrying || isWaiting ? 'Canceling...' : 'Connecting...'} + {isConnected ? t('tunnels.disconnecting') : isRetrying || isWaiting ? t('tunnels.canceling') : t('tunnels.connecting')} )} @@ -436,13 +438,13 @@ export function TunnelObject({ {(statusValue === 'ERROR' || statusValue === 'FAILED') && status?.reason && ( - Error: + {t('tunnels.error')}: {status.reason} {status.reason && status.reason.includes('Max retries exhausted') && ( <> - Check your Docker logs for the error reason, join the Discord or @@ -461,12 +463,12 @@ export function TunnelObject({ - {statusValue === 'WAITING' ? 'Waiting for retry' : 'Retrying connection'} + {statusValue === 'WAITING' ? t('tunnels.waitingForRetry') : t('tunnels.retryingConnection')} - Attempt {status.retryCount} of {status.maxRetries} + {t('tunnels.attempt', { current: status.retryCount, max: status.maxRetries })} {status.nextRetryIn && ( - • Next retry in {status.nextRetryIn} seconds + • {t('tunnels.nextRetryIn', { seconds: status.nextRetryIn })} )} @@ -478,7 +480,7 @@ export function TunnelObject({ ) : ( - No tunnel connections configured + {t('tunnels.noTunnelConnections')} )} diff --git a/src/ui/Apps/Tunnel/TunnelViewer.tsx b/src/ui/Apps/Tunnel/TunnelViewer.tsx index 337f3b24..dcec9123 100644 --- a/src/ui/Apps/Tunnel/TunnelViewer.tsx +++ b/src/ui/Apps/Tunnel/TunnelViewer.tsx @@ -1,5 +1,6 @@ import React from "react"; import {TunnelObject} from "./TunnelObject.tsx"; +import {useTranslation} from 'react-i18next'; interface TunnelConnection { sourcePort: number; @@ -52,15 +53,15 @@ export function TunnelViewer({ tunnelActions = {}, onTunnelAction }: SSHTunnelViewerProps): React.ReactElement { + const {t} = useTranslation(); const activeHost: SSHHost | undefined = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined; if (!activeHost || !activeHost.tunnelConnections || activeHost.tunnelConnections.length === 0) { return ( - No SSH Tunnels + {t('tunnels.noSshTunnels')} - Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel - connections. + {t('tunnels.createFirstTunnelMessage')} ); @@ -69,7 +70,7 @@ export function TunnelViewer({ return ( - SSH Tunnels + {t('tunnels.title')} void; @@ -32,6 +33,7 @@ export function Homepage({ onAuthSuccess, isTopbarOpen = true }: HomepageProps): React.ReactElement { + const {t} = useTranslation(); const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [isAdmin, setIsAdmin] = useState(false); const [username, setUsername] = useState(null); @@ -70,13 +72,20 @@ export function Homepage({ } }, [isAuthenticated]); + const topOffset = isTopbarOpen ? 66 : 0; + const topPadding = isTopbarOpen ? 66 : 0; + return ( + className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out" + style={{ paddingTop: `${topPadding}px` }}> {!loggedIn ? ( - + ) : ( - - + + - Logged in! + {t('homepage.loggedInTitle')} - You are logged in! Use the sidebar to access all available tools. To get started, - create an SSH Host in the SSH Manager tab. Once created, you can connect to that - host using the other apps in the sidebar. + {t('homepage.loggedInMessage')} diff --git a/src/ui/Homepage/HomepageAlertCard.tsx b/src/ui/Homepage/HomepageAlertCard.tsx index d2f34722..e2f774fa 100644 --- a/src/ui/Homepage/HomepageAlertCard.tsx +++ b/src/ui/Homepage/HomepageAlertCard.tsx @@ -3,6 +3,7 @@ import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components import {Button} from "@/components/ui/button.tsx"; import {Badge} from "@/components/ui/badge.tsx"; import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react"; +import {useTranslation} from "react-i18next"; interface TermixAlert { id: string; @@ -64,6 +65,8 @@ const getTypeBadgeVariant = (type?: string) => { }; export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement { + const {t} = useTranslation(); + if (!alert) { return null; } @@ -79,10 +82,10 @@ export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): const diffTime = expiryDate.getTime() - now.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - if (diffDays < 0) return 'Expired'; - if (diffDays === 0) return 'Expires today'; - if (diffDays === 1) return 'Expires tomorrow'; - return `Expires in ${diffDays} days`; + if (diffDays < 0) return t('common.expired'); + if (diffDays === 0) return t('common.expiresToday'); + if (diffDays === 1) return t('common.expiresTomorrow'); + return t('common.expiresInDays', {days: diffDays}); }; return ( diff --git a/src/ui/Homepage/HomepageAlertManager.tsx b/src/ui/Homepage/HomepageAlertManager.tsx index 4aa8dd70..6e68b941 100644 --- a/src/ui/Homepage/HomepageAlertManager.tsx +++ b/src/ui/Homepage/HomepageAlertManager.tsx @@ -2,6 +2,7 @@ import React, {useEffect, useState} from "react"; import {HomepageAlertCard} from "./HomepageAlertCard.tsx"; import {Button} from "@/components/ui/button.tsx"; import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts"; +import {useTranslation} from "react-i18next"; interface TermixAlert { id: string; @@ -20,6 +21,7 @@ interface AlertManagerProps { } export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement { + const {t} = useTranslation(); const [alerts, setAlerts] = useState([]); const [currentAlertIndex, setCurrentAlertIndex] = useState(0); const [loading, setLoading] = useState(false); @@ -57,7 +59,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea setAlerts(sortedAlerts); setCurrentAlertIndex(0); } catch (err) { - setError('Failed to load alerts'); + setError(t('homepage.failedToLoadAlerts')); } finally { setLoading(false); } @@ -81,7 +83,7 @@ export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): Rea return prevIndex; }); } catch (err) { - setError('Failed to dismiss alert'); + setError(t('homepage.failedToDismissAlert')); } }; diff --git a/src/ui/Homepage/HomepageAuth.tsx b/src/ui/Homepage/HomepageAuth.tsx index 51212b33..5b1ca837 100644 --- a/src/ui/Homepage/HomepageAuth.tsx +++ b/src/ui/Homepage/HomepageAuth.tsx @@ -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 && ( - First User + {t('auth.firstUser')} - 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')}{" "} - GitHub issue + GitHub Issue . )} {!registrationAllowed && !internalLoggedIn && ( - Registration Disabled + {t('auth.registerTitle')} - New account registration is currently disabled by an admin. Please log in or contact an - administrator. + {t('messages.registrationDisabled')} )} {totpRequired && ( - Two-Factor Authentication - Enter the 6-digit code from your authenticator app + {t('auth.twoFactorAuth')} + {t('auth.enterCode')} - Authentication Code + {t('auth.verifyCode')} - Or enter a backup code if you don't have access to your authenticator + {t('auth.backupCode')} @@ -467,7 +468,7 @@ export function HomepageAuth({ disabled={totpLoading || totpCode.length < 6} onClick={handleTOTPVerification} > - {totpLoading ? Spinner : "Verify"} + {totpLoading ? Spinner : t('auth.verifyCode')} - Cancel + {t('common.cancel')} )} @@ -506,7 +507,7 @@ export function HomepageAuth({ aria-selected={tab === "login"} disabled={loading || firstUser} > - Login + {t('common.login')} - Sign Up + {t('common.register')} {oidcConfigured && ( - External + {t('auth.external')} )} - {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')} @@ -561,7 +562,7 @@ export function HomepageAuth({ {tab === "external" && ( <> - Login using your configured external identity provider + {t('auth.loginWithExternalDesc')} - {oidcLoading ? Spinner : "Login with External Provider"} + {oidcLoading ? Spinner : t('auth.loginWithExternal')} > )} @@ -578,12 +579,11 @@ export function HomepageAuth({ {resetStep === "initiate" && ( <> - Enter your username to receive a password reset code. The code - will be logged in the docker container logs. + {t('auth.resetCodeDesc')} - Username + {t('common.username')} - {resetLoading ? Spinner : "Send Reset Code"} + {resetLoading ? Spinner : t('auth.sendResetCode')} > @@ -609,12 +609,11 @@ export function HomepageAuth({ {resetStep === "verify" && ( <>o - Enter the 6-digit code from the docker container logs for - user: {localUsername} + {t('auth.enterResetCode')} {localUsername} - Reset Code + {t('auth.resetCode')} - {resetLoading ? Spinner : "Verify Code"} + {resetLoading ? Spinner : t('auth.verifyCodeButton')} - Back + {t('common.back')} > @@ -654,10 +653,9 @@ export function HomepageAuth({ {resetSuccess && ( <> - Success! + {t('auth.passwordResetSuccess')} - Your password has been successfully reset! You can now log in - with your new password. + {t('auth.passwordResetSuccessDesc')} - Go to Login + {t('auth.goToLogin')} > )} @@ -676,12 +674,11 @@ export function HomepageAuth({ {resetStep === "newPassword" && !resetSuccess && ( <> - Enter your new password for - user: {localUsername} + {t('auth.enterNewPassword')} {localUsername} - New Password + {t('auth.newPassword')} - Confirm Password + {t('auth.confirmNewPassword')} - {resetLoading ? Spinner : "Reset Password"} + {resetLoading ? Spinner : t('auth.resetPasswordButton')} - Back + {t('common.back')} > @@ -736,7 +733,7 @@ export function HomepageAuth({ ) : ( - Username + {t('common.username')} - Password + {t('common.password')} setPassword(e.target.value)} disabled={loading || internalLoggedIn}/> {tab === "signup" && ( - Confirm Password + {t('common.confirmPassword')} - {loading ? Spinner : (tab === "login" ? "Login" : "Sign Up")} + {loading ? Spinner : (tab === "login" ? t('common.login') : t('auth.signUp'))} {tab === "login" && ( - Reset Password + {t('auth.resetPasswordButton')} )} )} + + + + + {t('common.language')} + + + + > )} {error && ( diff --git a/src/ui/Homepage/HompageUpdateLog.tsx b/src/ui/Homepage/HompageUpdateLog.tsx index c95f5bb0..667eed78 100644 --- a/src/ui/Homepage/HompageUpdateLog.tsx +++ b/src/ui/Homepage/HompageUpdateLog.tsx @@ -3,6 +3,7 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Separator} from "@/components/ui/separator.tsx"; import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts"; +import {useTranslation} from "react-i18next"; interface HomepageUpdateLogProps extends React.ComponentProps<"div"> { loggedIn: boolean; @@ -51,6 +52,7 @@ interface VersionResponse { } export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { + const {t} = useTranslation(); const [releases, setReleases] = useState(null); const [versionInfo, setVersionInfo] = useState(null); const [loading, setLoading] = useState(false); @@ -69,7 +71,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { setError(null); }) .catch(err => { - setError('Failed to fetch update information'); + setError(t('common.failedToFetchUpdateInfo')); }) .finally(() => setLoading(false)); } @@ -90,15 +92,15 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { return ( - Updates & Releases + {t('common.updatesAndReleases')} {versionInfo && versionInfo.status === 'requires_update' && ( - Update Available + {t('common.updateAvailable')} - A new version ({versionInfo.version}) is available. + {t('common.newVersionAvailable', { version: versionInfo.version })} )} @@ -117,7 +119,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {error && ( - Error + {t('common.error')} {error} )} @@ -135,7 +137,7 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {release.isPrerelease && ( - Pre-release + {t('common.preRelease')} )} @@ -158,9 +160,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) { {releases && releases.items.length === 0 && !loading && ( - No Releases + {t('common.noReleases')} - No releases found. + {t('common.noReleasesFound')} )} diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx index 9ddcec72..cdc25b26 100644 --- a/src/ui/Navigation/LeftSidebar.tsx +++ b/src/ui/Navigation/LeftSidebar.tsx @@ -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(null); - const [adminCount, setAdminCount] = React.useState(0); - - const [users, setUsers] = React.useState>([]); - const [newAdminUsername, setNewAdminUsername] = React.useState(""); - const [usersLoading, setUsersLoading] = React.useState(false); - const [makeAdminLoading, setMakeAdminLoading] = React.useState(false); - const [makeAdminError, setMakeAdminError] = React.useState(null); - const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null); - const [oidcConfig, setOidcConfig] = React.useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(true); @@ -140,7 +120,7 @@ export function LeftSidebar({ const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager'); const openSshManagerTab = () => { if (sshManagerTab || isSplitScreenActive) return; - const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any); + const id = addTab({type: 'ssh_manager'} as any); setCurrentTab(id); }; const adminTab = tabList.find((t) => t.type === 'admin'); @@ -150,7 +130,7 @@ export function LeftSidebar({ setCurrentTab(adminTab.id); return; } - const id = addTab({type: 'admin', title: 'Admin'} as any); + const id = addTab({type: 'admin'} as any); setCurrentTab(id); }; @@ -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 { @@ -232,7 +186,7 @@ export function LeftSidebar({ }, 50); } } catch (err: any) { - setHostsError('Failed to load hosts'); + setHostsError(t('leftSidebar.failedToLoadHosts')); } }, []); @@ -275,7 +229,7 @@ export function LeftSidebar({ const hostsByFolder = React.useMemo(() => { const map: Record = {}; filteredHosts.forEach(h => { - const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder'; + const folder = h.folder && h.folder.trim() ? h.folder : t('leftSidebar.noFolder'); if (!map[folder]) map[folder] = []; map[folder].push(h); }); @@ -285,8 +239,8 @@ export function LeftSidebar({ const sortedFolders = React.useMemo(() => { const folders = Object.keys(hostsByFolder); folders.sort((a, b) => { - if (a === 'No Folder') return -1; - if (b === 'No Folder') return 1; + if (a === t('leftSidebar.noFolder')) return -1; + if (b === t('leftSidebar.noFolder')) return 1; return a.localeCompare(b); }); return folders; @@ -304,7 +258,7 @@ export function LeftSidebar({ setDeleteError(null); if (!deletePassword.trim()) { - setDeleteError("Password is required"); + setDeleteError(t('leftSidebar.passwordRequired')); setDeleteLoading(false); return; } @@ -315,101 +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 makeUserAdmin = 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 removeAdminStatus = 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) { - } - }; - - const deleteUser = 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) { - } - }; - return ( @@ -421,6 +285,7 @@ export function LeftSidebar({ variant="outline" onClick={() => setIsSidebarOpen(!isSidebarOpen)} className="w-[28px] h-[28px] absolute right-5" + title={t('common.toggleSidebar')} > @@ -431,9 +296,9 @@ export function LeftSidebar({ + title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : isSplitScreenActive ? t('interface.disabledDuringSplitScreen') : undefined}> - Host Manager + {t('nav.hostManager')} @@ -442,7 +307,7 @@ export function LeftSidebar({ 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" /> @@ -452,7 +317,7 @@ export function LeftSidebar({ - {hostsError} + {t('leftSidebar.failedToLoadHosts')} )} @@ -460,7 +325,7 @@ export function LeftSidebar({ {hostsLoading && ( - Loading hosts... + {t('hosts.loadingHosts')} )} @@ -487,7 +352,7 @@ export function LeftSidebar({ style={{width: '100%'}} disabled={disabled} > - {username ? username : 'Signed out'} + {username ? username : t('common.logout')} @@ -506,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); }}> - Profile & Security + {t('profile.title')} {isAdmin && ( { if (isAdmin) openAdminTab(); }}> - Admin Settings + {t('admin.title')} )} - Sign out + {t('common.logout')} setDeleteAccountOpen(true)} - disabled={isAdmin && adminCount <= 1} > - - Delete Account - {isAdmin && adminCount <= 1 && " (Last Admin)"} + + {t('leftSidebar.deleteAccount')} @@ -586,7 +448,7 @@ export function LeftSidebar({ onClick={(e) => e.stopPropagation()} > - Delete Account + {t('leftSidebar.deleteAccount')} × @@ -605,48 +467,33 @@ export function LeftSidebar({ - This action cannot be undone. This will permanently delete your account and all - associated data. + {t('leftSidebar.deleteAccountWarning')} - Warning + {t('common.warning')} - Deleting your account will remove all your data including SSH hosts, - configurations, and settings. - This action is irreversible. + {t('leftSidebar.deleteAccountWarningDetails')} {deleteError && ( - Error + {t('common.error')} {deleteError} )} - {isAdmin && adminCount <= 1 && ( - - Cannot Delete Account - - 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. - - - )} - - Confirm Password + {t('leftSidebar.confirmPassword')} setDeletePassword(e.target.value)} - placeholder="Enter your password to confirm" + placeholder={t('placeholders.confirmPassword')} required - disabled={isAdmin && adminCount <= 1} /> @@ -655,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')} - Cancel + {t('leftSidebar.cancel')} diff --git a/src/ui/Navigation/Tabs/Tab.tsx b/src/ui/Navigation/Tabs/Tab.tsx index 217aeb33..5b6a9d9d 100644 --- a/src/ui/Navigation/Tabs/Tab.tsx +++ b/src/ui/Navigation/Tabs/Tab.tsx @@ -1,6 +1,7 @@ import React from "react"; import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {Button} from "@/components/ui/button.tsx"; +import {useTranslation} from 'react-i18next'; import { Home, SeparatorVertical, @@ -37,6 +38,7 @@ export function Tab({ disableSplit = false, disableClose = false }: TabProps): React.ReactElement { + const {t} = useTranslation(); if (tabType === "home") { return ( {isServer ? : isFileManager ? : } - {title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')} + {title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : t('nav.terminal'))} {canSplit && ( @@ -99,7 +101,7 @@ export function Tab({ onClick={onActivate} disabled={disableActivate} > - {title || "SSH Manager"} + {title || t('nav.sshManager')} - {title || "Admin"} + {title || t('nav.admin')} ([ - {id: 1, type: 'home', title: 'Home'} + {id: 1, type: 'home', title: t('nav.home')} ]); const [currentTab, setCurrentTab] = useState(1); const [allSplitScreenTab, setAllSplitScreenTab] = useState([]); const nextTabId = useRef(2); function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { - const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal'); + const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal')); const baseTitle = (desiredTitle || defaultTitle).trim(); const match = baseTitle.match(/^(.*) \((\d+)\)$/); const root = match ? match[1] : baseTitle; diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx index d89f6cc2..50904763 100644 --- a/src/ui/Navigation/TopNavbar.tsx +++ b/src/ui/Navigation/TopNavbar.tsx @@ -13,6 +13,7 @@ import { import {Input} from "@/components/ui/input.tsx"; import {Checkbox} from "@/components/ui/checkbox.tsx"; import {Separator} from "@/components/ui/separator.tsx"; +import {useTranslation} from "react-i18next"; interface TopNavbarProps { isTopbarOpen: boolean; @@ -23,6 +24,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac const {state} = useSidebar(); const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any; const leftPosition = state === "collapsed" ? "26px" : "264px"; + const {t} = useTranslation(); const [toolsSheetOpen, setToolsSheetOpen] = useState(false); const [isRecording, setIsRecording] = useState(false); @@ -267,7 +269,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac setToolsSheetOpen(true)} > @@ -325,13 +327,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac onClick={(e) => e.stopPropagation()} > - SSH Tools + {t('sshTools.title')} setToolsSheetOpen(false)} className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center" - title="Close SSH Tools" + title={t('sshTools.closeTools')} > × @@ -340,7 +342,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac - Key Recording + {t('sshTools.keyRecording')} @@ -352,7 +354,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac className="flex-1" variant="outline" > - Start Key Recording + {t('sshTools.startKeyRecording')} ) : ( - Stop Key Recording + {t('sshTools.stopKeyRecording')} )} @@ -368,8 +370,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac {isRecording && ( <> - Select - terminals: + {t('sshTools.selectTerminals')} {terminalTabs.map(tab => ( - Type commands (all - keys supported): + {t('sshTools.typeCommands')} - Commands will be sent to {selectedTabIds.length} selected - terminal(s). + {t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })} > @@ -415,7 +414,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac - Settings + {t('sshTools.settings')} @@ -428,14 +427,14 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac htmlFor="enable-copy-paste" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white" > - Enable right‑click copy/paste + {t('sshTools.enableRightClickCopyPaste')} - Have ideas for what should come next for ssh tools? Share them on{" "} + {t('sshTools.shareIdeas')}{" "} (null); const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); @@ -25,7 +28,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [tempToken, setTempToken] = useState(""); const [resetLoading, setResetLoading] = useState(false); - const [resetSuccess, setResetSuccess] = useState(false); async function handleInitiatePasswordReset() { setError(null); @@ -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); } @@ -48,7 +50,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setConfirmPassword(""); setTempToken(""); setError(null); - setResetSuccess(false); } async function handleVerifyResetCode() { @@ -60,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); } @@ -71,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; } @@ -85,16 +86,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) { try { await completePasswordReset(userInfo.username, tempToken, newPassword); - setResetStep("initiate"); - setResetCode(""); - setNewPassword(""); - setConfirmPassword(""); - setTempToken(""); - setError(null); - - setResetSuccess(true); + 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); } @@ -112,15 +107,15 @@ export function PasswordReset({userInfo}: PasswordResetProps) { - Password + {t('common.password')} - Change your account password + {t('common.changeAccountPassword')} <> - {resetStep === "initiate" && !resetSuccess && ( + {resetStep === "initiate" && ( <> - {resetLoading ? Spinner : "Send Reset Code"} + {resetLoading ? Spinner : t('common.sendResetCode')} > @@ -138,12 +133,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) { {resetStep === "verify" && ( <> - Enter the 6-digit code from the docker container logs for - user: {userInfo.username} + {t('common.enterSixDigitCode')} {userInfo.username} - Reset Code + {t('common.resetCode')} setResetCode(e.target.value.replace(/\D/g, ''))} disabled={resetLoading} - placeholder="000000" + placeholder={t('placeholders.enterCode')} /> - {resetLoading ? Spinner : "Verify Code"} + {resetLoading ? Spinner : t('common.verifyCode')} - Back + {t('common.back')} > )} - {resetSuccess && ( - <> - - Success! - - Your password has been successfully reset! You can now log in - with your new password. - - - > - )} - - {resetStep === "newPassword" && !resetSuccess && ( + {resetStep === "newPassword" && ( <> - Enter your new password for - user: {userInfo.username} + {t('common.enterNewPassword')} {userInfo.username} - New Password + {t('common.newPassword')} - Confirm Password + {t('common.confirmPassword')} - {resetLoading ? Spinner : "Reset Password"} + {resetLoading ? Spinner : t('common.resetPassword')} - Back + {t('common.back')} > )} {error && ( - Error + {t('common.error')} {error} )} diff --git a/src/ui/User/TOTPSetup.tsx b/src/ui/User/TOTPSetup.tsx index 4bbd00d8..9239cdab 100644 --- a/src/ui/User/TOTPSetup.tsx +++ b/src/ui/User/TOTPSetup.tsx @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.t import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react"; import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts"; import { toast } from "sonner"; +import { useTranslation } from 'react-i18next'; interface TOTPSetupProps { isEnabled: boolean; @@ -15,6 +16,7 @@ interface TOTPSetupProps { } export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) { + const {t} = useTranslation(); const [isEnabled, setIsEnabled] = useState(initialEnabled); const [isSettingUp, setIsSettingUp] = useState(false); const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init"); @@ -55,7 +57,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const response = await enableTOTP(verificationCode); setBackupCodes(response.backup_codes); setSetupStep("backup"); - toast.success("Two-factor authentication enabled successfully!"); + toast.success(t('auth.twoFactorEnabledSuccess')); } catch (err: any) { setError(err?.response?.data?.error || "Invalid verification code"); } finally { @@ -74,7 +76,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet setPassword(""); setDisableCode(""); onStatusChange?.(false); - toast.success("Two-factor authentication disabled"); + toast.success(t('auth.twoFactorDisabled')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to disable TOTP"); } finally { @@ -88,7 +90,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet try { const response = await generateBackupCodes(password || undefined, disableCode || undefined); setBackupCodes(response.backup_codes); - toast.success("New backup codes generated"); + toast.success(t('auth.newBackupCodesGenerated')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to generate backup codes"); } finally { @@ -98,7 +100,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const copyToClipboard = (text: string, label: string) => { navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard`); + toast.success(t('messages.copiedToClipboard', {item: label})); }; const downloadBackupCodes = () => { @@ -114,7 +116,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet a.download = 'termix-backup-codes.txt'; a.click(); URL.revokeObjectURL(url); - toast.success("Backup codes downloaded"); + toast.success(t('auth.backupCodesDownloaded')); }; const handleComplete = () => { @@ -131,50 +133,50 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Your account is protected with two-factor authentication + {t('auth.twoFactorProtected')} - Enabled + {t('common.enabled')} - Two-factor authentication is currently active on your account + {t('auth.twoFactorActive')} - Disable 2FA - Backup Codes + {t('auth.disable2FA')} + {t('auth.backupCodes')} - Warning + {t('common.warning')} - Disabling two-factor authentication will make your account less secure + {t('auth.disableTwoFactorWarning')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -186,29 +188,29 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleDisable} disabled={loading || (!password && !disableCode)} > - Disable Two-Factor Authentication + {t('auth.disableTwoFactor')} - Generate new backup codes if you've lost your existing ones + {t('auth.generateNewBackupCodesText')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -219,20 +221,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleGenerateNewBackupCodes} disabled={loading || (!password && !disableCode)} > - Generate New Backup Codes + {t('auth.generateNewBackupCodes')} {backupCodes.length > 0 && ( - Your Backup Codes + {t('auth.yourBackupCodes')} - Download + {t('auth.download')} @@ -248,7 +250,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet {error && ( - Error + {t('common.error')} {error} )} @@ -261,9 +263,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Set Up Two-Factor Authentication + {t('auth.setupTwoFactorTitle')} - Step 1: Scan the QR code with your authenticator app + {t('auth.step1ScanQR')} @@ -272,7 +274,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Manual Entry Code + {t('auth.manualEntryCode')} - If you can't scan the QR code, enter this code manually in your authenticator app + {t('auth.cannotScanQRText')} setSetupStep("verify")} className="w-full"> - Next: Verify Code + {t('auth.nextVerifyCode')} @@ -304,14 +306,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Verify Your Authenticator + {t('auth.verifyAuthenticator')} - Step 2: Enter the 6-digit code from your authenticator app + {t('auth.step2EnterCode')} - Verification Code + {t('auth.verificationCode')} - Error + {t('common.error')} {error} )} @@ -337,14 +339,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={() => setSetupStep("qr")} disabled={loading} > - Back + {t('auth.back')} - {loading ? "Verifying..." : "Verify and Enable"} + {loading ? t('interface.verifying') : t('auth.verifyAndEnable')} @@ -356,17 +358,17 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Save Your Backup Codes + {t('auth.saveBackupCodesTitle')} - Step 3: Store these codes in a safe place + {t('auth.step3StoreCodesSecurely')} - Important + {t('common.important')} - Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device. + {t('auth.importantBackupCodesText')} @@ -393,7 +395,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Complete Setup + {t('auth.completeSetup')} @@ -405,23 +407,23 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Add an extra layer of security to your account + {t('auth.addExtraSecurityLayer')} - Not Enabled + {t('common.notEnabled')} - Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in. + {t('auth.notEnabledText')} - {loading ? "Setting up..." : "Enable Two-Factor Authentication"} + {loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')} {error && ( diff --git a/src/ui/User/UserProfile.tsx b/src/ui/User/UserProfile.tsx index 9a4bccc0..2f165472 100644 --- a/src/ui/User/UserProfile.tsx +++ b/src/ui/User/UserProfile.tsx @@ -10,12 +10,15 @@ import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx"; import {getUserInfo} from "@/ui/main-axios.ts"; import {toast} from "sonner"; import {PasswordReset} from "@/ui/User/PasswordReset.tsx"; +import {useTranslation} from "react-i18next"; +import {LanguageSwitcher} from "@/components/LanguageSwitcher"; interface UserProfileProps { isTopbarOpen?: boolean; } export function UserProfile({isTopbarOpen = true}: UserProfileProps) { + const {t} = useTranslation(); const [userInfo, setUserInfo] = useState<{ username: string; is_admin: boolean; @@ -41,7 +44,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { totp_enabled: info.totp_enabled || false }); } catch (err: any) { - setError(err?.response?.data?.error || "Failed to load user information"); + setError(err?.response?.data?.error || t('errors.loadFailed')); } finally { setLoading(false); } @@ -58,7 +61,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Loading user profile... + {t('common.loading')} @@ -70,8 +73,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Error - {error || "Failed to load user profile"} + {t('common.error')} + {error || t('errors.loadFailed')} ); @@ -84,20 +87,20 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { maxHeight: 'calc(100vh - 60px)' }}> - User Profile - Manage your account settings and security + {t('common.profile')} + {t('profile.description')} - Profile + {t('common.profile')} {!userInfo.is_oidc && ( - Security + {t('profile.security')} )} @@ -105,45 +108,55 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Account Information - Your account details and settings + {t('profile.accountInfo')} + {t('profile.description')} - Username + {t('common.username')} {userInfo.username} - Account Type + {t('profile.role')} - {userInfo.is_admin ? "Administrator" : "User"} + {userInfo.is_admin ? t('interface.administrator') : t('interface.user')} - Authentication Method + {t('profile.authMethod')} - {userInfo.is_oidc ? "External (OIDC)" : "Local"} + {userInfo.is_oidc ? t('profile.external') : t('profile.local')} - Two-Factor Authentication + {t('profile.twoFactorAuth')} {userInfo.is_oidc ? ( - Locked (OIDC Auth) + {t('auth.lockedOidcAuth')} ) : ( userInfo.totp_enabled ? ( - Enabled + {t('common.enabled')} ) : ( - Disabled + {t('common.disabled')} ) )} + + + + + {t('common.language')} + {t('profile.selectPreferredLanguage')} + + + + diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 99627071..6fa29322 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -143,6 +143,7 @@ interface UserInfo { id: string; username: string; is_admin: boolean; + is_oidc: boolean; } interface UserCount { @@ -897,7 +898,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> const response = await authApi.post('/users/totp/setup'); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'setup TOTP'); throw error; } } @@ -907,7 +908,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string; const response = await authApi.post('/users/totp/enable', { totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'enable TOTP'); throw error; } } @@ -917,7 +918,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis const response = await authApi.post('/users/totp/disable', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'disable TOTP'); throw error; } } @@ -927,7 +928,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'verify TOTP login'); throw error; } } @@ -937,7 +938,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string) const response = await authApi.post('/users/totp/backup-codes', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'generate backup codes'); throw error; } }
No tunnel connections configured
{t('tunnels.noTunnelConnections')}
- Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel - connections. + {t('tunnels.createFirstTunnelMessage')}
- You are logged in! Use the sidebar to access all available tools. To get started, - create an SSH Host in the SSH Manager tab. Once created, you can connect to that - host using the other apps in the sidebar. + {t('homepage.loggedInMessage')}
Enter the 6-digit code from your authenticator app
{t('auth.enterCode')}
- Or enter a backup code if you don't have access to your authenticator + {t('auth.backupCode')}
Login using your configured external identity provider
{t('auth.loginWithExternalDesc')}
Enter your username to receive a password reset code. The code - will be logged in the docker container logs.
{t('auth.resetCodeDesc')}
Enter the 6-digit code from the docker container logs for - user: {localUsername}
{t('auth.enterResetCode')} {localUsername}
Enter your new password for - user: {localUsername}
{t('auth.enterNewPassword')} {localUsername}
- Commands will be sent to {selectedTabIds.length} selected - terminal(s). + {t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })}
- Have ideas for what should come next for ssh tools? Share them on{" "} + {t('sshTools.shareIdeas')}{" "} (null); const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate"); @@ -25,7 +28,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { const [confirmPassword, setConfirmPassword] = useState(""); const [tempToken, setTempToken] = useState(""); const [resetLoading, setResetLoading] = useState(false); - const [resetSuccess, setResetSuccess] = useState(false); async function handleInitiatePasswordReset() { setError(null); @@ -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); } @@ -48,7 +50,6 @@ export function PasswordReset({userInfo}: PasswordResetProps) { setConfirmPassword(""); setTempToken(""); setError(null); - setResetSuccess(false); } async function handleVerifyResetCode() { @@ -60,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); } @@ -71,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; } @@ -85,16 +86,10 @@ export function PasswordReset({userInfo}: PasswordResetProps) { try { await completePasswordReset(userInfo.username, tempToken, newPassword); - setResetStep("initiate"); - setResetCode(""); - setNewPassword(""); - setConfirmPassword(""); - setTempToken(""); - setError(null); - - setResetSuccess(true); + 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); } @@ -112,15 +107,15 @@ export function PasswordReset({userInfo}: PasswordResetProps) { - Password + {t('common.password')} - Change your account password + {t('common.changeAccountPassword')} <> - {resetStep === "initiate" && !resetSuccess && ( + {resetStep === "initiate" && ( <> - {resetLoading ? Spinner : "Send Reset Code"} + {resetLoading ? Spinner : t('common.sendResetCode')} > @@ -138,12 +133,11 @@ export function PasswordReset({userInfo}: PasswordResetProps) { {resetStep === "verify" && ( <> - Enter the 6-digit code from the docker container logs for - user: {userInfo.username} + {t('common.enterSixDigitCode')} {userInfo.username} - Reset Code + {t('common.resetCode')} setResetCode(e.target.value.replace(/\D/g, ''))} disabled={resetLoading} - placeholder="000000" + placeholder={t('placeholders.enterCode')} /> - {resetLoading ? Spinner : "Verify Code"} + {resetLoading ? Spinner : t('common.verifyCode')} - Back + {t('common.back')} > )} - {resetSuccess && ( - <> - - Success! - - Your password has been successfully reset! You can now log in - with your new password. - - - > - )} - - {resetStep === "newPassword" && !resetSuccess && ( + {resetStep === "newPassword" && ( <> - Enter your new password for - user: {userInfo.username} + {t('common.enterNewPassword')} {userInfo.username} - New Password + {t('common.newPassword')} - Confirm Password + {t('common.confirmPassword')} - {resetLoading ? Spinner : "Reset Password"} + {resetLoading ? Spinner : t('common.resetPassword')} - Back + {t('common.back')} > )} {error && ( - Error + {t('common.error')} {error} )} diff --git a/src/ui/User/TOTPSetup.tsx b/src/ui/User/TOTPSetup.tsx index 4bbd00d8..9239cdab 100644 --- a/src/ui/User/TOTPSetup.tsx +++ b/src/ui/User/TOTPSetup.tsx @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.t import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react"; import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts"; import { toast } from "sonner"; +import { useTranslation } from 'react-i18next'; interface TOTPSetupProps { isEnabled: boolean; @@ -15,6 +16,7 @@ interface TOTPSetupProps { } export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) { + const {t} = useTranslation(); const [isEnabled, setIsEnabled] = useState(initialEnabled); const [isSettingUp, setIsSettingUp] = useState(false); const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init"); @@ -55,7 +57,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const response = await enableTOTP(verificationCode); setBackupCodes(response.backup_codes); setSetupStep("backup"); - toast.success("Two-factor authentication enabled successfully!"); + toast.success(t('auth.twoFactorEnabledSuccess')); } catch (err: any) { setError(err?.response?.data?.error || "Invalid verification code"); } finally { @@ -74,7 +76,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet setPassword(""); setDisableCode(""); onStatusChange?.(false); - toast.success("Two-factor authentication disabled"); + toast.success(t('auth.twoFactorDisabled')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to disable TOTP"); } finally { @@ -88,7 +90,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet try { const response = await generateBackupCodes(password || undefined, disableCode || undefined); setBackupCodes(response.backup_codes); - toast.success("New backup codes generated"); + toast.success(t('auth.newBackupCodesGenerated')); } catch (err: any) { setError(err?.response?.data?.error || "Failed to generate backup codes"); } finally { @@ -98,7 +100,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet const copyToClipboard = (text: string, label: string) => { navigator.clipboard.writeText(text); - toast.success(`${label} copied to clipboard`); + toast.success(t('messages.copiedToClipboard', {item: label})); }; const downloadBackupCodes = () => { @@ -114,7 +116,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet a.download = 'termix-backup-codes.txt'; a.click(); URL.revokeObjectURL(url); - toast.success("Backup codes downloaded"); + toast.success(t('auth.backupCodesDownloaded')); }; const handleComplete = () => { @@ -131,50 +133,50 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Your account is protected with two-factor authentication + {t('auth.twoFactorProtected')} - Enabled + {t('common.enabled')} - Two-factor authentication is currently active on your account + {t('auth.twoFactorActive')} - Disable 2FA - Backup Codes + {t('auth.disable2FA')} + {t('auth.backupCodes')} - Warning + {t('common.warning')} - Disabling two-factor authentication will make your account less secure + {t('auth.disableTwoFactorWarning')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -186,29 +188,29 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleDisable} disabled={loading || (!password && !disableCode)} > - Disable Two-Factor Authentication + {t('auth.disableTwoFactor')} - Generate new backup codes if you've lost your existing ones + {t('auth.generateNewBackupCodesText')} - Password or TOTP Code + {t('auth.passwordOrTotpCode')} setPassword(e.target.value)} /> - Or + {t('auth.or')} setDisableCode(e.target.value.replace(/\D/g, ''))} @@ -219,20 +221,20 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={handleGenerateNewBackupCodes} disabled={loading || (!password && !disableCode)} > - Generate New Backup Codes + {t('auth.generateNewBackupCodes')} {backupCodes.length > 0 && ( - Your Backup Codes + {t('auth.yourBackupCodes')} - Download + {t('auth.download')} @@ -248,7 +250,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet {error && ( - Error + {t('common.error')} {error} )} @@ -261,9 +263,9 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Set Up Two-Factor Authentication + {t('auth.setupTwoFactorTitle')} - Step 1: Scan the QR code with your authenticator app + {t('auth.step1ScanQR')} @@ -272,7 +274,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Manual Entry Code + {t('auth.manualEntryCode')} - If you can't scan the QR code, enter this code manually in your authenticator app + {t('auth.cannotScanQRText')} setSetupStep("verify")} className="w-full"> - Next: Verify Code + {t('auth.nextVerifyCode')} @@ -304,14 +306,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Verify Your Authenticator + {t('auth.verifyAuthenticator')} - Step 2: Enter the 6-digit code from your authenticator app + {t('auth.step2EnterCode')} - Verification Code + {t('auth.verificationCode')} - Error + {t('common.error')} {error} )} @@ -337,14 +339,14 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet onClick={() => setSetupStep("qr")} disabled={loading} > - Back + {t('auth.back')} - {loading ? "Verifying..." : "Verify and Enable"} + {loading ? t('interface.verifying') : t('auth.verifyAndEnable')} @@ -356,17 +358,17 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet return ( - Save Your Backup Codes + {t('auth.saveBackupCodesTitle')} - Step 3: Store these codes in a safe place + {t('auth.step3StoreCodesSecurely')} - Important + {t('common.important')} - Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device. + {t('auth.importantBackupCodesText')} @@ -393,7 +395,7 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Complete Setup + {t('auth.completeSetup')} @@ -405,23 +407,23 @@ export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSet - Two-Factor Authentication + {t('auth.twoFactorTitle')} - Add an extra layer of security to your account + {t('auth.addExtraSecurityLayer')} - Not Enabled + {t('common.notEnabled')} - Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in. + {t('auth.notEnabledText')} - {loading ? "Setting up..." : "Enable Two-Factor Authentication"} + {loading ? t('common.settingUp') : t('auth.enableTwoFactorButton')} {error && ( diff --git a/src/ui/User/UserProfile.tsx b/src/ui/User/UserProfile.tsx index 9a4bccc0..2f165472 100644 --- a/src/ui/User/UserProfile.tsx +++ b/src/ui/User/UserProfile.tsx @@ -10,12 +10,15 @@ import {TOTPSetup} from "@/ui/User/TOTPSetup.tsx"; import {getUserInfo} from "@/ui/main-axios.ts"; import {toast} from "sonner"; import {PasswordReset} from "@/ui/User/PasswordReset.tsx"; +import {useTranslation} from "react-i18next"; +import {LanguageSwitcher} from "@/components/LanguageSwitcher"; interface UserProfileProps { isTopbarOpen?: boolean; } export function UserProfile({isTopbarOpen = true}: UserProfileProps) { + const {t} = useTranslation(); const [userInfo, setUserInfo] = useState<{ username: string; is_admin: boolean; @@ -41,7 +44,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { totp_enabled: info.totp_enabled || false }); } catch (err: any) { - setError(err?.response?.data?.error || "Failed to load user information"); + setError(err?.response?.data?.error || t('errors.loadFailed')); } finally { setLoading(false); } @@ -58,7 +61,7 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Loading user profile... + {t('common.loading')} @@ -70,8 +73,8 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Error - {error || "Failed to load user profile"} + {t('common.error')} + {error || t('errors.loadFailed')} ); @@ -84,20 +87,20 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { maxHeight: 'calc(100vh - 60px)' }}> - User Profile - Manage your account settings and security + {t('common.profile')} + {t('profile.description')} - Profile + {t('common.profile')} {!userInfo.is_oidc && ( - Security + {t('profile.security')} )} @@ -105,45 +108,55 @@ export function UserProfile({isTopbarOpen = true}: UserProfileProps) { - Account Information - Your account details and settings + {t('profile.accountInfo')} + {t('profile.description')} - Username + {t('common.username')} {userInfo.username} - Account Type + {t('profile.role')} - {userInfo.is_admin ? "Administrator" : "User"} + {userInfo.is_admin ? t('interface.administrator') : t('interface.user')} - Authentication Method + {t('profile.authMethod')} - {userInfo.is_oidc ? "External (OIDC)" : "Local"} + {userInfo.is_oidc ? t('profile.external') : t('profile.local')} - Two-Factor Authentication + {t('profile.twoFactorAuth')} {userInfo.is_oidc ? ( - Locked (OIDC Auth) + {t('auth.lockedOidcAuth')} ) : ( userInfo.totp_enabled ? ( - Enabled + {t('common.enabled')} ) : ( - Disabled + {t('common.disabled')} ) )} + + + + + {t('common.language')} + {t('profile.selectPreferredLanguage')} + + + + diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 99627071..6fa29322 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -143,6 +143,7 @@ interface UserInfo { id: string; username: string; is_admin: boolean; + is_oidc: boolean; } interface UserCount { @@ -897,7 +898,7 @@ export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> const response = await authApi.post('/users/totp/setup'); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'setup TOTP'); throw error; } } @@ -907,7 +908,7 @@ export async function enableTOTP(totp_code: string): Promise<{ message: string; const response = await authApi.post('/users/totp/enable', { totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'enable TOTP'); throw error; } } @@ -917,7 +918,7 @@ export async function disableTOTP(password?: string, totp_code?: string): Promis const response = await authApi.post('/users/totp/disable', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'disable TOTP'); throw error; } } @@ -927,7 +928,7 @@ export async function verifyTOTPLogin(temp_token: string, totp_code: string): Pr const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'verify TOTP login'); throw error; } } @@ -937,7 +938,7 @@ export async function generateBackupCodes(password?: string, totp_code?: string) const response = await authApi.post('/users/totp/backup-codes', { password, totp_code }); return response.data; } catch (error) { - handleApiError(error as AxiosError); + handleApiError(error as AxiosError, 'generate backup codes'); throw error; } }
Enter the 6-digit code from the docker container logs for - user: {userInfo.username}
{t('common.enterSixDigitCode')} {userInfo.username}
Enter your new password for - user: {userInfo.username}
{t('common.enterNewPassword')} {userInfo.username}
Or
{t('auth.or')}
- Generate new backup codes if you've lost your existing ones + {t('auth.generateNewBackupCodesText')}
- If you can't scan the QR code, enter this code manually in your authenticator app + {t('auth.cannotScanQRText')}
Manage your account settings and security
{t('profile.description')}
{userInfo.username}
- {userInfo.is_admin ? "Administrator" : "User"} + {userInfo.is_admin ? t('interface.administrator') : t('interface.user')}
- {userInfo.is_oidc ? "External (OIDC)" : "Local"} + {userInfo.is_oidc ? t('profile.external') : t('profile.local')}
{userInfo.is_oidc ? ( - Locked (OIDC Auth) + {t('auth.lockedOidcAuth')} ) : ( userInfo.totp_enabled ? ( - Enabled + {t('common.enabled')} ) : ( - Disabled + {t('common.disabled')} ) )}
{t('profile.selectPreferredLanguage')}