Fix RBAC role system bugs and improve UX #446
@@ -93,6 +93,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/rbac(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -90,6 +90,15 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/rbac(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location ~ ^/credentials(/.*)?$ {
|
||||
proxy_pass http://127.0.0.1:30001;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
@@ -8,6 +8,7 @@ import alertRoutes from "./routes/alerts.js";
|
||||
import credentialsRoutes from "./routes/credentials.js";
|
||||
import snippetsRoutes from "./routes/snippets.js";
|
||||
import terminalRoutes from "./routes/terminal.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
import fs from "fs";
|
||||
@@ -1436,6 +1437,7 @@ app.use("/alerts", alertRoutes);
|
||||
app.use("/credentials", credentialsRoutes);
|
||||
app.use("/snippets", snippetsRoutes);
|
||||
app.use("/terminal", terminalRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
|
||||
app.use(
|
||||
(
|
||||
|
||||
@@ -330,6 +330,81 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
resource_name TEXT,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
try {
|
||||
@@ -559,6 +634,331 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC Phase 1: Host Access table
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS host_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT,
|
||||
role_id INTEGER,
|
||||
granted_by TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'use',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed_at TEXT,
|
||||
access_count INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
databaseLogger.info("Created host_access table", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create host_access table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: Add role_id column to existing host_access table
|
||||
try {
|
||||
sqlite.prepare("SELECT role_id FROM host_access LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec("ALTER TABLE host_access ADD COLUMN role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE");
|
||||
databaseLogger.info("Added role_id column to host_access table", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (alterError) {
|
||||
databaseLogger.warn("Failed to add role_id column", {
|
||||
operation: "schema_migration",
|
||||
error: alterError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC Phase 2: Roles tables
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM roles LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
permissions TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
databaseLogger.info("Created roles table", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create roles table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM user_roles LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_roles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
role_id INTEGER NOT NULL,
|
||||
granted_by TEXT,
|
||||
granted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
databaseLogger.info("Created user_roles table", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create user_roles table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// RBAC Phase 3: Audit logging tables
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM audit_logs LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_id TEXT,
|
||||
resource_name TEXT,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
error_message TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
databaseLogger.info("Created audit_logs table", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create audit_logs table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM session_recordings LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS session_recordings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_id INTEGER,
|
||||
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at TEXT,
|
||||
duration INTEGER,
|
||||
commands TEXT,
|
||||
dangerous_actions TEXT,
|
||||
recording_path TEXT,
|
||||
terminated_by_owner INTEGER DEFAULT 0,
|
||||
termination_reason TEXT,
|
||||
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
|
||||
);
|
||||
`);
|
||||
databaseLogger.info("Created session_recordings table", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create session_recordings table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old system roles and seed correct ones
|
||||
try {
|
||||
// First, check what roles exist
|
||||
const existingRoles = sqlite.prepare("SELECT name, is_system FROM roles").all() as Array<{ name: string; is_system: number }>;
|
||||
databaseLogger.info("Current roles in database", {
|
||||
operation: "schema_migration",
|
||||
roles: existingRoles,
|
||||
});
|
||||
|
||||
// Migration: Remove ALL old unwanted roles (system or not) and keep only admin and user
|
||||
try {
|
||||
const validSystemRoles = ['admin', 'user'];
|
||||
const unwantedRoleNames = ['superAdmin', 'powerUser', 'readonly', 'member'];
|
||||
let deletedCount = 0;
|
||||
|
||||
// First delete known unwanted role names
|
||||
const deleteByName = sqlite.prepare("DELETE FROM roles WHERE name = ?");
|
||||
for (const roleName of unwantedRoleNames) {
|
||||
const result = deleteByName.run(roleName);
|
||||
if (result.changes > 0) {
|
||||
deletedCount += result.changes;
|
||||
databaseLogger.info(`Deleted role by name: ${roleName}`, {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Then delete any system roles that are not admin or user
|
||||
const deleteOldSystemRole = sqlite.prepare("DELETE FROM roles WHERE name = ? AND is_system = 1");
|
||||
for (const role of existingRoles) {
|
||||
if (role.is_system === 1 && !validSystemRoles.includes(role.name) && !unwantedRoleNames.includes(role.name)) {
|
||||
const result = deleteOldSystemRole.run(role.name);
|
||||
if (result.changes > 0) {
|
||||
deletedCount += result.changes;
|
||||
databaseLogger.info(`Deleted system role: ${role.name}`, {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.info("Cleanup completed", {
|
||||
operation: "schema_migration",
|
||||
deletedCount,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
databaseLogger.warn("Failed to clean up old system roles", {
|
||||
operation: "schema_migration",
|
||||
error: cleanupError,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure only admin and user system roles exist
|
||||
const systemRoles = [
|
||||
{
|
||||
name: "admin",
|
||||
displayName: "rbac.roles.admin",
|
||||
description: "Administrator with full access",
|
||||
permissions: null,
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
displayName: "rbac.roles.user",
|
||||
description: "Regular user",
|
||||
permissions: null,
|
||||
},
|
||||
];
|
||||
|
||||
for (const role of systemRoles) {
|
||||
const existingRole = sqlite.prepare("SELECT id FROM roles WHERE name = ?").get(role.name);
|
||||
if (!existingRole) {
|
||||
// Create if doesn't exist
|
||||
try {
|
||||
sqlite.prepare(`
|
||||
INSERT INTO roles (name, display_name, description, is_system, permissions)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
`).run(role.name, role.displayName, role.description, role.permissions);
|
||||
} catch (insertError) {
|
||||
databaseLogger.warn(`Failed to create system role: ${role.name}`, {
|
||||
operation: "schema_migration",
|
||||
error: insertError,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.info("System roles migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
// Migrate existing is_admin users to roles
|
||||
try {
|
||||
const adminUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 1").all() as { id: string }[];
|
||||
const normalUsers = sqlite.prepare("SELECT id FROM users WHERE is_admin = 0").all() as { id: string }[];
|
||||
|
||||
const adminRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'admin'").get() as { id: number } | undefined;
|
||||
const userRole = sqlite.prepare("SELECT id FROM roles WHERE name = 'user'").get() as { id: number } | undefined;
|
||||
|
||||
if (adminRole) {
|
||||
const insertUserRole = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
for (const admin of adminUsers) {
|
||||
try {
|
||||
insertUserRole.run(admin.id, adminRole.id);
|
||||
} catch (error) {
|
||||
// Ignore duplicate errors
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.info("Migrated admin users to admin role", {
|
||||
operation: "schema_migration",
|
||||
count: adminUsers.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (userRole) {
|
||||
const insertUserRole = sqlite.prepare(`
|
||||
INSERT OR IGNORE INTO user_roles (user_id, role_id, granted_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
for (const user of normalUsers) {
|
||||
try {
|
||||
insertUserRole.run(user.id, userRole.id);
|
||||
} catch (error) {
|
||||
// Ignore duplicate errors
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.info("Migrated normal users to user role", {
|
||||
operation: "schema_migration",
|
||||
count: normalUsers.length,
|
||||
});
|
||||
}
|
||||
} catch (migrationError) {
|
||||
databaseLogger.warn("Failed to migrate existing users to roles", {
|
||||
operation: "schema_migration",
|
||||
error: migrationError,
|
||||
});
|
||||
}
|
||||
} catch (seedError) {
|
||||
databaseLogger.warn("Failed to seed system roles", {
|
||||
operation: "schema_migration",
|
||||
error: seedError,
|
||||
});
|
||||
}
|
||||
|
||||
databaseLogger.success("Schema migration completed", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
|
||||
@@ -279,3 +279,140 @@ export const commandHistory = sqliteTable("command_history", {
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// RBAC Phase 1: Host Sharing
|
||||
export const hostAccess = sqliteTable("host_access", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
|
||||
// Share target: either userId OR roleId (at least one must be set)
|
||||
userId: text("user_id")
|
||||
.references(() => users.id, { onDelete: "cascade" }), // Optional
|
||||
roleId: integer("role_id")
|
||||
.references(() => roles.id, { onDelete: "cascade" }), // Optional
|
||||
|
||||
grantedBy: text("granted_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
// Permission level
|
||||
permissionLevel: text("permission_level")
|
||||
.notNull()
|
||||
.default("use"), // "view" | "use" | "manage"
|
||||
|
||||
// Time-based access
|
||||
expiresAt: text("expires_at"), // NULL = never expires
|
||||
|
||||
// Metadata
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastAccessedAt: text("last_accessed_at"),
|
||||
accessCount: integer("access_count").notNull().default(0),
|
||||
});
|
||||
|
||||
// RBAC Phase 2: Roles
|
||||
export const roles = sqliteTable("roles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull().unique(),
|
||||
displayName: text("display_name").notNull(), // For i18n
|
||||
description: text("description"),
|
||||
|
||||
// System roles cannot be deleted
|
||||
isSystem: integer("is_system", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
|
||||
// Permissions stored as JSON array (optional - used for grouping only in current phase)
|
||||
permissions: text("permissions"), // ["hosts.*", "credentials.read", ...] - optional
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const userRoles = sqliteTable("user_roles", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
roleId: integer("role_id")
|
||||
.notNull()
|
||||
.references(() => roles.id, { onDelete: "cascade" }),
|
||||
|
||||
grantedBy: text("granted_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
grantedAt: text("granted_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
// RBAC Phase 3: Audit Logging
|
||||
export const auditLogs = sqliteTable("audit_logs", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
// Who
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
username: text("username").notNull(), // Snapshot in case user deleted
|
||||
|
||||
// What
|
||||
action: text("action").notNull(), // "create", "read", "update", "delete", "share"
|
||||
resourceType: text("resource_type").notNull(), // "host", "credential", "user", "session"
|
||||
resourceId: text("resource_id"), // Can be text or number, store as text
|
||||
resourceName: text("resource_name"), // Human-readable identifier
|
||||
|
||||
// Context
|
||||
details: text("details"), // JSON: { oldValue, newValue, reason, ... }
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
|
||||
// Result
|
||||
success: integer("success", { mode: "boolean" }).notNull(),
|
||||
errorMessage: text("error_message"),
|
||||
|
||||
// When
|
||||
timestamp: text("timestamp")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sessionRecordings = sqliteTable("session_recordings", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
|
||||
hostId: integer("host_id")
|
||||
.notNull()
|
||||
.references(() => sshData.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accessId: integer("access_id").references(() => hostAccess.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
|
||||
// Session info
|
||||
startedAt: text("started_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
endedAt: text("ended_at"),
|
||||
duration: integer("duration"), // seconds
|
||||
|
||||
// Command log (lightweight)
|
||||
commands: text("commands"), // JSON: [{ts, cmd, exitCode, blocked}]
|
||||
dangerousActions: text("dangerous_actions"), // JSON: blocked commands
|
||||
|
||||
// Full recording (optional, heavy)
|
||||
recordingPath: text("recording_path"), // Path to .cast file
|
||||
|
||||
// Metadata
|
||||
terminatedByOwner: integer("terminated_by_owner", { mode: "boolean" })
|
||||
.default(false),
|
||||
terminationReason: text("termination_reason"),
|
||||
});
|
||||
|
||||
908
src/backend/database/routes/rbac.ts
Normal file
908
src/backend/database/routes/rbac.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import {
|
||||
hostAccess,
|
||||
sshData,
|
||||
users,
|
||||
roles,
|
||||
userRoles,
|
||||
auditLogs,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const permissionManager = PermissionManager.getInstance();
|
||||
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
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
|
||||
*/
|
||||
router.post(
|
||||
"/host/:id/share",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const hostId = parseInt(req.params.id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
targetType = "user", // "user" or "role"
|
||||
targetUserId,
|
||||
targetRoleId,
|
||||
durationHours,
|
||||
permissionLevel = "use",
|
||||
} = req.body;
|
||||
|
||||
// Validate target type
|
||||
if (!["user", "role"].includes(targetType)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid target type. Must be 'user' or 'role'" });
|
||||
}
|
||||
|
||||
// Validate required fields based on target type
|
||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target user ID is required when sharing with user" });
|
||||
}
|
||||
if (targetType === "role" && !targetRoleId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Target role ID is required when sharing with role" });
|
||||
}
|
||||
|
||||
// Verify user owns the host
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length === 0) {
|
||||
databaseLogger.warn("Attempt to share host not owned by user", {
|
||||
operation: "share_host",
|
||||
userId,
|
||||
hostId,
|
||||
});
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
// Verify target exists (user or role)
|
||||
if (targetType === "user") {
|
||||
const targetUser = await db
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "Target user not found" });
|
||||
}
|
||||
} else {
|
||||
const targetRole = await db
|
||||
.select({ id: roles.id, name: roles.name })
|
||||
.from(roles)
|
||||
.where(eq(roles.id, targetRoleId))
|
||||
.limit(1);
|
||||
|
||||
if (targetRole.length === 0) {
|
||||
return res.status(404).json({ error: "Target role not found" });
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate expiry time
|
||||
let expiresAt: string | null = null;
|
||||
if (
|
||||
durationHours &&
|
||||
typeof durationHours === "number" &&
|
||||
durationHours > 0
|
||||
) {
|
||||
const expiryDate = new Date();
|
||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
||||
expiresAt = expiryDate.toISOString();
|
||||
}
|
||||
|
||||
// Validate permission level
|
||||
const validLevels = ["view", "use", "manage"];
|
||||
if (!validLevels.includes(permissionLevel)) {
|
||||
return res.status(400).json({
|
||||
error: "Invalid permission level",
|
||||
validLevels,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if access already exists
|
||||
const whereConditions = [eq(hostAccess.hostId, hostId)];
|
||||
if (targetType === "user") {
|
||||
whereConditions.push(eq(hostAccess.userId, targetUserId));
|
||||
} else {
|
||||
whereConditions.push(eq(hostAccess.roleId, targetRoleId));
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(and(...whereConditions))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing access
|
||||
await db
|
||||
.update(hostAccess)
|
||||
.set({
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
})
|
||||
.where(eq(hostAccess.id, existing[0].id));
|
||||
|
||||
databaseLogger.info("Updated existing host access", {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
targetType,
|
||||
targetUserId: targetType === "user" ? targetUserId : undefined,
|
||||
targetRoleId: targetType === "role" ? targetRoleId : undefined,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "Host access updated",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Create new access
|
||||
const result = await db.insert(hostAccess).values({
|
||||
hostId,
|
||||
userId: targetType === "user" ? targetUserId : null,
|
||||
roleId: targetType === "role" ? targetRoleId : null,
|
||||
grantedBy: userId,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
databaseLogger.info("Created host access", {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
hostName: host[0].name,
|
||||
targetType,
|
||||
targetUserId: targetType === "user" ? targetUserId : undefined,
|
||||
targetRoleId: targetType === "role" ? targetRoleId : undefined,
|
||||
permissionLevel,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Host shared successfully with ${targetType}`,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to share host", error, {
|
||||
operation: "share_host",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to share host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Revoke host access
|
||||
* DELETE /rbac/host/:id/access/:accessId
|
||||
*/
|
||||
router.delete(
|
||||
"/host/:id/access/:accessId",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const hostId = parseInt(req.params.id, 10);
|
||||
const accessId = parseInt(req.params.accessId, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(hostId) || isNaN(accessId)) {
|
||||
return res.status(400).json({ error: "Invalid ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify user owns the host
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length === 0) {
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
// Delete the access
|
||||
await db.delete(hostAccess).where(eq(hostAccess.id, accessId));
|
||||
|
||||
databaseLogger.info("Revoked host access", {
|
||||
operation: "revoke_host_access",
|
||||
hostId,
|
||||
accessId,
|
||||
userId,
|
||||
});
|
||||
|
||||
res.json({ success: true, message: "Access revoked" });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to revoke host access", error, {
|
||||
operation: "revoke_host_access",
|
||||
hostId,
|
||||
accessId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to revoke access" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get host access list
|
||||
* GET /rbac/host/:id/access
|
||||
*/
|
||||
router.get(
|
||||
"/host/:id/access",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const hostId = parseInt(req.params.id, 10);
|
||||
const userId = req.userId!;
|
||||
|
||||
if (isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify user owns the host
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length === 0) {
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
// Get all access records (both user and role based)
|
||||
const rawAccessList = await db
|
||||
.select({
|
||||
id: hostAccess.id,
|
||||
userId: hostAccess.userId,
|
||||
roleId: hostAccess.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
grantedByUsername: sql<string>`(SELECT username FROM users WHERE id = ${hostAccess.grantedBy})`,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
createdAt: hostAccess.createdAt,
|
||||
lastAccessedAt: hostAccess.lastAccessedAt,
|
||||
accessCount: hostAccess.accessCount,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.leftJoin(users, eq(hostAccess.userId, users.id))
|
||||
.leftJoin(roles, eq(hostAccess.roleId, roles.id))
|
||||
.where(eq(hostAccess.hostId, hostId))
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
|
||||
// Format access list with type information
|
||||
const accessList = rawAccessList.map((access) => ({
|
||||
id: access.id,
|
||||
targetType: access.userId ? "user" : "role",
|
||||
userId: access.userId,
|
||||
roleId: access.roleId,
|
||||
username: access.username,
|
||||
roleName: access.roleName,
|
||||
roleDisplayName: access.roleDisplayName,
|
||||
grantedBy: access.grantedBy,
|
||||
grantedByUsername: access.grantedByUsername,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
createdAt: access.createdAt,
|
||||
lastAccessedAt: access.lastAccessedAt,
|
||||
accessCount: access.accessCount,
|
||||
}));
|
||||
|
||||
res.json({ accessList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get host access list", error, {
|
||||
operation: "get_host_access_list",
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get access list" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get user's shared hosts (hosts shared WITH this user)
|
||||
* GET /rbac/shared-hosts
|
||||
*/
|
||||
router.get(
|
||||
"/shared-hosts",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const userId = req.userId!;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const sharedHosts = await db
|
||||
.select({
|
||||
id: sshData.id,
|
||||
name: sshData.name,
|
||||
ip: sshData.ip,
|
||||
port: sshData.port,
|
||||
username: sshData.username,
|
||||
folder: sshData.folder,
|
||||
tags: sshData.tags,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
grantedBy: hostAccess.grantedBy,
|
||||
ownerUsername: users.username,
|
||||
})
|
||||
.from(hostAccess)
|
||||
.innerJoin(sshData, eq(hostAccess.hostId, sshData.id))
|
||||
.innerJoin(users, eq(sshData.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.userId, userId),
|
||||
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(hostAccess.createdAt));
|
||||
|
||||
res.json({ sharedHosts });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get shared hosts", error, {
|
||||
operation: "get_shared_hosts",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get shared hosts" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get all roles
|
||||
* GET /rbac/roles
|
||||
*/
|
||||
router.get(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const allRoles = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
|
||||
const rolesWithParsedPermissions = allRoles.map((role) => ({
|
||||
...role,
|
||||
permissions: JSON.parse(role.permissions),
|
||||
}));
|
||||
|
||||
res.json({ roles: rolesWithParsedPermissions });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get roles", error, {
|
||||
operation: "get_roles",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Role Management (CRUD)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get all roles
|
||||
* GET /rbac/roles
|
||||
*/
|
||||
router.get(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const rolesList = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
displayName: roles.displayName,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
createdAt: roles.createdAt,
|
||||
updatedAt: roles.updatedAt,
|
||||
})
|
||||
.from(roles)
|
||||
.orderBy(roles.isSystem, roles.name);
|
||||
|
||||
res.json({ roles: rolesList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get roles", error, {
|
||||
operation: "get_roles",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Create new role
|
||||
* POST /rbac/roles
|
||||
*/
|
||||
router.post(
|
||||
"/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { name, displayName, description } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!isNonEmptyString(name) || !isNonEmptyString(displayName)) {
|
||||
return res.status(400).json({
|
||||
error: "Role name and display name are required",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate name format (alphanumeric, underscore, hyphen only)
|
||||
if (!/^[a-z0-9_-]+$/.test(name)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Role name must contain only lowercase letters, numbers, underscores, and hyphens",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if role name already exists
|
||||
const existing = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, name))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({
|
||||
error: "A role with this name already exists",
|
||||
});
|
||||
}
|
||||
|
||||
// Create new role
|
||||
const result = await db.insert(roles).values({
|
||||
name,
|
||||
displayName,
|
||||
description: description || null,
|
||||
isSystem: false,
|
||||
permissions: null, // Roles are for grouping only
|
||||
});
|
||||
|
||||
const newRoleId = result.lastInsertRowid;
|
||||
|
||||
databaseLogger.info("Created new role", {
|
||||
operation: "create_role",
|
||||
roleId: newRoleId,
|
||||
roleName: name,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
roleId: newRoleId,
|
||||
message: "Role created successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to create role", error, {
|
||||
operation: "create_role",
|
||||
roleName: name,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to create role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Update role
|
||||
* PUT /rbac/roles/:id
|
||||
*/
|
||||
router.put(
|
||||
"/roles/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
const { displayName, description } = req.body;
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return res.status(400).json({ error: "Invalid role ID" });
|
||||
}
|
||||
|
||||
// Validate at least one field to update
|
||||
if (!displayName && description === undefined) {
|
||||
return res.status(400).json({
|
||||
error: "At least one field (displayName or description) is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get existing role
|
||||
const existingRole = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
isSystem: roles.isSystem,
|
||||
})
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (existingRole.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updates: {
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
updatedAt: string;
|
||||
} = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (displayName) {
|
||||
updates.displayName = displayName;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.description = description || null;
|
||||
}
|
||||
|
||||
// Update role
|
||||
await db.update(roles).set(updates).where(eq(roles.id, roleId));
|
||||
|
||||
databaseLogger.info("Updated role", {
|
||||
operation: "update_role",
|
||||
roleId,
|
||||
roleName: existingRole[0].name,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update role", error, {
|
||||
operation: "update_role",
|
||||
roleId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to update role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Delete role
|
||||
* DELETE /rbac/roles/:id
|
||||
*/
|
||||
router.delete(
|
||||
"/roles/:id",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const roleId = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return res.status(400).json({ error: "Invalid role ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get role details
|
||||
const role = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
isSystem: roles.isSystem,
|
||||
})
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (role.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
// Cannot delete system roles
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error: "Cannot delete system roles",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if role is in use
|
||||
const usageCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.roleId, roleId));
|
||||
|
||||
if (usageCount[0].count > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete role: ${usageCount[0].count} user(s) are assigned to this role`,
|
||||
usageCount: usageCount[0].count,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if role is used in host_access
|
||||
const hostAccessCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(hostAccess)
|
||||
.where(eq(hostAccess.roleId, roleId));
|
||||
|
||||
if (hostAccessCount[0].count > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete role: ${hostAccessCount[0].count} host(s) are shared with this role`,
|
||||
hostAccessCount: hostAccessCount[0].count,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete role
|
||||
await db.delete(roles).where(eq(roles.id, roleId));
|
||||
|
||||
databaseLogger.info("Deleted role", {
|
||||
operation: "delete_role",
|
||||
roleId,
|
||||
roleName: role[0].name,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete role", error, {
|
||||
operation: "delete_role",
|
||||
roleId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to delete role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// User-Role Assignment
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Assign role to user
|
||||
* POST /rbac/users/:userId/roles
|
||||
*/
|
||||
router.post(
|
||||
"/users/:userId/roles",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const targetUserId = req.params.userId;
|
||||
const currentUserId = req.userId!;
|
||||
|
||||
try {
|
||||
const { roleId } = req.body;
|
||||
|
||||
if (typeof roleId !== "number") {
|
||||
return res.status(400).json({ error: "Role ID is required" });
|
||||
}
|
||||
|
||||
// Verify target user exists
|
||||
const targetUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, targetUserId))
|
||||
.limit(1);
|
||||
|
||||
if (targetUser.length === 0) {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
// Verify role exists
|
||||
const role = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (role.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
// Prevent manual assignment of system roles
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"System roles (admin, user) are automatically assigned and cannot be manually assigned",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already assigned
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(userRoles)
|
||||
.where(
|
||||
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: "Role already assigned" });
|
||||
}
|
||||
|
||||
// Assign role
|
||||
await db.insert(userRoles).values({
|
||||
userId: targetUserId,
|
||||
roleId,
|
||||
grantedBy: currentUserId,
|
||||
});
|
||||
|
||||
// Invalidate permission cache
|
||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||
|
||||
databaseLogger.info("Assigned role to user", {
|
||||
operation: "assign_role",
|
||||
targetUserId,
|
||||
roleId,
|
||||
roleName: role[0].name,
|
||||
grantedBy: currentUserId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role assigned successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to assign role", error, {
|
||||
operation: "assign_role",
|
||||
targetUserId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to assign role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Remove role from user
|
||||
* DELETE /rbac/users/:userId/roles/:roleId
|
||||
*/
|
||||
router.delete(
|
||||
"/users/:userId/roles/:roleId",
|
||||
authenticateJWT,
|
||||
permissionManager.requireAdmin(),
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const targetUserId = req.params.userId;
|
||||
const roleId = parseInt(req.params.roleId, 10);
|
||||
|
||||
if (isNaN(roleId)) {
|
||||
return res.status(400).json({ error: "Invalid role ID" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify role exists and get its details
|
||||
const role = await db
|
||||
.select({
|
||||
id: roles.id,
|
||||
name: roles.name,
|
||||
isSystem: roles.isSystem,
|
||||
})
|
||||
.from(roles)
|
||||
.where(eq(roles.id, roleId))
|
||||
.limit(1);
|
||||
|
||||
if (role.length === 0) {
|
||||
return res.status(404).json({ error: "Role not found" });
|
||||
}
|
||||
|
||||
// Prevent removal of system roles
|
||||
if (role[0].isSystem) {
|
||||
return res.status(403).json({
|
||||
error:
|
||||
"System roles (admin, user) are automatically assigned and cannot be removed",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the user-role assignment
|
||||
await db
|
||||
.delete(userRoles)
|
||||
.where(
|
||||
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
||||
);
|
||||
|
||||
// Invalidate permission cache
|
||||
permissionManager.invalidateUserPermissionCache(targetUserId);
|
||||
|
||||
databaseLogger.info("Removed role from user", {
|
||||
operation: "remove_role",
|
||||
targetUserId,
|
||||
roleId,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "Role removed successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to remove role", error, {
|
||||
operation: "remove_role",
|
||||
targetUserId,
|
||||
roleId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to remove role" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Get user's roles
|
||||
* GET /rbac/users/:userId/roles
|
||||
*/
|
||||
router.get(
|
||||
"/users/:userId/roles",
|
||||
authenticateJWT,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
const targetUserId = req.params.userId;
|
||||
const currentUserId = req.userId!;
|
||||
|
||||
// Users can only see their own roles unless they're admin
|
||||
if (
|
||||
targetUserId !== currentUserId &&
|
||||
!(await permissionManager.isAdmin(currentUserId))
|
||||
) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
|
||||
try {
|
||||
const userRolesList = await db
|
||||
.select({
|
||||
id: userRoles.id,
|
||||
roleId: roles.id,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
description: roles.description,
|
||||
isSystem: roles.isSystem,
|
||||
grantedAt: userRoles.grantedAt,
|
||||
})
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, targetUserId));
|
||||
|
||||
res.json({ roles: userRolesList });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get user roles", error, {
|
||||
operation: "get_user_roles",
|
||||
targetUserId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get user roles" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
sshFolders,
|
||||
commandHistory,
|
||||
recentActivity,
|
||||
hostAccess,
|
||||
userRoles,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||
import { eq, and, desc, isNotNull, or, isNull, gte, sql, inArray } from "drizzle-orm";
|
||||
import type { Request, Response } from "express";
|
||||
import multer from "multer";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
@@ -665,8 +667,98 @@ router.get(
|
||||
return res.status(400).json({ error: "Invalid userId" });
|
||||
}
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Get user's role IDs
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
const roleIds = userRoleIds.map((r) => r.roleId);
|
||||
|
||||
// Query own hosts + shared hosts with access check
|
||||
const rawData = await db
|
||||
.select({
|
||||
// All ssh_data fields
|
||||
id: sshData.id,
|
||||
userId: sshData.userId,
|
||||
name: sshData.name,
|
||||
ip: sshData.ip,
|
||||
port: sshData.port,
|
||||
username: sshData.username,
|
||||
folder: sshData.folder,
|
||||
tags: sshData.tags,
|
||||
pin: sshData.pin,
|
||||
authType: sshData.authType,
|
||||
password: sshData.password,
|
||||
key: sshData.key,
|
||||
keyPassword: sshData.key_password,
|
||||
keyType: sshData.keyType,
|
||||
enableTerminal: sshData.enableTerminal,
|
||||
enableTunnel: sshData.enableTunnel,
|
||||
tunnelConnections: sshData.tunnelConnections,
|
||||
jumpHosts: sshData.jumpHosts,
|
||||
enableFileManager: sshData.enableFileManager,
|
||||
defaultPath: sshData.defaultPath,
|
||||
autostartPassword: sshData.autostartPassword,
|
||||
autostartKey: sshData.autostartKey,
|
||||
autostartKeyPassword: sshData.autostartKeyPassword,
|
||||
forceKeyboardInteractive: sshData.forceKeyboardInteractive,
|
||||
statsConfig: sshData.statsConfig,
|
||||
terminalConfig: sshData.terminalConfig,
|
||||
createdAt: sshData.createdAt,
|
||||
updatedAt: sshData.updatedAt,
|
||||
credentialId: sshData.credentialId,
|
||||
overrideCredentialUsername: sshData.overrideCredentialUsername,
|
||||
quickActions: sshData.quickActions,
|
||||
|
||||
// Shared access info
|
||||
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
|
||||
permissionLevel: hostAccess.permissionLevel,
|
||||
expiresAt: hostAccess.expiresAt,
|
||||
})
|
||||
.from(sshData)
|
||||
.leftJoin(
|
||||
hostAccess,
|
||||
and(
|
||||
eq(hostAccess.hostId, sshData.id),
|
||||
or(
|
||||
eq(hostAccess.userId, userId),
|
||||
roleIds.length > 0 ? inArray(hostAccess.roleId, roleIds) : sql`false`,
|
||||
),
|
||||
or(
|
||||
isNull(hostAccess.expiresAt),
|
||||
gte(hostAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
or(
|
||||
eq(sshData.userId, userId), // Own hosts
|
||||
and(
|
||||
// Shared to user directly (not expired)
|
||||
eq(hostAccess.userId, userId),
|
||||
or(
|
||||
isNull(hostAccess.expiresAt),
|
||||
gte(hostAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
roleIds.length > 0
|
||||
? and(
|
||||
// Shared to user's role (not expired)
|
||||
inArray(hostAccess.roleId, roleIds),
|
||||
or(
|
||||
isNull(hostAccess.expiresAt),
|
||||
gte(hostAccess.expiresAt, now),
|
||||
),
|
||||
)
|
||||
: sql`false`,
|
||||
),
|
||||
);
|
||||
|
||||
// Decrypt and format the data
|
||||
const data = await SimpleDBOps.select(
|
||||
db.select().from(sshData).where(eq(sshData.userId, userId)),
|
||||
Promise.resolve(rawData),
|
||||
"ssh_data",
|
||||
userId,
|
||||
);
|
||||
@@ -700,6 +792,11 @@ router.get(
|
||||
? JSON.parse(row.terminalConfig as string)
|
||||
: undefined,
|
||||
forceKeyboardInteractive: row.forceKeyboardInteractive === "true",
|
||||
|
||||
// Add shared access metadata
|
||||
isShared: !!row.isShared,
|
||||
permissionLevel: row.permissionLevel || undefined,
|
||||
sharedExpiresAt: row.expiresAt || undefined,
|
||||
};
|
||||
|
||||
return (await resolveHostCredentials(baseHost)) || baseHost;
|
||||
@@ -1462,6 +1559,29 @@ async function resolveHostCredentials(
|
||||
host: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
// Skip credential resolution for shared hosts
|
||||
// Shared users cannot access the owner's encrypted credentials
|
||||
if (host.isShared && host.credentialId) {
|
||||
sshLogger.info(
|
||||
`Skipping credential resolution for shared host ${host.id} with credentialId ${host.credentialId}`,
|
||||
{
|
||||
operation: "resolve_host_credentials_shared",
|
||||
hostId: host.id as number,
|
||||
isShared: host.isShared,
|
||||
},
|
||||
);
|
||||
// Return host without resolving credentials
|
||||
// The frontend should handle credential auth for shared hosts differently
|
||||
const result = { ...host };
|
||||
if (host.key_password !== undefined) {
|
||||
if (result.keyPassword === undefined) {
|
||||
result.keyPassword = host.key_password;
|
||||
}
|
||||
delete result.key_password;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (host.credentialId && host.userId) {
|
||||
const credentialId = host.credentialId as number;
|
||||
const userId = host.userId as string;
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
sshCredentialUsage,
|
||||
recentActivity,
|
||||
snippets,
|
||||
roles,
|
||||
userRoles,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -210,6 +212,41 @@ router.post("/create", async (req, res) => {
|
||||
totp_backup_codes: null,
|
||||
});
|
||||
|
||||
// Assign default role to new user
|
||||
try {
|
||||
const defaultRoleName = isFirstUser ? "admin" : "user";
|
||||
const defaultRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, defaultRoleName))
|
||||
.limit(1);
|
||||
|
||||
if (defaultRole.length > 0) {
|
||||
await db.insert(userRoles).values({
|
||||
userId: id,
|
||||
roleId: defaultRole[0].id,
|
||||
grantedBy: id, // Self-assigned during registration
|
||||
});
|
||||
authLogger.info("Assigned default role to new user", {
|
||||
operation: "assign_default_role",
|
||||
userId: id,
|
||||
roleName: defaultRoleName,
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Default role not found during user registration", {
|
||||
operation: "assign_default_role",
|
||||
userId: id,
|
||||
roleName: defaultRoleName,
|
||||
});
|
||||
}
|
||||
} catch (roleError) {
|
||||
authLogger.error("Failed to assign default role", roleError, {
|
||||
operation: "assign_default_role",
|
||||
userId: id,
|
||||
});
|
||||
// Don't fail user creation if role assignment fails
|
||||
}
|
||||
|
||||
try {
|
||||
await authManager.registerUser(id, password);
|
||||
} catch (encryptionError) {
|
||||
@@ -816,6 +853,41 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
scopes: String(config.scopes),
|
||||
});
|
||||
|
||||
// Assign default role to new OIDC user
|
||||
try {
|
||||
const defaultRoleName = isFirstUser ? "admin" : "user";
|
||||
const defaultRole = await db
|
||||
.select({ id: roles.id })
|
||||
.from(roles)
|
||||
.where(eq(roles.name, defaultRoleName))
|
||||
.limit(1);
|
||||
|
||||
if (defaultRole.length > 0) {
|
||||
await db.insert(userRoles).values({
|
||||
userId: id,
|
||||
roleId: defaultRole[0].id,
|
||||
grantedBy: id, // Self-assigned during registration
|
||||
});
|
||||
authLogger.info("Assigned default role to new OIDC user", {
|
||||
operation: "assign_default_role_oidc",
|
||||
userId: id,
|
||||
roleName: defaultRoleName,
|
||||
});
|
||||
} else {
|
||||
authLogger.warn("Default role not found during OIDC user registration", {
|
||||
operation: "assign_default_role_oidc",
|
||||
userId: id,
|
||||
roleName: defaultRoleName,
|
||||
});
|
||||
}
|
||||
} catch (roleError) {
|
||||
authLogger.error("Failed to assign default role to OIDC user", roleError, {
|
||||
operation: "assign_default_role_oidc",
|
||||
userId: id,
|
||||
});
|
||||
// Don't fail user creation if role assignment fails
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionDurationMs =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
|
||||
456
src/backend/utils/permission-manager.ts
Normal file
456
src/backend/utils/permission-manager.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { db } from "../database/db/index.js";
|
||||
import {
|
||||
hostAccess,
|
||||
roles,
|
||||
userRoles,
|
||||
sshData,
|
||||
users,
|
||||
} from "../database/db/schema.js";
|
||||
import { eq, and, or, isNull, gte, sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
userId?: string;
|
||||
dataKey?: Buffer;
|
||||
}
|
||||
|
||||
interface HostAccessInfo {
|
||||
hasAccess: boolean;
|
||||
isOwner: boolean;
|
||||
isShared: boolean;
|
||||
permissionLevel?: string;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
interface PermissionCheckResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
class PermissionManager {
|
||||
private static instance: PermissionManager;
|
||||
private permissionCache: Map<string, { permissions: string[]; timestamp: number }>;
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
private constructor() {
|
||||
this.permissionCache = new Map();
|
||||
|
||||
// Auto-cleanup expired host access every 1 minute
|
||||
setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredAccess().catch((error) => {
|
||||
databaseLogger.error(
|
||||
"Failed to run periodic host access cleanup",
|
||||
error,
|
||||
{
|
||||
operation: "host_access_cleanup_periodic",
|
||||
},
|
||||
);
|
||||
});
|
||||
},
|
||||
60 * 1000,
|
||||
);
|
||||
|
||||
// Clear permission cache every 5 minutes
|
||||
setInterval(
|
||||
() => {
|
||||
this.clearPermissionCache();
|
||||
},
|
||||
this.CACHE_TTL,
|
||||
);
|
||||
}
|
||||
|
||||
static getInstance(): PermissionManager {
|
||||
if (!this.instance) {
|
||||
this.instance = new PermissionManager();
|
||||
}
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired host access entries
|
||||
*/
|
||||
private async cleanupExpiredAccess(): Promise<void> {
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const result = await db
|
||||
.delete(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
sql`${hostAccess.expiresAt} IS NOT NULL`,
|
||||
sql`${hostAccess.expiresAt} <= ${now}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: hostAccess.id });
|
||||
|
||||
if (result.length > 0) {
|
||||
databaseLogger.info("Cleaned up expired host access", {
|
||||
operation: "host_access_cleanup",
|
||||
count: result.length,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to cleanup expired host access", error, {
|
||||
operation: "host_access_cleanup_failed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear permission cache
|
||||
*/
|
||||
private clearPermissionCache(): void {
|
||||
this.permissionCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate permission cache for a specific user
|
||||
*/
|
||||
invalidateUserPermissionCache(userId: string): void {
|
||||
this.permissionCache.delete(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user permissions from roles
|
||||
*/
|
||||
async getUserPermissions(userId: string): Promise<string[]> {
|
||||
// Check cache first
|
||||
const cached = this.permissionCache.get(userId);
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
|
||||
return cached.permissions;
|
||||
}
|
||||
|
||||
try {
|
||||
const userRoleRecords = await db
|
||||
.select({
|
||||
permissions: roles.permissions,
|
||||
})
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(eq(userRoles.userId, userId));
|
||||
|
||||
const allPermissions = new Set<string>();
|
||||
for (const record of userRoleRecords) {
|
||||
try {
|
||||
const permissions = JSON.parse(record.permissions) as string[];
|
||||
for (const perm of permissions) {
|
||||
allPermissions.add(perm);
|
||||
}
|
||||
} catch (parseError) {
|
||||
databaseLogger.warn("Failed to parse role permissions", {
|
||||
operation: "get_user_permissions",
|
||||
userId,
|
||||
error: parseError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const permissionsArray = Array.from(allPermissions);
|
||||
|
||||
// Cache the result
|
||||
this.permissionCache.set(userId, {
|
||||
permissions: permissionsArray,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return permissionsArray;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get user permissions", error, {
|
||||
operation: "get_user_permissions",
|
||||
userId,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
* Supports wildcards: "hosts.*", "*"
|
||||
*/
|
||||
async hasPermission(
|
||||
userId: string,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const userPermissions = await this.getUserPermissions(userId);
|
||||
|
||||
// Check for wildcard "*" (god mode)
|
||||
if (userPermissions.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check exact match
|
||||
if (userPermissions.includes(permission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check wildcard matches
|
||||
const parts = permission.split(".");
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
const wildcardPermission = parts.slice(0, i).join(".") + ".*";
|
||||
if (userPermissions.includes(wildcardPermission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a specific host
|
||||
*/
|
||||
async canAccessHost(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
||||
): Promise<HostAccessInfo> {
|
||||
try {
|
||||
// Check if user is the owner
|
||||
const host = await db
|
||||
.select()
|
||||
.from(sshData)
|
||||
.where(and(eq(sshData.id, hostId), eq(sshData.userId, userId)))
|
||||
.limit(1);
|
||||
|
||||
if (host.length > 0) {
|
||||
return {
|
||||
hasAccess: true,
|
||||
isOwner: true,
|
||||
isShared: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if host is shared with user
|
||||
const now = new Date().toISOString();
|
||||
const sharedAccess = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(
|
||||
eq(hostAccess.hostId, hostId),
|
||||
eq(hostAccess.userId, userId),
|
||||
or(
|
||||
isNull(hostAccess.expiresAt),
|
||||
gte(hostAccess.expiresAt, now),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (sharedAccess.length > 0) {
|
||||
const access = sharedAccess[0];
|
||||
|
||||
// Check permission level for write/delete actions
|
||||
if (action === "write" || action === "delete") {
|
||||
const level = access.permissionLevel;
|
||||
if (level === "readonly") {
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: level,
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update last accessed time
|
||||
try {
|
||||
db.update(hostAccess)
|
||||
.set({
|
||||
lastAccessedAt: now,
|
||||
accessCount: sql`${hostAccess.accessCount} + 1`,
|
||||
})
|
||||
.where(eq(hostAccess.id, access.id))
|
||||
.run();
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to update host access stats", {
|
||||
operation: "update_host_access_stats",
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: true,
|
||||
isOwner: false,
|
||||
isShared: true,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: false,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check host access", error, {
|
||||
operation: "can_access_host",
|
||||
userId,
|
||||
hostId,
|
||||
action,
|
||||
});
|
||||
return {
|
||||
hasAccess: false,
|
||||
isOwner: false,
|
||||
isShared: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is admin (backward compatibility)
|
||||
*/
|
||||
async isAdmin(userId: string): Promise<boolean> {
|
||||
try {
|
||||
// Check old is_admin field
|
||||
const user = await db
|
||||
.select({ isAdmin: users.is_admin })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
|
||||
if (user.length > 0 && user[0].isAdmin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has admin or super_admin role
|
||||
const adminRoles = await db
|
||||
.select({ roleName: roles.name })
|
||||
.from(userRoles)
|
||||
.innerJoin(roles, eq(userRoles.roleId, roles.id))
|
||||
.where(
|
||||
and(
|
||||
eq(userRoles.userId, userId),
|
||||
or(eq(roles.name, "admin"), eq(roles.name, "super_admin")),
|
||||
),
|
||||
);
|
||||
|
||||
return adminRoles.length > 0;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to check admin status", error, {
|
||||
operation: "is_admin",
|
||||
userId,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require specific permission
|
||||
*/
|
||||
requirePermission(permission: string) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const hasPermission = await this.hasPermission(userId, permission);
|
||||
|
||||
if (!hasPermission) {
|
||||
databaseLogger.warn("Permission denied", {
|
||||
operation: "permission_check",
|
||||
userId,
|
||||
permission,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: "Insufficient permissions",
|
||||
required: permission,
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require host access
|
||||
*/
|
||||
requireHostAccess(
|
||||
hostIdParam: string = "id",
|
||||
action: "read" | "write" | "execute" | "delete" | "share" = "read",
|
||||
) {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const hostId = parseInt(req.params[hostIdParam], 10);
|
||||
|
||||
if (isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const accessInfo = await this.canAccessHost(userId, hostId, action);
|
||||
|
||||
if (!accessInfo.hasAccess) {
|
||||
databaseLogger.warn("Host access denied", {
|
||||
operation: "host_access_check",
|
||||
userId,
|
||||
hostId,
|
||||
action,
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
error: "Access denied to host",
|
||||
hostId,
|
||||
action,
|
||||
});
|
||||
}
|
||||
|
||||
// Attach access info to request for use in route handlers
|
||||
(req as any).hostAccessInfo = accessInfo;
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require admin role (backward compatible)
|
||||
*/
|
||||
requireAdmin() {
|
||||
return async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const userId = req.userId;
|
||||
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const isAdmin = await this.isAdmin(userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
databaseLogger.warn("Admin access denied", {
|
||||
operation: "admin_check",
|
||||
userId,
|
||||
path: req.path,
|
||||
});
|
||||
|
||||
return res.status(403).json({ error: "Admin access required" });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { PermissionManager };
|
||||
export type { AuthenticatedRequest, HostAccessInfo, PermissionCheckResult };
|
||||
@@ -36,24 +36,50 @@ export function useConfirmation() {
|
||||
};
|
||||
|
||||
const confirmWithToast = (
|
||||
message: string,
|
||||
callback: () => void,
|
||||
variant: "default" | "destructive" = "default",
|
||||
) => {
|
||||
const actionText = variant === "destructive" ? "Delete" : "Confirm";
|
||||
const cancelText = "Cancel";
|
||||
opts: ConfirmationOptions | string,
|
||||
callback?: () => void,
|
||||
variant?: "default" | "destructive",
|
||||
): Promise<boolean> => {
|
||||
// Legacy signature support
|
||||
if (typeof opts === "string" && callback) {
|
||||
const actionText = variant === "destructive" ? "Delete" : "Confirm";
|
||||
const cancelText = "Cancel";
|
||||
|
||||
toast(message, {
|
||||
action: {
|
||||
label: actionText,
|
||||
onClick: callback,
|
||||
},
|
||||
cancel: {
|
||||
label: cancelText,
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 10000,
|
||||
className: variant === "destructive" ? "border-red-500" : "",
|
||||
toast(opts, {
|
||||
action: {
|
||||
label: actionText,
|
||||
onClick: callback,
|
||||
},
|
||||
cancel: {
|
||||
label: cancelText,
|
||||
onClick: () => {},
|
||||
},
|
||||
duration: 10000,
|
||||
className: variant === "destructive" ? "border-red-500" : "",
|
||||
});
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
// New Promise-based signature
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const options = opts as ConfirmationOptions;
|
||||
const actionText = options.confirmText || "Confirm";
|
||||
const cancelText = options.cancelText || "Cancel";
|
||||
const variantClass = options.variant === "destructive" ? "border-red-500" : "";
|
||||
|
||||
toast(options.title, {
|
||||
description: options.description,
|
||||
action: {
|
||||
label: actionText,
|
||||
onClick: () => resolve(true),
|
||||
},
|
||||
cancel: {
|
||||
label: cancelText,
|
||||
onClick: () => resolve(false),
|
||||
},
|
||||
duration: 10000,
|
||||
className: variantClass,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -387,7 +387,11 @@
|
||||
"documentation": "Documentation",
|
||||
"retry": "Retry",
|
||||
"checking": "Checking...",
|
||||
"checkingDatabase": "Checking database connection..."
|
||||
"checkingDatabase": "Checking database connection...",
|
||||
"actions": "Actions",
|
||||
"remove": "Remove",
|
||||
"revoke": "Revoke",
|
||||
"create": "Create"
|
||||
},
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
@@ -1765,6 +1769,172 @@
|
||||
"ram": "RAM",
|
||||
"notAvailable": "N/A"
|
||||
},
|
||||
"rbac": {
|
||||
"shareHost": "Share Host",
|
||||
"shareHostTitle": "Share Host Access",
|
||||
"shareHostDescription": "Grant temporary or permanent access to this host",
|
||||
"targetUser": "Target User",
|
||||
"selectUser": "Select a user to share with",
|
||||
"duration": "Duration",
|
||||
"durationHours": "Duration (hours)",
|
||||
"neverExpires": "Never expires",
|
||||
"permissionLevel": "Permission Level",
|
||||
"permissionLevels": {
|
||||
"readonly": "Read-Only",
|
||||
"readonlyDesc": "Can view only, no command input",
|
||||
"restricted": "Restricted",
|
||||
"restrictedDesc": "Blocks dangerous commands (passwd, rm -rf, etc.)",
|
||||
"monitored": "Monitored",
|
||||
"monitoredDesc": "Records all commands but doesn't block (Recommended)",
|
||||
"full": "Full Access",
|
||||
"fullDesc": "No restrictions (Not recommended)"
|
||||
},
|
||||
"blockedCommands": "Blocked Commands",
|
||||
"blockedCommandsPlaceholder": "Enter commands to block, e.g., passwd, rm, dd",
|
||||
"maxSessionDuration": "Max Session Duration (minutes)",
|
||||
"createTempUser": "Create Temporary User",
|
||||
"createTempUserDesc": "Creates a restricted user on the server instead of sharing your credentials. Requires sudo access. Most secure option.",
|
||||
"expiresAt": "Expires At",
|
||||
"expiresIn": "Expires in {{hours}} hours",
|
||||
"expired": "Expired",
|
||||
"grantedBy": "Granted By",
|
||||
"accessLevel": "Access Level",
|
||||
"lastAccessed": "Last Accessed",
|
||||
"accessCount": "Access Count",
|
||||
"revokeAccess": "Revoke Access",
|
||||
"confirmRevokeAccess": "Are you sure you want to revoke access for {{username}}?",
|
||||
"hostSharedSuccessfully": "Host shared successfully with {{username}}",
|
||||
"hostAccessUpdated": "Host access updated",
|
||||
"failedToShareHost": "Failed to share host",
|
||||
"accessRevokedSuccessfully": "Access revoked successfully",
|
||||
"failedToRevokeAccess": "Failed to revoke access",
|
||||
"shared": "Shared",
|
||||
"sharedHosts": "Shared Hosts",
|
||||
"sharedWithMe": "Shared With Me",
|
||||
"noSharedHosts": "No hosts shared with you",
|
||||
"owner": "Owner",
|
||||
"viewAccessList": "View Access List",
|
||||
"accessList": "Access List",
|
||||
"noAccessGranted": "No access has been granted for this host",
|
||||
"noAccessGrantedMessage": "No users have been granted access to this host yet",
|
||||
"manageAccessFor": "Manage access for",
|
||||
"totalAccessRecords": "{{count}} access record(s)",
|
||||
"neverAccessed": "Never",
|
||||
"timesAccessed": "{{count}} time(s)",
|
||||
"daysRemaining": "{{days}} day(s)",
|
||||
"hoursRemaining": "{{hours}} hour(s)",
|
||||
"expired": "Expired",
|
||||
"failedToFetchAccessList": "Failed to fetch access list",
|
||||
"currentAccess": "Current Access",
|
||||
"securityWarning": "Security Warning",
|
||||
"securityWarningMessage": "Sharing credentials gives the user full access to perform any operations on the server, including changing passwords and deleting files. Only share with trusted users.",
|
||||
"tempUserRecommended": "We recommend enabling 'Create Temporary User' for better security.",
|
||||
"roleManagement": "Role Management",
|
||||
"manageRoles": "Manage Roles",
|
||||
"manageRolesFor": "Manage roles for {{username}}",
|
||||
"assignRole": "Assign Role",
|
||||
"removeRole": "Remove Role",
|
||||
"userRoles": "User Roles",
|
||||
"permissions": "Permissions",
|
||||
"systemRole": "System Role",
|
||||
"customRole": "Custom Role",
|
||||
"roleAssignedSuccessfully": "Role assigned to {{username}} successfully",
|
||||
"failedToAssignRole": "Failed to assign role",
|
||||
"roleRemovedSuccessfully": "Role removed from {{username}} successfully",
|
||||
"failedToRemoveRole": "Failed to remove role",
|
||||
"cannotRemoveSystemRole": "Cannot remove system role",
|
||||
"cannotShareWithSelf": "Cannot share host with yourself",
|
||||
"noCustomRolesToAssign": "No custom roles available. System roles are auto-assigned.",
|
||||
"credentialSharingWarning": "Credential Authentication Not Supported for Sharing",
|
||||
"credentialSharingWarningDescription": "This host uses credential-based authentication. Shared users will not be able to connect because credentials are encrypted per-user and cannot be shared. Please use password or key-based authentication for hosts you intend to share.",
|
||||
"auditLogs": "Audit Logs",
|
||||
"viewAuditLogs": "View Audit Logs",
|
||||
"action": "Action",
|
||||
"resourceType": "Resource Type",
|
||||
"resourceName": "Resource Name",
|
||||
"timestamp": "Timestamp",
|
||||
"ipAddress": "IP Address",
|
||||
"userAgent": "User Agent",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"details": "Details",
|
||||
"noAuditLogs": "No audit logs available",
|
||||
"sessionRecordings": "Session Recordings",
|
||||
"viewRecording": "View Recording",
|
||||
"downloadRecording": "Download Recording",
|
||||
"dangerousCommand": "Dangerous Command Detected",
|
||||
"commandBlocked": "Command Blocked",
|
||||
"terminateSession": "Terminate Session",
|
||||
"sessionTerminated": "Session terminated by host owner",
|
||||
"sharedAccessExpired": "Your shared access to this host has expired",
|
||||
"sharedAccessExpiresIn": "Shared access expires in {{hours}} hours",
|
||||
"roles": {
|
||||
"label": "Roles",
|
||||
"admin": "Administrator",
|
||||
"user": "User"
|
||||
},
|
||||
"createRole": "Create Role",
|
||||
"editRole": "Edit Role",
|
||||
"roleName": "Role Name",
|
||||
"displayName": "Display Name",
|
||||
"description": "Description",
|
||||
"assignRoles": "Assign Roles",
|
||||
"userRoleAssignment": "User-Role Assignment",
|
||||
"selectUserPlaceholder": "Select a user",
|
||||
"searchUsers": "Search users...",
|
||||
"noUserFound": "No user found",
|
||||
"currentRoles": "Current Roles",
|
||||
"noRolesAssigned": "No roles assigned",
|
||||
"assignNewRole": "Assign New Role",
|
||||
"selectRolePlaceholder": "Select a role",
|
||||
"searchRoles": "Search roles...",
|
||||
"noRoleFound": "No role found",
|
||||
"assign": "Assign",
|
||||
"roleCreatedSuccessfully": "Role created successfully",
|
||||
"roleUpdatedSuccessfully": "Role updated successfully",
|
||||
"roleDeletedSuccessfully": "Role deleted successfully",
|
||||
"failedToLoadRoles": "Failed to load roles",
|
||||
"failedToSaveRole": "Failed to save role",
|
||||
"failedToDeleteRole": "Failed to delete role",
|
||||
"roleDisplayNameRequired": "Role display name is required",
|
||||
"roleNameRequired": "Role name is required",
|
||||
"roleNameHint": "Use lowercase letters, numbers, underscores, and hyphens only",
|
||||
"displayNamePlaceholder": "Developer",
|
||||
"descriptionPlaceholder": "Software developers and engineers",
|
||||
"confirmDeleteRole": "Delete Role",
|
||||
"confirmDeleteRoleDescription": "Are you sure you want to delete the role '{{name}}'? This action cannot be undone.",
|
||||
"confirmRemoveRole": "Remove Role",
|
||||
"confirmRemoveRoleDescription": "Are you sure you want to remove this role from the user?",
|
||||
"editRoleDescription": "Update role information",
|
||||
"createRoleDescription": "Create a new custom role for grouping users",
|
||||
"assignRolesDescription": "Manage role assignments for users",
|
||||
"noRoles": "No roles found",
|
||||
"selectRole": "Select Role",
|
||||
"type": "Type",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"saveHostFirst": "Save Host First",
|
||||
"saveHostFirstDescription": "Please save the host before configuring sharing settings.",
|
||||
"shareWithUser": "Share with User",
|
||||
"shareWithRole": "Share with Role",
|
||||
"share": "Share",
|
||||
"target": "Target",
|
||||
"expires": "Expires",
|
||||
"never": "Never",
|
||||
"noAccessRecords": "No access records found",
|
||||
"sharedSuccessfully": "Shared successfully",
|
||||
"failedToShare": "Failed to share",
|
||||
"confirmRevokeAccessDescription": "Are you sure you want to revoke this access?",
|
||||
"hours": "hours",
|
||||
"sharing": "Sharing",
|
||||
"selectUserAndRole": "Please select both a user and a role",
|
||||
"view": "View Only",
|
||||
"viewDesc": "Can view and connect to the host in read-only mode",
|
||||
"use": "Use",
|
||||
"useDesc": "Can use the host normally but cannot modify host configuration",
|
||||
"manage": "Manage",
|
||||
"manageDesc": "Full control including modifying host configuration and sharing settings"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "Search for hosts or quick actions...",
|
||||
"recentActivity": "Recent Activity",
|
||||
@@ -1788,4 +1958,4 @@
|
||||
"close": "Close",
|
||||
"hostManager": "Host Manager"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,10 @@
|
||||
"retry": "重试",
|
||||
"checking": "检查中...",
|
||||
"checkingDatabase": "正在检查数据库连接...",
|
||||
"actions": "操作",
|
||||
"remove": "移除",
|
||||
"revoke": "撤销",
|
||||
"create": "创建",
|
||||
"saving": "保存中...",
|
||||
"version": "Version"
|
||||
},
|
||||
@@ -1764,6 +1768,172 @@
|
||||
"ram": "内存",
|
||||
"notAvailable": "不可用"
|
||||
},
|
||||
"rbac": {
|
||||
"shareHost": "分享主机",
|
||||
"shareHostTitle": "分享主机访问权限",
|
||||
"shareHostDescription": "授予临时或永久访问此主机的权限",
|
||||
"targetUser": "目标用户",
|
||||
"selectUser": "选择要分享的用户",
|
||||
"duration": "时长",
|
||||
"durationHours": "时长(小时)",
|
||||
"neverExpires": "永不过期",
|
||||
"permissionLevel": "权限级别",
|
||||
"permissionLevels": {
|
||||
"readonly": "只读",
|
||||
"readonlyDesc": "仅可查看,无法输入命令",
|
||||
"restricted": "受限",
|
||||
"restrictedDesc": "阻止危险命令(passwd、rm -rf等)",
|
||||
"monitored": "监控",
|
||||
"monitoredDesc": "记录所有命令但不阻止(推荐)",
|
||||
"full": "完全访问",
|
||||
"fullDesc": "无任何限制(不推荐)"
|
||||
},
|
||||
"blockedCommands": "阻止的命令",
|
||||
"blockedCommandsPlaceholder": "输入要阻止的命令,如:passwd, rm, dd",
|
||||
"maxSessionDuration": "最大会话时长(分钟)",
|
||||
"createTempUser": "创建临时用户",
|
||||
"createTempUserDesc": "在服务器上创建受限用户而不是共享您的凭据。需要sudo权限。最安全的选项。",
|
||||
"expiresAt": "过期时间",
|
||||
"expiresIn": "{{hours}}小时后过期",
|
||||
"expired": "已过期",
|
||||
"grantedBy": "授予者",
|
||||
"accessLevel": "访问级别",
|
||||
"lastAccessed": "最后访问",
|
||||
"accessCount": "访问次数",
|
||||
"revokeAccess": "撤销访问",
|
||||
"confirmRevokeAccess": "确定要撤销{{username}}的访问权限吗?",
|
||||
"hostSharedSuccessfully": "已成功与{{username}}分享主机",
|
||||
"hostAccessUpdated": "主机访问已更新",
|
||||
"failedToShareHost": "分享主机失败",
|
||||
"accessRevokedSuccessfully": "访问权限已成功撤销",
|
||||
"failedToRevokeAccess": "撤销访问失败",
|
||||
"shared": "共享",
|
||||
"sharedHosts": "共享主机",
|
||||
"sharedWithMe": "与我共享",
|
||||
"noSharedHosts": "没有与您共享的主机",
|
||||
"owner": "所有者",
|
||||
"viewAccessList": "查看访问列表",
|
||||
"accessList": "访问列表",
|
||||
"noAccessGranted": "此主机尚未授予任何访问权限",
|
||||
"noAccessGrantedMessage": "还没有用户被授予此主机的访问权限",
|
||||
"manageAccessFor": "管理访问权限",
|
||||
"totalAccessRecords": "{{count}} 条访问记录",
|
||||
"neverAccessed": "从未访问",
|
||||
"timesAccessed": "{{count}} 次",
|
||||
"daysRemaining": "{{days}} 天",
|
||||
"hoursRemaining": "{{hours}} 小时",
|
||||
"expired": "已过期",
|
||||
"failedToFetchAccessList": "获取访问列表失败",
|
||||
"currentAccess": "当前访问",
|
||||
"securityWarning": "安全警告",
|
||||
"securityWarningMessage": "分享凭据会让用户完全访问服务器并执行任何操作,包括更改密码和删除文件。仅与受信任的用户共享。",
|
||||
"tempUserRecommended": "我们建议启用'创建临时用户'以获得更好的安全性。",
|
||||
"roleManagement": "角色管理",
|
||||
"manageRoles": "管理角色",
|
||||
"manageRolesFor": "管理 {{username}} 的角色",
|
||||
"assignRole": "分配角色",
|
||||
"removeRole": "移除角色",
|
||||
"userRoles": "用户角色",
|
||||
"permissions": "权限",
|
||||
"systemRole": "系统角色",
|
||||
"customRole": "自定义角色",
|
||||
"roleAssignedSuccessfully": "已成功为{{username}}分配角色",
|
||||
"failedToAssignRole": "分配角色失败",
|
||||
"roleRemovedSuccessfully": "已成功从{{username}}移除角色",
|
||||
"failedToRemoveRole": "移除角色失败",
|
||||
"cannotRemoveSystemRole": "无法移除系统角色",
|
||||
"cannotShareWithSelf": "不能与自己共享主机",
|
||||
"noCustomRolesToAssign": "没有可用的自定义角色。系统角色已自动分配。",
|
||||
"credentialSharingWarning": "不支持共享使用凭据认证的主机",
|
||||
"credentialSharingWarningDescription": "此主机使用凭据认证。由于凭据是按用户加密的无法共享,共享用户将无法连接。请为计划共享的主机使用密码或密钥认证。",
|
||||
"auditLogs": "审计日志",
|
||||
"viewAuditLogs": "查看审计日志",
|
||||
"action": "操作",
|
||||
"resourceType": "资源类型",
|
||||
"resourceName": "资源名称",
|
||||
"timestamp": "时间戳",
|
||||
"ipAddress": "IP地址",
|
||||
"userAgent": "用户代理",
|
||||
"success": "成功",
|
||||
"failed": "失败",
|
||||
"details": "详情",
|
||||
"noAuditLogs": "无可用审计日志",
|
||||
"sessionRecordings": "会话录制",
|
||||
"viewRecording": "查看录制",
|
||||
"downloadRecording": "下载录制",
|
||||
"dangerousCommand": "检测到危险命令",
|
||||
"commandBlocked": "命令已阻止",
|
||||
"terminateSession": "终止会话",
|
||||
"sessionTerminated": "会话已被主机所有者终止",
|
||||
"sharedAccessExpired": "您对此主机的共享访问权限已过期",
|
||||
"sharedAccessExpiresIn": "共享访问将在{{hours}}小时后过期",
|
||||
"roles": {
|
||||
"label": "角色",
|
||||
"admin": "管理员",
|
||||
"user": "用户"
|
||||
},
|
||||
"createRole": "创建角色",
|
||||
"editRole": "编辑角色",
|
||||
"roleName": "角色名称",
|
||||
"displayName": "显示名称",
|
||||
"description": "描述",
|
||||
"assignRoles": "分配角色",
|
||||
"userRoleAssignment": "用户角色分配",
|
||||
"selectUserPlaceholder": "选择用户",
|
||||
"searchUsers": "搜索用户...",
|
||||
"noUserFound": "未找到用户",
|
||||
"currentRoles": "当前角色",
|
||||
"noRolesAssigned": "未分配角色",
|
||||
"assignNewRole": "分配新角色",
|
||||
"selectRolePlaceholder": "选择角色",
|
||||
"searchRoles": "搜索角色...",
|
||||
"noRoleFound": "未找到角色",
|
||||
"assign": "分配",
|
||||
"roleCreatedSuccessfully": "角色创建成功",
|
||||
"roleUpdatedSuccessfully": "角色更新成功",
|
||||
"roleDeletedSuccessfully": "角色删除成功",
|
||||
"failedToLoadRoles": "加载角色失败",
|
||||
"failedToSaveRole": "保存角色失败",
|
||||
"failedToDeleteRole": "删除角色失败",
|
||||
"roleDisplayNameRequired": "角色显示名称是必需的",
|
||||
"roleNameRequired": "角色名称是必需的",
|
||||
"roleNameHint": "仅使用小写字母、数字、下划线和连字符",
|
||||
"displayNamePlaceholder": "开发者",
|
||||
"descriptionPlaceholder": "软件开发人员和工程师",
|
||||
"confirmDeleteRole": "删除角色",
|
||||
"confirmDeleteRoleDescription": "确定要删除角色'{{name}}'吗?此操作无法撤销。",
|
||||
"confirmRemoveRole": "移除角色",
|
||||
"confirmRemoveRoleDescription": "确定要从用户中移除此角色吗?",
|
||||
"editRoleDescription": "更新角色信息",
|
||||
"createRoleDescription": "创建新的自定义角色以分组用户",
|
||||
"assignRolesDescription": "管理用户的角色分配",
|
||||
"noRoles": "未找到角色",
|
||||
"selectRole": "选择角色",
|
||||
"type": "类型",
|
||||
"user": "用户",
|
||||
"role": "角色",
|
||||
"saveHostFirst": "请先保存主机",
|
||||
"saveHostFirstDescription": "请先保存主机后再配置分享设置。",
|
||||
"shareWithUser": "与用户分享",
|
||||
"shareWithRole": "与角色分享",
|
||||
"share": "分享",
|
||||
"target": "目标",
|
||||
"expires": "过期时间",
|
||||
"never": "永不",
|
||||
"noAccessRecords": "未找到访问记录",
|
||||
"sharedSuccessfully": "分享成功",
|
||||
"failedToShare": "分享失败",
|
||||
"confirmRevokeAccessDescription": "确定要撤销此访问权限吗?",
|
||||
"hours": "小时",
|
||||
"sharing": "分享",
|
||||
"selectUserAndRole": "请选择用户和角色",
|
||||
"view": "仅查看",
|
||||
"viewDesc": "可以查看和连接主机,但仅限只读模式",
|
||||
"use": "使用",
|
||||
"useDesc": "可以正常使用主机,但不能修改主机配置",
|
||||
"manage": "管理",
|
||||
"manageDesc": "完全控制,包括修改主机配置和分享设置"
|
||||
},
|
||||
"commandPalette": {
|
||||
"searchPlaceholder": "搜索主机或快速操作...",
|
||||
"recentActivity": "最近活动",
|
||||
@@ -1787,4 +1957,4 @@
|
||||
"close": "关闭",
|
||||
"hostManager": "主机管理器"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,11 @@ export interface SSHHost {
|
||||
terminalConfig?: TerminalConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// Shared access metadata
|
||||
isShared?: boolean;
|
||||
permissionLevel?: "view" | "manage";
|
||||
sharedExpiresAt?: string;
|
||||
}
|
||||
|
||||
export interface JumpHostData {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
Smartphone,
|
||||
Globe,
|
||||
Clock,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -66,7 +67,14 @@ import {
|
||||
revokeAllUserSessions,
|
||||
linkOIDCToPasswordAccount,
|
||||
unlinkOIDCFromPasswordAccount,
|
||||
getUserRoles,
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
getRoles,
|
||||
type UserRole,
|
||||
type Role,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { RoleManagement } from "./RoleManagement.tsx";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
isTopbarOpen?: boolean;
|
||||
@@ -119,6 +127,16 @@ export function AdminSettings({
|
||||
null,
|
||||
);
|
||||
|
||||
// Role management states
|
||||
const [rolesDialogOpen, setRolesDialogOpen] = React.useState(false);
|
||||
const [selectedUser, setSelectedUser] = React.useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
} | null>(null);
|
||||
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
|
||||
const [availableRoles, setAvailableRoles] = React.useState<Role[]>([]);
|
||||
const [rolesLoading, setRolesLoading] = React.useState(false);
|
||||
|
||||
const [securityInitialized, setSecurityInitialized] = React.useState(true);
|
||||
const [currentUser, setCurrentUser] = React.useState<{
|
||||
id: string;
|
||||
@@ -267,6 +285,65 @@ export function AdminSettings({
|
||||
}
|
||||
};
|
||||
|
||||
// Role management functions
|
||||
const handleOpenRolesDialog = async (user: {
|
||||
id: string;
|
||||
username: string;
|
||||
}) => {
|
||||
setSelectedUser(user);
|
||||
setRolesDialogOpen(true);
|
||||
setRolesLoading(true);
|
||||
|
||||
try {
|
||||
// Load user's current roles
|
||||
const rolesResponse = await getUserRoles(user.id);
|
||||
setUserRoles(rolesResponse.roles || []);
|
||||
|
||||
// Load all available roles
|
||||
const allRolesResponse = await getRoles();
|
||||
setAvailableRoles(allRolesResponse.roles || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load roles:", error);
|
||||
toast.error(t("rbac.failedToLoadRoles"));
|
||||
} finally {
|
||||
setRolesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignRole = async (roleId: number) => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
await assignRoleToUser(selectedUser.id, roleId);
|
||||
toast.success(
|
||||
t("rbac.roleAssignedSuccessfully", { username: selectedUser.username }),
|
||||
);
|
||||
|
||||
// Reload user roles
|
||||
const rolesResponse = await getUserRoles(selectedUser.id);
|
||||
setUserRoles(rolesResponse.roles || []);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToAssignRole"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveRole = async (roleId: number) => {
|
||||
if (!selectedUser) return;
|
||||
|
||||
try {
|
||||
await removeRoleFromUser(selectedUser.id, roleId);
|
||||
toast.success(
|
||||
t("rbac.roleRemovedSuccessfully", { username: selectedUser.username }),
|
||||
);
|
||||
|
||||
// Reload user roles
|
||||
const rolesResponse = await getUserRoles(selectedUser.id);
|
||||
setUserRoles(rolesResponse.roles || []);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToRemoveRole"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRegistration = async (checked: boolean) => {
|
||||
setRegLoading(true);
|
||||
try {
|
||||
@@ -771,6 +848,10 @@ export function AdminSettings({
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.adminManagement")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="roles" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("rbac.roles.label")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="security" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{t("admin.databaseSecurity")}
|
||||
@@ -1081,88 +1162,92 @@ export function AdminSettings({
|
||||
{t("admin.loadingUsers")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.username")}
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.type")}
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{user.is_oidc && user.password_hash
|
||||
? "Dual Auth"
|
||||
: user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleUnlinkOIDC(user.id, user.username)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title="Unlink OIDC (keep password only)"
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.username")}</TableHead>
|
||||
<TableHead>{t("admin.type")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.username}
|
||||
{user.is_admin && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.is_oidc && user.password_hash
|
||||
? "Dual Auth"
|
||||
: user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteUser(user.username)
|
||||
handleLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleUnlinkOIDC(user.id, user.username)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title="Unlink OIDC (keep password only)"
|
||||
>
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleOpenRolesDialog({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
||||
title={t("rbac.manageRoles")}
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteUser(user.username)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1189,115 +1274,107 @@ export function AdminSettings({
|
||||
No active sessions found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">Device</TableHead>
|
||||
<TableHead className="px-4">User</TableHead>
|
||||
<TableHead className="px-4">Created</TableHead>
|
||||
<TableHead className="px-4">Last Active</TableHead>
|
||||
<TableHead className="px-4">Expires</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.actions")}
|
||||
</TableHead>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(session.lastActiveAt);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={
|
||||
session.isRevoked ? "opacity-50" : undefined
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
Revoked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeSession(session.id)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(
|
||||
session.userId,
|
||||
)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title="Revoke all sessions for this user"
|
||||
>
|
||||
Revoke All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sessions.map((session) => {
|
||||
const DeviceIcon =
|
||||
session.deviceType === "desktop"
|
||||
? Monitor
|
||||
: session.deviceType === "mobile"
|
||||
? Smartphone
|
||||
: Globe;
|
||||
|
||||
const createdDate = new Date(session.createdAt);
|
||||
const lastActiveDate = new Date(
|
||||
session.lastActiveAt,
|
||||
);
|
||||
const expiresDate = new Date(session.expiresAt);
|
||||
|
||||
const formatDate = (date: Date) =>
|
||||
date.toLocaleDateString() +
|
||||
" " +
|
||||
date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={session.id}
|
||||
className={
|
||||
session.isRevoked ? "opacity-50" : undefined
|
||||
}
|
||||
>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<DeviceIcon className="h-4 w-4" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">
|
||||
{session.deviceInfo}
|
||||
</span>
|
||||
{session.isRevoked && (
|
||||
<span className="text-xs text-red-600">
|
||||
Revoked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{session.username || session.userId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(createdDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(lastActiveDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 text-sm text-muted-foreground">
|
||||
{formatDate(expiresDate)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeSession(session.id)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={session.isRevoked}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{session.username && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRevokeAllUserSessions(
|
||||
session.userId,
|
||||
)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
|
||||
title="Revoke all sessions for this user"
|
||||
>
|
||||
Revoke All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1345,59 +1422,55 @@ export function AdminSettings({
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium">{t("admin.currentAdmins")}</h4>
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.username")}
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.type")}
|
||||
</TableHead>
|
||||
<TableHead className="px-4">
|
||||
{t("admin.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users
|
||||
.filter((u) => u.is_admin)
|
||||
.map((admin) => (
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="px-4 font-medium">
|
||||
{admin.username}
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{admin.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRemoveAdminStatus(admin.username)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.removeAdminButton")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("admin.username")}</TableHead>
|
||||
<TableHead>{t("admin.type")}</TableHead>
|
||||
<TableHead>{t("admin.actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users
|
||||
.filter((u) => u.is_admin)
|
||||
.map((admin) => (
|
||||
<TableRow key={admin.id}>
|
||||
<TableCell className="font-medium">
|
||||
{admin.username}
|
||||
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
|
||||
{t("admin.adminBadge")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{admin.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRemoveAdminStatus(admin.username)
|
||||
}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
>
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("admin.removeAdminButton")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="roles" className="space-y-6">
|
||||
<RoleManagement />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="security" className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1613,6 +1686,114 @@ export function AdminSettings({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Role Management Dialog */}
|
||||
<Dialog open={rolesDialogOpen} onOpenChange={setRolesDialogOpen}>
|
||||
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("rbac.manageRoles")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rbac.manageRolesFor", {
|
||||
username: selectedUser?.username || "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{rolesLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Current Roles */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("rbac.currentRoles")}</Label>
|
||||
{userRoles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("rbac.noRolesAssigned")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
|
||||
{userRoles.map((userRole) => (
|
||||
<div
|
||||
key={userRole.roleId}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t(userRole.roleDisplayName)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{userRole.roleName}
|
||||
</p>
|
||||
</div>
|
||||
{userRole.isSystem ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("rbac.systemRole")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveRole(userRole.roleId)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assign New Role */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("rbac.assignNewRole")}</Label>
|
||||
<div className="flex gap-2">
|
||||
{availableRoles
|
||||
.filter(
|
||||
(role) =>
|
||||
!role.isSystem &&
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
)
|
||||
.map((role) => (
|
||||
<Button
|
||||
key={role.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleAssignRole(role.id)}
|
||||
>
|
||||
{t(role.displayName)}
|
||||
</Button>
|
||||
))}
|
||||
{availableRoles.filter(
|
||||
(role) =>
|
||||
!role.isSystem &&
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
).length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("rbac.noCustomRolesToAssign")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setRolesDialogOpen(false)}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
650
src/ui/desktop/admin/RoleManagement.tsx
Normal file
650
src/ui/desktop/admin/RoleManagement.tsx
Normal file
@@ -0,0 +1,650 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Textarea } from "@/components/ui/textarea.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Users,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
getUserList,
|
||||
getUserRoles,
|
||||
assignRoleToUser,
|
||||
removeRoleFromUser,
|
||||
type Role,
|
||||
type UserRole,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover.tsx";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export function RoleManagement(): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
const [users, setUsers] = React.useState<User[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Create/Edit Role Dialog
|
||||
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
|
||||
const [editingRole, setEditingRole] = React.useState<Role | null>(null);
|
||||
const [roleName, setRoleName] = React.useState("");
|
||||
const [roleDisplayName, setRoleDisplayName] = React.useState("");
|
||||
const [roleDescription, setRoleDescription] = React.useState("");
|
||||
|
||||
// Assign Role Dialog
|
||||
const [assignDialogOpen, setAssignDialogOpen] = React.useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
|
||||
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
|
||||
|
||||
// Combobox states
|
||||
const [userComboOpen, setUserComboOpen] = React.useState(false);
|
||||
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
|
||||
|
||||
// Load roles
|
||||
const loadRoles = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getRoles();
|
||||
setRoles(response.roles || []);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToLoadRoles"));
|
||||
console.error("Failed to load roles:", error);
|
||||
setRoles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Load users
|
||||
const loadUsers = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await getUserList();
|
||||
// Map UserInfo to User format
|
||||
const mappedUsers = (response.users || []).map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
}));
|
||||
setUsers(mappedUsers);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
setUsers([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadRoles();
|
||||
loadUsers();
|
||||
}, [loadRoles, loadUsers]);
|
||||
|
||||
// Create role
|
||||
const handleCreateRole = () => {
|
||||
setEditingRole(null);
|
||||
setRoleName("");
|
||||
setRoleDisplayName("");
|
||||
setRoleDescription("");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
// Edit role
|
||||
const handleEditRole = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
setRoleName(role.name);
|
||||
setRoleDisplayName(role.displayName);
|
||||
setRoleDescription(role.description || "");
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
// Save role
|
||||
const handleSaveRole = async () => {
|
||||
if (!roleDisplayName.trim()) {
|
||||
toast.error(t("rbac.roleDisplayNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingRole && !roleName.trim()) {
|
||||
toast.error(t("rbac.roleNameRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
// Update existing role
|
||||
await updateRole(editingRole.id, {
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleUpdatedSuccessfully"));
|
||||
} else {
|
||||
// Create new role
|
||||
await createRole({
|
||||
name: roleName,
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleCreatedSuccessfully"));
|
||||
}
|
||||
|
||||
setRoleDialogOpen(false);
|
||||
loadRoles();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToSaveRole"));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete role
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmDeleteRole"),
|
||||
description: t("rbac.confirmDeleteRoleDescription", {
|
||||
name: role.displayName,
|
||||
}),
|
||||
confirmText: t("common.delete"),
|
||||
cancelText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteRole(role.id);
|
||||
toast.success(t("rbac.roleDeletedSuccessfully"));
|
||||
loadRoles();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToDeleteRole"));
|
||||
}
|
||||
};
|
||||
|
||||
// Open assign dialog
|
||||
const handleOpenAssignDialog = async () => {
|
||||
setSelectedUserId("");
|
||||
setSelectedRoleId(null);
|
||||
setUserRoles([]);
|
||||
setAssignDialogOpen(true);
|
||||
};
|
||||
|
||||
// Load user roles when user is selected
|
||||
const handleUserSelect = async (userId: string) => {
|
||||
setSelectedUserId(userId);
|
||||
setUserRoles([]);
|
||||
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await getUserRoles(userId);
|
||||
setUserRoles(response.roles || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load user roles:", error);
|
||||
setUserRoles([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Assign role to user
|
||||
const handleAssignRole = async () => {
|
||||
if (!selectedUserId || !selectedRoleId) {
|
||||
toast.error(t("rbac.selectUserAndRole"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await assignRoleToUser(selectedUserId, selectedRoleId);
|
||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
||||
toast.success(
|
||||
t("rbac.roleAssignedSuccessfully", {
|
||||
username: selectedUser?.username || selectedUserId,
|
||||
}),
|
||||
);
|
||||
setSelectedRoleId(null);
|
||||
handleUserSelect(selectedUserId);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToAssignRole"));
|
||||
}
|
||||
};
|
||||
|
||||
// Remove role from user
|
||||
const handleRemoveUserRole = async (roleId: number) => {
|
||||
if (!selectedUserId) return;
|
||||
|
||||
try {
|
||||
await removeRoleFromUser(selectedUserId, roleId);
|
||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
||||
toast.success(
|
||||
t("rbac.roleRemovedSuccessfully", {
|
||||
username: selectedUser?.username || selectedUserId,
|
||||
}),
|
||||
);
|
||||
handleUserSelect(selectedUserId);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToRemoveRole"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Roles Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
{t("rbac.roleManagement")}
|
||||
</h3>
|
||||
<Button onClick={handleCreateRole}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rbac.createRole")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("rbac.roleName")}</TableHead>
|
||||
<TableHead>{t("rbac.displayName")}</TableHead>
|
||||
<TableHead>{t("rbac.description")}</TableHead>
|
||||
<TableHead>{t("rbac.type")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noRoles")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-mono">{role.name}</TableCell>
|
||||
<TableCell>{t(role.displayName)}</TableCell>
|
||||
<TableCell className="max-w-xs truncate">
|
||||
{role.description || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{role.isSystem ? (
|
||||
<Badge variant="secondary">{t("rbac.systemRole")}</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">{t("rbac.customRole")}</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{!role.isSystem && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEditRole(role)}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteRole(role)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* User-Role Assignment Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
{t("rbac.userRoleAssignment")}
|
||||
</h3>
|
||||
<Button onClick={handleOpenAssignDialog}>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
{t("rbac.assignRoles")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Role Dialog */}
|
||||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingRole ? t("rbac.editRole") : t("rbac.createRole")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingRole
|
||||
? t("rbac.editRoleDescription")
|
||||
: t("rbac.createRoleDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{!editingRole && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-name">{t("rbac.roleName")}</Label>
|
||||
<Input
|
||||
id="role-name"
|
||||
value={roleName}
|
||||
onChange={(e) => setRoleName(e.target.value.toLowerCase())}
|
||||
placeholder="developer"
|
||||
disabled={!!editingRole}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("rbac.roleNameHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-display-name">{t("rbac.displayName")}</Label>
|
||||
<Input
|
||||
id="role-display-name"
|
||||
value={roleDisplayName}
|
||||
onChange={(e) => setRoleDisplayName(e.target.value)}
|
||||
placeholder={t("rbac.displayNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-description">{t("rbac.description")}</Label>
|
||||
<Textarea
|
||||
id="role-description"
|
||||
value={roleDescription}
|
||||
onChange={(e) => setRoleDescription(e.target.value)}
|
||||
placeholder={t("rbac.descriptionPlaceholder")}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRoleDialogOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSaveRole}>
|
||||
{editingRole ? t("common.save") : t("common.create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Assign Role Dialog */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
|
||||
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("rbac.assignRoles")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rbac.assignRolesDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* User Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
|
||||
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={userComboOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedUserId
|
||||
? users.find((u) => u.id === selectedUserId)?.username +
|
||||
(users.find((u) => u.id === selectedUserId)?.is_admin
|
||||
? " (Admin)"
|
||||
: "")
|
||||
: t("rbac.selectUserPlaceholder")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("rbac.searchUsers")} />
|
||||
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{users.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={`${user.username} ${user.id}`}
|
||||
onSelect={() => {
|
||||
handleUserSelect(user.id);
|
||||
setUserComboOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedUserId === user.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{user.username}
|
||||
{user.is_admin ? " (Admin)" : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Current User Roles */}
|
||||
{selectedUserId && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("rbac.currentRoles")}</Label>
|
||||
{userRoles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("rbac.noRolesAssigned")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
|
||||
{userRoles.map((userRole, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 border rounded"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t(userRole.roleDisplayName)}
|
||||
</p>
|
||||
{userRole.roleDisplayName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{userRole.roleName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{userRole.isSystem ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("rbac.systemRole")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
handleRemoveUserRole(userRole.roleId)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assign New Role */}
|
||||
{selectedUserId && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-select">{t("rbac.assignNewRole")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={roleComboOpen}
|
||||
className="flex-1 justify-between"
|
||||
>
|
||||
{selectedRoleId !== null
|
||||
? (() => {
|
||||
const role = roles.find(
|
||||
(r) => r.id === selectedRoleId,
|
||||
);
|
||||
return role
|
||||
? `${t(role.displayName)}${role.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
|
||||
: t("rbac.selectRolePlaceholder");
|
||||
})()
|
||||
: t("rbac.selectRolePlaceholder")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("rbac.searchRoles")} />
|
||||
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{roles
|
||||
.filter(
|
||||
(role) =>
|
||||
!role.isSystem &&
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
)
|
||||
.map((role) => (
|
||||
<CommandItem
|
||||
key={role.id}
|
||||
value={`${role.displayName} ${role.name} ${role.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedRoleId(role.id);
|
||||
setRoleComboOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedRoleId === role.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{t(role.displayName)}
|
||||
{role.isSystem
|
||||
? ` (${t("rbac.systemRole")})`
|
||||
: ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button onClick={handleAssignRole} disabled={!selectedRoleId}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rbac.assign")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAssignDialogOpen(false)}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CredentialSelector } from "@/ui/desktop/apps/credentials/CredentialSelector.tsx";
|
||||
import { HostSharingTab } from "./HostSharingTab.tsx";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
@@ -1189,6 +1190,11 @@ export function HostManagerEditor({
|
||||
<TabsTrigger value="statistics">
|
||||
{t("hosts.statistics")}
|
||||
</TabsTrigger>
|
||||
{!editingHost?.isShared && (
|
||||
<TabsTrigger value="sharing">
|
||||
{t("rbac.sharing")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">
|
||||
@@ -3327,18 +3333,27 @@ export function HostManagerEditor({
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sharing" className="space-y-6">
|
||||
<HostSharingTab
|
||||
hostId={editingHost?.id}
|
||||
isNewHost={!editingHost?.id}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
<Button className="translate-y-2" type="submit" variant="outline">
|
||||
{editingHost
|
||||
? editingHost.id
|
||||
? t("hosts.updateHost")
|
||||
: t("hosts.cloneHost")
|
||||
: t("hosts.addHost")}
|
||||
</Button>
|
||||
{!(editingHost?.permissionLevel === "view") && (
|
||||
<Button className="translate-y-2" type="submit" variant="outline">
|
||||
{editingHost
|
||||
? editingHost.id
|
||||
? t("hosts.updateHost")
|
||||
: t("hosts.cloneHost")
|
||||
: t("hosts.addHost")}
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
@@ -61,6 +61,8 @@ import {
|
||||
HardDrive,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
Share2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
@@ -1230,6 +1232,14 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
{host.name ||
|
||||
`${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
{(host as any).isShared && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0 text-violet-500 border-violet-500/50"
|
||||
>
|
||||
{t("rbac.shared")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port}
|
||||
|
||||
585
src/ui/desktop/apps/host-manager/HostSharingTab.tsx
Normal file
585
src/ui/desktop/apps/host-manager/HostSharingTab.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import {
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
Shield,
|
||||
Clock,
|
||||
UserCircle,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getRoles,
|
||||
getUserList,
|
||||
getUserInfo,
|
||||
shareHost,
|
||||
getHostAccess,
|
||||
revokeHostAccess,
|
||||
getSSHHostById,
|
||||
type Role,
|
||||
type AccessRecord,
|
||||
type SSHHost,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover.tsx";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface HostSharingTabProps {
|
||||
hostId: number | undefined;
|
||||
isNewHost: boolean;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_LEVELS = [
|
||||
{ value: "view", labelKey: "rbac.view" },
|
||||
{ value: "manage", labelKey: "rbac.manage" },
|
||||
];
|
||||
|
||||
export function HostSharingTab({
|
||||
hostId,
|
||||
isNewHost,
|
||||
}: HostSharingTabProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [shareType, setShareType] = React.useState<"user" | "role">("user");
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
|
||||
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [permissionLevel, setPermissionLevel] = React.useState("view");
|
||||
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
|
||||
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
const [users, setUsers] = React.useState<User[]>([]);
|
||||
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [currentUserId, setCurrentUserId] = React.useState<string>("");
|
||||
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
|
||||
|
||||
const [userComboOpen, setUserComboOpen] = React.useState(false);
|
||||
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
|
||||
|
||||
// Load roles
|
||||
const loadRoles = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await getRoles();
|
||||
setRoles(response.roles || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load roles:", error);
|
||||
setRoles([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load users
|
||||
const loadUsers = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await getUserList();
|
||||
// Map UserInfo to User format
|
||||
const mappedUsers = (response.users || []).map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
}));
|
||||
setUsers(mappedUsers);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
setUsers([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load access list
|
||||
const loadAccessList = React.useCallback(async () => {
|
||||
if (!hostId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getHostAccess(hostId);
|
||||
setAccessList(response.accessList || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load access list:", error);
|
||||
setAccessList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
// Load host data
|
||||
const loadHostData = React.useCallback(async () => {
|
||||
if (!hostId) return;
|
||||
|
||||
try {
|
||||
const host = await getSSHHostById(hostId);
|
||||
setHostData(host);
|
||||
} catch (error) {
|
||||
console.error("Failed to load host data:", error);
|
||||
setHostData(null);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadRoles();
|
||||
loadUsers();
|
||||
if (!isNewHost) {
|
||||
loadAccessList();
|
||||
loadHostData();
|
||||
}
|
||||
}, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
|
||||
|
||||
// Load current user ID
|
||||
React.useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const userInfo = await getUserInfo();
|
||||
setCurrentUserId(userInfo.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to load current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
// Share host
|
||||
const handleShare = async () => {
|
||||
if (!hostId) {
|
||||
toast.error(t("rbac.saveHostFirst"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareType === "user" && !selectedUserId) {
|
||||
toast.error(t("rbac.selectUser"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareType === "role" && !selectedRoleId) {
|
||||
toast.error(t("rbac.selectRole"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent sharing with self
|
||||
if (shareType === "user" && selectedUserId === currentUserId) {
|
||||
toast.error(t("rbac.cannotShareWithSelf"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await shareHost(hostId, {
|
||||
targetType: shareType,
|
||||
targetUserId: shareType === "user" ? selectedUserId : undefined,
|
||||
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
|
||||
permissionLevel,
|
||||
durationHours: expiresInHours
|
||||
? parseInt(expiresInHours, 10)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
toast.success(t("rbac.sharedSuccessfully"));
|
||||
setSelectedUserId("");
|
||||
setSelectedRoleId(null);
|
||||
setExpiresInHours("");
|
||||
loadAccessList();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToShare"));
|
||||
}
|
||||
};
|
||||
|
||||
// Revoke access
|
||||
const handleRevoke = async (accessId: number) => {
|
||||
if (!hostId) return;
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmRevokeAccess"),
|
||||
description: t("rbac.confirmRevokeAccessDescription"),
|
||||
confirmText: t("common.revoke"),
|
||||
cancelText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await revokeHostAccess(hostId, accessId);
|
||||
toast.success(t("rbac.accessRevokedSuccessfully"));
|
||||
loadAccessList();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToRevokeAccess"));
|
||||
}
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Check if expired
|
||||
const isExpired = (expiresAt: string | null) => {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
};
|
||||
|
||||
// Filter out current user from the users list
|
||||
const availableUsers = React.useMemo(() => {
|
||||
return users.filter((user) => user.id !== currentUserId);
|
||||
}, [users, currentUserId]);
|
||||
|
||||
const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
|
||||
const selectedRole = roles.find((r) => r.id === selectedRoleId);
|
||||
|
||||
if (isNewHost) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("rbac.saveHostFirstDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Credential Authentication Warning */}
|
||||
{hostData?.authType === "Credential" && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("rbac.credentialSharingWarning")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("rbac.credentialSharingWarningDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Share Form */}
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
{t("rbac.shareHost")}
|
||||
</h3>
|
||||
|
||||
{/* Share Type Selection */}
|
||||
<Tabs
|
||||
value={shareType}
|
||||
onValueChange={(v) => setShareType(v as "user" | "role")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="user" className="flex items-center gap-2">
|
||||
<UserCircle className="h-4 w-4" />
|
||||
{t("rbac.shareWithUser")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="role" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("rbac.shareWithRole")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="user" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
|
||||
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={userComboOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedUser
|
||||
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
|
||||
: t("rbac.selectUserPlaceholder")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("rbac.searchUsers")} />
|
||||
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{availableUsers.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={`${user.username} ${user.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedUserId(user.id);
|
||||
setUserComboOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedUserId === user.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{user.username}
|
||||
{user.is_admin ? " (Admin)" : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="role" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-select">{t("rbac.selectRole")}</Label>
|
||||
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={roleComboOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedRole
|
||||
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
|
||||
: t("rbac.selectRolePlaceholder")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("rbac.searchRoles")} />
|
||||
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{roles.map((role) => (
|
||||
<CommandItem
|
||||
key={role.id}
|
||||
value={`${role.displayName} ${role.name} ${role.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedRoleId(role.id);
|
||||
setRoleComboOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedRoleId === role.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{t(role.displayName)}
|
||||
{role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Permission Level */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-level">{t("rbac.permissionLevel")}</Label>
|
||||
<Select
|
||||
value={permissionLevel || "use"}
|
||||
onValueChange={(v) => setPermissionLevel(v || "use")}
|
||||
>
|
||||
<SelectTrigger id="permission-level">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PERMISSION_LEVELS.map((level) => (
|
||||
<SelectItem key={level.value} value={level.value}>
|
||||
{t(level.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Expiration */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expires-in">{t("rbac.durationHours")}</Label>
|
||||
<Input
|
||||
id="expires-in"
|
||||
type="number"
|
||||
value={expiresInHours}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "" || /^\d+$/.test(value)) {
|
||||
setExpiresInHours(value);
|
||||
}
|
||||
}}
|
||||
placeholder={t("rbac.neverExpires")}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={handleShare} className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rbac.share")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Access List */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
{t("rbac.accessList")}
|
||||
</h3>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("rbac.type")}</TableHead>
|
||||
<TableHead>{t("rbac.target")}</TableHead>
|
||||
<TableHead>{t("rbac.permissionLevel")}</TableHead>
|
||||
<TableHead>{t("rbac.grantedBy")}</TableHead>
|
||||
<TableHead>{t("rbac.expires")}</TableHead>
|
||||
<TableHead>{t("rbac.accessCount")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : accessList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noAccessRecords")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
accessList.map((access) => (
|
||||
<TableRow
|
||||
key={access.id}
|
||||
className={isExpired(access.expiresAt) ? "opacity-50" : ""}
|
||||
>
|
||||
<TableCell>
|
||||
{access.targetType === "user" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<UserCircle className="h-3 w-3" />
|
||||
{t("rbac.user")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
{t("rbac.role")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{access.targetType === "user"
|
||||
? access.username
|
||||
: t(access.roleDisplayName || access.roleName || "")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{access.permissionLevel}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{access.grantedByUsername}</TableCell>
|
||||
<TableCell>
|
||||
{access.expiresAt ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span
|
||||
className={
|
||||
isExpired(access.expiresAt) ? "text-red-500" : ""
|
||||
}
|
||||
>
|
||||
{formatDate(access.expiresAt)}
|
||||
{isExpired(access.expiresAt) && (
|
||||
<span className="ml-2">({t("rbac.expired")})</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
t("rbac.never")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{access.accessCount}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRevoke(access.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
deleteAccount,
|
||||
logoutUser,
|
||||
isElectron,
|
||||
getUserRoles,
|
||||
type UserRole,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { PasswordReset } from "@/ui/desktop/user/PasswordReset.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -105,6 +107,7 @@ export function UserProfile({
|
||||
useState<boolean>(
|
||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||
);
|
||||
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
@@ -133,6 +136,15 @@ export function UserProfile({
|
||||
is_dual_auth: info.is_dual_auth || false,
|
||||
totp_enabled: info.totp_enabled || false,
|
||||
});
|
||||
|
||||
// Fetch user roles
|
||||
try {
|
||||
const rolesResponse = await getUserRoles(info.userId);
|
||||
setUserRoles(rolesResponse.roles || []);
|
||||
} catch (rolesErr) {
|
||||
console.error("Failed to fetch user roles:", rolesErr);
|
||||
setUserRoles([]);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { error?: string } } };
|
||||
setError(error?.response?.data?.error || t("errors.loadFailed"));
|
||||
@@ -304,11 +316,26 @@ export function UserProfile({
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.role")}
|
||||
</Label>
|
||||
<p className="text-lg font-medium mt-1 text-white">
|
||||
{userInfo.is_admin
|
||||
? t("interface.administrator")
|
||||
: t("interface.user")}
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
{userRoles.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{userRoles.map((role) => (
|
||||
<span
|
||||
key={role.roleId}
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-medium bg-muted/50 text-white border border-border"
|
||||
>
|
||||
{t(role.roleDisplayName)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-lg font-medium text-white">
|
||||
{userInfo.is_admin
|
||||
? t("interface.administrator")
|
||||
: t("interface.user")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
|
||||
@@ -12,6 +12,48 @@ import type {
|
||||
DockerLogOptions,
|
||||
DockerValidation,
|
||||
} from "../types/index.js";
|
||||
|
||||
// ============================================================================
|
||||
// RBAC TYPE DEFINITIONS
|
||||
// ============================================================================
|
||||
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
displayName: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
permissions: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
userId: string;
|
||||
roleId: number;
|
||||
roleName: string;
|
||||
roleDisplayName: string;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string;
|
||||
grantedAt: string;
|
||||
}
|
||||
|
||||
export interface AccessRecord {
|
||||
id: number;
|
||||
targetType: "user" | "role";
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
username: string | null;
|
||||
roleName: string | null;
|
||||
roleDisplayName: string | null;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
lastAccessedAt: string | null;
|
||||
accessCount: number;
|
||||
}
|
||||
import {
|
||||
apiLogger,
|
||||
authLogger,
|
||||
@@ -599,6 +641,9 @@ function initializeApiInstances() {
|
||||
// Homepage API (port 30006)
|
||||
homepageApi = createApiInstance(getApiUrl("", 30006), "HOMEPAGE");
|
||||
|
||||
// RBAC API (port 30001)
|
||||
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
|
||||
|
||||
// Docker Management API (port 30007)
|
||||
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
|
||||
}
|
||||
@@ -621,6 +666,9 @@ export let authApi: AxiosInstance;
|
||||
// Homepage API (port 30006)
|
||||
export let homepageApi: AxiosInstance;
|
||||
|
||||
// RBAC API (port 30001)
|
||||
export let rbacApi: AxiosInstance;
|
||||
|
||||
// Docker Management API (port 30007)
|
||||
export let dockerApi: AxiosInstance;
|
||||
|
||||
@@ -3132,6 +3180,129 @@ export async function unlinkOIDCFromPasswordAccount(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RBAC MANAGEMENT
|
||||
// ============================================================================
|
||||
|
||||
// Role Management
|
||||
export async function getRoles(): Promise<{ roles: Role[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get("/rbac/roles");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch roles");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRole(roleData: {
|
||||
name: string;
|
||||
displayName: string;
|
||||
description?: string | null;
|
||||
}): Promise<{ role: Role }> {
|
||||
try {
|
||||
const response = await rbacApi.post("/rbac/roles", roleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "create role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateRole(
|
||||
roleId: number,
|
||||
roleData: {
|
||||
displayName?: string;
|
||||
description?: string | null;
|
||||
},
|
||||
): Promise<{ role: Role }> {
|
||||
try {
|
||||
const response = await rbacApi.put(`/rbac/roles/${roleId}`, roleData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "update role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRole(roleId: number): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "delete role");
|
||||
}
|
||||
}
|
||||
|
||||
// User-Role Management
|
||||
export async function getUserRoles(userId: string): Promise<{ roles: UserRole[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch user roles");
|
||||
}
|
||||
}
|
||||
|
||||
export async function assignRoleToUser(
|
||||
userId: string,
|
||||
roleId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, { roleId });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "assign role to user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeRoleFromUser(
|
||||
userId: string,
|
||||
roleId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(`/rbac/users/${userId}/roles/${roleId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove role from user");
|
||||
}
|
||||
}
|
||||
|
||||
// Host Sharing Management
|
||||
export async function shareHost(
|
||||
hostId: number,
|
||||
shareData: {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
permissionLevel: string;
|
||||
durationHours?: number;
|
||||
},
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.post(`/rbac/host/${hostId}/share`, shareData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "share host");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostAccess(hostId: number): Promise<{ accessList: AccessRecord[] }> {
|
||||
try {
|
||||
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch host access");
|
||||
}
|
||||
}
|
||||
|
||||
export async function revokeHostAccess(
|
||||
hostId: number,
|
||||
accessId: number,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await rbacApi.delete(`/rbac/host/${hostId}/access/${accessId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "revoke host access");
|
||||
|
||||
// ============================================================================
|
||||
// DOCKER MANAGEMENT API
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user