diff --git a/src/backend/dashboard.ts b/src/backend/dashboard.ts index 86978dbf..b4f087bf 100644 --- a/src/backend/dashboard.ts +++ b/src/backend/dashboard.ts @@ -58,6 +58,31 @@ app.use(express.json({ limit: "1mb" })); app.use(authManager.createAuthMiddleware()); +/** + * @openapi + * /uptime: + * get: + * summary: Get server uptime + * description: Returns the uptime of the server in various formats. + * tags: + * - Dashboard + * responses: + * 200: + * description: Server uptime information. + * content: + * application/json: + * schema: + * type: object + * properties: + * uptimeMs: + * type: number + * uptimeSeconds: + * type: number + * formatted: + * type: string + * 500: + * description: Failed to get uptime. + */ app.get("/uptime", async (req, res) => { try { const uptimeMs = Date.now() - serverStartTime; @@ -77,6 +102,28 @@ app.get("/uptime", async (req, res) => { } }); +/** + * @openapi + * /activity/recent: + * get: + * summary: Get recent activity + * description: Fetches the most recent activities for the authenticated user. + * tags: + * - Dashboard + * parameters: + * - in: query + * name: limit + * schema: + * type: integer + * description: The maximum number of activities to return. + * responses: + * 200: + * description: A list of recent activities. + * 401: + * description: Session expired. + * 500: + * description: Failed to get recent activity. + */ app.get("/activity/recent", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; @@ -108,6 +155,40 @@ app.get("/activity/recent", async (req, res) => { } }); +/** + * @openapi + * /activity/log: + * post: + * summary: Log a new activity + * description: Logs a new user activity, such as accessing a terminal or file manager. This endpoint is rate-limited. + * tags: + * - Dashboard + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * type: + * type: string + * enum: [terminal, file_manager, server_stats, tunnel, docker] + * hostId: + * type: integer + * hostName: + * type: string + * responses: + * 200: + * description: Activity logged successfully or rate-limited. + * 400: + * description: Invalid request body. + * 401: + * description: Session expired. + * 404: + * description: Host not found or access denied. + * 500: + * description: Failed to log activity. + */ app.post("/activity/log", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; @@ -224,6 +305,22 @@ app.post("/activity/log", async (req, res) => { } }); +/** + * @openapi + * /activity/reset: + * delete: + * summary: Reset recent activity + * description: Clears all recent activity for the authenticated user. + * tags: + * - Dashboard + * responses: + * 200: + * description: Recent activity cleared. + * 401: + * description: Session expired. + * 500: + * description: Failed to reset activity. + */ app.delete("/activity/reset", async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 220919c3..e4d25af6 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -206,10 +206,46 @@ app.use(bodyParser.urlencoded({ limit: "1gb", extended: true })); app.use(bodyParser.raw({ limit: "5gb", type: "application/octet-stream" })); app.use(cookieParser()); +/** + * @openapi + * /health: + * get: + * summary: Health check + * description: Returns the health status of the server. + * tags: + * - General + * responses: + * 200: + * description: Server is healthy. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: ok + */ app.get("/health", (req, res) => { res.json({ status: "ok" }); }); +/** + * @openapi + * /version: + * get: + * summary: Get version information + * description: Returns the local and remote version of the application. + * tags: + * - General + * responses: + * 200: + * description: Version information. + * 404: + * description: Local version not set. + * 500: + * description: Fetch error. + */ app.get("/version", authenticateJWT, async (req, res) => { let localVersion = process.env.VERSION; @@ -308,6 +344,31 @@ app.get("/version", authenticateJWT, async (req, res) => { } }); +/** + * @openapi + * /releases/rss: + * get: + * summary: Get releases in RSS format + * description: Returns the latest releases from the GitHub repository in an RSS-like JSON format. + * tags: + * - General + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * description: The page number of the releases to fetch. + * - in: query + * name: per_page + * schema: + * type: integer + * description: The number of releases to fetch per page. + * responses: + * 200: + * description: Releases in RSS format. + * 500: + * description: Failed to generate RSS format. + */ app.get("/releases/rss", authenticateJWT, async (req, res) => { try { const page = parseInt(req.query.page as string) || 1; @@ -364,6 +425,20 @@ app.get("/releases/rss", authenticateJWT, async (req, res) => { } }); +/** + * @openapi + * /encryption/status: + * get: + * summary: Get encryption status + * description: Returns the security status of the application. + * tags: + * - Encryption + * responses: + * 200: + * description: Security status. + * 500: + * description: Failed to get security status. + */ app.get("/encryption/status", requireAdmin, async (req, res) => { try { const securityStatus = { @@ -385,6 +460,20 @@ app.get("/encryption/status", requireAdmin, async (req, res) => { } }); +/** + * @openapi + * /encryption/initialize: + * post: + * summary: Initialize security system + * description: Initializes the security system for the application. + * tags: + * - Encryption + * responses: + * 200: + * description: Security system initialized successfully. + * 500: + * description: Failed to initialize security system. + */ app.post("/encryption/initialize", requireAdmin, async (req, res) => { try { const authManager = AuthManager.getInstance(); @@ -408,6 +497,20 @@ app.post("/encryption/initialize", requireAdmin, async (req, res) => { } }); +/** + * @openapi + * /encryption/regenerate: + * post: + * summary: Regenerate JWT secret + * description: Regenerates the system JWT secret. This will invalidate all existing JWT tokens. + * tags: + * - Encryption + * responses: + * 200: + * description: System JWT secret regenerated. + * 500: + * description: Failed to regenerate JWT secret. + */ app.post("/encryption/regenerate", requireAdmin, async (req, res) => { try { apiLogger.warn("System JWT secret regenerated via API", { @@ -429,6 +532,20 @@ app.post("/encryption/regenerate", requireAdmin, async (req, res) => { } }); +/** + * @openapi + * /encryption/regenerate-jwt: + * post: + * summary: Regenerate JWT secret + * description: Regenerates the JWT secret. This will invalidate all existing JWT tokens. + * tags: + * - Encryption + * responses: + * 200: + * description: New JWT secret generated. + * 500: + * description: Failed to regenerate JWT secret. + */ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => { try { apiLogger.warn("JWT secret regenerated via API", { @@ -449,6 +566,33 @@ app.post("/encryption/regenerate-jwt", requireAdmin, async (req, res) => { } }); +/** + * @openapi + * /database/export: + * post: + * summary: Export user data + * description: Exports the user's data as a SQLite database file. + * tags: + * - Database + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * password: + * type: string + * responses: + * 200: + * description: User data exported successfully. + * 400: + * description: Password required for export. + * 401: + * description: Invalid password. + * 500: + * description: Failed to export user data. + */ app.post("/database/export", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; @@ -899,6 +1043,36 @@ app.post("/database/export", authenticateJWT, async (req, res) => { } }); +/** + * @openapi + * /database/import: + * post: + * summary: Import user data + * description: Imports user data from a SQLite database file. + * tags: + * - Database + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * password: + * type: string + * responses: + * 200: + * description: Incremental import completed successfully. + * 400: + * description: No file uploaded or password required for import. + * 401: + * description: Invalid password. + * 500: + * description: Failed to import SQLite data. + */ app.post( "/database/import", authenticateJWT, @@ -1363,6 +1537,31 @@ app.post( }, ); +/** + * @openapi + * /database/export/preview: + * post: + * summary: Preview user data export + * description: Generates a preview of the user data export, including statistics about the data. + * tags: + * - Database + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * scope: + * type: string + * includeCredentials: + * type: boolean + * responses: + * 200: + * description: Export preview generated successfully. + * 500: + * description: Failed to generate export preview. + */ app.post("/database/export/preview", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; @@ -1398,6 +1597,33 @@ app.post("/database/export/preview", authenticateJWT, async (req, res) => { } }); +/** + * @openapi + * /database/restore: + * post: + * summary: Restore database from backup + * description: Restores the database from an encrypted backup file. + * tags: + * - Database + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * backupPath: + * type: string + * targetPath: + * type: string + * responses: + * 200: + * description: Database restored successfully. + * 400: + * description: Backup path is required or invalid encrypted backup file. + * 500: + * description: Database restore failed. + */ app.post("/database/restore", requireAdmin, async (req, res) => { try { const { backupPath, targetPath } = req.body; @@ -1479,6 +1705,20 @@ async function initializeSecurity() { } } +/** + * @openapi + * /database/migration/status: + * get: + * summary: Get database migration status + * description: Returns the status of the database migration. + * tags: + * - Database + * responses: + * 200: + * description: Migration status. + * 500: + * description: Failed to get migration status. + */ app.get( "/database/migration/status", authenticateJWT, @@ -1532,6 +1772,20 @@ app.get( }, ); +/** + * @openapi + * /database/migration/history: + * get: + * summary: Get database migration history + * description: Returns the history of database migrations. + * tags: + * - Database + * responses: + * 200: + * description: Migration history. + * 500: + * description: Failed to get migration history. + */ app.get( "/database/migration/history", authenticateJWT, diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 463a2cb3..f38ad243 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -671,6 +671,13 @@ const migrateSchema = () => { `); } catch (createError) { databaseLogger.warn("Failed to create network_topology table", { + operation: "schema_migration", + error: createError, + }); + } + } + + try { sqlite.prepare("SELECT id FROM host_access LIMIT 1").get(); } catch { try { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index b3e0c327..70393d47 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -301,6 +301,14 @@ export const networkTopology = sqliteTable("network_topology", { .notNull() .references(() => users.id, { onDelete: "cascade" }), topology: text("topology"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + export const hostAccess = sqliteTable("host_access", { id: integer("id").primaryKey({ autoIncrement: true }), hostId: integer("host_id") diff --git a/src/backend/database/routes/alerts.ts b/src/backend/database/routes/alerts.ts index cf653109..16ada79f 100644 --- a/src/backend/database/routes/alerts.ts +++ b/src/backend/database/routes/alerts.ts @@ -99,8 +99,20 @@ const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); -// Route: Get alerts for the authenticated user (excluding dismissed ones) -// GET /alerts +/** + * @openapi + * /alerts: + * get: + * summary: Get active alerts + * description: Fetches active alerts for the authenticated user, excluding those that have been dismissed. + * tags: + * - Alerts + * responses: + * 200: + * description: A list of active alerts. + * 500: + * description: Failed to fetch alerts. + */ router.get("/", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; @@ -131,8 +143,33 @@ router.get("/", authenticateJWT, async (req, res) => { } }); -// Route: Dismiss an alert for the authenticated user -// POST /alerts/dismiss +/** + * @openapi + * /alerts/dismiss: + * post: + * summary: Dismiss an alert + * description: Marks an alert as dismissed for the authenticated user. + * tags: + * - Alerts + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * alertId: + * type: string + * responses: + * 200: + * description: Alert dismissed successfully. + * 400: + * description: Alert ID is required. + * 409: + * description: Alert already dismissed. + * 500: + * description: Failed to dismiss alert. + */ router.post("/dismiss", authenticateJWT, async (req, res) => { try { const { alertId } = req.body; @@ -170,8 +207,20 @@ router.post("/dismiss", authenticateJWT, async (req, res) => { } }); -// Route: Get dismissed alerts for a user -// GET /alerts/dismissed/:userId +/** + * @openapi + * /alerts/dismissed: + * get: + * summary: Get dismissed alerts + * description: Fetches a list of alerts that have been dismissed by the authenticated user. + * tags: + * - Alerts + * responses: + * 200: + * description: A list of dismissed alerts. + * 500: + * description: Failed to fetch dismissed alerts. + */ router.get("/dismissed", authenticateJWT, async (req, res) => { try { const userId = (req as AuthenticatedRequest).userId; @@ -194,8 +243,33 @@ router.get("/dismissed", authenticateJWT, async (req, res) => { } }); -// Route: Undismiss an alert for the authenticated user (remove from dismissed list) -// DELETE /alerts/dismiss +/** + * @openapi + * /alerts/dismiss: + * delete: + * summary: Undismiss an alert + * description: Removes an alert from the dismissed list for the authenticated user. + * tags: + * - Alerts + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * alertId: + * type: string + * responses: + * 200: + * description: Alert undismissed successfully. + * 400: + * description: Alert ID is required. + * 404: + * description: Dismissed alert not found. + * 500: + * description: Failed to undismiss alert. + */ router.delete("/dismiss", authenticateJWT, async (req, res) => { try { const { alertId } = req.body; diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 6d0b0ab5..e25eacc8 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -84,8 +84,52 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -// Create a new credential -// POST /credentials +/** + * @openapi + * /credentials: + * post: + * summary: Create a new credential + * description: Creates a new SSH credential for the authenticated user. + * tags: + * - Credentials + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * folder: + * type: string + * tags: + * type: array + * items: + * type: string + * authType: + * type: string + * enum: [password, key] + * username: + * type: string + * password: + * type: string + * key: + * type: string + * keyPassword: + * type: string + * keyType: + * type: string + * responses: + * 201: + * description: Credential created successfully. + * 400: + * description: Invalid request body. + * 500: + * description: Failed to create credential. + */ router.post( "/", authenticateJWT, @@ -231,8 +275,22 @@ router.post( }, ); -// Get all credentials for the authenticated user -// GET /credentials +/** + * @openapi + * /credentials: + * get: + * summary: Get all credentials + * description: Retrieves all SSH credentials for the authenticated user. + * tags: + * - Credentials + * responses: + * 200: + * description: A list of credentials. + * 400: + * description: Invalid userId. + * 500: + * description: Failed to fetch credentials. + */ router.get( "/", authenticateJWT, @@ -264,8 +322,22 @@ router.get( }, ); -// Get all unique credential folders for the authenticated user -// GET /credentials/folders +/** + * @openapi + * /credentials/folders: + * get: + * summary: Get credential folders + * description: Retrieves all unique credential folders for the authenticated user. + * tags: + * - Credentials + * responses: + * 200: + * description: A list of folder names. + * 400: + * description: Invalid userId. + * 500: + * description: Failed to fetch credential folders. + */ router.get( "/folders", authenticateJWT, @@ -302,8 +374,30 @@ router.get( }, ); -// Get a specific credential by ID (with plain text secrets) -// GET /credentials/:id +/** + * @openapi + * /credentials/{id}: + * get: + * summary: Get a specific credential + * description: Retrieves a specific credential by its ID, including secrets. + * tags: + * - Credentials + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: The requested credential. + * 400: + * description: Invalid request. + * 404: + * description: Credential not found. + * 500: + * description: Failed to fetch credential. + */ router.get( "/:id", authenticateJWT, @@ -366,8 +460,41 @@ router.get( }, ); -// Update a credential -// PUT /credentials/:id +/** + * @openapi + * /credentials/{id}: + * put: + * summary: Update a credential + * description: Updates a specific credential by its ID. + * tags: + * - Credentials + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: + * type: string + * responses: + * 200: + * description: The updated credential. + * 400: + * description: Invalid request. + * 404: + * description: Credential not found. + * 500: + * description: Failed to update credential. + */ router.put( "/:id", authenticateJWT, @@ -510,8 +637,30 @@ router.put( }, ); -// Delete a credential -// DELETE /credentials/:id +/** + * @openapi + * /credentials/{id}: + * delete: + * summary: Delete a credential + * description: Deletes a specific credential by its ID. + * tags: + * - Credentials + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Credential deleted successfully. + * 400: + * description: Invalid request. + * 404: + * description: Credential not found. + * 500: + * description: Failed to delete credential. + */ router.delete( "/:id", authenticateJWT, @@ -626,8 +775,35 @@ router.delete( }, ); -// Apply a credential to an SSH host (for quick application) -// POST /credentials/:id/apply-to-host/:hostId +/** + * @openapi + * /credentials/{id}/apply-to-host/{hostId}: + * post: + * summary: Apply a credential to a host + * description: Applies a credential to an SSH host for quick application. + * tags: + * - Credentials + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: path + * name: hostId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Credential applied to host successfully. + * 400: + * description: Invalid request. + * 404: + * description: Credential not found. + * 500: + * description: Failed to apply credential to host. + */ router.post( "/:id/apply-to-host/:hostId", authenticateJWT, @@ -705,8 +881,28 @@ router.post( }, ); -// Get hosts using a specific credential -// GET /credentials/:id/hosts +/** + * @openapi + * /credentials/{id}/hosts: + * get: + * summary: Get hosts using a credential + * description: Retrieves a list of hosts that are using a specific credential. + * tags: + * - Credentials + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: A list of hosts. + * 400: + * description: Invalid request. + * 500: + * description: Failed to fetch hosts using credential. + */ router.get( "/:id/hosts", authenticateJWT, @@ -800,8 +996,33 @@ function formatSSHHostOutput( }; } -// Rename a credential folder -// PUT /credentials/folders/rename +/** + * @openapi + * /credentials/folders/rename: + * put: + * summary: Rename a credential folder + * description: Renames a credential folder for the authenticated user. + * tags: + * - Credentials + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * oldName: + * type: string + * newName: + * type: string + * responses: + * 200: + * description: Folder renamed successfully. + * 400: + * description: Both oldName and newName are required. + * 500: + * description: Failed to rename folder. + */ router.put( "/folders/rename", authenticateJWT, @@ -840,8 +1061,33 @@ router.put( }, ); -// Detect SSH key type endpoint -// POST /credentials/detect-key-type +/** + * @openapi + * /credentials/detect-key-type: + * post: + * summary: Detect SSH key type + * description: Detects the type of an SSH private key. + * tags: + * - Credentials + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * privateKey: + * type: string + * keyPassword: + * type: string + * responses: + * 200: + * description: Key type detection result. + * 400: + * description: Private key is required. + * 500: + * description: Failed to detect key type. + */ router.post( "/detect-key-type", authenticateJWT, @@ -874,8 +1120,31 @@ router.post( }, ); -// Detect SSH public key type endpoint -// POST /credentials/detect-public-key-type +/** + * @openapi + * /credentials/detect-public-key-type: + * post: + * summary: Detect SSH public key type + * description: Detects the type of an SSH public key. + * tags: + * - Credentials + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * publicKey: + * type: string + * responses: + * 200: + * description: Key type detection result. + * 400: + * description: Public key is required. + * 500: + * description: Failed to detect public key type. + */ router.post( "/detect-public-key-type", authenticateJWT, @@ -909,8 +1178,35 @@ router.post( }, ); -// Validate SSH key pair endpoint -// POST /credentials/validate-key-pair +/** + * @openapi + * /credentials/validate-key-pair: + * post: + * summary: Validate SSH key pair + * description: Validates if a given SSH private key and public key match. + * tags: + * - Credentials + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * privateKey: + * type: string + * publicKey: + * type: string + * keyPassword: + * type: string + * responses: + * 200: + * description: Key pair validation result. + * 400: + * description: Private key and public key are required. + * 500: + * description: Failed to validate key pair. + */ router.post( "/validate-key-pair", authenticateJWT, @@ -953,8 +1249,32 @@ router.post( }, ); -// Generate new SSH key pair endpoint -// POST /credentials/generate-key-pair +/** + * @openapi + * /credentials/generate-key-pair: + * post: + * summary: Generate new SSH key pair + * description: Generates a new SSH key pair. + * tags: + * - Credentials + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * keyType: + * type: string + * keySize: + * type: integer + * passphrase: + * type: string + * responses: + * 200: + * description: The new key pair. + * 500: + * description: Failed to generate SSH key pair. + */ router.post( "/generate-key-pair", authenticateJWT, @@ -996,8 +1316,33 @@ router.post( }, ); -// Generate public key from private key endpoint -// POST /credentials/generate-public-key +/** + * @openapi + * /credentials/generate-public-key: + * post: + * summary: Generate public key from private key + * description: Generates a public key from a given private key. + * tags: + * - Credentials + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * privateKey: + * type: string + * keyPassword: + * type: string + * responses: + * 200: + * description: The generated public key. + * 400: + * description: Private key is required. + * 500: + * description: Failed to generate public key. + */ router.post( "/generate-public-key", authenticateJWT, @@ -1283,7 +1628,7 @@ async function deploySSHKeyToHost( .replace(/'/g, "'\\''"); conn.exec( - `printf '%s\\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, + `printf '%s\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`, (err, stream) => { if (err) { clearTimeout(addTimeout); @@ -1502,8 +1847,41 @@ async function deploySSHKeyToHost( }); } -// Deploy SSH Key to Host endpoint -// POST /credentials/:id/deploy-to-host +/** + * @openapi + * /credentials/{id}/deploy-to-host: + * post: + * summary: Deploy SSH key to a host + * description: Deploys an SSH public key to a target host's authorized_keys file. + * tags: + * - Credentials + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * targetHostId: + * type: integer + * responses: + * 200: + * description: SSH key deployed successfully. + * 400: + * description: Credential ID and target host ID are required. + * 401: + * description: Authentication required. + * 404: + * description: Credential or target host not found. + * 500: + * description: Failed to deploy SSH key. + */ router.post( "/:id/deploy-to-host", authenticateJWT, diff --git a/src/backend/database/routes/network-topology.ts b/src/backend/database/routes/network-topology.ts index 2522a259..96c72d2e 100644 --- a/src/backend/database/routes/network-topology.ts +++ b/src/backend/database/routes/network-topology.ts @@ -9,6 +9,22 @@ const router = express.Router(); const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); +/** + * @openapi + * /network-topology: + * get: + * summary: Get network topology + * description: Retrieves the network topology for the authenticated user. + * tags: + * - Network Topology + * responses: + * 200: + * description: The network topology. + * 401: + * description: User not authenticated. + * 500: + * description: Failed to fetch network topology. + */ router.get( "/", authenticateJWT, @@ -24,7 +40,7 @@ router.get( .select() .from(networkTopology) .where(eq(networkTopology.userId, userId)); - + if (result.length > 0) { const topologyStr = result[0].topology; const topology = topologyStr ? JSON.parse(topologyStr) : null; @@ -34,11 +50,43 @@ router.get( } } catch (error) { console.error("Error fetching network topology:", error); - return res.status(500).json({ error: "Failed to fetch network topology", details: (error as Error).message }); + return res + .status(500) + .json({ + error: "Failed to fetch network topology", + details: (error as Error).message, + }); } }, ); +/** + * @openapi + * /network-topology: + * post: + * summary: Save network topology + * description: Saves the network topology for the authenticated user. + * tags: + * - Network Topology + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * topology: + * type: object + * responses: + * 200: + * description: Network topology saved successfully. + * 400: + * description: Topology data is required. + * 401: + * description: User not authenticated. + * 500: + * description: Failed to save network topology. + */ router.post( "/", authenticateJWT, @@ -55,15 +103,16 @@ router.post( } const db = getDb(); - + // Ensure topology is a string - const topologyStr = typeof topology === 'string' ? topology : JSON.stringify(topology); - + const topologyStr = + typeof topology === "string" ? topology : JSON.stringify(topology); + const existing = await db .select() .from(networkTopology) .where(eq(networkTopology.userId, userId)); - + if (existing.length > 0) { // Update existing record await db @@ -76,11 +125,16 @@ router.post( .insert(networkTopology) .values({ userId, topology: topologyStr }); } - + return res.json({ success: true }); } catch (error) { console.error("Error saving network topology:", error); - return res.status(500).json({ error: "Failed to save network topology", details: (error as Error).message }); + return res + .status(500) + .json({ + error: "Failed to save network topology", + details: (error as Error).message, + }); } }, ); diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 6e6f0033..3655675b 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -27,8 +27,51 @@ function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; } -//Share a host with a user or role -//POST /rbac/host/:id/share +/** + * @openapi + * /rbac/host/{id}/share: + * post: + * summary: Share a host + * description: Shares a host with a user or a role. + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * targetType: + * type: string + * enum: [user, role] + * targetUserId: + * type: string + * targetRoleId: + * type: integer + * durationHours: + * type: number + * permissionLevel: + * type: string + * enum: [view] + * responses: + * 200: + * description: Host shared successfully. + * 400: + * description: Invalid request body. + * 403: + * description: Not host owner. + * 404: + * description: Target user or role not found. + * 500: + * description: Failed to share host. + */ router.post( "/host/:id/share", authenticateJWT, @@ -227,8 +270,35 @@ router.post( }, ); -// Revoke host access -// DELETE /rbac/host/:id/access/:accessId +/** + * @openapi + * /rbac/host/{id}/access/{accessId}: + * delete: + * summary: Revoke host access + * description: Revokes a user's or role's access to a host. + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * - in: path + * name: accessId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Access revoked successfully. + * 400: + * description: Invalid ID. + * 403: + * description: Not host owner. + * 500: + * description: Failed to revoke access. + */ router.delete( "/host/:id/access/:accessId", authenticateJWT, @@ -267,8 +337,30 @@ router.delete( }, ); -// Get host access list -// GET /rbac/host/:id/access +/** + * @openapi + * /rbac/host/{id}/access: + * get: + * summary: Get host access list + * description: Retrieves the list of users and roles that have access to a host. + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: The access list for the host. + * 400: + * description: Invalid host ID. + * 403: + * description: Not host owner. + * 500: + * description: Failed to get access list. + */ router.get( "/host/:id/access", authenticateJWT, @@ -338,8 +430,20 @@ router.get( }, ); -// Get user's shared hosts (hosts shared WITH this user) -// GET /rbac/shared-hosts +/** + * @openapi + * /rbac/shared-hosts: + * get: + * summary: Get shared hosts + * description: Retrieves the list of hosts that have been shared with the authenticated user. + * tags: + * - RBAC + * responses: + * 200: + * description: A list of shared hosts. + * 500: + * description: Failed to get shared hosts. + */ router.get( "/shared-hosts", authenticateJWT, @@ -385,8 +489,20 @@ router.get( }, ); -// Get all roles -// GET /rbac/roles +/** + * @openapi + * /rbac/roles: + * get: + * summary: Get all roles + * description: Retrieves a list of all roles. + * tags: + * - RBAC + * responses: + * 200: + * description: A list of roles. + * 500: + * description: Failed to get roles. + */ router.get( "/roles", authenticateJWT, @@ -413,8 +529,20 @@ router.get( }, ); -// Get all roles -// GET /rbac/roles +/** + * @openapi + * /rbac/roles: + * get: + * summary: Get all roles + * description: Retrieves a list of all roles. + * tags: + * - RBAC + * responses: + * 200: + * description: A list of roles. + * 500: + * description: Failed to get roles. + */ router.get( "/roles", authenticateJWT, @@ -443,8 +571,37 @@ router.get( }, ); -// Create new role -// POST /rbac/roles +/** + * @openapi + * /rbac/roles: + * post: + * summary: Create a new role + * description: Creates a new role. + * tags: + * - RBAC + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * displayName: + * type: string + * description: + * type: string + * responses: + * 201: + * description: Role created successfully. + * 400: + * description: Invalid request body. + * 409: + * description: A role with this name already exists. + * 500: + * description: Failed to create role. + */ router.post( "/roles", authenticateJWT, @@ -503,8 +660,41 @@ router.post( }, ); -// Update role -// PUT /rbac/roles/:id +/** + * @openapi + * /rbac/roles/{id}: + * put: + * summary: Update a role + * description: Updates a role by its ID. + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * displayName: + * type: string + * description: + * type: string + * responses: + * 200: + * description: Role updated successfully. + * 400: + * description: Invalid request body or role ID. + * 404: + * description: Role not found. + * 500: + * description: Failed to update role. + */ router.put( "/roles/:id", authenticateJWT, @@ -570,8 +760,32 @@ router.put( }, ); -// Delete role -// DELETE /rbac/roles/:id +/** + * @openapi + * /rbac/roles/{id}: + * delete: + * summary: Delete a role + * description: Deletes a role by its ID. + * tags: + * - RBAC + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Role deleted successfully. + * 400: + * description: Invalid role ID. + * 403: + * description: Cannot delete system roles. + * 404: + * description: Role not found. + * 500: + * description: Failed to delete role. + */ router.delete( "/roles/:id", authenticateJWT, @@ -634,8 +848,43 @@ router.delete( }, ); -// Assign role to user -// POST /rbac/users/:userId/roles +/** + * @openapi + * /rbac/users/{userId}/roles: + * post: + * summary: Assign a role to a user + * description: Assigns a role to a user. + * tags: + * - RBAC + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * roleId: + * type: integer + * responses: + * 200: + * description: Role assigned successfully. + * 400: + * description: Role ID is required. + * 403: + * description: System roles cannot be manually assigned. + * 404: + * description: User or role not found. + * 409: + * description: Role already assigned. + * 500: + * description: Failed to assign role. + */ router.post( "/users/:userId/roles", authenticateJWT, @@ -746,8 +995,37 @@ router.post( }, ); -// Remove role from user -// DELETE /rbac/users/:userId/roles/:roleId +/** + * @openapi + * /rbac/users/{userId}/roles/{roleId}: + * delete: + * summary: Remove a role from a user + * description: Removes a role from a user. + * tags: + * - RBAC + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * - in: path + * name: roleId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Role removed successfully. + * 400: + * description: Invalid role ID. + * 403: + * description: System roles cannot be removed. + * 404: + * description: Role not found. + * 500: + * description: Failed to remove role. + */ router.delete( "/users/:userId/roles/:roleId", authenticateJWT, @@ -805,8 +1083,28 @@ router.delete( }, ); -// Get user's roles -// GET /rbac/users/:userId/roles +/** + * @openapi + * /rbac/users/{userId}/roles: + * get: + * summary: Get user's roles + * description: Retrieves a list of roles for a specific user. + * tags: + * - RBAC + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: A list of roles. + * 403: + * description: Access denied. + * 500: + * description: Failed to get user roles. + */ router.get( "/users/:userId/roles", authenticateJWT, diff --git a/src/backend/database/routes/snippets.ts b/src/backend/database/routes/snippets.ts index 51001350..0b1cabf5 100644 --- a/src/backend/database/routes/snippets.ts +++ b/src/backend/database/routes/snippets.ts @@ -17,8 +17,22 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -// Get all snippet folders -// GET /snippets/folders +/** + * @openapi + * /snippets/folders: + * get: + * summary: Get all snippet folders + * description: Retrieves all snippet folders for the authenticated user. + * tags: + * - Snippets + * responses: + * 200: + * description: A list of snippet folders. + * 400: + * description: Invalid userId. + * 500: + * description: Failed to fetch snippet folders. + */ router.get( "/folders", authenticateJWT, @@ -46,8 +60,37 @@ router.get( }, ); -// Create a new snippet folder -// POST /snippets/folders +/** + * @openapi + * /snippets/folders: + * post: + * summary: Create a new snippet folder + * description: Creates a new snippet folder for the authenticated user. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * color: + * type: string + * icon: + * type: string + * responses: + * 201: + * description: Snippet folder created successfully. + * 400: + * description: Folder name is required. + * 409: + * description: Folder with this name already exists. + * 500: + * description: Failed to create snippet folder. + */ router.post( "/folders", authenticateJWT, @@ -110,8 +153,41 @@ router.post( }, ); -// Update snippet folder metadata (color, icon) -// PUT /snippets/folders/:name/metadata +/** + * @openapi + * /snippets/folders/{name}/metadata: + * put: + * summary: Update snippet folder metadata + * description: Updates the metadata (color, icon) of a snippet folder. + * tags: + * - Snippets + * parameters: + * - in: path + * name: name + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * color: + * type: string + * icon: + * type: string + * responses: + * 200: + * description: Snippet folder metadata updated successfully. + * 400: + * description: Invalid request. + * 404: + * description: Folder not found. + * 500: + * description: Failed to update snippet folder metadata. + */ router.put( "/folders/:name/metadata", authenticateJWT, @@ -194,8 +270,37 @@ router.put( }, ); -// Rename snippet folder -// PUT /snippets/folders/rename +/** + * @openapi + * /snippets/folders/rename: + * put: + * summary: Rename a snippet folder + * description: Renames a snippet folder for the authenticated user. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * oldName: + * type: string + * newName: + * type: string + * responses: + * 200: + * description: Folder renamed successfully. + * 400: + * description: Invalid request. + * 404: + * description: Folder not found. + * 409: + * description: Folder with new name already exists. + * 500: + * description: Failed to rename snippet folder. + */ router.put( "/folders/rename", authenticateJWT, @@ -282,8 +387,28 @@ router.put( }, ); -// Delete snippet folder -// DELETE /snippets/folders/:name +/** + * @openapi + * /snippets/folders/{name}: + * delete: + * summary: Delete a snippet folder + * description: Deletes a snippet folder and moves its snippets to the root. + * tags: + * - Snippets + * parameters: + * - in: path + * name: name + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Snippet folder deleted successfully. + * 400: + * description: Invalid request. + * 500: + * description: Failed to delete snippet folder. + */ router.delete( "/folders/:name", authenticateJWT, @@ -338,8 +463,40 @@ router.delete( }, ); -// Reorder snippets (bulk update) -// PUT /snippets/reorder +/** + * @openapi + * /snippets/reorder: + * put: + * summary: Reorder snippets + * description: Bulk updates the order and folder of snippets. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * snippets: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * order: + * type: integer + * folder: + * type: string + * responses: + * 200: + * description: Snippets reordered successfully. + * 400: + * description: Invalid request. + * 500: + * description: Failed to reorder snippets. + */ router.put( "/reorder", authenticateJWT, @@ -405,8 +562,35 @@ router.put( }, ); -// Execute a snippet on a host -// POST /snippets/execute +/** + * @openapi + * /snippets/execute: + * post: + * summary: Execute a snippet on a host + * description: Executes a snippet on a specified host. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * snippetId: + * type: integer + * hostId: + * type: integer + * responses: + * 200: + * description: Snippet executed successfully. + * 400: + * description: Snippet ID and Host ID are required. + * 404: + * description: Snippet or host not found. + * 500: + * description: Failed to execute snippet. + */ router.post( "/execute", authenticateJWT, @@ -662,8 +846,22 @@ router.post( }, ); -// Get all snippets for the authenticated user -// GET /snippets +/** + * @openapi + * /snippets: + * get: + * summary: Get all snippets + * description: Retrieves all snippets for the authenticated user. + * tags: + * - Snippets + * responses: + * 200: + * description: A list of snippets. + * 400: + * description: Invalid userId. + * 500: + * description: Failed to fetch snippets. + */ router.get( "/", authenticateJWT, @@ -696,8 +894,30 @@ router.get( }, ); -// Get a specific snippet by ID -// GET /snippets/:id +/** + * @openapi + * /snippets/{id}: + * get: + * summary: Get a specific snippet + * description: Retrieves a specific snippet by its ID. + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: The requested snippet. + * 400: + * description: Invalid request parameters. + * 404: + * description: Snippet not found. + * 500: + * description: Failed to fetch snippet. + */ router.get( "/:id", authenticateJWT, @@ -735,8 +955,39 @@ router.get( }, ); -// Create a new snippet -// POST /snippets +/** + * @openapi + * /snippets: + * post: + * summary: Create a new snippet + * description: Creates a new snippet for the authenticated user. + * tags: + * - Snippets + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * content: + * type: string + * description: + * type: string + * folder: + * type: string + * order: + * type: integer + * responses: + * 201: + * description: Snippet created successfully. + * 400: + * description: Name and content are required. + * 500: + * description: Failed to create snippet. + */ router.post( "/", authenticateJWT, @@ -806,8 +1057,47 @@ router.post( }, ); -// Update a snippet -// PUT /snippets/:id +/** + * @openapi + * /snippets/{id}: + * put: + * summary: Update a snippet + * description: Updates a specific snippet by its ID. + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * content: + * type: string + * description: + * type: string + * folder: + * type: string + * order: + * type: integer + * responses: + * 200: + * description: The updated snippet. + * 400: + * description: Invalid request. + * 404: + * description: Snippet not found. + * 500: + * description: Failed to update snippet. + */ router.put( "/:id", authenticateJWT, @@ -883,8 +1173,30 @@ router.put( }, ); -// Delete a snippet -// DELETE /snippets/:id +/** + * @openapi + * /snippets/{id}: + * delete: + * summary: Delete a snippet + * description: Deletes a specific snippet by its ID. + * tags: + * - Snippets + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Snippet deleted successfully. + * 400: + * description: Invalid request. + * 404: + * description: Snippet not found. + * 500: + * description: Failed to delete snippet. + */ router.delete( "/:id", authenticateJWT, diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index e97fca86..fed3fa88 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -53,6 +53,22 @@ const permissionManager = PermissionManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); +/** + * @openapi + * /ssh/db/host/internal: + * get: + * summary: Get internal SSH host data + * description: Returns internal SSH host data for autostart tunnels. Requires internal auth token. + * tags: + * - SSH + * responses: + * 200: + * description: A list of autostart hosts. + * 403: + * description: Forbidden. + * 500: + * description: Failed to fetch autostart SSH data. + */ router.get("/db/host/internal", async (req: Request, res: Response) => { try { const internalToken = req.headers["x-internal-auth-token"]; @@ -135,6 +151,22 @@ router.get("/db/host/internal", async (req: Request, res: Response) => { } }); +/** + * @openapi + * /ssh/db/host/internal/all: + * get: + * summary: Get all internal SSH host data + * description: Returns all internal SSH host data. Requires internal auth token. + * tags: + * - SSH + * responses: + * 200: + * description: A list of all hosts. + * 401: + * description: Invalid or missing internal authentication token. + * 500: + * description: Failed to fetch all hosts. + */ router.get("/db/host/internal/all", async (req: Request, res: Response) => { try { const internalToken = req.headers["x-internal-auth-token"]; @@ -194,8 +226,22 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => { } }); -// Route: Create SSH data (requires JWT) -// POST /ssh/host +/** + * @openapi + * /ssh/db/host: + * post: + * summary: Create SSH host + * description: Creates a new SSH host configuration. + * tags: + * - SSH + * responses: + * 200: + * description: Host created successfully. + * 400: + * description: Invalid SSH data. + * 500: + * description: Failed to save SSH data. + */ router.post( "/db/host", authenticateJWT, @@ -438,8 +484,32 @@ router.post( }, ); -// Route: Update SSH data (requires JWT) -// PUT /ssh/host/:id +/** + * @openapi + * /ssh/db/host/{id}: + * put: + * summary: Update SSH host + * description: Updates an existing SSH host configuration. + * tags: + * - SSH + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Host updated successfully. + * 400: + * description: Invalid SSH data. + * 403: + * description: Access denied. + * 404: + * description: Host not found. + * 500: + * description: Failed to update SSH data. + */ router.put( "/db/host/:id", authenticateJWT, @@ -785,8 +855,22 @@ router.put( }, ); -// Route: Get SSH data for the authenticated user (requires JWT) -// GET /ssh/host +/** + * @openapi + * /ssh/db/host: + * get: + * summary: Get all SSH hosts + * description: Retrieves all SSH hosts for the authenticated user. + * tags: + * - SSH + * responses: + * 200: + * description: A list of SSH hosts. + * 400: + * description: Invalid userId. + * 500: + * description: Failed to fetch SSH data. + */ router.get( "/db/host", authenticateJWT, @@ -966,8 +1050,30 @@ router.get( }, ); -// Route: Get SSH host by ID (requires JWT) -// GET /ssh/host/:id +/** + * @openapi + * /ssh/db/host/{id}: + * get: + * summary: Get SSH host by ID + * description: Retrieves a specific SSH host by its ID. + * tags: + * - SSH + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: The requested SSH host. + * 400: + * description: Invalid userId or hostId. + * 404: + * description: SSH host not found. + * 500: + * description: Failed to fetch SSH host. + */ router.get( "/db/host/:id", authenticateJWT, @@ -1041,8 +1147,30 @@ router.get( }, ); -// Route: Export SSH host with decrypted credentials (requires data access) -// GET /ssh/db/host/:id/export +/** + * @openapi + * /ssh/db/host/{id}/export: + * get: + * summary: Export SSH host + * description: Exports a specific SSH host with decrypted credentials. + * tags: + * - SSH + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: The exported SSH host. + * 400: + * description: Invalid userId or hostId. + * 404: + * description: SSH host not found. + * 500: + * description: Failed to export SSH host. + */ router.get( "/db/host/:id/export", authenticateJWT, @@ -1121,8 +1249,30 @@ router.get( }, ); -// Route: Delete SSH host by id (requires JWT) -// DELETE /ssh/host/:id +/** + * @openapi + * /ssh/db/host/{id}: + * delete: + * summary: Delete SSH host + * description: Deletes an SSH host by its ID. + * tags: + * - SSH + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: SSH host deleted successfully. + * 400: + * description: Invalid userId or id. + * 404: + * description: SSH host not found. + * 500: + * description: Failed to delete SSH host. + */ router.delete( "/db/host/:id", authenticateJWT, @@ -1237,8 +1387,28 @@ router.delete( }, ); -// Route: Get recent files (requires JWT) -// GET /ssh/file_manager/recent +/** + * @openapi + * /ssh/file_manager/recent: + * get: + * summary: Get recent files + * description: Retrieves a list of recent files for a specific host. + * tags: + * - SSH + * parameters: + * - in: query + * name: hostId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: A list of recent files. + * 400: + * description: Invalid userId or hostId. + * 500: + * description: Failed to fetch recent files. + */ router.get( "/file_manager/recent", authenticateJWT, @@ -1279,8 +1449,35 @@ router.get( }, ); -// Route: Add recent file (requires JWT) -// POST /ssh/file_manager/recent +/** + * @openapi + * /ssh/file_manager/recent: + * post: + * summary: Add recent file + * description: Adds a file to the list of recent files for a host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * path: + * type: string + * name: + * type: string + * responses: + * 200: + * description: Recent file added. + * 400: + * description: Invalid data. + * 500: + * description: Failed to add recent file. + */ router.post( "/file_manager/recent", authenticateJWT, @@ -1328,8 +1525,33 @@ router.post( }, ); -// Route: Remove recent file (requires JWT) -// DELETE /ssh/file_manager/recent +/** + * @openapi + * /ssh/file_manager/recent: + * delete: + * summary: Remove recent file + * description: Removes a file from the list of recent files for a host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * path: + * type: string + * responses: + * 200: + * description: Recent file removed. + * 400: + * description: Invalid data. + * 500: + * description: Failed to remove recent file. + */ router.delete( "/file_manager/recent", authenticateJWT, @@ -1361,8 +1583,28 @@ router.delete( }, ); -// Route: Get pinned files (requires JWT) -// GET /ssh/file_manager/pinned +/** + * @openapi + * /ssh/file_manager/pinned: + * get: + * summary: Get pinned files + * description: Retrieves a list of pinned files for a specific host. + * tags: + * - SSH + * parameters: + * - in: query + * name: hostId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: A list of pinned files. + * 400: + * description: Invalid userId or hostId. + * 500: + * description: Failed to fetch pinned files. + */ router.get( "/file_manager/pinned", authenticateJWT, @@ -1402,8 +1644,37 @@ router.get( }, ); -// Route: Add pinned file (requires JWT) -// POST /ssh/file_manager/pinned +/** + * @openapi + * /ssh/file_manager/pinned: + * post: + * summary: Add pinned file + * description: Adds a file to the list of pinned files for a host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * path: + * type: string + * name: + * type: string + * responses: + * 200: + * description: File pinned. + * 400: + * description: Invalid data. + * 409: + * description: File already pinned. + * 500: + * description: Failed to pin file. + */ router.post( "/file_manager/pinned", authenticateJWT, @@ -1448,8 +1719,33 @@ router.post( }, ); -// Route: Remove pinned file (requires JWT) -// DELETE /ssh/file_manager/pinned +/** + * @openapi + * /ssh/file_manager/pinned: + * delete: + * summary: Remove pinned file + * description: Removes a file from the list of pinned files for a host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * path: + * type: string + * responses: + * 200: + * description: Pinned file removed. + * 400: + * description: Invalid data. + * 500: + * description: Failed to remove pinned file. + */ router.delete( "/file_manager/pinned", authenticateJWT, @@ -1481,8 +1777,28 @@ router.delete( }, ); -// Route: Get shortcuts (requires JWT) -// GET /ssh/file_manager/shortcuts +/** + * @openapi + * /ssh/file_manager/shortcuts: + * get: + * summary: Get shortcuts + * description: Retrieves a list of shortcuts for a specific host. + * tags: + * - SSH + * parameters: + * - in: query + * name: hostId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: A list of shortcuts. + * 400: + * description: Invalid userId or hostId. + * 500: + * description: Failed to fetch shortcuts. + */ router.get( "/file_manager/shortcuts", authenticateJWT, @@ -1522,8 +1838,37 @@ router.get( }, ); -// Route: Add shortcut (requires JWT) -// POST /ssh/file_manager/shortcuts +/** + * @openapi + * /ssh/file_manager/shortcuts: + * post: + * summary: Add shortcut + * description: Adds a shortcut for a specific host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * path: + * type: string + * name: + * type: string + * responses: + * 200: + * description: Shortcut added. + * 400: + * description: Invalid data. + * 409: + * description: Shortcut already exists. + * 500: + * description: Failed to add shortcut. + */ router.post( "/file_manager/shortcuts", authenticateJWT, @@ -1568,8 +1913,33 @@ router.post( }, ); -// Route: Remove shortcut (requires JWT) -// DELETE /ssh/file_manager/shortcuts +/** + * @openapi + * /ssh/file_manager/shortcuts: + * delete: + * summary: Remove shortcut + * description: Removes a shortcut for a specific host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * path: + * type: string + * responses: + * 200: + * description: Shortcut removed. + * 400: + * description: Invalid data. + * 500: + * description: Failed to remove shortcut. + */ router.delete( "/file_manager/shortcuts", authenticateJWT, @@ -1601,8 +1971,28 @@ router.delete( }, ); -// Route: Get command history for a host -// GET /ssh/command-history/:hostId +/** + * @openapi + * /ssh/command-history/{hostId}: + * get: + * summary: Get command history + * description: Retrieves the command history for a specific host. + * tags: + * - SSH + * parameters: + * - in: path + * name: hostId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: A list of commands. + * 400: + * description: Invalid userId or hostId. + * 500: + * description: Failed to fetch command history. + */ router.get( "/command-history/:hostId", authenticateJWT, @@ -1647,8 +2037,33 @@ router.get( }, ); -// Route: Delete command from history -// DELETE /ssh/command-history +/** + * @openapi + * /ssh/command-history: + * delete: + * summary: Delete command from history + * description: Deletes a specific command from the history of a host. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * command: + * type: string + * responses: + * 200: + * description: Command deleted from history. + * 400: + * description: Invalid data. + * 500: + * description: Failed to delete command. + */ router.delete( "/command-history", authenticateJWT, @@ -1700,9 +2115,8 @@ async function resolveHostCredentials( if (requestingUserId && requestingUserId !== ownerId) { try { - const { SharedCredentialManager } = await import( - "../../utils/shared-credential-manager.js" - ); + const { SharedCredentialManager } = + await import("../../utils/shared-credential-manager.js"); const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCred = await sharedCredManager.getSharedCredentialForUser( host.id as number, @@ -1790,8 +2204,33 @@ async function resolveHostCredentials( } } -// Route: Rename folder (requires JWT) -// PUT /ssh/db/folders/rename +/** + * @openapi + * /ssh/folders/rename: + * put: + * summary: Rename folder + * description: Renames a folder for SSH hosts and credentials. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * oldName: + * type: string + * newName: + * type: string + * responses: + * 200: + * description: Folder renamed successfully. + * 400: + * description: Old name and new name are required. + * 500: + * description: Failed to rename folder. + */ router.put( "/folders/rename", authenticateJWT, @@ -1865,8 +2304,22 @@ router.put( }, ); -// Route: Get all folders with metadata (requires JWT) -// GET /ssh/db/folders +/** + * @openapi + * /ssh/folders: + * get: + * summary: Get all folders + * description: Retrieves all folders for the authenticated user. + * tags: + * - SSH + * responses: + * 200: + * description: A list of folders. + * 400: + * description: Invalid user ID. + * 500: + * description: Failed to fetch folders. + */ router.get("/folders", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -1890,8 +2343,35 @@ router.get("/folders", authenticateJWT, async (req: Request, res: Response) => { } }); -// Route: Update folder metadata (requires JWT) -// PUT /ssh/db/folders/metadata +/** + * @openapi + * /ssh/folders/metadata: + * put: + * summary: Update folder metadata + * description: Updates the metadata (color, icon) of a folder. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * color: + * type: string + * icon: + * type: string + * responses: + * 200: + * description: Folder metadata updated successfully. + * 400: + * description: Folder name is required. + * 500: + * description: Failed to update folder metadata. + */ router.put( "/folders/metadata", authenticateJWT, @@ -1944,8 +2424,28 @@ router.put( }, ); -// Route: Delete all hosts in folder (requires JWT) -// DELETE /ssh/db/folders/:name/hosts +/** + * @openapi + * /ssh/folders/{name}/hosts: + * delete: + * summary: Delete all hosts in folder + * description: Deletes all SSH hosts within a specific folder. + * tags: + * - SSH + * parameters: + * - in: path + * name: name + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Hosts deleted successfully. + * 400: + * description: Invalid folder name. + * 500: + * description: Failed to delete hosts in folder. + */ router.delete( "/folders/:name/hosts", authenticateJWT, @@ -2063,8 +2563,31 @@ router.delete( }, ); -// Route: Bulk import SSH hosts (requires JWT) -// POST /ssh/bulk-import +/** + * @openapi + * /ssh/bulk-import: + * post: + * summary: Bulk import SSH hosts + * description: Bulk imports multiple SSH hosts. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hosts: + * type: array + * items: + * type: object + * responses: + * 200: + * description: Import completed. + * 400: + * description: Invalid request body. + */ router.post( "/bulk-import", authenticateJWT, @@ -2221,8 +2744,33 @@ router.post( }, ); -// Route: Enable autostart for SSH configuration (requires JWT) -// POST /ssh/autostart/enable +/** + * @openapi + * /ssh/autostart/enable: + * post: + * summary: Enable autostart for SSH configuration + * description: Enables autostart for a specific SSH configuration. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sshConfigId: + * type: number + * responses: + * 200: + * description: AutoStart enabled successfully. + * 400: + * description: Valid sshConfigId is required. + * 404: + * description: SSH configuration not found. + * 500: + * description: Internal server error. + */ router.post( "/autostart/enable", authenticateJWT, @@ -2375,8 +2923,31 @@ router.post( }, ); -// Route: Disable autostart for SSH configuration (requires JWT) -// DELETE /ssh/autostart/disable +/** + * @openapi + * /ssh/autostart/disable: + * delete: + * summary: Disable autostart for SSH configuration + * description: Disables autostart for a specific SSH configuration. + * tags: + * - SSH + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sshConfigId: + * type: number + * responses: + * 200: + * description: AutoStart disabled successfully. + * 400: + * description: Valid sshConfigId is required. + * 500: + * description: Internal server error. + */ router.delete( "/autostart/disable", authenticateJWT, @@ -2421,8 +2992,20 @@ router.delete( }, ); -// Route: Get autostart status for user's SSH configurations (requires JWT) -// GET /ssh/autostart/status +/** + * @openapi + * /ssh/autostart/status: + * get: + * summary: Get autostart status + * description: Retrieves the autostart status for the user's SSH configurations. + * tags: + * - SSH + * responses: + * 200: + * description: A list of autostart configurations. + * 500: + * description: Internal server error. + */ router.get( "/autostart/status", authenticateJWT, diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts index f73c7dfb..80dcf872 100644 --- a/src/backend/database/routes/terminal.ts +++ b/src/backend/database/routes/terminal.ts @@ -17,8 +17,33 @@ const authManager = AuthManager.getInstance(); const authenticateJWT = authManager.createAuthMiddleware(); const requireDataAccess = authManager.createDataAccessMiddleware(); -// Save command to history -// POST /terminal/command_history +/** + * @openapi + * /terminal/command_history: + * post: + * summary: Save command to history + * description: Saves a command to the command history for a specific host. + * tags: + * - Terminal + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * command: + * type: string + * responses: + * 201: + * description: Command saved successfully. + * 400: + * description: Missing required parameters. + * 500: + * description: Failed to save command. + */ router.post( "/command_history", authenticateJWT, @@ -59,8 +84,28 @@ router.post( }, ); -// Get command history for a specific host -// GET /terminal/command_history/:hostId +/** + * @openapi + * /terminal/command_history/{hostId}: + * get: + * summary: Get command history + * description: Retrieves the command history for a specific host. + * tags: + * - Terminal + * parameters: + * - in: path + * name: hostId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: A list of commands. + * 400: + * description: Invalid request parameters. + * 500: + * description: Failed to fetch history. + */ router.get( "/command_history/:hostId", authenticateJWT, @@ -107,8 +152,33 @@ router.get( }, ); -// Delete a specific command from history -// POST /terminal/command_history/delete +/** + * @openapi + * /terminal/command_history/delete: + * post: + * summary: Delete a specific command from history + * description: Deletes a specific command from the history of a host. + * tags: + * - Terminal + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * command: + * type: string + * responses: + * 200: + * description: Command deleted successfully. + * 400: + * description: Missing required parameters. + * 500: + * description: Failed to delete command. + */ router.post( "/command_history/delete", authenticateJWT, @@ -150,8 +220,28 @@ router.post( }, ); -// Clear command history for a specific host (optional feature) -// DELETE /terminal/command_history/:hostId +/** + * @openapi + * /terminal/command_history/{hostId}: + * delete: + * summary: Clear command history + * description: Clears the entire command history for a specific host. + * tags: + * - Terminal + * parameters: + * - in: path + * name: hostId + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Command history cleared successfully. + * 400: + * description: Invalid request. + * 500: + * description: Failed to clear history. + */ router.delete( "/command_history/:hostId", authenticateJWT, diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 29aa6a31..192fa2dd 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -214,8 +214,37 @@ async function deleteUserAndRelatedData(userId: string): Promise { } } -// Route: Create traditional user (username/password) -// POST /users/create +/** + * @openapi + * /users/create: + * post: + * summary: Create a new user + * description: Creates a new user with a username and password. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * password: + * type: string + * responses: + * 200: + * description: User created successfully. + * 400: + * description: Username and password are required. + * 403: + * description: Registration is currently disabled. + * 409: + * description: Username already exists. + * 500: + * description: Failed to create user. + */ router.post("/create", async (req, res) => { try { const row = db.$client @@ -365,8 +394,22 @@ router.post("/create", async (req, res) => { } }); -// Route: Create OIDC provider configuration (admin only) -// POST /users/oidc-config +/** + * @openapi + * /users/oidc-config: + * post: + * summary: Configure OIDC provider + * description: Creates or updates the OIDC provider configuration. + * tags: + * - Users + * responses: + * 200: + * description: OIDC configuration updated. + * 403: + * description: Not authorized. + * 500: + * description: Failed to update OIDC config. + */ router.post("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { @@ -501,8 +544,22 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => { } }); -// Route: Disable OIDC configuration (admin only) -// DELETE /users/oidc-config +/** + * @openapi + * /users/oidc-config: + * delete: + * summary: Disable OIDC configuration + * description: Disables the OIDC provider configuration. + * tags: + * - Users + * responses: + * 200: + * description: OIDC configuration disabled. + * 403: + * description: Not authorized. + * 500: + * description: Failed to disable OIDC config. + */ router.delete("/oidc-config", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { @@ -523,8 +580,20 @@ router.delete("/oidc-config", authenticateJWT, async (req, res) => { } }); -// Route: Get OIDC configuration (public - needed for login page) -// GET /users/oidc-config +/** + * @openapi + * /users/oidc-config: + * get: + * summary: Get OIDC configuration + * description: Returns the public OIDC configuration. + * tags: + * - Users + * responses: + * 200: + * description: Public OIDC configuration. + * 500: + * description: Failed to get OIDC config. + */ router.get("/oidc-config", async (req, res) => { try { const row = db.$client @@ -550,8 +619,20 @@ router.get("/oidc-config", async (req, res) => { } }); -// Route: Get OIDC configuration for Admin (admin only) -// GET /users/oidc-config/admin +/** + * @openapi + * /users/oidc-config/admin: + * get: + * summary: Get OIDC configuration for admin + * description: Returns the full OIDC configuration for an admin. + * tags: + * - Users + * responses: + * 200: + * description: Full OIDC configuration. + * 500: + * description: Failed to get OIDC config for admin. + */ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { @@ -607,8 +688,22 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => { } }); -// Route: Get OIDC authorization URL -// GET /users/oidc/authorize +/** + * @openapi + * /users/oidc/authorize: + * get: + * summary: Get OIDC authorization URL + * description: Returns the OIDC authorization URL. + * tags: + * - Users + * responses: + * 200: + * description: OIDC authorization URL. + * 404: + * description: OIDC not configured. + * 500: + * description: Failed to generate authorization URL. + */ router.get("/oidc/authorize", async (req, res) => { try { const row = db.$client @@ -656,8 +751,20 @@ router.get("/oidc/authorize", async (req, res) => { } }); -// Route: OIDC callback - exchange code for token and create/login user -// GET /users/oidc/callback +/** + * @openapi + * /users/oidc/callback: + * get: + * summary: OIDC callback + * description: Handles the OIDC callback, exchanges the code for a token, and creates or logs in the user. + * tags: + * - Users + * responses: + * 302: + * description: Redirects to the frontend with a success or error message. + * 400: + * description: Code and state are required. + */ router.get("/oidc/callback", async (req, res) => { const { code, state } = req.query; @@ -1069,8 +1176,39 @@ router.get("/oidc/callback", async (req, res) => { } }); -// Route: Get user JWT by username and password (traditional login) -// POST /users/login +/** + * @openapi + * /users/login: + * post: + * summary: User login + * description: Authenticates a user and returns a JWT. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * password: + * type: string + * responses: + * 200: + * description: Login successful. + * 400: + * description: Invalid username or password. + * 401: + * description: Invalid username or password. + * 403: + * description: Password authentication is currently disabled. + * 429: + * description: Too many login attempts. + * 500: + * description: Login failed. + */ router.post("/login", async (req, res) => { const { username, password } = req.body; const clientIp = req.ip || req.socket.remoteAddress || "unknown"; @@ -1268,8 +1406,20 @@ router.post("/login", async (req, res) => { } }); -// Route: Logout user -// POST /users/logout +/** + * @openapi + * /users/logout: + * post: + * summary: User logout + * description: Logs out the user and clears the JWT cookie. + * tags: + * - Users + * responses: + * 200: + * description: Logged out successfully. + * 500: + * description: Logout failed. + */ router.post("/logout", authenticateJWT, async (req, res) => { try { const authReq = req as AuthenticatedRequest; @@ -1304,8 +1454,22 @@ router.post("/logout", authenticateJWT, async (req, res) => { } }); -// Route: Get current user's info using JWT -// GET /users/me +/** + * @openapi + * /users/me: + * get: + * summary: Get current user's info + * description: Retrieves information about the currently authenticated user. + * tags: + * - Users + * responses: + * 200: + * description: User information. + * 401: + * description: Invalid userId or user not found. + * 500: + * description: Failed to get username. + */ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { const userId = (req as AuthenticatedRequest).userId; @@ -1339,8 +1503,20 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => { } }); -// Route: Check if system requires initial setup (public - for first-time setup detection) -// GET /users/setup-required +/** + * @openapi + * /users/setup-required: + * get: + * summary: Check if setup is required + * description: Checks if the system requires initial setup (i.e., no users exist). + * tags: + * - Users + * responses: + * 200: + * description: Setup status. + * 500: + * description: Failed to check setup status. + */ router.get("/setup-required", async (req, res) => { try { const countResult = db.$client @@ -1357,8 +1533,22 @@ router.get("/setup-required", async (req, res) => { } }); -// Route: Count users (admin only - for dashboard statistics) -// GET /users/count +/** + * @openapi + * /users/count: + * get: + * summary: Count users + * description: Returns the total number of users in the system. + * tags: + * - Users + * responses: + * 200: + * description: User count. + * 403: + * description: Admin access required. + * 500: + * description: Failed to count users. + */ router.get("/count", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { @@ -1378,8 +1568,20 @@ router.get("/count", authenticateJWT, async (req, res) => { } }); -// Route: DB health check (actually queries DB) -// GET /users/db-health +/** + * @openapi + * /users/db-health: + * get: + * summary: Database health check + * description: Checks if the database is accessible. + * tags: + * - Users + * responses: + * 200: + * description: Database is accessible. + * 500: + * description: Database not accessible. + */ router.get("/db-health", requireAdmin, async (req, res) => { try { db.$client.prepare("SELECT 1").get(); @@ -1390,8 +1592,20 @@ router.get("/db-health", requireAdmin, async (req, res) => { } }); -// Route: Get registration allowed status (public - needed for login page) -// GET /users/registration-allowed +/** + * @openapi + * /users/registration-allowed: + * get: + * summary: Get registration status + * description: Checks if user registration is currently allowed. + * tags: + * - Users + * responses: + * 200: + * description: Registration status. + * 500: + * description: Failed to get registration allowed status. + */ router.get("/registration-allowed", async (req, res) => { try { const row = db.$client @@ -1406,8 +1620,33 @@ router.get("/registration-allowed", async (req, res) => { } }); -// Route: Set registration allowed status (admin only) -// PATCH /users/registration-allowed +/** + * @openapi + * /users/registration-allowed: + * patch: + * summary: Set registration status + * description: Enables or disables user registration. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * allowed: + * type: boolean + * responses: + * 200: + * description: Registration status updated. + * 400: + * description: Invalid value for allowed. + * 403: + * description: Not authorized. + * 500: + * description: Failed to set registration allowed status. + */ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { @@ -1429,8 +1668,20 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => { } }); -// Route: Get password login allowed status (public - needed for login page) -// GET /users/password-login-allowed +/** + * @openapi + * /users/password-login-allowed: + * get: + * summary: Get password login status + * description: Checks if password-based login is currently allowed. + * tags: + * - Users + * responses: + * 200: + * description: Password login status. + * 500: + * description: Failed to get password login allowed status. + */ router.get("/password-login-allowed", async (req, res) => { try { const row = db.$client @@ -1445,8 +1696,33 @@ router.get("/password-login-allowed", async (req, res) => { } }); -// Route: Set password login allowed status (admin only) -// PATCH /users/password-login-allowed +/** + * @openapi + * /users/password-login-allowed: + * patch: + * summary: Set password login status + * description: Enables or disables password-based login. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * allowed: + * type: boolean + * responses: + * 200: + * description: Password login status updated. + * 400: + * description: Invalid value for allowed. + * 403: + * description: Not authorized. + * 500: + * description: Failed to set password login allowed status. + */ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { @@ -1470,8 +1746,37 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => { } }); -// Route: Delete user account -// DELETE /users/delete-account +/** + * @openapi + * /users/delete-account: + * delete: + * summary: Delete user account + * description: Deletes the authenticated user's account. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * password: + * type: string + * responses: + * 200: + * description: Account deleted successfully. + * 400: + * description: Password is required. + * 401: + * description: Incorrect password. + * 403: + * description: Cannot delete external authentication accounts or the last admin user. + * 404: + * description: User not found. + * 500: + * description: Failed to delete account. + */ router.delete("/delete-account", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { password } = req.body; @@ -1526,8 +1831,35 @@ router.delete("/delete-account", authenticateJWT, async (req, res) => { } }); -// Route: Initiate password reset -// POST /users/initiate-reset +/** + * @openapi + * /users/initiate-reset: + * post: + * summary: Initiate password reset + * description: Initiates the password reset process for a user. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * responses: + * 200: + * description: Password reset code has been generated. + * 400: + * description: Username is required. + * 403: + * description: Password reset not available for external authentication users. + * 404: + * description: User not found. + * 500: + * description: Failed to initiate password reset. + */ router.post("/initiate-reset", async (req, res) => { const { username } = req.body; @@ -1578,8 +1910,33 @@ router.post("/initiate-reset", async (req, res) => { } }); -// Route: Verify reset code -// POST /users/verify-reset-code +/** + * @openapi + * /users/verify-reset-code: + * post: + * summary: Verify reset code + * description: Verifies the password reset code. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * resetCode: + * type: string + * responses: + * 200: + * description: Reset code verified. + * 400: + * description: Invalid or expired reset code. + * 500: + * description: Failed to verify reset code. + */ router.post("/verify-reset-code", async (req, res) => { const { username, resetCode } = req.body; @@ -1636,8 +1993,37 @@ router.post("/verify-reset-code", async (req, res) => { } }); -// Route: Complete password reset -// POST /users/complete-reset +/** + * @openapi + * /users/complete-reset: + * post: + * summary: Complete password reset + * description: Completes the password reset process with a new password. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * tempToken: + * type: string + * newPassword: + * type: string + * responses: + * 200: + * description: Password has been successfully reset. + * 400: + * description: Invalid or expired temporary token. + * 404: + * description: User not found. + * 500: + * description: Failed to complete password reset. + */ router.post("/complete-reset", async (req, res) => { const { username, tempToken, newPassword } = req.body; @@ -1824,6 +2210,35 @@ router.post("/complete-reset", async (req, res) => { } }); +/** + * @openapi + * /users/change-password: + * post: + * summary: Change user password + * description: Changes the authenticated user's password. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * oldPassword: + * type: string + * newPassword: + * type: string + * responses: + * 200: + * description: Password changed successfully. + * 400: + * description: Old and new passwords are required. + * 401: + * description: Incorrect current password. + * 500: + * description: Failed to update password and re-encrypt data. + */ router.post("/change-password", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { oldPassword, newPassword } = req.body; @@ -1868,8 +2283,22 @@ router.post("/change-password", authenticateJWT, async (req, res) => { res.json({ message: "Password changed successfully. Please log in again." }); }); -// Route: List all users (admin only) -// GET /users/list +/** + * @openapi + * /users/list: + * get: + * summary: List all users + * description: Retrieves a list of all users in the system. + * tags: + * - Users + * responses: + * 200: + * description: A list of users. + * 403: + * description: Not authorized. + * 500: + * description: Failed to list users. + */ router.get("/list", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; try { @@ -1895,8 +2324,35 @@ router.get("/list", authenticateJWT, async (req, res) => { } }); -// Route: Make user admin (admin only) -// POST /users/make-admin +/** + * @openapi + * /users/make-admin: + * post: + * summary: Make user admin + * description: Grants admin privileges to a user. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * responses: + * 200: + * description: User is now an admin. + * 400: + * description: Username is required or user is already an admin. + * 403: + * description: Not authorized. + * 404: + * description: User not found. + * 500: + * description: Failed to make user admin. + */ router.post("/make-admin", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { username } = req.body; @@ -1948,8 +2404,35 @@ router.post("/make-admin", authenticateJWT, async (req, res) => { } }); -// Route: Remove admin status (admin only) -// POST /users/remove-admin +/** + * @openapi + * /users/remove-admin: + * post: + * summary: Remove admin status + * description: Revokes admin privileges from a user. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * username: + * type: string + * responses: + * 200: + * description: Admin status removed from user. + * 400: + * description: Username is required or cannot remove your own admin status. + * 403: + * description: Not authorized. + * 404: + * description: User not found. + * 500: + * description: Failed to remove admin status. + */ router.post("/remove-admin", authenticateJWT, async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { username } = req.body; @@ -2007,8 +2490,37 @@ router.post("/remove-admin", authenticateJWT, async (req, res) => { } }); -// Route: Verify TOTP during login -// POST /users/totp/verify-login +/** + * @openapi + * /users/totp/verify-login: + * post: + * summary: Verify TOTP during login + * description: Verifies the TOTP code during login. + * tags: + * - Users + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * temp_token: + * type: string + * totp_code: + * type: string + * responses: + * 200: + * description: TOTP verification successful. + * 400: + * description: Token and TOTP code are required. + * 401: + * description: Invalid temporary token or TOTP code. + * 404: + * description: User not found. + * 500: + * description: TOTP verification failed. + */ router.post("/totp/verify-login", async (req, res) => { const { temp_token, totp_code } = req.body; @@ -2144,835 +2656,3 @@ router.post("/totp/verify-login", async (req, res) => { 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 AuthenticatedRequest).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) { - authLogger.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 AuthenticatedRequest).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) { - authLogger.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 AuthenticatedRequest).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) { - authLogger.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 AuthenticatedRequest).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) { - authLogger.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) => { - const userId = (req as AuthenticatedRequest).userId; - const { username } = req.body; - - if (!isNonEmptyString(username)) { - return res.status(400).json({ error: "Username is required" }); - } - - try { - const adminUser = await db.select().from(users).where(eq(users.id, userId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { - return res.status(403).json({ error: "Not authorized" }); - } - - if (adminUser[0].username === username) { - return res.status(400).json({ error: "Cannot delete your own account" }); - } - - const targetUser = await db - .select() - .from(users) - .where(eq(users.username, username)); - if (!targetUser || targetUser.length === 0) { - return res.status(404).json({ error: "User not found" }); - } - - if (targetUser[0].is_admin) { - const adminCount = db.$client - .prepare("SELECT COUNT(*) as count FROM users WHERE is_admin = 1") - .get(); - if (((adminCount as { count?: number })?.count || 0) <= 1) { - return res - .status(403) - .json({ error: "Cannot delete the last admin user" }); - } - } - - const targetUserId = targetUser[0].id; - - // Use the comprehensive deletion utility - await deleteUserAndRelatedData(targetUserId); - - authLogger.success( - `User ${username} deleted by admin ${adminUser[0].username}`, - ); - res.json({ message: `User ${username} deleted successfully` }); - } catch (err) { - authLogger.error("Failed to delete user", err); - - if (err && typeof err === "object" && "code" in err) { - if (err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") { - res.status(400).json({ - error: - "Cannot delete user: User has associated data that cannot be removed", - }); - } else { - res.status(500).json({ error: `Database error: ${err.code}` }); - } - } else { - res.status(500).json({ error: "Failed to delete account" }); - } - } -}); - -// Route: User data unlock - used when session expires -// POST /users/unlock-data -router.post("/unlock-data", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - const { password } = req.body; - - if (!password) { - return res.status(400).json({ error: "Password is required" }); - } - - try { - const unlocked = await authManager.authenticateUser(userId, password); - if (unlocked) { - res.json({ - success: true, - message: "Data unlocked successfully", - }); - } else { - authLogger.warn("Failed to unlock user data - invalid password", { - operation: "user_data_unlock_failed", - userId, - }); - res.status(401).json({ error: "Invalid password" }); - } - } catch (err) { - authLogger.error("Data unlock failed", err, { - operation: "user_data_unlock_error", - userId, - }); - res.status(500).json({ error: "Failed to unlock data" }); - } -}); - -// Route: Check user data unlock status -// GET /users/data-status -router.get("/data-status", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - - try { - res.json({ - unlocked: true, - message: "Data is unlocked", - }); - } catch (err) { - authLogger.error("Failed to check data status", err, { - operation: "data_status_check_failed", - userId, - }); - res.status(500).json({ error: "Failed to check data status" }); - } -}); - -// Route: Change user password (re-encrypt data keys) -// POST /users/change-password -router.post("/change-password", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - const { currentPassword, newPassword } = req.body; - - if (!currentPassword || !newPassword) { - return res.status(400).json({ - error: "Current password and new password are required", - }); - } - - if (newPassword.length < 8) { - return res.status(400).json({ - error: "New password must be at least 8 characters long", - }); - } - - try { - const success = await authManager.changeUserPassword( - userId, - currentPassword, - newPassword, - ); - - if (success) { - const saltRounds = parseInt(process.env.SALT || "10", 10); - const newPasswordHash = await bcrypt.hash(newPassword, saltRounds); - await db - .update(users) - .set({ password_hash: newPasswordHash }) - .where(eq(users.id, userId)); - - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); - - authLogger.success("User password changed successfully", { - operation: "password_change_success", - userId, - }); - - res.json({ - success: true, - message: "Password changed successfully", - }); - } else { - authLogger.warn("Password change failed - invalid current password", { - operation: "password_change_failed", - userId, - }); - res.status(401).json({ error: "Current password is incorrect" }); - } - } catch (err) { - authLogger.error("Password change failed", err, { - operation: "password_change_error", - userId, - }); - res.status(500).json({ error: "Failed to change password" }); - } -}); - -// Route: Get sessions (all for admin, own for user) -// GET /users/sessions -router.get("/sessions", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).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]; - let sessionList; - - if (userRecord.is_admin) { - sessionList = await authManager.getAllSessions(); - - const enrichedSessions = await Promise.all( - sessionList.map(async (session) => { - const sessionUser = await db - .select({ username: users.username }) - .from(users) - .where(eq(users.id, session.userId)) - .limit(1); - - return { - ...session, - username: sessionUser[0]?.username || "Unknown", - }; - }), - ); - - return res.json({ sessions: enrichedSessions }); - } else { - sessionList = await authManager.getUserSessions(userId); - return res.json({ sessions: sessionList }); - } - } catch (err) { - authLogger.error("Failed to get sessions", err); - res.status(500).json({ error: "Failed to get sessions" }); - } -}); - -// Route: Revoke a specific session -// DELETE /users/sessions/:sessionId -router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - const { sessionId } = req.params; - - if (!sessionId) { - return res.status(400).json({ error: "Session ID 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]; - - const sessionRecords = await db - .select() - .from(sessions) - .where(eq(sessions.id, sessionId)) - .limit(1); - - if (sessionRecords.length === 0) { - return res.status(404).json({ error: "Session not found" }); - } - - const session = sessionRecords[0]; - - if (!userRecord.is_admin && session.userId !== userId) { - return res - .status(403) - .json({ error: "Not authorized to revoke this session" }); - } - - const success = await authManager.revokeSession(sessionId); - - if (success) { - authLogger.success("Session revoked", { - operation: "session_revoke", - sessionId, - revokedBy: userId, - sessionUserId: session.userId, - }); - res.json({ success: true, message: "Session revoked successfully" }); - } else { - res.status(500).json({ error: "Failed to revoke session" }); - } - } catch (err) { - authLogger.error("Failed to revoke session", err); - res.status(500).json({ error: "Failed to revoke session" }); - } -}); - -// Route: Revoke all sessions for a user -// POST /users/sessions/revoke-all -router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - const { targetUserId, exceptCurrent } = req.body; - - 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]; - - let revokeUserId = userId; - if (targetUserId && userRecord.is_admin) { - revokeUserId = targetUserId; - } else if (targetUserId && targetUserId !== userId) { - return res.status(403).json({ - error: "Not authorized to revoke sessions for other users", - }); - } - - let currentSessionId: string | undefined; - if (exceptCurrent) { - const token = - req.cookies?.jwt || req.headers?.authorization?.split(" ")[1]; - if (token) { - const payload = await authManager.verifyJWTToken(token); - currentSessionId = payload?.sessionId; - } - } - - const revokedCount = await authManager.revokeAllUserSessions( - revokeUserId, - currentSessionId, - ); - - authLogger.success("User sessions revoked", { - operation: "user_sessions_revoke_all", - revokeUserId, - revokedBy: userId, - exceptCurrent, - revokedCount, - }); - - res.json({ - message: `${revokedCount} session(s) revoked successfully`, - count: revokedCount, - }); - } catch (err) { - authLogger.error("Failed to revoke user sessions", err); - res.status(500).json({ error: "Failed to revoke sessions" }); - } -}); - -// Route: Link OIDC user to existing password account (merge accounts) -// POST /users/link-oidc-to-password -router.post("/link-oidc-to-password", authenticateJWT, async (req, res) => { - const adminUserId = (req as AuthenticatedRequest).userId; - const { oidcUserId, targetUsername } = req.body; - - if (!isNonEmptyString(oidcUserId) || !isNonEmptyString(targetUsername)) { - return res.status(400).json({ - error: "OIDC user ID and target username are required", - }); - } - - try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminUserId)); - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { - return res.status(403).json({ error: "Admin access required" }); - } - - const oidcUserRecords = await db - .select() - .from(users) - .where(eq(users.id, oidcUserId)); - if (!oidcUserRecords || oidcUserRecords.length === 0) { - return res.status(404).json({ error: "OIDC user not found" }); - } - - const oidcUser = oidcUserRecords[0]; - - if (!oidcUser.is_oidc) { - return res.status(400).json({ - error: "Source user is not an OIDC user", - }); - } - - const targetUserRecords = await db - .select() - .from(users) - .where(eq(users.username, targetUsername)); - if (!targetUserRecords || targetUserRecords.length === 0) { - return res.status(404).json({ error: "Target password user not found" }); - } - - const targetUser = targetUserRecords[0]; - - if (targetUser.is_oidc || !targetUser.password_hash) { - return res.status(400).json({ - error: "Target user must be a password-based account", - }); - } - - if (targetUser.client_id && targetUser.oidc_identifier) { - return res.status(400).json({ - error: "Target user already has OIDC authentication configured", - }); - } - - authLogger.info("Linking OIDC user to password account", { - operation: "link_oidc_to_password", - oidcUserId, - oidcUsername: oidcUser.username, - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }); - - await db - .update(users) - .set({ - is_oidc: true, - oidc_identifier: oidcUser.oidc_identifier, - client_id: oidcUser.client_id, - client_secret: oidcUser.client_secret, - issuer_url: oidcUser.issuer_url, - authorization_url: oidcUser.authorization_url, - token_url: oidcUser.token_url, - identifier_path: oidcUser.identifier_path, - name_path: oidcUser.name_path, - scopes: oidcUser.scopes || "openid email profile", - }) - .where(eq(users.id, targetUser.id)); - - try { - await authManager.convertToOIDCEncryption(targetUser.id); - } catch (encryptionError) { - authLogger.error( - "Failed to convert encryption to OIDC during linking", - encryptionError, - { - operation: "link_convert_encryption_failed", - userId: targetUser.id, - }, - ); - await db - .update(users) - .set({ - is_oidc: false, - oidc_identifier: null, - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", - scopes: "openid email profile", - }) - .where(eq(users.id, targetUser.id)); - - return res.status(500).json({ - error: - "Failed to convert encryption for dual-auth. Please ensure the password account has encryption setup.", - details: - encryptionError instanceof Error - ? encryptionError.message - : "Unknown error", - }); - } - - await authManager.revokeAllUserSessions(oidcUserId); - authManager.logoutUser(oidcUserId); - - await deleteUserAndRelatedData(oidcUserId); - - try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - authLogger.error("Failed to persist account linking to disk", saveError, { - operation: "link_oidc_save_failed", - oidcUserId, - targetUserId: targetUser.id, - }); - } - - authLogger.success( - `OIDC user ${oidcUser.username} linked to password account ${targetUser.username}`, - { - operation: "link_oidc_to_password_success", - oidcUserId, - oidcUsername: oidcUser.username, - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }, - ); - - res.json({ - success: true, - message: `OIDC user ${oidcUser.username} has been linked to ${targetUser.username}. The password account can now use both password and OIDC login.`, - }); - } catch (err) { - authLogger.error("Failed to link OIDC user to password account", err, { - operation: "link_oidc_to_password_failed", - oidcUserId, - targetUsername, - adminUserId, - }); - res.status(500).json({ - error: "Failed to link accounts", - details: err instanceof Error ? err.message : "Unknown error", - }); - } -}); - -// Route: Unlink OIDC from password account (admin only) -// POST /users/unlink-oidc-from-password -router.post("/unlink-oidc-from-password", authenticateJWT, async (req, res) => { - const adminUserId = (req as AuthenticatedRequest).userId; - const { userId } = req.body; - - if (!userId) { - return res.status(400).json({ - error: "User ID is required", - }); - } - - try { - const adminUser = await db - .select() - .from(users) - .where(eq(users.id, adminUserId)); - - if (!adminUser || adminUser.length === 0 || !adminUser[0].is_admin) { - authLogger.warn("Non-admin attempted to unlink OIDC from password", { - operation: "unlink_oidc_unauthorized", - adminUserId, - targetUserId: userId, - }); - return res.status(403).json({ - error: "Admin privileges required", - }); - } - - const targetUserRecords = await db - .select() - .from(users) - .where(eq(users.id, userId)); - - if (!targetUserRecords || targetUserRecords.length === 0) { - return res.status(404).json({ - error: "User not found", - }); - } - - const targetUser = targetUserRecords[0]; - - if (!targetUser.is_oidc) { - return res.status(400).json({ - error: "User does not have OIDC authentication enabled", - }); - } - - if (!targetUser.password_hash || targetUser.password_hash === "") { - return res.status(400).json({ - error: - "Cannot unlink OIDC from a user without password authentication. This would leave the user unable to login.", - }); - } - - authLogger.info("Unlinking OIDC from password account", { - operation: "unlink_oidc_from_password_start", - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }); - - await db - .update(users) - .set({ - is_oidc: false, - oidc_identifier: null, - client_id: "", - client_secret: "", - issuer_url: "", - authorization_url: "", - token_url: "", - identifier_path: "", - name_path: "", - scopes: "openid email profile", - }) - .where(eq(users.id, targetUser.id)); - - try { - const { saveMemoryDatabaseToFile } = await import("../db/index.js"); - await saveMemoryDatabaseToFile(); - } catch (saveError) { - authLogger.error( - "Failed to save database after unlinking OIDC", - saveError, - { - operation: "unlink_oidc_save_failed", - targetUserId: targetUser.id, - }, - ); - } - - authLogger.success("OIDC unlinked from password account successfully", { - operation: "unlink_oidc_from_password_success", - targetUserId: targetUser.id, - targetUsername: targetUser.username, - adminUserId, - }); - - res.json({ - success: true, - message: `OIDC authentication has been removed from ${targetUser.username}. User can now only login with password.`, - }); - } catch (err) { - authLogger.error("Failed to unlink OIDC from password account", err, { - operation: "unlink_oidc_from_password_failed", - targetUserId: userId, - adminUserId, - }); - res.status(500).json({ - error: "Failed to unlink OIDC", - details: err instanceof Error ? err.message : "Unknown error", - }); - } -}); - -export default router; diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts index ee984be4..7d3de258 100644 --- a/src/backend/ssh/docker.ts +++ b/src/backend/ssh/docker.ts @@ -365,7 +365,34 @@ app.use(express.urlencoded({ limit: "100mb", extended: true })); const authManager = AuthManager.getInstance(); app.use(authManager.createAuthMiddleware()); -// POST /docker/ssh/connect - Establish SSH session +/** + * @openapi + * /docker/ssh/connect: + * post: + * summary: Establish SSH session for Docker + * description: Establishes an SSH session to a host for Docker operations. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * responses: + * 200: + * description: SSH connection established. + * 400: + * description: Missing sessionId or hostId. + * 401: + * description: Authentication required. + * 403: + * description: Docker is not enabled for this host. + * 404: + * description: Host not found. + * 500: + * description: SSH connection failed. + */ app.post("/docker/ssh/connect", async (req, res) => { const { sessionId, @@ -929,7 +956,29 @@ app.post("/docker/ssh/connect", async (req, res) => { } }); -// POST /docker/ssh/disconnect - Close SSH session +/** + * @openapi + * /docker/ssh/disconnect: + * post: + * summary: Disconnect SSH session + * description: Closes an active SSH session for Docker operations. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * responses: + * 200: + * description: SSH session disconnected. + * 400: + * description: Session ID is required. + */ app.post("/docker/ssh/disconnect", async (req, res) => { const { sessionId } = req.body; @@ -942,7 +991,35 @@ app.post("/docker/ssh/disconnect", async (req, res) => { res.json({ success: true, message: "SSH session disconnected" }); }); -// POST /docker/ssh/connect-totp - Verify TOTP and complete connection +/** + * @openapi + * /docker/ssh/connect-totp: + * post: + * summary: Verify TOTP and complete connection + * description: Verifies the TOTP code and completes the SSH connection. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * totpCode: + * type: string + * responses: + * 200: + * description: TOTP verified, SSH connection established. + * 400: + * description: Session ID and TOTP code required. + * 401: + * description: Invalid TOTP code. + * 404: + * description: TOTP session expired. + */ app.post("/docker/ssh/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; const userId = (req as any).userId; @@ -1105,7 +1182,29 @@ app.post("/docker/ssh/connect-totp", async (req, res) => { session.finish(responses); }); -// POST /docker/ssh/keepalive - Keep session alive +/** + * @openapi + * /docker/ssh/keepalive: + * post: + * summary: Keep SSH session alive + * description: Keeps an active SSH session alive. + * tags: + * - Docker + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * responses: + * 200: + * description: Session keepalive successful. + * 400: + * description: Session ID is required or session not found. + */ app.post("/docker/ssh/keepalive", async (req, res) => { const { sessionId } = req.body; @@ -1133,7 +1232,26 @@ app.post("/docker/ssh/keepalive", async (req, res) => { }); }); -// GET /docker/ssh/status - Check session status +/** + * @openapi + * /docker/ssh/status: + * get: + * summary: Check SSH session status + * description: Checks the status of an active SSH session. + * tags: + * - Docker + * parameters: + * - in: query + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Session status. + * 400: + * description: Session ID is required. + */ app.get("/docker/ssh/status", async (req, res) => { const sessionId = req.query.sessionId as string; @@ -1146,7 +1264,28 @@ app.get("/docker/ssh/status", async (req, res) => { res.json({ success: true, connected: isConnected }); }); -// GET /docker/validate/:sessionId - Validate Docker availability +/** + * @openapi + * /docker/validate/{sessionId}: + * get: + * summary: Validate Docker availability + * description: Validates if Docker is available on the host. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Docker availability status. + * 400: + * description: SSH session not found or not connected. + * 500: + * description: Validation failed. + */ app.get("/docker/validate/:sessionId", async (req, res) => { const { sessionId } = req.params; const userId = (req as any).userId; @@ -1236,7 +1375,32 @@ app.get("/docker/validate/:sessionId", async (req, res) => { } }); -// GET /docker/containers/:sessionId - List all containers +/** + * @openapi + * /docker/containers/{sessionId}: + * get: + * summary: List all containers + * description: Lists all Docker containers on the host. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: query + * name: all + * schema: + * type: boolean + * responses: + * 200: + * description: A list of containers. + * 400: + * description: SSH session not found or not connected. + * 500: + * description: Failed to list containers. + */ app.get("/docker/containers/:sessionId", async (req, res) => { const { sessionId } = req.params; const all = req.query.all !== "false"; @@ -1297,7 +1461,35 @@ app.get("/docker/containers/:sessionId", async (req, res) => { } }); -// GET /docker/containers/:sessionId/:containerId - Get container details +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}: + * get: + * summary: Get container details + * description: Retrieves detailed information about a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Container details. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to get container details. + */ app.get("/docker/containers/:sessionId/:containerId", async (req, res) => { const { sessionId, containerId } = req.params; const userId = (req as any).userId; @@ -1356,7 +1548,35 @@ app.get("/docker/containers/:sessionId/:containerId", async (req, res) => { } }); -// POST /docker/containers/:sessionId/:containerId/start - Start container +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/start: + * post: + * summary: Start container + * description: Starts a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Container started successfully. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to start container. + */ app.post( "/docker/containers/:sessionId/:containerId/start", async (req, res) => { @@ -1414,7 +1634,35 @@ app.post( }, ); -// POST /docker/containers/:sessionId/:containerId/stop - Stop container +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/stop: + * post: + * summary: Stop container + * description: Stops a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Container stopped successfully. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to stop container. + */ app.post( "/docker/containers/:sessionId/:containerId/stop", async (req, res) => { @@ -1472,7 +1720,35 @@ app.post( }, ); -// POST /docker/containers/:sessionId/:containerId/restart - Restart container +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/restart: + * post: + * summary: Restart container + * description: Restarts a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Container restarted successfully. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to restart container. + */ app.post( "/docker/containers/:sessionId/:containerId/restart", async (req, res) => { @@ -1530,7 +1806,35 @@ app.post( }, ); -// POST /docker/containers/:sessionId/:containerId/pause - Pause container +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/pause: + * post: + * summary: Pause container + * description: Pauses a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Container paused successfully. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to pause container. + */ app.post( "/docker/containers/:sessionId/:containerId/pause", async (req, res) => { @@ -1588,7 +1892,35 @@ app.post( }, ); -// POST /docker/containers/:sessionId/:containerId/unpause - Unpause container +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/unpause: + * post: + * summary: Unpause container + * description: Unpauses a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Container unpaused successfully. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to unpause container. + */ app.post( "/docker/containers/:sessionId/:containerId/unpause", async (req, res) => { @@ -1646,7 +1978,39 @@ app.post( }, ); -// DELETE /docker/containers/:sessionId/:containerId/remove - Remove container +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/remove: + * delete: + * summary: Remove container + * description: Removes a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * - in: query + * name: force + * schema: + * type: boolean + * responses: + * 200: + * description: Container removed successfully. + * 400: + * description: SSH session not found or not connected, or cannot remove a running container. + * 404: + * description: Container not found. + * 500: + * description: Failed to remove container. + */ app.delete( "/docker/containers/:sessionId/:containerId/remove", async (req, res) => { @@ -1718,7 +2082,51 @@ app.delete( }, ); -// GET /docker/containers/:sessionId/:containerId/logs - Get container logs +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/logs: + * get: + * summary: Get container logs + * description: Retrieves logs for a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * - in: query + * name: tail + * schema: + * type: integer + * - in: query + * name: timestamps + * schema: + * type: boolean + * - in: query + * name: since + * schema: + * type: string + * - in: query + * name: until + * schema: + * type: string + * responses: + * 200: + * description: Container logs. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to get container logs. + */ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => { const { sessionId, containerId } = req.params; const tail = req.query.tail ? parseInt(req.query.tail as string) : 100; @@ -1795,7 +2203,35 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => { } }); -// GET /docker/containers/:sessionId/:containerId/stats - Get container stats +/** + * @openapi + * /docker/containers/{sessionId}/{containerId}/stats: + * get: + * summary: Get container stats + * description: Retrieves stats for a specific container. + * tags: + * - Docker + * parameters: + * - in: path + * name: sessionId + * required: true + * schema: + * type: string + * - in: path + * name: containerId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Container stats. + * 400: + * description: SSH session not found or not connected. + * 404: + * description: Container not found. + * 500: + * description: Failed to get container stats. + */ app.get( "/docker/containers/:sessionId/:containerId/stats", async (req, res) => { diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index bcfa80f6..2e5ade28 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -416,6 +416,24 @@ function detectBinary(buffer: Buffer): boolean { return nullBytes / sampleSize > 0.01; } +/** + * @openapi + * /ssh/file_manager/ssh/connect: + * post: + * summary: Connect to SSH for file manager + * description: Establishes an SSH connection for file manager operations. + * tags: + * - File Manager + * responses: + * 200: + * description: SSH connection established. + * 400: + * description: Missing SSH connection parameters. + * 401: + * description: Authentication required. + * 500: + * description: SSH connection failed. + */ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { const { sessionId, @@ -986,6 +1004,26 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => { } }); +/** + * @openapi + * /ssh/file_manager/ssh/connect-totp: + * post: + * summary: Verify TOTP and complete connection + * description: Verifies the TOTP code and completes the SSH connection for file manager. + * tags: + * - File Manager + * responses: + * 200: + * description: TOTP verified, SSH connection established. + * 400: + * description: Session ID and TOTP code required. + * 401: + * description: Invalid TOTP code or authentication required. + * 404: + * description: TOTP session expired. + * 408: + * description: TOTP session timeout. + */ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; @@ -1149,18 +1187,71 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => { session.finish(responses); }); +/** + * @openapi + * /ssh/file_manager/ssh/disconnect: + * post: + * summary: Disconnect from SSH + * description: Closes an active SSH connection for file manager. + * tags: + * - File Manager + * responses: + * 200: + * description: SSH connection disconnected. + */ app.post("/ssh/file_manager/ssh/disconnect", (req, res) => { const { sessionId } = req.body; cleanupSession(sessionId); res.json({ status: "success", message: "SSH connection disconnected" }); }); +/** + * @openapi + * /ssh/file_manager/ssh/status: + * get: + * summary: Get SSH connection status + * description: Checks the status of an SSH connection for file manager. + * tags: + * - File Manager + * parameters: + * - in: query + * name: sessionId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: SSH connection status. + */ app.get("/ssh/file_manager/ssh/status", (req, res) => { const sessionId = req.query.sessionId as string; const isConnected = !!sshSessions[sessionId]?.isConnected; res.json({ status: "success", connected: isConnected }); }); +/** + * @openapi + * /ssh/file_manager/ssh/keepalive: + * post: + * summary: Keep SSH session alive + * description: Keeps an active SSH session for file manager alive. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * responses: + * 200: + * description: Session keepalive successful. + * 400: + * description: Session ID is required or session not found. + */ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => { const { sessionId } = req.body; @@ -1188,6 +1279,33 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/listFiles: + * get: + * summary: List files in a directory + * description: Lists the files and directories in a given path on the remote host. + * tags: + * - File Manager + * parameters: + * - in: query + * name: sessionId + * required: true + * schema: + * type: string + * - in: query + * name: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: A list of files and directories. + * 400: + * description: Session ID is required or SSH connection not established. + * 500: + * description: Failed to list files. + */ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; @@ -1387,6 +1505,33 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => { trySFTP(); }); +/** + * @openapi + * /ssh/file_manager/ssh/identifySymlink: + * get: + * summary: Identify symbolic link + * description: Identifies the target of a symbolic link. + * tags: + * - File Manager + * parameters: + * - in: query + * name: sessionId + * required: true + * schema: + * type: string + * - in: query + * name: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Symbolic link information. + * 400: + * description: Missing required parameters or SSH connection not established. + * 500: + * description: Failed to identify symbolic link. + */ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; @@ -1454,6 +1599,35 @@ app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/readFile: + * get: + * summary: Read a file + * description: Reads the content of a file from the remote host. + * tags: + * - File Manager + * parameters: + * - in: query + * name: sessionId + * required: true + * schema: + * type: string + * - in: query + * name: path + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The content of the file. + * 400: + * description: Missing required parameters or file too large. + * 404: + * description: File not found. + * 500: + * description: Failed to read file. + */ app.get("/ssh/file_manager/ssh/readFile", (req, res) => { const sessionId = req.query.sessionId as string; const sshConn = sshSessions[sessionId]; @@ -1592,6 +1766,35 @@ app.get("/ssh/file_manager/ssh/readFile", (req, res) => { ); }); +/** + * @openapi + * /ssh/file_manager/ssh/writeFile: + * post: + * summary: Write to a file + * description: Writes content to a file on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * content: + * type: string + * responses: + * 200: + * description: File written successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 500: + * description: Failed to write file. + */ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { const { sessionId, path: filePath, content } = req.body; const sshConn = sshSessions[sessionId]; @@ -1737,10 +1940,15 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { if (err) { fileLogger.error("Fallback write command failed:", err); if (!res.headersSent) { - return res.status(500).json({ - error: `Write failed: ${err.message}`, - toast: { type: "error", message: `Write failed: ${err.message}` }, - }); + return res + .status(500) + .json({ + error: `Write failed: ${err.message}`, + toast: { + type: "error", + message: `Write failed: ${err.message}`, + }, + }); } return; } @@ -1803,6 +2011,37 @@ app.post("/ssh/file_manager/ssh/writeFile", async (req, res) => { trySFTP(); }); +/** + * @openapi + * /ssh/file_manager/ssh/uploadFile: + * post: + * summary: Upload a file + * description: Uploads a file to the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * content: + * type: string + * fileName: + * type: string + * responses: + * 200: + * description: File uploaded successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 500: + * description: Failed to upload file. + */ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { const { sessionId, path: filePath, content, fileName } = req.body; const sshConn = sshSessions[sessionId]; @@ -2103,6 +2342,37 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => { trySFTP(); }); +/** + * @openapi + * /ssh/file_manager/ssh/createFile: + * post: + * summary: Create a file + * description: Creates an empty file on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * fileName: + * type: string + * responses: + * 200: + * description: File created successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 403: + * description: Permission denied. + * 500: + * description: Failed to create file. + */ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { const { sessionId, path: filePath, fileName } = req.body; const sshConn = sshSessions[sessionId]; @@ -2204,6 +2474,37 @@ app.post("/ssh/file_manager/ssh/createFile", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/createFolder: + * post: + * summary: Create a folder + * description: Creates a new folder on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * folderName: + * type: string + * responses: + * 200: + * description: Folder created successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 403: + * description: Permission denied. + * 500: + * description: Failed to create folder. + */ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => { const { sessionId, path: folderPath, folderName } = req.body; const sshConn = sshSessions[sessionId]; @@ -2305,6 +2606,37 @@ app.post("/ssh/file_manager/ssh/createFolder", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/deleteItem: + * delete: + * summary: Delete a file or directory + * description: Deletes a file or directory on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * isDirectory: + * type: boolean + * responses: + * 200: + * description: Item deleted successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 403: + * description: Permission denied. + * 500: + * description: Failed to delete item. + */ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { const { sessionId, path: itemPath, isDirectory } = req.body; const sshConn = sshSessions[sessionId]; @@ -2407,6 +2739,37 @@ app.delete("/ssh/file_manager/ssh/deleteItem", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/renameItem: + * put: + * summary: Rename a file or directory + * description: Renames a file or directory on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * oldPath: + * type: string + * newName: + * type: string + * responses: + * 200: + * description: Item renamed successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 403: + * description: Permission denied. + * 500: + * description: Failed to rename item. + */ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => { const { sessionId, oldPath, newName } = req.body; const sshConn = sshSessions[sessionId]; @@ -2515,6 +2878,39 @@ app.put("/ssh/file_manager/ssh/renameItem", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/moveItem: + * put: + * summary: Move a file or directory + * description: Moves a file or directory on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * oldPath: + * type: string + * newPath: + * type: string + * responses: + * 200: + * description: Item moved successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 403: + * description: Permission denied. + * 408: + * description: Move operation timed out. + * 500: + * description: Failed to move item. + */ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { const { sessionId, oldPath, newPath } = req.body; const sshConn = sshSessions[sessionId]; @@ -2640,6 +3036,37 @@ app.put("/ssh/file_manager/ssh/moveItem", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/downloadFile: + * post: + * summary: Download a file + * description: Downloads a file from the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * hostId: + * type: integer + * userId: + * type: string + * responses: + * 200: + * description: The file content. + * 400: + * description: Missing required parameters or file too large. + * 500: + * description: Failed to download file. + */ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { const { sessionId, path: filePath, hostId, userId } = req.body; @@ -2741,6 +3168,39 @@ app.post("/ssh/file_manager/ssh/downloadFile", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/copyItem: + * post: + * summary: Copy a file or directory + * description: Copies a file or directory on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * sourcePath: + * type: string + * targetDir: + * type: string + * hostId: + * type: integer + * userId: + * type: string + * responses: + * 200: + * description: Item copied successfully. + * 400: + * description: Missing required parameters or SSH connection not established. + * 500: + * description: Failed to copy item. + */ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { const { sessionId, sourcePath, targetDir, hostId, userId } = req.body; @@ -2904,6 +3364,33 @@ app.post("/ssh/file_manager/ssh/copyItem", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/executeFile: + * post: + * summary: Execute a file + * description: Executes a file on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * filePath: + * type: string + * responses: + * 200: + * description: File execution result. + * 400: + * description: Missing required parameters or SSH connection not available. + * 500: + * description: Failed to execute file. + */ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { const { sessionId, filePath } = req.body; const sshConn = sshSessions[sessionId]; @@ -3002,6 +3489,37 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { }); }); +/** + * @openapi + * /ssh/file_manager/ssh/changePermissions: + * post: + * summary: Change file permissions + * description: Changes the permissions of a file on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * path: + * type: string + * permissions: + * type: string + * responses: + * 200: + * description: Permissions changed successfully. + * 400: + * description: Missing required parameters or SSH connection not available. + * 408: + * description: Permission change timed out. + * 500: + * description: Failed to change permissions. + */ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { const { sessionId, path, permissions } = req.body; const sshConn = sshSessions[sessionId]; @@ -3319,8 +3837,39 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { }); }); -// Route: Compress files/folders (requires JWT) -// POST /ssh/file_manager/ssh/compressFiles +/** + * @openapi + * /ssh/file_manager/ssh/compressFiles: + * post: + * summary: Compress files + * description: Compresses files and/or directories on the remote host. + * tags: + * - File Manager + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * paths: + * type: array + * items: + * type: string + * archiveName: + * type: string + * format: + * type: string + * responses: + * 200: + * description: Files compressed successfully. + * 400: + * description: Missing required parameters or unsupported compression format. + * 500: + * description: Failed to compress files. + */ app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => { const { sessionId, paths, archiveName, format } = req.body; diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index f9e20f8a..dde7451f 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -1803,6 +1803,10 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ } catch (e) { statsLogger.debug("Failed to collect ports metrics", { operation: "ports_metrics_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + let firewall: { type: "iptables" | "nftables" | "none"; status: "active" | "inactive" | "unknown"; @@ -1920,6 +1924,20 @@ function tcpPing( }); } +/** + * @openapi + * /status: + * get: + * summary: Get all host statuses + * description: Retrieves the status of all hosts for the authenticated user. + * tags: + * - Server Stats + * responses: + * 200: + * description: A map of host IDs to their status entries. + * 401: + * description: Session expired - please log in again. + */ app.get("/status", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; @@ -1942,6 +1960,28 @@ app.get("/status", async (req, res) => { res.json(result); }); +/** + * @openapi + * /status/{id}: + * get: + * summary: Get host status by ID + * description: Retrieves the status of a specific host by its ID. + * tags: + * - Server Stats + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Host status entry. + * 401: + * description: Session expired - please log in again. + * 404: + * description: Status not available. + */ app.get("/status/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; @@ -1966,6 +2006,20 @@ app.get("/status/:id", validateHostId, async (req, res) => { res.json(statusEntry); }); +/** + * @openapi + * /clear-connections: + * post: + * summary: Clear all SSH connections + * description: Clears all SSH connections from the connection pool. + * tags: + * - Server Stats + * responses: + * 200: + * description: All SSH connections cleared. + * 401: + * description: Session expired - please log in again. + */ app.post("/clear-connections", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; @@ -1980,6 +2034,20 @@ app.post("/clear-connections", async (req, res) => { res.json({ message: "All SSH connections cleared" }); }); +/** + * @openapi + * /refresh: + * post: + * summary: Refresh polling + * description: Clears all SSH connections and refreshes host polling. + * tags: + * - Server Stats + * responses: + * 200: + * description: Polling refreshed. + * 401: + * description: Session expired - please log in again. + */ app.post("/refresh", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; @@ -1996,6 +2064,35 @@ app.post("/refresh", async (req, res) => { res.json({ message: "Polling refreshed" }); }); +/** + * @openapi + * /host-updated: + * post: + * summary: Start polling for updated host + * description: Starts polling for a specific host after it has been updated. + * tags: + * - Server Stats + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * responses: + * 200: + * description: Host polling started. + * 400: + * description: Invalid hostId. + * 401: + * description: Session expired - please log in again. + * 404: + * description: Host not found. + * 500: + * description: Failed to start polling. + */ app.post("/host-updated", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; @@ -2031,6 +2128,33 @@ app.post("/host-updated", async (req, res) => { } }); +/** + * @openapi + * /host-deleted: + * post: + * summary: Stop polling for deleted host + * description: Stops polling for a specific host after it has been deleted. + * tags: + * - Server Stats + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * responses: + * 200: + * description: Host polling stopped. + * 400: + * description: Invalid hostId. + * 401: + * description: Session expired - please log in again. + * 500: + * description: Failed to stop polling. + */ app.post("/host-deleted", async (req, res) => { const userId = (req as AuthenticatedRequest).userId; const { hostId } = req.body; @@ -2059,6 +2183,28 @@ app.post("/host-deleted", async (req, res) => { } }); +/** + * @openapi + * /metrics/{id}: + * get: + * summary: Get host metrics + * description: Retrieves current metrics for a specific host including CPU, memory, disk, network, processes, and system information. + * tags: + * - Server Stats + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Host metrics data. + * 401: + * description: Session expired - please log in again. + * 404: + * description: Metrics not available. + */ app.get("/metrics/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; @@ -2096,6 +2242,30 @@ app.get("/metrics/:id", validateHostId, async (req, res) => { }); }); +/** + * @openapi + * /metrics/start/{id}: + * post: + * summary: Start metrics collection + * description: Establishes an SSH connection and starts collecting metrics for a specific host. + * tags: + * - Server Stats + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Metrics collection started successfully, or TOTP required. + * 401: + * description: Session expired - please log in again. + * 404: + * description: Host not found. + * 500: + * description: Failed to start metrics collection. + */ app.post("/metrics/start/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; @@ -2275,6 +2445,37 @@ app.post("/metrics/start/:id", validateHostId, async (req, res) => { } }); +/** + * @openapi + * /metrics/stop/{id}: + * post: + * summary: Stop metrics collection + * description: Stops metrics collection for a specific host and cleans up the SSH session. + * tags: + * - Server Stats + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * viewerSessionId: + * type: string + * responses: + * 200: + * description: Metrics collection stopped successfully. + * 401: + * description: Session expired - please log in again. + * 500: + * description: Failed to stop metrics collection. + */ app.post("/metrics/stop/:id", validateHostId, async (req, res) => { const id = Number(req.params.id); const userId = (req as AuthenticatedRequest).userId; @@ -2317,6 +2518,37 @@ app.post("/metrics/stop/:id", validateHostId, async (req, res) => { } }); +/** + * @openapi + * /metrics/connect-totp: + * post: + * summary: Complete TOTP verification for metrics + * description: Verifies the TOTP code and completes the metrics SSH connection. + * tags: + * - Server Stats + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * sessionId: + * type: string + * totpCode: + * type: string + * responses: + * 200: + * description: TOTP verified, metrics connection established. + * 400: + * description: Missing sessionId or totpCode. + * 401: + * description: Session expired or invalid TOTP code. + * 404: + * description: TOTP session not found or expired. + * 500: + * description: Failed to verify TOTP. + */ app.post("/metrics/connect-totp", async (req, res) => { const { sessionId, totpCode } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -2452,6 +2684,35 @@ app.post("/metrics/connect-totp", async (req, res) => { } }); +/** + * @openapi + * /metrics/heartbeat: + * post: + * summary: Update viewer heartbeat + * description: Updates the heartbeat timestamp for a metrics viewer session to keep it alive. + * tags: + * - Server Stats + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * viewerSessionId: + * type: string + * responses: + * 200: + * description: Heartbeat updated successfully. + * 400: + * description: Invalid viewerSessionId. + * 401: + * description: Session expired - please log in again. + * 404: + * description: Viewer session not found. + * 500: + * description: Failed to update heartbeat. + */ app.post("/metrics/heartbeat", async (req, res) => { const { viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -2484,6 +2745,33 @@ app.post("/metrics/heartbeat", async (req, res) => { } }); +/** + * @openapi + * /metrics/register-viewer: + * post: + * summary: Register metrics viewer + * description: Registers a new viewer session for a host to track who is viewing metrics. + * tags: + * - Server Stats + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * responses: + * 200: + * description: Viewer registered successfully. + * 400: + * description: Invalid hostId. + * 401: + * description: Session expired - please log in again. + * 500: + * description: Failed to register viewer. + */ app.post("/metrics/register-viewer", async (req, res) => { const { hostId } = req.body; const userId = (req as AuthenticatedRequest).userId; @@ -2514,6 +2802,35 @@ app.post("/metrics/register-viewer", async (req, res) => { } }); +/** + * @openapi + * /metrics/unregister-viewer: + * post: + * summary: Unregister metrics viewer + * description: Unregisters a viewer session when they stop viewing metrics for a host. + * tags: + * - Server Stats + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * hostId: + * type: integer + * viewerSessionId: + * type: string + * responses: + * 200: + * description: Viewer unregistered successfully. + * 400: + * description: Invalid hostId or viewerSessionId. + * 401: + * description: Session expired - please log in again. + * 500: + * description: Failed to unregister viewer. + */ app.post("/metrics/unregister-viewer", async (req, res) => { const { hostId, viewerSessionId } = req.body; const userId = (req as AuthenticatedRequest).userId; diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index fbef615b..632fa109 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -1450,10 +1450,42 @@ async function killRemoteTunnelByMarker( } } +/** + * @openapi + * /ssh/tunnel/status: + * get: + * summary: Get all tunnel statuses + * description: Retrieves the status of all SSH tunnels. + * tags: + * - SSH Tunnels + * responses: + * 200: + * description: A list of all tunnel statuses. + */ app.get("/ssh/tunnel/status", (req, res) => { res.json(getAllTunnelStatus()); }); +/** + * @openapi + * /ssh/tunnel/status/{tunnelName}: + * get: + * summary: Get tunnel status by name + * description: Retrieves the status of a specific SSH tunnel by its name. + * tags: + * - SSH Tunnels + * parameters: + * - in: path + * name: tunnelName + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Tunnel status. + * 404: + * description: Tunnel not found. + */ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => { const { tunnelName } = req.params; const status = connectionStatus.get(tunnelName); @@ -1465,6 +1497,39 @@ app.get("/ssh/tunnel/status/:tunnelName", (req, res) => { res.json({ name: tunnelName, status }); }); +/** + * @openapi + * /ssh/tunnel/connect: + * post: + * summary: Connect SSH tunnel + * description: Establishes an SSH tunnel connection with the specified configuration. + * tags: + * - SSH Tunnels + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * sourceHostId: + * type: integer + * tunnelIndex: + * type: integer + * responses: + * 200: + * description: Connection request received. + * 400: + * description: Invalid tunnel configuration. + * 401: + * description: Authentication required. + * 403: + * description: Access denied to this host. + * 500: + * description: Failed to connect tunnel. + */ app.post( "/ssh/tunnel/connect", authenticateJWT, @@ -1619,6 +1684,35 @@ app.post( }, ); +/** + * @openapi + * /ssh/tunnel/disconnect: + * post: + * summary: Disconnect SSH tunnel + * description: Disconnects an active SSH tunnel. + * tags: + * - SSH Tunnels + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * tunnelName: + * type: string + * responses: + * 200: + * description: Disconnect request received. + * 400: + * description: Tunnel name required. + * 401: + * description: Authentication required. + * 403: + * description: Access denied. + * 500: + * description: Failed to disconnect tunnel. + */ app.post( "/ssh/tunnel/disconnect", authenticateJWT, @@ -1683,6 +1777,35 @@ app.post( }, ); +/** + * @openapi + * /ssh/tunnel/cancel: + * post: + * summary: Cancel tunnel retry + * description: Cancels the retry mechanism for a failed SSH tunnel connection. + * tags: + * - SSH Tunnels + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * tunnelName: + * type: string + * responses: + * 200: + * description: Cancel request received. + * 400: + * description: Tunnel name required. + * 401: + * description: Authentication required. + * 403: + * description: Access denied. + * 500: + * description: Failed to cancel tunnel retry. + */ app.post( "/ssh/tunnel/cancel", authenticateJWT, diff --git a/src/backend/utils/user-data-import.ts b/src/backend/utils/user-data-import.ts index ab161e2c..b06035a1 100644 --- a/src/backend/utils/user-data-import.ts +++ b/src/backend/utils/user-data-import.ts @@ -194,7 +194,7 @@ class UserDataImport { continue; } - const newHostData = { + const newHostData: any = { ...host, userId: targetUserId, updatedAt: new Date().toISOString(), @@ -204,7 +204,7 @@ class UserDataImport { newHostData.createdAt = new Date().toISOString(); } - let processedHostData = newHostData; + let processedHostData: any = newHostData; if (options.userDataKey) { processedHostData = DataCrypto.encryptRecord( "ssh_data", @@ -275,7 +275,7 @@ class UserDataImport { continue; } - const newCredentialData = { + const newCredentialData: any = { ...credential, userId: targetUserId, updatedAt: new Date().toISOString(), @@ -287,7 +287,7 @@ class UserDataImport { newCredentialData.createdAt = new Date().toISOString(); } - let processedCredentialData = newCredentialData; + let processedCredentialData: any = newCredentialData; if (options.userDataKey) { processedCredentialData = DataCrypto.encryptRecord( "ssh_credentials", diff --git a/src/locales/en.json b/src/locales/en.json index 9781e412..bf3c0ba1 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1740,6 +1740,7 @@ "state": "State", "process": "Process", "noData": "No listening ports data" + }, "firewall": { "title": "Firewall", "active": "Active", diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index 31477eb2..f8e18fc0 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -7,7 +7,8 @@ export type WidgetType = | "processes" | "system" | "login_stats" - | "ports"; + | "ports" + | "firewall"; export interface ListeningPort { protocol: "tcp" | "udp"; @@ -21,7 +22,7 @@ export interface ListeningPort { export interface PortsMetrics { source: "ss" | "netstat" | "none"; ports: ListeningPort[]; - | "firewall"; +} export interface FirewallRule { chain: string; diff --git a/src/ui/desktop/apps/HostManagerApp.tsx b/src/ui/desktop/apps/HostManagerApp.tsx index fce7e0df..6feb771b 100644 --- a/src/ui/desktop/apps/HostManagerApp.tsx +++ b/src/ui/desktop/apps/HostManagerApp.tsx @@ -1,4 +1,4 @@ -import { HostManager } from "@/ui/desktop/apps/host-manager/HostManager"; +import { HostManager } from "@/ui/desktop/apps/host-manager/hosts/HostManager"; import React from "react"; const HostManagerApp: React.FC = () => { diff --git a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx index 20ab7472..48f9f654 100644 --- a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx +++ b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx @@ -270,7 +270,8 @@ export function ServerStats({ case "ports": return ( - + ); + case "firewall": return ( diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index 5fcb0618..775aef16 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -361,6 +361,7 @@ export function AppView({ rightSidebarOpen={rightSidebarOpen} rightSidebarWidth={rightSidebarWidth} isStandalone={true} + /> ) : t.type === "tunnel" ? (