feat: Improve rbac UI and fixes some bugs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "主机管理器"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<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={() =>
|
||||
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={() =>
|
||||
handleOpenRolesDialog({
|
||||
handleLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
||||
title={t("rbac.manageRoles")}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title="Link to password account"
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{user.is_oidc && user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteUser(user.username)
|
||||
handleUnlinkOIDC(user.id, user.username)
|
||||
}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
disabled={user.is_admin}
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
title="Unlink OIDC (keep password only)"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Unlink className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
@@ -1284,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>Device</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last Active</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>
|
||||
{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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -1440,55 +1422,47 @@ 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>
|
||||
{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>
|
||||
<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>
|
||||
@@ -1719,7 +1693,9 @@ export function AdminSettings({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("rbac.manageRoles")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("rbac.manageRolesFor", { username: selectedUser?.username || "" })}
|
||||
{t("rbac.manageRolesFor", {
|
||||
username: selectedUser?.username || "",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1737,19 +1713,25 @@ export function AdminSettings({
|
||||
{t("rbac.noRolesAssigned")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<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="font-medium">
|
||||
{t(userRole.roleDisplayName)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{userRole.roleName}
|
||||
</p>
|
||||
</div>
|
||||
{userRole.roleName !== "admin" && userRole.roleName !== "user" && (
|
||||
{userRole.isSystem ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("rbac.systemRole")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
|
||||
@@ -27,7 +27,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { Shield, Plus, Edit, Trash2, Users } from "lucide-react";
|
||||
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";
|
||||
@@ -43,6 +51,19 @@ import {
|
||||
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;
|
||||
@@ -73,6 +94,10 @@ export function RoleManagement(): React.ReactElement {
|
||||
);
|
||||
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);
|
||||
@@ -221,7 +246,11 @@ export function RoleManagement(): React.ReactElement {
|
||||
try {
|
||||
await assignRoleToUser(selectedUserId, selectedRoleId);
|
||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
||||
toast.success(t("rbac.roleAssignedSuccessfully", { username: selectedUser?.username || selectedUserId }));
|
||||
toast.success(
|
||||
t("rbac.roleAssignedSuccessfully", {
|
||||
username: selectedUser?.username || selectedUserId,
|
||||
}),
|
||||
);
|
||||
setSelectedRoleId(null);
|
||||
handleUserSelect(selectedUserId);
|
||||
} catch (error) {
|
||||
@@ -236,7 +265,11 @@ export function RoleManagement(): React.ReactElement {
|
||||
try {
|
||||
await removeRoleFromUser(selectedUserId, roleId);
|
||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
||||
toast.success(t("rbac.roleRemovedSuccessfully", { username: selectedUser?.username || selectedUserId }));
|
||||
toast.success(
|
||||
t("rbac.roleRemovedSuccessfully", {
|
||||
username: selectedUser?.username || selectedUserId,
|
||||
}),
|
||||
);
|
||||
handleUserSelect(selectedUserId);
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToRemoveRole"));
|
||||
@@ -265,19 +298,27 @@ export function RoleManagement(): React.ReactElement {
|
||||
<TableHead>{t("rbac.displayName")}</TableHead>
|
||||
<TableHead>{t("rbac.description")}</TableHead>
|
||||
<TableHead>{t("rbac.type")}</TableHead>
|
||||
<TableHead className="text-right">{t("common.actions")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
<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">
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noRoles")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -408,25 +449,65 @@ export function RoleManagement(): React.ReactElement {
|
||||
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("rbac.assignRoles")}</DialogTitle>
|
||||
<DialogDescription>{t("rbac.assignRolesDescription")}</DialogDescription>
|
||||
<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>
|
||||
<Select value={selectedUserId || ""} onValueChange={handleUserSelect}>
|
||||
<SelectTrigger id="user-select">
|
||||
<SelectValue placeholder={t("rbac.selectUserPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.username}{user.is_admin ? " (Admin)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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 */}
|
||||
@@ -438,14 +519,16 @@ export function RoleManagement(): React.ReactElement {
|
||||
{t("rbac.noRolesAssigned")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<p className="font-medium">
|
||||
{t(userRole.roleDisplayName)}
|
||||
</p>
|
||||
{userRole.roleDisplayName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{userRole.roleName}
|
||||
@@ -453,13 +536,21 @@ export function RoleManagement(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveUserRole(userRole.roleId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{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>
|
||||
))}
|
||||
@@ -473,29 +564,68 @@ export function RoleManagement(): React.ReactElement {
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role-select">{t("rbac.assignNewRole")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={selectedRoleId !== null ? selectedRoleId.toString() : ""}
|
||||
onValueChange={(value) => {
|
||||
const parsed = parseInt(value, 10);
|
||||
setSelectedRoleId(isNaN(parsed) ? null : parsed);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="role-select">
|
||||
<SelectValue placeholder={t("rbac.selectRolePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles
|
||||
.filter(
|
||||
(role) =>
|
||||
!userRoles.some((ur) => ur.roleId === role.id),
|
||||
)
|
||||
.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
{t(role.displayName)}{role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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")}
|
||||
@@ -506,7 +636,10 @@ export function RoleManagement(): React.ReactElement {
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAssignDialogOpen(false)}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -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 (
|
||||
<Alert>
|
||||
@@ -271,7 +304,10 @@ export function HostSharingTab({
|
||||
</h3>
|
||||
|
||||
{/* Share Type Selection */}
|
||||
<Tabs value={shareType} onValueChange={(v) => setShareType(v as "user" | "role")}>
|
||||
<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" />
|
||||
@@ -286,42 +322,106 @@ export function HostSharingTab({
|
||||
<TabsContent value="user" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
|
||||
<Select value={selectedUserId || ""} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger id="user-select">
|
||||
<SelectValue placeholder={t("rbac.selectUserPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.username}{user.is_admin ? " (Admin)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
<Select
|
||||
value={selectedRoleId !== null ? selectedRoleId.toString() : ""}
|
||||
onValueChange={(v) => {
|
||||
const parsed = parseInt(v, 10);
|
||||
setSelectedRoleId(isNaN(parsed) ? null : parsed);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="role-select">
|
||||
<SelectValue placeholder={t("rbac.selectRolePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.id} value={role.id.toString()}>
|
||||
{t(role.displayName)}{role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
@@ -329,7 +429,10 @@ export function HostSharingTab({
|
||||
{/* Permission Level */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="permission-level">{t("rbac.permissionLevel")}</Label>
|
||||
<Select value={permissionLevel || "use"} onValueChange={(v) => setPermissionLevel(v || "use")}>
|
||||
<Select
|
||||
value={permissionLevel || "use"}
|
||||
onValueChange={(v) => setPermissionLevel(v || "use")}
|
||||
>
|
||||
<SelectTrigger id="permission-level">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -345,9 +448,7 @@ export function HostSharingTab({
|
||||
|
||||
{/* Expiration */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expires-in">
|
||||
{t("rbac.durationHours")}
|
||||
</Label>
|
||||
<Label htmlFor="expires-in">{t("rbac.durationHours")}</Label>
|
||||
<Input
|
||||
id="expires-in"
|
||||
type="number"
|
||||
@@ -385,19 +486,27 @@ export function HostSharingTab({
|
||||
<TableHead>{t("rbac.grantedBy")}</TableHead>
|
||||
<TableHead>{t("rbac.expires")}</TableHead>
|
||||
<TableHead>{t("rbac.accessCount")}</TableHead>
|
||||
<TableHead className="text-right">{t("common.actions")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
<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">
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noAccessRecords")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -409,12 +518,18 @@ export function HostSharingTab({
|
||||
>
|
||||
<TableCell>
|
||||
{access.targetType === "user" ? (
|
||||
<Badge variant="outline" className="flex items-center gap-1 w-fit">
|
||||
<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">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
{t("rbac.role")}
|
||||
</Badge>
|
||||
@@ -433,7 +548,11 @@ export function HostSharingTab({
|
||||
{access.expiresAt ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className={isExpired(access.expiresAt) ? "text-red-500" : ""}>
|
||||
<span
|
||||
className={
|
||||
isExpired(access.expiresAt) ? "text-red-500" : ""
|
||||
}
|
||||
>
|
||||
{formatDate(access.expiresAt)}
|
||||
{isExpired(access.expiresAt) && (
|
||||
<span className="ml-2">({t("rbac.expired")})</span>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user