Fix RBAC role system bugs and improve UX #446

Merged
ZacharyZcR merged 7 commits from fix/rbac-improvements into dev-1.10.0 2025-12-20 02:13:36 +00:00
20 changed files with 4389 additions and 266 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(
(

View File

@@ -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",
});

View File

@@ -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"),
});

View 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;

View File

@@ -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;

View File

@@ -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"

View 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 };

View File

@@ -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,
});
});
};

View File

@@ -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"
}
}
}

View File

@@ -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": "主机管理器"
}
}
}

View File

@@ -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 {

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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}

View 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>
);
}

View File

@@ -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">

View File

@@ -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
// ============================================================================