diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 5e6126bf..fb72157e 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -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; diff --git a/docker/nginx.conf b/docker/nginx.conf index db5546f0..eea71293 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -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; diff --git a/src/backend/database/routes/rbac.ts b/src/backend/database/routes/rbac.ts index 80e6efed..64d21ade 100644 --- a/src/backend/database/routes/rbac.ts +++ b/src/backend/database/routes/rbac.ts @@ -52,15 +52,21 @@ router.post( // Validate target type if (!["user", "role"].includes(targetType)) { - return res.status(400).json({ error: "Invalid target type. Must be 'user' or 'role'" }); + 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" }); + 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" }); + return res + .status(400) + .json({ error: "Target role ID is required when sharing with role" }); } // Verify user owns the host @@ -104,7 +110,11 @@ router.post( // Calculate expiry time let expiresAt: string | null = null; - if (durationHours && typeof durationHours === "number" && durationHours > 0) { + if ( + durationHours && + typeof durationHours === "number" && + durationHours > 0 + ) { const expiryDate = new Date(); expiryDate.setHours(expiryDate.getHours() + durationHours); expiresAt = expiryDate.toISOString(); @@ -299,7 +309,7 @@ router.get( .orderBy(desc(hostAccess.createdAt)); // Format access list with type information - const accessList = rawAccessList.map(access => ({ + const accessList = rawAccessList.map((access) => ({ id: access.id, targetType: access.userId ? "user" : "role", userId: access.userId, @@ -361,10 +371,7 @@ router.get( .where( and( eq(hostAccess.userId, userId), - or( - isNull(hostAccess.expiresAt), - gte(hostAccess.expiresAt, now), - ), + or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)), ), ) .orderBy(desc(hostAccess.createdAt)); @@ -729,15 +736,20 @@ router.post( 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), - ), + and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), ) .limit(1); @@ -794,14 +806,34 @@ router.delete( } 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), - ), + and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)), ); // Invalidate permission cache diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index fd18585e..8ce7fa31 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1881,10 +1881,14 @@ "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", @@ -1954,4 +1958,4 @@ "close": "Close", "hostManager": "Host Manager" } -} \ No newline at end of file +} diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 234723c5..7544dd64 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -389,7 +389,7 @@ "actions": "操作", "remove": "移除", "revoke": "撤销", - "create": "创建" + "create": "创建", "saving": "保存中...", "version": "Version" }, @@ -1880,10 +1880,14 @@ "assignRoles": "分配角色", "userRoleAssignment": "用户角色分配", "selectUserPlaceholder": "选择用户", + "searchUsers": "搜索用户...", + "noUserFound": "未找到用户", "currentRoles": "当前角色", "noRolesAssigned": "未分配角色", "assignNewRole": "分配新角色", "selectRolePlaceholder": "选择角色", + "searchRoles": "搜索角色...", + "noRoleFound": "未找到角色", "assign": "分配", "roleCreatedSuccessfully": "角色创建成功", "roleUpdatedSuccessfully": "角色更新成功", @@ -1953,4 +1957,4 @@ "close": "关闭", "hostManager": "主机管理器" } -} \ No newline at end of file +} diff --git a/src/ui/desktop/admin/AdminSettings.tsx b/src/ui/desktop/admin/AdminSettings.tsx index e15b9e73..e271db3d 100644 --- a/src/ui/desktop/admin/AdminSettings.tsx +++ b/src/ui/desktop/admin/AdminSettings.tsx @@ -286,7 +286,10 @@ export function AdminSettings({ }; // Role management functions - const handleOpenRolesDialog = async (user: { id: string; username: string }) => { + const handleOpenRolesDialog = async (user: { + id: string; + username: string; + }) => { setSelectedUser(user); setRolesDialogOpen(true); setRolesLoading(true); @@ -312,7 +315,9 @@ export function AdminSettings({ try { await assignRoleToUser(selectedUser.id, roleId); - toast.success(t("rbac.roleAssignedSuccessfully", { username: selectedUser.username })); + toast.success( + t("rbac.roleAssignedSuccessfully", { username: selectedUser.username }), + ); // Reload user roles const rolesResponse = await getUserRoles(selectedUser.id); @@ -325,16 +330,11 @@ export function AdminSettings({ const handleRemoveRole = async (roleId: number) => { if (!selectedUser) return; - // Prevent removal of system roles - const roleToRemove = userRoles.find((r) => r.roleId === roleId); - if (roleToRemove && (roleToRemove.roleName === "admin" || roleToRemove.roleName === "user")) { - toast.error(t("rbac.cannotRemoveSystemRole")); - return; - } - try { await removeRoleFromUser(selectedUser.id, roleId); - toast.success(t("rbac.roleRemovedSuccessfully", { username: selectedUser.username })); + toast.success( + t("rbac.roleRemovedSuccessfully", { username: selectedUser.username }), + ); // Reload user roles const rolesResponse = await getUserRoles(selectedUser.id); @@ -1162,102 +1162,92 @@ export function AdminSettings({ {t("admin.loadingUsers")} ) : ( -
- - - - - {t("admin.username")} - - - {t("admin.type")} - - - {t("admin.actions")} - - - - - {users.map((user) => ( - - - {user.username} - {user.is_admin && ( - - {t("admin.adminBadge")} - - )} - - - {user.is_oidc && user.password_hash - ? "Dual Auth" - : user.is_oidc - ? t("admin.external") - : t("admin.local")} - - -
- {user.is_oidc && !user.password_hash && ( - - )} - {user.is_oidc && user.password_hash && ( - - )} +
+ + + {t("admin.username")} + {t("admin.type")} + {t("admin.actions")} + + + + {users.map((user) => ( + + + {user.username} + {user.is_admin && ( + + {t("admin.adminBadge")} + + )} + + + {user.is_oidc && user.password_hash + ? "Dual Auth" + : user.is_oidc + ? t("admin.external") + : t("admin.local")} + + +
+ {user.is_oidc && !user.password_hash && ( + )} + {user.is_oidc && user.password_hash && ( -
-
-
- ))} -
-
-
+ )} + + + + + + ))} + + )} @@ -1284,115 +1274,107 @@ export function AdminSettings({ No active sessions found. ) : ( -
-
- - - - Device - User - Created - Last Active - Expires - - {t("admin.actions")} - +
+ + + Device + User + Created + Last Active + Expires + {t("admin.actions")} + + + + {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 ( + + +
+ +
+ + {session.deviceInfo} + + {session.isRevoked && ( + + Revoked + + )} +
+
+
+ + {session.username || session.userId} + + + {formatDate(createdDate)} + + + {formatDate(lastActiveDate)} + + + {formatDate(expiresDate)} + + +
+ + {session.username && ( + + )} +
+
- - - {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 ( - - -
- -
- - {session.deviceInfo} - - {session.isRevoked && ( - - Revoked - - )} -
-
-
- - {session.username || session.userId} - - - {formatDate(createdDate)} - - - {formatDate(lastActiveDate)} - - - {formatDate(expiresDate)} - - -
- - {session.username && ( - - )} -
-
-
- ); - })} -
-
-
-
+ ); + })} + + )} @@ -1440,55 +1422,47 @@ export function AdminSettings({

{t("admin.currentAdmins")}

-
- - - - - {t("admin.username")} - - - {t("admin.type")} - - - {t("admin.actions")} - - - - - {users - .filter((u) => u.is_admin) - .map((admin) => ( - - - {admin.username} - - {t("admin.adminBadge")} - - - - {admin.is_oidc - ? t("admin.external") - : t("admin.local")} - - - - - - ))} - -
-
+ + + + {t("admin.username")} + {t("admin.type")} + {t("admin.actions")} + + + + {users + .filter((u) => u.is_admin) + .map((admin) => ( + + + {admin.username} + + {t("admin.adminBadge")} + + + + {admin.is_oidc + ? t("admin.external") + : t("admin.local")} + + + + + + ))} + +
@@ -1719,7 +1693,9 @@ export function AdminSettings({ {t("rbac.manageRoles")} - {t("rbac.manageRolesFor", { username: selectedUser?.username || "" })} + {t("rbac.manageRolesFor", { + username: selectedUser?.username || "", + })} @@ -1737,19 +1713,25 @@ export function AdminSettings({ {t("rbac.noRolesAssigned")}

) : ( -
+
{userRoles.map((userRole) => (
-

{t(userRole.roleDisplayName)}

+

+ {t(userRole.roleDisplayName)} +

{userRole.roleName}

- {userRole.roleName !== "admin" && userRole.roleName !== "user" && ( + {userRole.isSystem ? ( + + {t("rbac.systemRole")} + + ) : ( + + + + + {t("rbac.noUserFound")} + + {users.map((user) => ( + { + handleUserSelect(user.id); + setUserComboOpen(false); + }} + > + + {user.username} + {user.is_admin ? " (Admin)" : ""} + + ))} + + + +
{/* Current User Roles */} @@ -438,14 +519,16 @@ export function RoleManagement(): React.ReactElement { {t("rbac.noRolesAssigned")}

) : ( -
+
{userRoles.map((userRole, index) => (
-

{t(userRole.roleDisplayName)}

+

+ {t(userRole.roleDisplayName)} +

{userRole.roleDisplayName && (

{userRole.roleName} @@ -453,13 +536,21 @@ export function RoleManagement(): React.ReactElement { )}

- + {userRole.isSystem ? ( + + {t("rbac.systemRole")} + + ) : ( + + )}
))} @@ -473,29 +564,68 @@ export function RoleManagement(): React.ReactElement {
- + + + + + + + + {t("rbac.noRoleFound")} + + {roles + .filter( + (role) => + !role.isSystem && + !userRoles.some((ur) => ur.roleId === role.id), + ) + .map((role) => ( + { + setSelectedRoleId(role.id); + setRoleComboOpen(false); + }} + > + + {t(role.displayName)} + {role.isSystem + ? ` (${t("rbac.systemRole")})` + : ""} + + ))} + + + +
- diff --git a/src/ui/desktop/apps/host-manager/HostSharingTab.tsx b/src/ui/desktop/apps/host-manager/HostSharingTab.tsx index 0375db1a..834e3f32 100644 --- a/src/ui/desktop/apps/host-manager/HostSharingTab.tsx +++ b/src/ui/desktop/apps/host-manager/HostSharingTab.tsx @@ -26,9 +26,16 @@ import { 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 { + 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"; @@ -44,6 +51,19 @@ import { 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; @@ -83,6 +103,9 @@ export function HostSharingTab({ const [currentUserId, setCurrentUserId] = React.useState(""); const [hostData, setHostData] = React.useState(null); + const [userComboOpen, setUserComboOpen] = React.useState(false); + const [roleComboOpen, setRoleComboOpen] = React.useState(false); + // Load roles const loadRoles = React.useCallback(async () => { try { @@ -191,7 +214,9 @@ export function HostSharingTab({ targetUserId: shareType === "user" ? selectedUserId : undefined, targetRoleId: shareType === "role" ? selectedRoleId : undefined, permissionLevel, - durationHours: expiresInHours ? parseInt(expiresInHours, 10) : undefined, + durationHours: expiresInHours + ? parseInt(expiresInHours, 10) + : undefined, }); toast.success(t("rbac.sharedSuccessfully")); @@ -238,6 +263,14 @@ export function HostSharingTab({ 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 ( @@ -271,7 +304,10 @@ export function HostSharingTab({ {/* Share Type Selection */} - setShareType(v as "user" | "role")}> + setShareType(v as "user" | "role")} + > @@ -286,42 +322,106 @@ export function HostSharingTab({
- + + + + + + + + {t("rbac.noUserFound")} + + {availableUsers.map((user) => ( + { + setSelectedUserId(user.id); + setUserComboOpen(false); + }} + > + + {user.username} + {user.is_admin ? " (Admin)" : ""} + + ))} + + + +
- + + + + + + + + {t("rbac.noRoleFound")} + + {roles.map((role) => ( + { + setSelectedRoleId(role.id); + setRoleComboOpen(false); + }} + > + + {t(role.displayName)} + {role.isSystem ? ` (${t("rbac.systemRole")})` : ""} + + ))} + + + +
@@ -329,7 +429,10 @@ export function HostSharingTab({ {/* Permission Level */}
- setPermissionLevel(v || "use")} + > @@ -345,9 +448,7 @@ export function HostSharingTab({ {/* Expiration */}
- + {t("rbac.grantedBy")} {t("rbac.expires")} {t("rbac.accessCount")} - {t("common.actions")} + + {t("common.actions")} + {loading ? ( - + {t("common.loading")} ) : accessList.length === 0 ? ( - + {t("rbac.noAccessRecords")} @@ -409,12 +518,18 @@ export function HostSharingTab({ > {access.targetType === "user" ? ( - + {t("rbac.user")} ) : ( - + {t("rbac.role")} @@ -433,7 +548,11 @@ export function HostSharingTab({ {access.expiresAt ? (
- + {formatDate(access.expiresAt)} {isExpired(access.expiresAt) && ( ({t("rbac.expired")}) diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index 1862c648..557c8121 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -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( localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false", ); + const [userRoles, setUserRoles] = useState([]); 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({ -

- {userInfo.is_admin - ? t("interface.administrator") - : t("interface.user")} -

+
+ {userRoles.length > 0 ? ( +
+ {userRoles.map((role) => ( + + {t(role.roleDisplayName)} + + ))} +
+ ) : ( +

+ {userInfo.is_admin + ? t("interface.administrator") + : t("interface.user")} +

+ )} +