diff --git a/.env b/.env index fbd02ad8..c22d1734 100644 --- a/.env +++ b/.env @@ -1 +1 @@ -VERSION=1.3.1 \ No newline at end of file +VERSION=1.4.0 \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5a2a2e8a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,56 @@ +# Contributing + +## Prerequisites + +- [Node.js](https://nodejs.org/en/download/) (built with v24) +- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) +- [Git](https://git-scm.com/downloads) + +## Installation + +1. Clone the repository: + ```sh + git clone https://github.com/LukeGus/Termix + ``` +2. Install the dependencies: + ```sh + npm install + ``` + +## Running the development server + +Run the following commands: + +```sh +npm run dev +npx tsc -p tsconfig.node.json +node ./dist/backend/starter.js +``` + +This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`. + +## Contributing + +1. **Fork the repository**: Click the "Fork" button at the top right of + the [repository page](https://github.com/LukeGus/Termix). +2. **Create a new branch**: + ```sh + git checkout -b feature/my-new-feature + ``` +3. **Make your changes**: Implement your feature, fix, or improvement. +4. **Commit your changes**: + ```sh + git commit -m "Add feature: my new feature" + ``` +5. **Push to your fork**: + ```sh + git push origin feature/my-new-feature + ``` +6. **Open a pull request**: Go to the original repository and create a PR with a clear description. + +## 📝 Guidelines + +- Follow the existing code style. Use Tailwind CSS with shadcn components. +- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded. +- Include meaningful commit messages. +- Link related issues when applicable. \ No newline at end of file diff --git a/README.md b/README.md index b36e2762..72d47b80 100644 --- a/README.md +++ b/README.md @@ -37,18 +37,17 @@ Termix is an open-source, forever-free, self-hosted all-in-one server management - **Remote File Editor** - Edit files directly on remote servers with syntax highlighting, file management features (uploading, removing, renaming, deleting files) - **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders - **Server Stats** - View CPU, memory, and HDD usage on any SSH server -- **User Authentication** - Secure user management with admin controls and OIDC support with more auth types planned +- **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 # Planned Features - **Improved Admin Control** - Give more fine-grained control over user and admin permissions, share hosts, etc -- **More auth types** - Add 2FA, TOTP, etc -- **Theming** - Modify themeing for all tools +- **Theming** - Modify theming for all tools - **Improved Terminal Support** - Add more terminal protocols such as VNC and RDP (anyone who has experience in integrating RDP into a web-application similar to Apache Guacamole, please contact me by creating an issue) - **Mobile Support** - Support a mobile app or version of the Termix website to manage servers from your phone # Installation -Visit the Termix [Docs](https://docs.termix.site/docs) for more information on how to install Termix. Otherwise, view a sample docker-compose file here: +Visit the Termix [Docs](https://docs.termix.site/install) for more information on how to install Termix. Otherwise, view a sample docker-compose file here: ```yaml services: termix: diff --git a/openapi.json b/openapi.json new file mode 100644 index 00000000..b8d6ce05 --- /dev/null +++ b/openapi.json @@ -0,0 +1,2262 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Termix API", + "version": "1.0.0", + "description": "Comprehensive API for Termix SSH management, file operations, tunneling, and server monitoring. This API provides endpoints for managing SSH hosts, file operations, tunnel connections, server monitoring, user management, and system alerts.", + "contact": { + "name": "Termix Development Team" + } + }, + "servers": [ + { + "url": "http://localhost:8081", + "description": "Main database and authentication server" + }, + { + "url": "http://localhost:8083", + "description": "SSH tunnel management server" + }, + { + "url": "http://localhost:8084", + "description": "SSH file manager server" + }, + { + "url": "http://localhost:8085", + "description": "Server statistics and monitoring server" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "tags": [ + { + "name": "System", + "description": "System health, version, and release information endpoints" + }, + { + "name": "SSH Hosts", + "description": "SSH host management, creation, updates, and deletion" + }, + { + "name": "File Manager", + "description": "File manager operations including recent, pinned, and shortcuts" + }, + { + "name": "SSH File Operations", + "description": "SSH file operations like reading, writing, creating, and deleting files" + }, + { + "name": "Tunnel Management", + "description": "SSH tunnel connection, disconnection, and status management" + }, + { + "name": "Server Statistics", + "description": "Server status monitoring and metrics collection" + }, + { + "name": "User Management", + "description": "User account management and administration" + }, + { + "name": "Authentication", + "description": "User authentication, login, and password management" + }, + { + "name": "TOTP", + "description": "Two-factor authentication using TOTP (Time-based One-Time Password)" + }, + { + "name": "Alerts", + "description": "System alerts and notifications management" + } + ], + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "schemas": { + "SSHHost": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "ip": { "type": "string" }, + "port": { "type": "integer" }, + "username": { "type": "string" }, + "folder": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "pin": { "type": "boolean" }, + "authType": { "type": "string", "enum": ["password", "key"] }, + "password": { "type": "string" }, + "key": { "type": "string" }, + "keyPassword": { "type": "string" }, + "keyType": { "type": "string" }, + "enableTerminal": { "type": "boolean" }, + "enableTunnel": { "type": "boolean" }, + "enableFileManager": { "type": "boolean" }, + "defaultPath": { "type": "string" }, + "tunnelConnections": { "type": "array", "items": { "type": "object" } }, + "createdAt": { "type": "string", "format": "date-time" }, + "updatedAt": { "type": "string", "format": "date-time" } + }, + "required": ["id", "ip", "port", "username", "authType"] + }, + "SSHHostData": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "ip": { "type": "string" }, + "port": { "type": "integer" }, + "username": { "type": "string" }, + "folder": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "pin": { "type": "boolean" }, + "authType": { "type": "string", "enum": ["password", "key"] }, + "password": { "type": "string" }, + "key": { "type": "string" }, + "keyPassword": { "type": "string" }, + "keyType": { "type": "string" }, + "enableTerminal": { "type": "boolean" }, + "enableTunnel": { "type": "boolean" }, + "enableFileManager": { "type": "boolean" }, + "defaultPath": { "type": "string" }, + "tunnelConnections": { "type": "array", "items": { "type": "object" } } + }, + "required": ["ip", "port", "username", "authType"] + }, + "TunnelConfig": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "hostName": { "type": "string" }, + "sourceIP": { "type": "string" }, + "sourceSSHPort": { "type": "integer" }, + "sourceUsername": { "type": "string" }, + "sourcePassword": { "type": "string" }, + "sourceAuthMethod": { "type": "string" }, + "sourceSSHKey": { "type": "string" }, + "sourceKeyPassword": { "type": "string" }, + "sourceKeyType": { "type": "string" }, + "endpointIP": { "type": "string" }, + "endpointSSHPort": { "type": "integer" }, + "endpointUsername": { "type": "string" }, + "endpointPassword": { "type": "string" }, + "endpointAuthMethod": { "type": "string" }, + "endpointSSHKey": { "type": "string" }, + "endpointKeyPassword": { "type": "string" }, + "endpointKeyType": { "type": "string" }, + "sourcePort": { "type": "integer" }, + "endpointPort": { "type": "integer" }, + "maxRetries": { "type": "integer" }, + "retryInterval": { "type": "integer" }, + "autoStart": { "type": "boolean" }, + "isPinned": { "type": "boolean" } + }, + "required": ["name", "hostName", "sourceIP", "sourceSSHPort", "sourceUsername", "endpointIP", "endpointSSHPort", "endpointUsername", "sourcePort", "endpointPort"] + }, + "TunnelStatus": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "reason": { "type": "string" }, + "errorType": { "type": "string" }, + "retryCount": { "type": "integer" }, + "maxRetries": { "type": "integer" }, + "nextRetryIn": { "type": "integer" }, + "retryExhausted": { "type": "boolean" } + } + }, + "ServerStatus": { + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["online", "offline"] }, + "lastChecked": { "type": "string", "format": "date-time" } + } + }, + "ServerMetrics": { + "type": "object", + "properties": { + "cpu": { + "type": "object", + "properties": { + "percent": { "type": "number" }, + "cores": { "type": "number" }, + "load": { "type": "array", "items": { "type": "number" }, "minItems": 3, "maxItems": 3 } + } + }, + "memory": { + "type": "object", + "properties": { + "percent": { "type": "number" }, + "usedGiB": { "type": "number" }, + "totalGiB": { "type": "number" } + } + }, + "disk": { + "type": "object", + "properties": { + "percent": { "type": "number" }, + "usedHuman": { "type": "string" }, + "totalHuman": { "type": "string" } + } + }, + "lastChecked": { "type": "string", "format": "date-time" } + } + }, + "FileManagerFile": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "type": { "type": "string", "enum": ["file", "directory"] }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" } + }, + "required": ["name", "path"] + }, + "UserInfo": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "username": { "type": "string" }, + "is_admin": { "type": "boolean" } + }, + "required": ["id", "username", "is_admin"] + }, + "AuthResponse": { + "type": "object", + "properties": { + "token": { "type": "string" } + }, + "required": ["token"] + }, + "Error": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "details": { "type": "string" } + } + } + }, + "parameters": { + "hostId": { + "name": "hostId", + "in": "query", + "description": "The ID of the SSH host", + "required": true, + "schema": { + "type": "integer" + } + }, + "sessionId": { + "name": "sessionId", + "in": "query", + "description": "The SSH session identifier", + "required": true, + "schema": { + "type": "string" + } + }, + "path": { + "name": "path", + "in": "query", + "description": "The file or directory path", + "required": true, + "schema": { + "type": "string" + } + }, + "tunnelName": { + "name": "tunnelName", + "in": "path", + "description": "The name of the tunnel", + "required": true, + "schema": { + "type": "string" + } + }, + "userId": { + "name": "userId", + "in": "path", + "description": "The user identifier", + "required": true, + "schema": { + "type": "string" + } + }, + "hostIdPath": { + "name": "id", + "in": "path", + "description": "The SSH host identifier", + "required": true, + "schema": { + "type": "integer" + } + }, + "serverIdPath": { + "name": "id", + "in": "path", + "description": "The server identifier", + "required": true, + "schema": { + "type": "integer" + } + } + }, + "responses": { + "BadRequest": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + }, + "Unauthorized": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + }, + "NotFound": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + }, + "InternalServerError": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + }, + "paths": { + "/health": { + "get": { + "summary": "Health check endpoint", + "description": "Simple health check to verify the API server is running and responsive. **Server: localhost:8081**", + "operationId": "getHealth", + "tags": ["System"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "string", "example": "ok" } + } + } + } + } + } + } + } + }, + "/version": { + "get": { + "summary": "Get version information and check for updates", + "description": "Get version information and check for updates. **Server: localhost:8081**", + "operationId": "getVersion", + "tags": ["System"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["up_to_date", "requires_update"] }, + "version": { "type": "string" }, + "latest_release": { + "type": "object", + "properties": { + "tag_name": { "type": "string" }, + "name": { "type": "string" }, + "published_at": { "type": "string" }, + "html_url": { "type": "string" } + } + }, + "cached": { "type": "boolean" }, + "cache_age": { "type": "number" } + } + } + } + } + }, + "401": { + "description": "Version information not available", + "content": { + "text/plain": { + "schema": { "type": "string" } + } + } + } + } + } + }, + "/releases/rss": { + "get": { + "summary": "Get releases in RSS format", + "description": "Get releases in RSS format. **Server: localhost:8081**", + "operationId": "getReleasesRSS", + "tags": ["System"], + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { "type": "integer", "default": 1 } + }, + { + "name": "per_page", + "in": "query", + "schema": { "type": "integer", "default": 20, "maximum": 100 } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "feed": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "description": { "type": "string" }, + "link": { "type": "string" }, + "updated": { "type": "string" } + } + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "link": { "type": "string" }, + "pubDate": { "type": "string" }, + "version": { "type": "string" }, + "isPrerelease": { "type": "boolean" }, + "isDraft": { "type": "boolean" }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "size": { "type": "number" }, + "download_count": { "type": "number" }, + "download_url": { "type": "string" } + } + } + } + } + } + }, + "total_count": { "type": "integer" }, + "cached": { "type": "boolean" }, + "cache_age": { "type": "number" } + } + } + } + } + } + } + } + }, + "/ssh/db/host": { + "get": { + "summary": "Get all SSH hosts", + "description": "Retrieve a list of all configured SSH hosts in the system. This endpoint requires authentication and returns host information including connection details, authentication methods, and enabled features. **Server: localhost:8081**", + "operationId": "getSSHHosts", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "Successfully retrieved SSH hosts", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/SSHHost" } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "post": { + "summary": "Create a new SSH host", + "description": "Create a new SSH host configuration. **Server: localhost:8081**", + "operationId": "createSSHHost", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "binary", + "description": "SSH private key file (optional)" + }, + "data": { + "type": "string", + "description": "JSON string containing host data" + } + } + } + }, + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHostData" } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHost" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/db/host/{id}": { + "get": { + "summary": "Get SSH host by ID", + "description": "Get SSH host by ID. **Server: localhost:8081**", + "operationId": "getSSHHostById", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostIdPath" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHost" } + } + } + }, + "404": { "$ref": "#/components/responses/NotFound" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "put": { + "summary": "Update SSH host", + "description": "Update SSH host configuration. **Server: localhost:8081**", + "operationId": "updateSSHHost", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "key": { + "type": "string", + "format": "binary", + "description": "SSH private key file (optional)" + }, + "data": { + "type": "string", + "description": "JSON string containing host data" + } + } + } + }, + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHostData" } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/SSHHost" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + }, + "delete": { + "summary": "Delete SSH host", + "description": "Delete SSH host configuration. **Server: localhost:8081**", + "operationId": "deleteSSHHost", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostIdPath" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, + "/ssh/db/folders": { + "get": { + "summary": "Get all SSH host folders", + "description": "Get all SSH host folders. **Server: localhost:8081**", + "operationId": "getSSHFolders", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "type": "string" } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/bulk-import": { + "post": { + "summary": "Bulk import SSH hosts", + "description": "Bulk import SSH hosts. **Server: localhost:8081**", + "operationId": "bulkImportSSHHosts", + "tags": ["SSH Hosts"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "hosts": { + "type": "array", + "items": { "$ref": "#/components/schemas/SSHHostData" } + } + }, + "required": ["hosts"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "success": { "type": "integer" }, + "failed": { "type": "integer" }, + "errors": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/file_manager/recent": { + "get": { + "summary": "Get recent files for a host", + "description": "Get recent files for a host. **Server: localhost:8081**", + "operationId": "getFileManagerRecent", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/FileManagerFile" } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "post": { + "summary": "Add file to recent list", + "description": "Add file to recent list. **Server: localhost:8081**", + "operationId": "addFileManagerRecent", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" }, + "hostId": { "type": "integer" } + }, + "required": ["name", "path", "isSSH", "hostId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "delete": { + "summary": "Remove file from recent list", + "description": "Remove file from recent list. **Server: localhost:8081**", + "operationId": "removeFileManagerRecent", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "File name", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isSSH", + "in": "query", + "description": "Whether this is an SSH file", + "required": true, + "schema": { "type": "boolean" } + }, + { + "name": "sshSessionId", + "in": "query", + "description": "SSH session ID", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "hostId", + "in": "query", + "description": "Host ID", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/file_manager/pinned": { + "get": { + "summary": "Get pinned files for a host", + "description": "Get pinned files for a host. **Server: localhost:8081**", + "operationId": "getFileManagerPinned", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/FileManagerFile" } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "post": { + "summary": "Add file to pinned list", + "description": "Add file to pinned list. **Server: localhost:8081**", + "operationId": "addFileManagerPinned", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" }, + "hostId": { "type": "integer" } + }, + "required": ["name", "path", "isSSH", "hostId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "delete": { + "summary": "Remove file from pinned list", + "description": "Remove file from pinned list. **Server: localhost:8081**", + "operationId": "removeFileManagerPinned", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "File name", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isSSH", + "in": "query", + "description": "Whether this is an SSH file", + "required": true, + "schema": { "type": "boolean" } + }, + { + "name": "sshSessionId", + "in": "query", + "description": "SSH session ID", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "hostId", + "in": "query", + "description": "Host ID", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/file_manager/shortcuts": { + "get": { + "summary": "Get file shortcuts for a host", + "description": "Get file shortcuts for a host. **Server: localhost:8081**", + "operationId": "getFileManagerShortcuts", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "$ref": "#/components/parameters/hostId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" } + }, + "required": ["name", "path"] + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "post": { + "summary": "Add file shortcut", + "description": "Add file shortcut. **Server: localhost:8081**", + "operationId": "addFileManagerShortcut", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "isSSH": { "type": "boolean" }, + "sshSessionId": { "type": "string" }, + "hostId": { "type": "integer" } + }, + "required": ["name", "path", "isSSH", "hostId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + }, + "delete": { + "summary": "Remove file shortcut", + "description": "Remove file shortcut. **Server: localhost:8081**", + "operationId": "removeFileManagerShortcut", + "tags": ["File Manager"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "name", + "in": "query", + "description": "File name", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isSSH", + "in": "query", + "description": "Whether this is an SSH file", + "required": true, + "schema": { "type": "boolean" } + }, + { + "name": "sshSessionId", + "in": "query", + "description": "SSH session ID", + "required": false, + "schema": { "type": "string" } + }, + { + "name": "hostId", + "in": "query", + "description": "Host ID", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/ssh/file_manager/ssh/connect": { + "post": { + "summary": "Connect to SSH server", + "description": "Connect to SSH server. **Server: localhost:8084**", + "operationId": "connectSSH", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "ip": { "type": "string" }, + "port": { "type": "integer" }, + "username": { "type": "string" }, + "password": { "type": "string" }, + "sshKey": { "type": "string" }, + "keyPassword": { "type": "string" } + }, + "required": ["sessionId", "ip", "username", "port"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + } + }, + "/ssh/file_manager/ssh/disconnect": { + "post": { + "summary": "Disconnect from SSH server", + "description": "Disconnect from SSH server. **Server: localhost:8084**", + "operationId": "disconnectSSH", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" } + }, + "required": ["sessionId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/status": { + "get": { + "summary": "Get SSH connection status", + "description": "Get SSH connection status. **Server: localhost:8084**", + "operationId": "getSSHStatus", + "tags": ["SSH File Operations"], + "parameters": [ + { + "$ref": "#/components/parameters/sessionId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "connected": { "type": "boolean" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/listFiles": { + "get": { + "summary": "List files in SSH directory", + "description": "List files in SSH directory. **Server: localhost:8084**", + "operationId": "listSSHFiles", + "tags": ["SSH File Operations"], + "parameters": [ + { + "$ref": "#/components/parameters/sessionId" + }, + { + "$ref": "#/components/parameters/path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "type": { "type": "string", "enum": ["file", "directory"] }, + "size": { "type": "number" }, + "modified": { "type": "string" }, + "permissions": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/readFile": { + "get": { + "summary": "Read SSH file content", + "description": "Read SSH file content. **Server: localhost:8084**", + "operationId": "readSSHFile", + "tags": ["SSH File Operations"], + "parameters": [ + { + "$ref": "#/components/parameters/sessionId" + }, + { + "$ref": "#/components/parameters/path" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "path": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/writeFile": { + "post": { + "summary": "Write content to SSH file", + "description": "Write content to SSH file. **Server: localhost:8084**", + "operationId": "writeSSHFile", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "path": { "type": "string" }, + "content": { "type": "string" } + }, + "required": ["sessionId", "path", "content"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/createFile": { + "post": { + "summary": "Create new SSH file", + "description": "Create new SSH file. **Server: localhost:8084**", + "operationId": "createSSHFile", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "path": { "type": "string" }, + "fileName": { "type": "string" }, + "content": { "type": "string" } + }, + "required": ["sessionId", "path", "fileName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/createFolder": { + "post": { + "summary": "Create new SSH folder", + "description": "Create new SSH folder. **Server: localhost:8084**", + "operationId": "createSSHFolder", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "path": { "type": "string" }, + "folderName": { "type": "string" } + }, + "required": ["sessionId", "path", "folderName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/deleteItem": { + "delete": { + "summary": "Delete SSH file or folder", + "description": "Delete SSH file or folder. **Server: localhost:8084**", + "operationId": "deleteSSHItem", + "tags": ["SSH File Operations"], + "parameters": [ + { + "name": "sessionId", + "in": "query", + "description": "SSH session ID", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "path", + "in": "query", + "description": "File or directory path", + "required": true, + "schema": { "type": "string" } + }, + { + "name": "isDirectory", + "in": "query", + "description": "Whether the item is a directory", + "required": true, + "schema": { "type": "boolean" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/file_manager/ssh/renameItem": { + "put": { + "summary": "Rename SSH file or folder", + "description": "Rename SSH file or folder. **Server: localhost:8084**", + "operationId": "renameSSHItem", + "tags": ["SSH File Operations"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionId": { "type": "string" }, + "oldPath": { "type": "string" }, + "newName": { "type": "string" } + }, + "required": ["sessionId", "oldPath", "newName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/tunnel/status": { + "get": { + "summary": "Get all tunnel statuses", + "description": "Get all tunnel statuses. **Server: localhost:8083**", + "operationId": "getTunnelStatuses", + "tags": ["Tunnel Management"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { "$ref": "#/components/schemas/TunnelStatus" } + } + } + } + } + } + } + }, + "/ssh/tunnel/status/{tunnelName}": { + "get": { + "summary": "Get tunnel status by name", + "description": "Get tunnel status by name. **Server: localhost:8083**", + "operationId": "getTunnelStatusByName", + "tags": ["Tunnel Management"], + "parameters": [ + { + "$ref": "#/components/parameters/tunnelName" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TunnelStatus" } + } + } + } + } + } + }, + "/ssh/tunnel/connect": { + "post": { + "summary": "Connect to tunnel", + "description": "Connect to tunnel. **Server: localhost:8083**", + "operationId": "connectTunnel", + "tags": ["Tunnel Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/TunnelConfig" } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/tunnel/disconnect": { + "post": { + "summary": "Disconnect tunnel", + "description": "Disconnect tunnel. **Server: localhost:8083**", + "operationId": "disconnectTunnel", + "tags": ["Tunnel Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tunnelName": { "type": "string" } + }, + "required": ["tunnelName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/ssh/tunnel/cancel": { + "post": { + "summary": "Cancel tunnel connection", + "description": "Cancel tunnel connection. **Server: localhost:8083**", + "operationId": "cancelTunnel", + "tags": ["Tunnel Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tunnelName": { "type": "string" } + }, + "required": ["tunnelName"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/status": { + "get": { + "summary": "Get all server statuses", + "description": "Get all server statuses. **Server: localhost:8085**", + "operationId": "getAllServerStatuses", + "tags": ["Server Statistics"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { "$ref": "#/components/schemas/ServerStatus" } + } + } + } + } + } + } + }, + "/status/{id}": { + "get": { + "summary": "Get server status by ID", + "description": "Get server status by ID. **Server: localhost:8085**", + "operationId": "getServerStatusById", + "tags": ["Server Statistics"], + "parameters": [ + { + "$ref": "#/components/parameters/serverIdPath" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ServerStatus" } + } + } + } + } + } + }, + "/metrics/{id}": { + "get": { + "summary": "Get server metrics by ID", + "description": "Get server metrics by ID. **Server: localhost:8085**", + "operationId": "getServerMetricsById", + "tags": ["Server Statistics"], + "parameters": [ + { + "$ref": "#/components/parameters/serverIdPath" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ServerMetrics" } + } + } + } + } + } + }, + "/refresh": { + "post": { + "summary": "Refresh server statistics", + "description": "Refresh server statistics. **Server: localhost:8085**", + "operationId": "refreshServerStats", + "tags": ["Server Statistics"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/users/create": { + "post": { + "summary": "Create new user account", + "description": "Create new user account. **Server: localhost:8081**", + "operationId": "createUser", + "tags": ["User Management"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + }, + "required": ["username", "password"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" } + } + } + }, + "/users/login": { + "post": { + "summary": "User login", + "description": "User login. **Server: localhost:8081**", + "operationId": "loginUser", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "password": { "type": "string" } + }, + "required": ["username", "password"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthResponse" } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/me": { + "get": { + "summary": "Get current user info", + "description": "Get current user info. **Server: localhost:8081**", + "operationId": "getUserInfo", + "tags": ["User Management"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UserInfo" } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/count": { + "get": { + "summary": "Get total user count", + "description": "Get total user count. **Server: localhost:8081**", + "operationId": "getUserCount", + "tags": ["User Management"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { "type": "integer" } + } + } + } + } + } + } + } + }, + "/users/registration-allowed": { + "get": { + "summary": "Check if user registration is allowed", + "description": "Check if user registration is allowed. **Server: localhost:8081**", + "operationId": "getRegistrationAllowed", + "tags": ["User Management"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allowed": { "type": "boolean" } + } + } + } + } + } + } + }, + "patch": { + "summary": "Update registration allowed status", + "description": "Update registration allowed status. **Server: localhost:8081**", + "operationId": "updateRegistrationAllowed", + "tags": ["User Management"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "allowed": { "type": "boolean" } + }, + "required": ["allowed"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/initiate-reset": { + "post": { + "summary": "Initiate password reset", + "description": "Initiate password reset. **Server: localhost:8081**", + "operationId": "initiatePasswordReset", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" } + }, + "required": ["username"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/users/verify-reset-code": { + "post": { + "summary": "Verify password reset code", + "description": "Verify password reset code. **Server: localhost:8081**", + "operationId": "verifyPasswordResetCode", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "resetCode": { "type": "string" } + }, + "required": ["username", "resetCode"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tempToken": { "type": "string" } + } + } + } + } + } + } + } + }, + "/users/complete-reset": { + "post": { + "summary": "Complete password reset", + "description": "Complete password reset. **Server: localhost:8081**", + "operationId": "completePasswordReset", + "tags": ["Authentication"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "username": { "type": "string" }, + "tempToken": { "type": "string" }, + "newPassword": { "type": "string" } + }, + "required": ["username", "tempToken", "newPassword"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + }, + "/users/totp/setup": { + "post": { + "summary": "Setup TOTP authentication", + "description": "Setup TOTP authentication. **Server: localhost:8081**", + "operationId": "setupTOTP", + "tags": ["TOTP"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { "type": "string" }, + "qr_code": { "type": "string" } + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/totp/enable": { + "post": { + "summary": "Enable TOTP authentication", + "description": "Enable TOTP authentication. **Server: localhost:8081**", + "operationId": "enableTOTP", + "tags": ["TOTP"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "totp_code": { "type": "string" } + }, + "required": ["totp_code"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "backup_codes": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/users/totp/verify-login": { + "post": { + "summary": "Verify TOTP during login", + "description": "Verify TOTP during login. **Server: localhost:8081**", + "operationId": "verifyTOTPLogin", + "tags": ["TOTP"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "temp_token": { "type": "string" }, + "totp_code": { "type": "string" } + }, + "required": ["temp_token", "totp_code"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AuthResponse" } + } + } + } + } + } + }, + "/alerts": { + "get": { + "summary": "Get all system alerts", + "description": "Get all system alerts. **Server: localhost:8081**", + "operationId": "getAllAlerts", + "tags": ["Alerts"], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "message": { "type": "string" }, + "expiresAt": { "type": "string" }, + "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "type": { "type": "string", "enum": ["info", "warning", "error", "success"] }, + "actionUrl": { "type": "string" }, + "actionText": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "/alerts/user/{userId}": { + "get": { + "summary": "Get alerts for specific user", + "description": "Get alerts for specific user. **Server: localhost:8081**", + "operationId": "getUserAlerts", + "tags": ["Alerts"], + "parameters": [ + { + "$ref": "#/components/parameters/userId" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "alerts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "title": { "type": "string" }, + "message": { "type": "string" }, + "expiresAt": { "type": "string" }, + "priority": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "type": { "type": "string", "enum": ["info", "warning", "error", "success"] }, + "actionUrl": { "type": "string" }, + "actionText": { "type": "string" } + } + } + } + } + } + } + } + } + } + } + }, + "/alerts/dismiss": { + "post": { + "summary": "Dismiss an alert", + "description": "Dismiss an alert. **Server: localhost:8081**", + "operationId": "dismissAlert", + "tags": ["Alerts"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "userId": { "type": "string" }, + "alertId": { "type": "string" } + }, + "required": ["userId", "alertId"] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + } + } + } + } + } + } + } + } + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 23dc8262..1ec9298d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,8 @@ "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", + "@types/qrcode": "^1.5.5", + "@types/speakeasy": "^2.0.10", "@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1", @@ -58,12 +60,14 @@ "nanoid": "^5.1.5", "next-themes": "^0.4.6", "node-fetch": "^3.3.2", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", + "speakeasy": "^2.0.0", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", @@ -3782,6 +3786,15 @@ "undici-types": "~7.10.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz", + "integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -3835,6 +3848,15 @@ "@types/send": "*" } }, + "node_modules/@types/speakeasy": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/speakeasy/-/speakeasy-2.0.10.tgz", + "integrity": "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ssh2": { "version": "1.15.5", "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", @@ -4400,6 +4422,15 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -4518,6 +4549,12 @@ "dev": true, "license": "MIT" }, + "node_modules/base32.js": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz", + "integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==", + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4771,6 +4808,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001727", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", @@ -4829,6 +4875,17 @@ "url": "https://polar.sh/cva" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -5062,6 +5119,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5136,6 +5202,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.2.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.0.tgz", @@ -5309,6 +5381,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -5994,6 +6072,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6270,6 +6357,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -7195,6 +7291,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7221,7 +7326,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7265,6 +7369,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -7393,6 +7506,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -7616,6 +7746,21 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -7797,6 +7942,12 @@ "node": ">= 18" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7962,6 +8113,18 @@ "node": ">=0.10.0" } }, + "node_modules/speakeasy": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz", + "integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==", + "license": "MIT", + "dependencies": { + "base32.js": "0.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/ssh2": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", @@ -8005,6 +8168,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -8611,6 +8800,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -8621,6 +8816,20 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8657,6 +8866,12 @@ "node": ">=0.4" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -8666,6 +8881,93 @@ "node": ">=18" } }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/package.json b/package.json index b016dcb0..bfb57933 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@tailwindcss/vite": "^4.1.11", "@types/bcryptjs": "^2.4.6", "@types/multer": "^2.0.0", + "@types/qrcode": "^1.5.5", + "@types/speakeasy": "^2.0.10", "@uiw/codemirror-extensions-hyper-link": "^4.24.1", "@uiw/codemirror-extensions-langs": "^4.24.1", "@uiw/codemirror-themes": "^4.24.1", @@ -62,12 +64,14 @@ "nanoid": "^5.1.5", "next-themes": "^0.4.6", "node-fetch": "^3.3.2", + "qrcode": "^1.5.4", "react": "^19.1.0", "react-dom": "^19.1.0", "react-hook-form": "^7.60.0", "react-resizable-panels": "^3.0.3", "react-xtermjs": "^1.0.10", "sonner": "^2.0.7", + "speakeasy": "^2.0.0", "ssh2": "^1.16.0", "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.11", diff --git a/src/App.tsx b/src/App.tsx index fb3e1525..2894ede1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,10 +2,11 @@ import React, {useState, useEffect} from "react" import {LeftSidebar} from "@/ui/Navigation/LeftSidebar.tsx" import {Homepage} from "@/ui/Homepage/Homepage.tsx" import {AppView} from "@/ui/Navigation/AppView.tsx" -import {HostManager} from "@/ui/apps/Host Manager/HostManager.tsx" +import {HostManager} from "@/ui/Apps/Host Manager/HostManager.tsx" import {TabProvider, useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx" import {TopNavbar} from "@/ui/Navigation/TopNavbar.tsx"; import { AdminSettings } from "@/ui/Admin/AdminSettings"; +import { UserProfile } from "@/ui/User/UserProfile.tsx"; import { Toaster } from "@/components/ui/sonner"; import { getUserInfo } from "@/ui/main-axios.ts"; @@ -86,6 +87,7 @@ function AppContent() { const showHome = currentTabData?.type === 'home'; const showSshManager = currentTabData?.type === 'ssh_manager'; const showAdmin = currentTabData?.type === 'admin'; + const showProfile = currentTabData?.type === 'profile'; return (
@@ -187,6 +189,20 @@ function AppContent() {
+
+ +
+ )} diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 186b8ff4..71ed1e22 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -411,6 +411,11 @@ 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'); addColumnIfNotExists('ssh_data', 'name', 'TEXT'); addColumnIfNotExists('ssh_data', 'folder', 'TEXT'); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 8d358993..81300eea 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -17,6 +17,10 @@ export const users = sqliteTable('users', { identifier_path: text('identifier_path'), name_path: text('name_path'), scopes: text().default("openid email profile"), + + totp_secret: text('totp_secret'), + totp_enabled: integer('totp_enabled', {mode: 'boolean'}).notNull().default(false), + totp_backup_codes: text('totp_backup_codes'), }); export const settings = sqliteTable('settings', { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index d43aa630..065ebc0b 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -6,6 +6,8 @@ import chalk from 'chalk'; import bcrypt from 'bcryptjs'; import {nanoid} from 'nanoid'; import jwt from 'jsonwebtoken'; +import speakeasy from 'speakeasy'; +import QRCode from 'qrcode'; import type {Request, Response, NextFunction} from 'express'; async function verifyOIDCToken(idToken: string, issuerUrl: string, clientId: string): Promise { @@ -206,6 +208,9 @@ router.post('/create', async (req, res) => { identifier_path: '', name_path: '', scopes: 'openid email profile', + totp_secret: null, + totp_enabled: false, + totp_backup_codes: null, }); logger.success(`Traditional user created: ${username} (is_admin: ${isFirstUser})`); @@ -546,6 +551,17 @@ router.post('/login', async (req, res) => { expiresIn: '50d', }); + if (userRecord.totp_enabled) { + return res.json({ + requires_totp: true, + temp_token: jwt.sign( + {userId: userRecord.id, pending_totp: true}, + jwtSecret, + {expiresIn: '10m'} + ) + }); + } + return res.json({ token, is_admin: !!userRecord.is_admin, @@ -579,7 +595,8 @@ router.get('/me', authenticateJWT, async (req: Request, res: Response) => { userId: user[0].id, username: user[0].username, is_admin: !!user[0].is_admin, - is_oidc: !!user[0].is_oidc + is_oidc: !!user[0].is_oidc, + totp_enabled: !!user[0].totp_enabled }); } catch (err) { logger.error('Failed to get username', err); @@ -929,6 +946,285 @@ router.post('/remove-admin', authenticateJWT, async (req, res) => { } }); +// Route: Verify TOTP during login +// POST /users/totp/verify-login +router.post('/totp/verify-login', async (req, res) => { + const {temp_token, totp_code} = req.body; + + if (!temp_token || !totp_code) { + return res.status(400).json({error: 'Token and TOTP code are required'}); + } + + const jwtSecret = process.env.JWT_SECRET || 'secret'; + + try { + const decoded = jwt.verify(temp_token, jwtSecret) as any; + if (!decoded.pending_totp) { + return res.status(401).json({error: 'Invalid temporary token'}); + } + + const user = await db.select().from(users).where(eq(users.id, decoded.userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled || !userRecord.totp_secret) { + return res.status(400).json({error: 'TOTP not enabled for this user'}); + } + + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + 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)}) + .where(eq(users.id, userRecord.id)); + } + + const token = jwt.sign({userId: userRecord.id}, jwtSecret, { + expiresIn: '50d', + }); + + return res.json({ + token, + is_admin: !!userRecord.is_admin, + username: userRecord.username + }); + + } catch (err) { + logger.error('TOTP verification failed', err); + return res.status(500).json({error: 'TOTP verification failed'}); + } +}); + +// Route: Setup TOTP +// 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) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is already enabled'}); + } + + const secret = speakeasy.generateSecret({ + name: `Termix (${userRecord.username})`, + length: 32 + }); + + await db.update(users) + .set({totp_secret: secret.base32}) + .where(eq(users.id, userId)); + + const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url || ''); + + res.json({ + secret: secret.base32, + qr_code: qrCodeUrl + }); + + } catch (err) { + logger.error('Failed to setup TOTP', err); + res.status(500).json({error: 'Failed to setup TOTP'}); + } +}); + +// Route: Enable TOTP +// POST /users/totp/enable +router.post('/totp/enable', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {totp_code} = req.body; + + if (!totp_code) { + return res.status(400).json({error: 'TOTP code is required'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is already enabled'}); + } + + if (!userRecord.totp_secret) { + return res.status(400).json({error: 'TOTP setup not initiated'}); + } + + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + if (!verified) { + return res.status(401).json({error: 'Invalid TOTP code'}); + } + + const backupCodes = Array.from({length: 8}, () => + Math.random().toString(36).substring(2, 10).toUpperCase() + ); + + await db.update(users) + .set({ + totp_enabled: true, + totp_backup_codes: JSON.stringify(backupCodes) + }) + .where(eq(users.id, userId)); + + res.json({ + message: 'TOTP enabled successfully', + backup_codes: backupCodes + }); + + } catch (err) { + logger.error('Failed to enable TOTP', err); + res.status(500).json({error: 'Failed to enable TOTP'}); + } +}); + +// Route: Disable TOTP +// POST /users/totp/disable +router.post('/totp/disable', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {password, totp_code} = req.body; + + if (!password && !totp_code) { + return res.status(400).json({error: 'Password or TOTP code is required'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is not enabled'}); + } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({error: 'Incorrect password'}); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + if (!verified) { + return res.status(401).json({error: 'Invalid TOTP code'}); + } + } else { + return res.status(400).json({error: 'Authentication required'}); + } + + await db.update(users) + .set({ + totp_enabled: false, + totp_secret: null, + totp_backup_codes: null + }) + .where(eq(users.id, userId)); + + res.json({message: 'TOTP disabled successfully'}); + + } catch (err) { + logger.error('Failed to disable TOTP', err); + res.status(500).json({error: 'Failed to disable TOTP'}); + } +}); + +// Route: Generate new backup codes +// POST /users/totp/backup-codes +router.post('/totp/backup-codes', authenticateJWT, async (req, res) => { + const userId = (req as any).userId; + const {password, totp_code} = req.body; + + if (!password && !totp_code) { + return res.status(400).json({error: 'Password or TOTP code is required'}); + } + + try { + const user = await db.select().from(users).where(eq(users.id, userId)); + if (!user || user.length === 0) { + return res.status(404).json({error: 'User not found'}); + } + + const userRecord = user[0]; + + if (!userRecord.totp_enabled) { + return res.status(400).json({error: 'TOTP is not enabled'}); + } + + if (password && !userRecord.is_oidc) { + const isMatch = await bcrypt.compare(password, userRecord.password_hash); + if (!isMatch) { + return res.status(401).json({error: 'Incorrect password'}); + } + } else if (totp_code) { + const verified = speakeasy.totp.verify({ + secret: userRecord.totp_secret!, + encoding: 'base32', + token: totp_code, + window: 2 + }); + + if (!verified) { + return res.status(401).json({error: 'Invalid TOTP code'}); + } + } else { + return res.status(400).json({error: 'Authentication required'}); + } + + const backupCodes = Array.from({length: 8}, () => + Math.random().toString(36).substring(2, 10).toUpperCase() + ); + + await db.update(users) + .set({totp_backup_codes: JSON.stringify(backupCodes)}) + .where(eq(users.id, userId)); + + res.json({backup_codes: backupCodes}); + + } catch (err) { + logger.error('Failed to generate backup codes', err); + res.status(500).json({error: 'Failed to generate backup codes'}); + } +}); + // Route: Delete user (admin only) // DELETE /users/delete-user router.delete('/delete-user', authenticateJWT, async (req, res) => { diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 8fb6c050..8823fd56 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -370,7 +370,9 @@ async function pollStatusesOnce(): Promise { const checks = hosts.map(async (h) => { const isOnline = await tcpPing(h.ip, h.port, 5000); - hostStatuses.set(h.id, {status: isOnline ? 'online' : 'offline', lastChecked: now}); + const now = new Date().toISOString(); + const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; + hostStatuses.set(h.id, statusEntry); return isOnline; }); @@ -396,15 +398,22 @@ app.get('/status/:id', async (req, res) => { return res.status(400).json({error: 'Invalid id'}); } - if (!hostStatuses.has(id)) { - await pollStatusesOnce(); + try { + const host = await fetchHostById(id); + if (!host) { + return res.status(404).json({error: 'Host not found'}); + } + + const isOnline = await tcpPing(host.ip, host.port, 5000); + const now = new Date().toISOString(); + const statusEntry: StatusEntry = {status: isOnline ? 'online' : 'offline', lastChecked: now}; + + hostStatuses.set(id, statusEntry); + res.json(statusEntry); + } catch (err) { + logger.error('Failed to check host status', err); + res.status(500).json({error: 'Failed to check host status'}); } - - const entry = hostStatuses.get(id); - if (!entry) { - return res.status(404).json({error: 'Host not found'}); - } - res.json(entry); }); app.post('/refresh', async (req, res) => { diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 7b23cac0..a77a7e90 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -137,7 +137,7 @@ wss.on('connection', (ws: WebSocket) => { ws.send(JSON.stringify({type: 'error', message: 'SSH connection timeout'})); cleanupSSH(connectionTimeout); } - }, 15000); + }, 60000); sshConn.on('ready', () => { clearTimeout(connectionTimeout); @@ -217,7 +217,7 @@ wss.on('connection', (ws: WebSocket) => { username, keepaliveInterval: 30000, keepaliveCountMax: 3, - readyTimeout: 10000, + readyTimeout: 60000, tcpKeepAlive: true, tcpKeepAliveInitialDelay: 30000, diff --git a/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx b/src/ui/Apps/File Manager/FIleManagerTopNavbar.tsx similarity index 100% rename from src/ui/apps/File Manager/FIleManagerTopNavbar.tsx rename to src/ui/Apps/File Manager/FIleManagerTopNavbar.tsx diff --git a/src/ui/apps/File Manager/FileManager.tsx b/src/ui/Apps/File Manager/FileManager.tsx similarity index 98% rename from src/ui/apps/File Manager/FileManager.tsx rename to src/ui/Apps/File Manager/FileManager.tsx index ddb397b9..ac2edd2d 100644 --- a/src/ui/apps/File Manager/FileManager.tsx +++ b/src/ui/Apps/File Manager/FileManager.tsx @@ -1,11 +1,11 @@ import React, {useState, useEffect, useRef} from "react"; -import {FileManagerLeftSidebar} from "@/ui/apps/File Manager/FileManagerLeftSidebar.tsx"; -import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx"; -import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx"; -import {FileManagerFileEditor} from "@/ui/apps/File Manager/FileManagerFileEditor.tsx"; -import {FileManagerOperations} from "@/ui/apps/File Manager/FileManagerOperations.tsx"; +import {FileManagerLeftSidebar} from "@/ui/Apps/File Manager/FileManagerLeftSidebar.tsx"; +import {FileManagerTabList} from "@/ui/Apps/File Manager/FileManagerTabList.tsx"; +import {FileManagerHomeView} from "@/ui/Apps/File Manager/FileManagerHomeView.tsx"; +import {FileManagerFileEditor} from "@/ui/Apps/File Manager/FileManagerFileEditor.tsx"; +import {FileManagerOperations} from "@/ui/Apps/File Manager/FileManagerOperations.tsx"; import {Button} from '@/components/ui/button.tsx'; -import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx"; +import {FIleManagerTopNavbar} from "@/ui/Apps/File Manager/FIleManagerTopNavbar.tsx"; import {cn} from '@/lib/utils.ts'; import {Save, RefreshCw, Settings, Trash2} from 'lucide-react'; import {Separator} from '@/components/ui/separator.tsx'; diff --git a/src/ui/apps/File Manager/FileManagerFileEditor.tsx b/src/ui/Apps/File Manager/FileManagerFileEditor.tsx similarity index 100% rename from src/ui/apps/File Manager/FileManagerFileEditor.tsx rename to src/ui/Apps/File Manager/FileManagerFileEditor.tsx diff --git a/src/ui/apps/File Manager/FileManagerHomeView.tsx b/src/ui/Apps/File Manager/FileManagerHomeView.tsx similarity index 100% rename from src/ui/apps/File Manager/FileManagerHomeView.tsx rename to src/ui/Apps/File Manager/FileManagerHomeView.tsx diff --git a/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx b/src/ui/Apps/File Manager/FileManagerLeftSidebar.tsx similarity index 100% rename from src/ui/apps/File Manager/FileManagerLeftSidebar.tsx rename to src/ui/Apps/File Manager/FileManagerLeftSidebar.tsx diff --git a/src/ui/apps/File Manager/FileManagerLeftSidebarFileViewer.tsx b/src/ui/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx similarity index 100% rename from src/ui/apps/File Manager/FileManagerLeftSidebarFileViewer.tsx rename to src/ui/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx diff --git a/src/ui/apps/File Manager/FileManagerOperations.tsx b/src/ui/Apps/File Manager/FileManagerOperations.tsx similarity index 100% rename from src/ui/apps/File Manager/FileManagerOperations.tsx rename to src/ui/Apps/File Manager/FileManagerOperations.tsx diff --git a/src/ui/apps/File Manager/FileManagerTabList.tsx b/src/ui/Apps/File Manager/FileManagerTabList.tsx similarity index 100% rename from src/ui/apps/File Manager/FileManagerTabList.tsx rename to src/ui/Apps/File Manager/FileManagerTabList.tsx diff --git a/src/ui/apps/Host Manager/HostManager.tsx b/src/ui/Apps/Host Manager/HostManager.tsx similarity index 96% rename from src/ui/apps/Host Manager/HostManager.tsx rename to src/ui/Apps/Host Manager/HostManager.tsx index c372d5c8..22a12b76 100644 --- a/src/ui/apps/Host Manager/HostManager.tsx +++ b/src/ui/Apps/Host Manager/HostManager.tsx @@ -1,8 +1,8 @@ import React, {useState} from "react"; -import {HostManagerHostViewer} from "@/ui/apps/Host Manager/HostManagerHostViewer.tsx" +import {HostManagerHostViewer} from "@/ui/Apps/Host Manager/HostManagerHostViewer.tsx" 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 {HostManagerHostEditor} from "@/ui/Apps/Host Manager/HostManagerHostEditor.tsx"; import {useSidebar} from "@/components/ui/sidebar.tsx"; interface HostManagerProps { diff --git a/src/ui/apps/Host Manager/HostManagerHostEditor.tsx b/src/ui/Apps/Host Manager/HostManagerHostEditor.tsx similarity index 100% rename from src/ui/apps/Host Manager/HostManagerHostEditor.tsx rename to src/ui/Apps/Host Manager/HostManagerHostEditor.tsx diff --git a/src/ui/apps/Host Manager/HostManagerHostViewer.tsx b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx similarity index 59% rename from src/ui/apps/Host Manager/HostManagerHostViewer.tsx rename to src/ui/Apps/Host Manager/HostManagerHostViewer.tsx index aa4e65ca..476eb895 100644 --- a/src/ui/apps/Host Manager/HostManagerHostViewer.tsx +++ b/src/ui/Apps/Host Manager/HostManagerHostViewer.tsx @@ -325,257 +325,7 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) { variant="outline" size="sm" onClick={() => { - const infoContent = ` -JSON Import Format Guide - -REQUIRED FIELDS: -• ip: Host IP address (string) -• port: SSH port (number, 1-65535) -• username: SSH username (string) -• authType: "password" or "key" - -AUTHENTICATION FIELDS: -• password: Required if authType is "password" -• key: SSH private key content (string) if authType is "key" -• keyPassword: Optional key passphrase -• keyType: Key type (auto, ssh-rsa, ssh-ed25519, etc.) - -OPTIONAL FIELDS: -• name: Display name (string) -• folder: Organization folder (string) -• tags: Array of tag strings -• pin: Pin to top (boolean) -• enableTerminal: Show in Terminal tab (boolean, default: true) -• enableTunnel: Show in Tunnel tab (boolean, default: true) -• enableFileManager: Show in File Manager tab (boolean, default: true) -• defaultPath: Default directory path (string) - -TUNNEL CONFIGURATION: -• tunnelConnections: Array of tunnel objects - - sourcePort: Local port (number) - - endpointPort: Remote port (number) - - endpointHost: Target host name (string) - - maxRetries: Retry attempts (number, default: 3) - - retryInterval: Retry delay in seconds (number, default: 10) - - autoStart: Auto-start on launch (boolean, default: false) - -EXAMPLE STRUCTURE: -{ - "hosts": [ - { - "name": "Web Server", - "ip": "192.168.1.100", - "port": 22, - "username": "admin", - "authType": "password", - "password": "your_password", - "folder": "Production", - "tags": ["web", "production"], - "pin": true, - "enableTerminal": true, - "enableTunnel": false, - "enableFileManager": true, - "defaultPath": "/var/www" - } - ] -} - -• Maximum 100 hosts per import -• File should contain a "hosts" array or be an array of host objects -• All fields are copyable for easy reference - `; - - const newWindow = window.open('', '_blank', 'width=600,height=800,scrollbars=yes,resizable=yes'); - if (newWindow) { - newWindow.document.write(` - - - - SSH JSON Import Guide - - - -

SSH JSON Import Format Guide

-

Use this guide to create JSON files for bulk importing SSH hosts. All examples are copyable.

- -

Required Fields

-
-
- ip - Host IP address (string) - -
-
- port - SSH port (number, 1-65535) - -
-
- username - SSH username (string) - -
-
- authType - "password" or "key" - -
-
- -

Authentication Fields

-
-
- password - Required if authType is "password" - -
-
- key - SSH private key content (string) if authType is "key" - -
-
- keyPassword - Optional key passphrase - -
-
- keyType - Key type (auto, ssh-rsa, ssh-ed25519, etc.) - -
-
- -

Optional Fields

-
-
- name - Display name (string) - -
-
- folder - Organization folder (string) - -
-
- tags - Array of tag strings - -
-
- pin - Pin to top (boolean) - -
-
- enableTerminal - Show in Terminal tab (boolean, default: true) - -
-
- enableTunnel - Show in Tunnel tab (boolean, default: true) - -
-
- enableFileManager - Show in File Manager tab (boolean, default: true) - -
-
- defaultPath - Default directory path (string) - -
-
- -

Tunnel Configuration

-
-
- tunnelConnections - Array of tunnel objects - -
-
-
- sourcePort - Local port (number) - -
-
- endpointPort - Remote port (number) - -
-
- endpointHost - Target host name (string) - -
-
- maxRetries - Retry attempts (number, default: 3) - -
-
- retryInterval - Retry delay in seconds (number, default: 10) - -
-
- autoStart - Auto-start on launch (boolean, default: false) - -
-
-
- -

Example JSON Structure

-
{
-  "hosts": [
-    {
-      "name": "Web Server",
-      "ip": "192.168.1.100",
-      "port": 22,
-      "username": "admin",
-      "authType": "password",
-      "password": "your_password",
-      "folder": "Production",
-      "tags": ["web", "production"],
-      "pin": true,
-      "enableTerminal": true,
-      "enableTunnel": false,
-      "enableFileManager": true,
-      "defaultPath": "/var/www"
-    }
-  ]
-}
- -

Important Notes

-
    -
  • Maximum 100 hosts per import
  • -
  • File should contain a "hosts" array or be an array of host objects
  • -
  • All fields are copyable for easy reference
  • -
  • Use the Download Sample button to get a complete example file
  • -
- - - `); - newWindow.document.close(); - } + window.open('https://docs.termix.site/json-import', '_blank'); }} > Format Guide diff --git a/src/ui/apps/Server/Server.tsx b/src/ui/Apps/Server/Server.tsx similarity index 97% rename from src/ui/apps/Server/Server.tsx rename to src/ui/Apps/Server/Server.tsx index 1b5c2f65..c97b0346 100644 --- a/src/ui/apps/Server/Server.tsx +++ b/src/ui/Apps/Server/Server.tsx @@ -5,8 +5,8 @@ import {Separator} from "@/components/ui/separator.tsx"; import {Button} from "@/components/ui/button.tsx"; import {Progress} from "@/components/ui/progress" import {Cpu, HardDrive, MemoryStick} from "lucide-react"; -import {Tunnel} from "@/ui/apps/Tunnel/Tunnel.tsx"; -import {getServerStatusById, getServerMetricsById, ServerMetrics} from "@/ui/main-axios.ts"; +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"; interface ServerProps { @@ -97,13 +97,12 @@ export function Server({ if (currentHostConfig?.id && isVisible) { fetchStatus(); fetchMetrics(); - // Only poll when component is visible to reduce unnecessary connections intervalId = window.setInterval(() => { if (isVisible) { fetchStatus(); fetchMetrics(); } - }, 300_000); // 5 minutes instead of 10 seconds + }, 30000); } return () => { @@ -116,7 +115,6 @@ export function Server({ const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8; const bottomMarginPx = 8; - // Check if a file manager tab for this host is already open const isFileManagerAlreadyOpen = React.useMemo(() => { if (!currentHostConfig) return false; return tabs.some((tab: any) => @@ -172,7 +170,7 @@ export function Server({ }} title="Refresh status and metrics" > - Refresh + Refresh Status {currentHostConfig?.enableFileManager && ( + + + + )} + + {(!internalLoggedIn && (!authLoading || !getCookie("jwt")) && !totpRequired) && ( <>
+
+ + )} + + {resetStep === "verify" && ( + <> +
+

Enter the 6-digit code from the docker container logs for + user: {userInfo.username}

+
+
+
+ + setResetCode(e.target.value.replace(/\D/g, ''))} + disabled={resetLoading} + placeholder="000000" + /> +
+ + +
+ + )} + + {resetSuccess && ( + <> + + Success! + + Your password has been successfully reset! You can now log in + with your new password. + + + + )} + + {resetStep === "newPassword" && !resetSuccess && ( + <> +
+

Enter your new password for + user: {userInfo.username}

+
+
+
+ + setNewPassword(e.target.value)} + disabled={resetLoading} + autoComplete="new-password" + /> +
+
+ + setConfirmPassword(e.target.value)} + disabled={resetLoading} + autoComplete="new-password" + /> +
+ + +
+ + )} + {error && ( + + Error + {error} + + )} + + + + ) +} \ No newline at end of file diff --git a/src/ui/User/TOTPSetup.tsx b/src/ui/User/TOTPSetup.tsx new file mode 100644 index 00000000..4bbd00d8 --- /dev/null +++ b/src/ui/User/TOTPSetup.tsx @@ -0,0 +1,437 @@ +import React, { useState } from "react"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { Input } from "@/components/ui/input.tsx"; +import { Label } from "@/components/ui/label.tsx"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs.tsx"; +import { Shield, Copy, Download, AlertCircle, CheckCircle2 } from "lucide-react"; +import { setupTOTP, enableTOTP, disableTOTP, generateBackupCodes } from "@/ui/main-axios.ts"; +import { toast } from "sonner"; + +interface TOTPSetupProps { + isEnabled: boolean; + onStatusChange?: (enabled: boolean) => void; +} + +export function TOTPSetup({ isEnabled: initialEnabled, onStatusChange }: TOTPSetupProps) { + const [isEnabled, setIsEnabled] = useState(initialEnabled); + const [isSettingUp, setIsSettingUp] = useState(false); + const [setupStep, setSetupStep] = useState<"init" | "qr" | "verify" | "backup">("init"); + const [qrCode, setQrCode] = useState(""); + const [secret, setSecret] = useState(""); + const [verificationCode, setVerificationCode] = useState(""); + const [backupCodes, setBackupCodes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [password, setPassword] = useState(""); + const [disableCode, setDisableCode] = useState(""); + + const handleSetupStart = async () => { + setError(null); + setLoading(true); + try { + const response = await setupTOTP(); + setQrCode(response.qr_code); + setSecret(response.secret); + setSetupStep("qr"); + setIsSettingUp(true); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to start TOTP setup"); + } finally { + setLoading(false); + } + }; + + const handleVerifyCode = async () => { + if (verificationCode.length !== 6) { + setError("Please enter a 6-digit code"); + return; + } + + setError(null); + setLoading(true); + try { + const response = await enableTOTP(verificationCode); + setBackupCodes(response.backup_codes); + setSetupStep("backup"); + toast.success("Two-factor authentication enabled successfully!"); + } catch (err: any) { + setError(err?.response?.data?.error || "Invalid verification code"); + } finally { + setLoading(false); + } + }; + + const handleDisable = async () => { + setError(null); + setLoading(true); + try { + await disableTOTP(password || undefined, disableCode || undefined); + setIsEnabled(false); + setIsSettingUp(false); + setSetupStep("init"); + setPassword(""); + setDisableCode(""); + onStatusChange?.(false); + toast.success("Two-factor authentication disabled"); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to disable TOTP"); + } finally { + setLoading(false); + } + }; + + const handleGenerateNewBackupCodes = async () => { + setError(null); + setLoading(true); + try { + const response = await generateBackupCodes(password || undefined, disableCode || undefined); + setBackupCodes(response.backup_codes); + toast.success("New backup codes generated"); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to generate backup codes"); + } finally { + setLoading(false); + } + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + toast.success(`${label} copied to clipboard`); + }; + + const downloadBackupCodes = () => { + const content = `Termix Two-Factor Authentication Backup Codes\n` + + `Generated: ${new Date().toISOString()}\n\n` + + `Keep these codes in a safe place. Each code can only be used once.\n\n` + + backupCodes.map((code, i) => `${i + 1}. ${code}`).join('\n'); + + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'termix-backup-codes.txt'; + a.click(); + URL.revokeObjectURL(url); + toast.success("Backup codes downloaded"); + }; + + const handleComplete = () => { + setIsEnabled(true); + setIsSettingUp(false); + setSetupStep("init"); + setVerificationCode(""); + onStatusChange?.(true); + }; + + if (isEnabled && !isSettingUp) { + return ( + + + + + Two-Factor Authentication + + + Your account is protected with two-factor authentication + + + + + + Enabled + + Two-factor authentication is currently active on your account + + + + + + Disable 2FA + Backup Codes + + + + + + Warning + + Disabling two-factor authentication will make your account less secure + + + +
+ + setPassword(e.target.value)} + /> +

Or

+ setDisableCode(e.target.value.replace(/\D/g, ''))} + /> +
+ + +
+ + +

+ Generate new backup codes if you've lost your existing ones +

+ +
+ + setPassword(e.target.value)} + /> +

Or

+ setDisableCode(e.target.value.replace(/\D/g, ''))} + /> +
+ + + + {backupCodes.length > 0 && ( +
+
+ + +
+
+ {backupCodes.map((code, i) => ( +
{code}
+ ))} +
+
+ )} +
+
+ + {error && ( + + + Error + {error} + + )} +
+
+ ); + } + + if (setupStep === "qr") { + return ( + + + Set Up Two-Factor Authentication + + Step 1: Scan the QR code with your authenticator app + + + +
+ TOTP QR Code +
+ +
+ +
+ + +
+

+ If you can't scan the QR code, enter this code manually in your authenticator app +

+
+ + +
+
+ ); + } + + if (setupStep === "verify") { + return ( + + + Verify Your Authenticator + + Step 2: Enter the 6-digit code from your authenticator app + + + +
+ + setVerificationCode(e.target.value.replace(/\D/g, ''))} + className="text-center text-2xl tracking-widest font-mono" + /> +
+ + {error && ( + + + Error + {error} + + )} + +
+ + +
+
+
+ ); + } + + if (setupStep === "backup") { + return ( + + + Save Your Backup Codes + + Step 3: Store these codes in a safe place + + + + + + Important + + Save these backup codes in a secure location. You can use them to access your account if you lose your authenticator device. + + + +
+
+ + +
+
+ {backupCodes.map((code, i) => ( +
+ {i + 1}. + {code} +
+ ))} +
+
+ + +
+
+ ); + } + + return ( + + + + + Two-Factor Authentication + + + Add an extra layer of security to your account + + + + + + Not Enabled + + Two-factor authentication adds an extra layer of security by requiring a code from your authenticator app when signing in. + + + + + + {error && ( + + + Error + {error} + + )} + + + ); +} \ No newline at end of file diff --git a/src/ui/User/UserProfile.tsx b/src/ui/User/UserProfile.tsx new file mode 100644 index 00000000..9a4bccc0 --- /dev/null +++ b/src/ui/User/UserProfile.tsx @@ -0,0 +1,166 @@ +import React, {useState, useEffect} from "react"; +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card.tsx"; +import {Button} from "@/components/ui/button.tsx"; +import {Input} from "@/components/ui/input.tsx"; +import {Label} from "@/components/ui/label.tsx"; +import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx"; +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx"; +import {User, Shield, Key, AlertCircle} from "lucide-react"; +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"; + +interface UserProfileProps { + isTopbarOpen?: boolean; +} + +export function UserProfile({isTopbarOpen = true}: UserProfileProps) { + const [userInfo, setUserInfo] = useState<{ + username: string; + is_admin: boolean; + is_oidc: boolean; + totp_enabled: boolean; + } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetchUserInfo(); + }, []); + + const fetchUserInfo = async () => { + setLoading(true); + setError(null); + try { + const info = await getUserInfo(); + setUserInfo({ + username: info.username, + is_admin: info.is_admin, + is_oidc: info.is_oidc, + totp_enabled: info.totp_enabled || false + }); + } catch (err: any) { + setError(err?.response?.data?.error || "Failed to load user information"); + } finally { + setLoading(false); + } + }; + + const handleTOTPStatusChange = (enabled: boolean) => { + if (userInfo) { + setUserInfo({...userInfo, totp_enabled: enabled}); + } + }; + + if (loading) { + return ( +
+ + +
Loading user profile...
+
+
+
+ ); + } + + if (error || !userInfo) { + return ( +
+ + + Error + {error || "Failed to load user profile"} + +
+ ); + } + + return ( +
+
+

User Profile

+

Manage your account settings and security

+
+ + + + + + Profile + + {!userInfo.is_oidc && ( + + + Security + + )} + + + + + + Account Information + Your account details and settings + + +
+
+ +

{userInfo.username}

+
+
+ +

+ {userInfo.is_admin ? "Administrator" : "User"} +

+
+
+ +

+ {userInfo.is_oidc ? "External (OIDC)" : "Local"} +

+
+
+ +

+ {userInfo.is_oidc ? ( + Locked (OIDC Auth) + ) : ( + userInfo.totp_enabled ? ( + + + Enabled + + ) : ( + Disabled + ) + )} +

+
+
+
+
+
+ + + + + {!userInfo.is_oidc && ( + + )} + +
+
+ ); +} \ No newline at end of file diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index fc3e3744..99627071 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -892,6 +892,56 @@ export async function updateOIDCConfig(config: any): Promise { // ALERTS // ============================================================================ +export async function setupTOTP(): Promise<{ secret: string; qr_code: string }> { + try { + const response = await authApi.post('/users/totp/setup'); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function enableTOTP(totp_code: string): Promise<{ message: string; backup_codes: string[] }> { + try { + const response = await authApi.post('/users/totp/enable', { totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function disableTOTP(password?: string, totp_code?: string): Promise<{ message: string }> { + try { + const response = await authApi.post('/users/totp/disable', { password, totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function verifyTOTPLogin(temp_token: string, totp_code: string): Promise { + try { + const response = await authApi.post('/users/totp/verify-login', { temp_token, totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + +export async function generateBackupCodes(password?: string, totp_code?: string): Promise<{ backup_codes: string[] }> { + try { + const response = await authApi.post('/users/totp/backup-codes', { password, totp_code }); + return response.data; + } catch (error) { + handleApiError(error as AxiosError); + throw error; + } +} + export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> { try { const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');