feat: Improve rbac UI and fixes some bugs
This commit is contained in:
@@ -93,6 +93,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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(/.*)?$ {
|
location ~ ^/credentials(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -90,6 +90,15 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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(/.*)?$ {
|
location ~ ^/credentials(/.*)?$ {
|
||||||
proxy_pass http://127.0.0.1:30001;
|
proxy_pass http://127.0.0.1:30001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -52,15 +52,21 @@ router.post(
|
|||||||
|
|
||||||
// Validate target type
|
// Validate target type
|
||||||
if (!["user", "role"].includes(targetType)) {
|
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
|
// Validate required fields based on target type
|
||||||
if (targetType === "user" && !isNonEmptyString(targetUserId)) {
|
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) {
|
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
|
// Verify user owns the host
|
||||||
@@ -104,7 +110,11 @@ router.post(
|
|||||||
|
|
||||||
// Calculate expiry time
|
// Calculate expiry time
|
||||||
let expiresAt: string | null = null;
|
let expiresAt: string | null = null;
|
||||||
if (durationHours && typeof durationHours === "number" && durationHours > 0) {
|
if (
|
||||||
|
durationHours &&
|
||||||
|
typeof durationHours === "number" &&
|
||||||
|
durationHours > 0
|
||||||
|
) {
|
||||||
const expiryDate = new Date();
|
const expiryDate = new Date();
|
||||||
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
expiryDate.setHours(expiryDate.getHours() + durationHours);
|
||||||
expiresAt = expiryDate.toISOString();
|
expiresAt = expiryDate.toISOString();
|
||||||
@@ -299,7 +309,7 @@ router.get(
|
|||||||
.orderBy(desc(hostAccess.createdAt));
|
.orderBy(desc(hostAccess.createdAt));
|
||||||
|
|
||||||
// Format access list with type information
|
// Format access list with type information
|
||||||
const accessList = rawAccessList.map(access => ({
|
const accessList = rawAccessList.map((access) => ({
|
||||||
id: access.id,
|
id: access.id,
|
||||||
targetType: access.userId ? "user" : "role",
|
targetType: access.userId ? "user" : "role",
|
||||||
userId: access.userId,
|
userId: access.userId,
|
||||||
@@ -361,10 +371,7 @@ router.get(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(hostAccess.userId, userId),
|
eq(hostAccess.userId, userId),
|
||||||
or(
|
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
|
||||||
isNull(hostAccess.expiresAt),
|
|
||||||
gte(hostAccess.expiresAt, now),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.orderBy(desc(hostAccess.createdAt));
|
.orderBy(desc(hostAccess.createdAt));
|
||||||
@@ -729,15 +736,20 @@ router.post(
|
|||||||
return res.status(404).json({ error: "Role not found" });
|
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
|
// Check if already assigned
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userRoles)
|
.from(userRoles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
||||||
eq(userRoles.userId, targetUserId),
|
|
||||||
eq(userRoles.roleId, roleId),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@@ -794,14 +806,34 @@ router.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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
|
// Delete the user-role assignment
|
||||||
await db
|
await db
|
||||||
.delete(userRoles)
|
.delete(userRoles)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userRoles.userId, targetUserId), eq(userRoles.roleId, roleId)),
|
||||||
eq(userRoles.userId, targetUserId),
|
|
||||||
eq(userRoles.roleId, roleId),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Invalidate permission cache
|
// Invalidate permission cache
|
||||||
|
|||||||
@@ -1881,10 +1881,14 @@
|
|||||||
"assignRoles": "Assign Roles",
|
"assignRoles": "Assign Roles",
|
||||||
"userRoleAssignment": "User-Role Assignment",
|
"userRoleAssignment": "User-Role Assignment",
|
||||||
"selectUserPlaceholder": "Select a user",
|
"selectUserPlaceholder": "Select a user",
|
||||||
|
"searchUsers": "Search users...",
|
||||||
|
"noUserFound": "No user found",
|
||||||
"currentRoles": "Current Roles",
|
"currentRoles": "Current Roles",
|
||||||
"noRolesAssigned": "No roles assigned",
|
"noRolesAssigned": "No roles assigned",
|
||||||
"assignNewRole": "Assign New Role",
|
"assignNewRole": "Assign New Role",
|
||||||
"selectRolePlaceholder": "Select a role",
|
"selectRolePlaceholder": "Select a role",
|
||||||
|
"searchRoles": "Search roles...",
|
||||||
|
"noRoleFound": "No role found",
|
||||||
"assign": "Assign",
|
"assign": "Assign",
|
||||||
"roleCreatedSuccessfully": "Role created successfully",
|
"roleCreatedSuccessfully": "Role created successfully",
|
||||||
"roleUpdatedSuccessfully": "Role updated successfully",
|
"roleUpdatedSuccessfully": "Role updated successfully",
|
||||||
|
|||||||
@@ -389,7 +389,7 @@
|
|||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
"revoke": "撤销",
|
"revoke": "撤销",
|
||||||
"create": "创建"
|
"create": "创建",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"version": "Version"
|
"version": "Version"
|
||||||
},
|
},
|
||||||
@@ -1880,10 +1880,14 @@
|
|||||||
"assignRoles": "分配角色",
|
"assignRoles": "分配角色",
|
||||||
"userRoleAssignment": "用户角色分配",
|
"userRoleAssignment": "用户角色分配",
|
||||||
"selectUserPlaceholder": "选择用户",
|
"selectUserPlaceholder": "选择用户",
|
||||||
|
"searchUsers": "搜索用户...",
|
||||||
|
"noUserFound": "未找到用户",
|
||||||
"currentRoles": "当前角色",
|
"currentRoles": "当前角色",
|
||||||
"noRolesAssigned": "未分配角色",
|
"noRolesAssigned": "未分配角色",
|
||||||
"assignNewRole": "分配新角色",
|
"assignNewRole": "分配新角色",
|
||||||
"selectRolePlaceholder": "选择角色",
|
"selectRolePlaceholder": "选择角色",
|
||||||
|
"searchRoles": "搜索角色...",
|
||||||
|
"noRoleFound": "未找到角色",
|
||||||
"assign": "分配",
|
"assign": "分配",
|
||||||
"roleCreatedSuccessfully": "角色创建成功",
|
"roleCreatedSuccessfully": "角色创建成功",
|
||||||
"roleUpdatedSuccessfully": "角色更新成功",
|
"roleUpdatedSuccessfully": "角色更新成功",
|
||||||
|
|||||||
@@ -286,7 +286,10 @@ export function AdminSettings({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Role management functions
|
// Role management functions
|
||||||
const handleOpenRolesDialog = async (user: { id: string; username: string }) => {
|
const handleOpenRolesDialog = async (user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
}) => {
|
||||||
setSelectedUser(user);
|
setSelectedUser(user);
|
||||||
setRolesDialogOpen(true);
|
setRolesDialogOpen(true);
|
||||||
setRolesLoading(true);
|
setRolesLoading(true);
|
||||||
@@ -312,7 +315,9 @@ export function AdminSettings({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await assignRoleToUser(selectedUser.id, roleId);
|
await assignRoleToUser(selectedUser.id, roleId);
|
||||||
toast.success(t("rbac.roleAssignedSuccessfully", { username: selectedUser.username }));
|
toast.success(
|
||||||
|
t("rbac.roleAssignedSuccessfully", { username: selectedUser.username }),
|
||||||
|
);
|
||||||
|
|
||||||
// Reload user roles
|
// Reload user roles
|
||||||
const rolesResponse = await getUserRoles(selectedUser.id);
|
const rolesResponse = await getUserRoles(selectedUser.id);
|
||||||
@@ -325,16 +330,11 @@ export function AdminSettings({
|
|||||||
const handleRemoveRole = async (roleId: number) => {
|
const handleRemoveRole = async (roleId: number) => {
|
||||||
if (!selectedUser) return;
|
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 {
|
try {
|
||||||
await removeRoleFromUser(selectedUser.id, roleId);
|
await removeRoleFromUser(selectedUser.id, roleId);
|
||||||
toast.success(t("rbac.roleRemovedSuccessfully", { username: selectedUser.username }));
|
toast.success(
|
||||||
|
t("rbac.roleRemovedSuccessfully", { username: selectedUser.username }),
|
||||||
|
);
|
||||||
|
|
||||||
// Reload user roles
|
// Reload user roles
|
||||||
const rolesResponse = await getUserRoles(selectedUser.id);
|
const rolesResponse = await getUserRoles(selectedUser.id);
|
||||||
@@ -1162,102 +1162,92 @@ export function AdminSettings({
|
|||||||
{t("admin.loadingUsers")}
|
{t("admin.loadingUsers")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md overflow-hidden">
|
<Table>
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead>{t("admin.username")}</TableHead>
|
||||||
<TableHead>
|
<TableHead>{t("admin.type")}</TableHead>
|
||||||
{t("admin.username")}
|
<TableHead>{t("admin.actions")}</TableHead>
|
||||||
</TableHead>
|
</TableRow>
|
||||||
<TableHead>
|
</TableHeader>
|
||||||
{t("admin.type")}
|
<TableBody>
|
||||||
</TableHead>
|
{users.map((user) => (
|
||||||
<TableHead>
|
<TableRow key={user.id}>
|
||||||
{t("admin.actions")}
|
<TableCell className="font-medium">
|
||||||
</TableHead>
|
{user.username}
|
||||||
</TableRow>
|
{user.is_admin && (
|
||||||
</TableHeader>
|
<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">
|
||||||
<TableBody>
|
{t("admin.adminBadge")}
|
||||||
{users.map((user) => (
|
</span>
|
||||||
<TableRow key={user.id}>
|
)}
|
||||||
<TableCell className="font-medium">
|
</TableCell>
|
||||||
{user.username}
|
<TableCell>
|
||||||
{user.is_admin && (
|
{user.is_oidc && user.password_hash
|
||||||
<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">
|
? "Dual Auth"
|
||||||
{t("admin.adminBadge")}
|
: user.is_oidc
|
||||||
</span>
|
? t("admin.external")
|
||||||
)}
|
: t("admin.local")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{user.is_oidc && user.password_hash
|
<div className="flex gap-2">
|
||||||
? "Dual Auth"
|
{user.is_oidc && !user.password_hash && (
|
||||||
: 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>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleOpenRolesDialog({
|
handleLinkOIDCUser({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
|
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||||
title={t("rbac.manageRoles")}
|
title="Link to password account"
|
||||||
>
|
>
|
||||||
<UserCog className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
{user.is_oidc && user.password_hash && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleDeleteUser(user.username)
|
handleUnlinkOIDC(user.id, user.username)
|
||||||
}
|
}
|
||||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||||
disabled={user.is_admin}
|
title="Unlink OIDC (keep password only)"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Unlink className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</TableCell>
|
<Button
|
||||||
</TableRow>
|
variant="ghost"
|
||||||
))}
|
size="sm"
|
||||||
</TableBody>
|
onClick={() =>
|
||||||
</Table>
|
handleOpenRolesDialog({
|
||||||
</div>
|
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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1284,115 +1274,107 @@ export function AdminSettings({
|
|||||||
No active sessions found.
|
No active sessions found.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="border rounded-md overflow-hidden">
|
<Table>
|
||||||
<div className="overflow-x-auto">
|
<TableHeader>
|
||||||
<Table>
|
<TableRow>
|
||||||
<TableHeader>
|
<TableHead>Device</TableHead>
|
||||||
<TableRow>
|
<TableHead>User</TableHead>
|
||||||
<TableHead>Device</TableHead>
|
<TableHead>Created</TableHead>
|
||||||
<TableHead>User</TableHead>
|
<TableHead>Last Active</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Expires</TableHead>
|
||||||
<TableHead>Last Active</TableHead>
|
<TableHead>{t("admin.actions")}</TableHead>
|
||||||
<TableHead>Expires</TableHead>
|
</TableRow>
|
||||||
<TableHead>
|
</TableHeader>
|
||||||
{t("admin.actions")}
|
<TableBody>
|
||||||
</TableHead>
|
{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>
|
</TableRow>
|
||||||
</TableHeader>
|
);
|
||||||
<TableBody>
|
})}
|
||||||
{sessions.map((session) => {
|
</TableBody>
|
||||||
const DeviceIcon =
|
</Table>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1440,55 +1422,47 @@ export function AdminSettings({
|
|||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="font-medium">{t("admin.currentAdmins")}</h4>
|
<h4 className="font-medium">{t("admin.currentAdmins")}</h4>
|
||||||
<div className="border rounded-md overflow-hidden">
|
<Table>
|
||||||
<Table>
|
<TableHeader>
|
||||||
<TableHeader>
|
<TableRow>
|
||||||
<TableRow>
|
<TableHead>{t("admin.username")}</TableHead>
|
||||||
<TableHead>
|
<TableHead>{t("admin.type")}</TableHead>
|
||||||
{t("admin.username")}
|
<TableHead>{t("admin.actions")}</TableHead>
|
||||||
</TableHead>
|
</TableRow>
|
||||||
<TableHead>
|
</TableHeader>
|
||||||
{t("admin.type")}
|
<TableBody>
|
||||||
</TableHead>
|
{users
|
||||||
<TableHead>
|
.filter((u) => u.is_admin)
|
||||||
{t("admin.actions")}
|
.map((admin) => (
|
||||||
</TableHead>
|
<TableRow key={admin.id}>
|
||||||
</TableRow>
|
<TableCell className="font-medium">
|
||||||
</TableHeader>
|
{admin.username}
|
||||||
<TableBody>
|
<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">
|
||||||
{users
|
{t("admin.adminBadge")}
|
||||||
.filter((u) => u.is_admin)
|
</span>
|
||||||
.map((admin) => (
|
</TableCell>
|
||||||
<TableRow key={admin.id}>
|
<TableCell>
|
||||||
<TableCell className="font-medium">
|
{admin.is_oidc
|
||||||
{admin.username}
|
? t("admin.external")
|
||||||
<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.local")}
|
||||||
{t("admin.adminBadge")}
|
</TableCell>
|
||||||
</span>
|
<TableCell>
|
||||||
</TableCell>
|
<Button
|
||||||
<TableCell>
|
variant="ghost"
|
||||||
{admin.is_oidc
|
size="sm"
|
||||||
? t("admin.external")
|
onClick={() =>
|
||||||
: t("admin.local")}
|
handleRemoveAdminStatus(admin.username)
|
||||||
</TableCell>
|
}
|
||||||
<TableCell>
|
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||||
<Button
|
>
|
||||||
variant="ghost"
|
<Shield className="h-4 w-4" />
|
||||||
size="sm"
|
{t("admin.removeAdminButton")}
|
||||||
onClick={() =>
|
</Button>
|
||||||
handleRemoveAdminStatus(admin.username)
|
</TableCell>
|
||||||
}
|
</TableRow>
|
||||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
))}
|
||||||
>
|
</TableBody>
|
||||||
<Shield className="h-4 w-4" />
|
</Table>
|
||||||
{t("admin.removeAdminButton")}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1719,7 +1693,9 @@ export function AdminSettings({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("rbac.manageRoles")}</DialogTitle>
|
<DialogTitle>{t("rbac.manageRoles")}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t("rbac.manageRolesFor", { username: selectedUser?.username || "" })}
|
{t("rbac.manageRolesFor", {
|
||||||
|
username: selectedUser?.username || "",
|
||||||
|
})}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -1737,19 +1713,25 @@ export function AdminSettings({
|
|||||||
{t("rbac.noRolesAssigned")}
|
{t("rbac.noRolesAssigned")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
|
||||||
{userRoles.map((userRole) => (
|
{userRoles.map((userRole) => (
|
||||||
<div
|
<div
|
||||||
key={userRole.roleId}
|
key={userRole.roleId}
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t(userRole.roleDisplayName)}</p>
|
<p className="font-medium">
|
||||||
|
{t(userRole.roleDisplayName)}
|
||||||
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{userRole.roleName}
|
{userRole.roleName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{userRole.roleName !== "admin" && userRole.roleName !== "user" && (
|
{userRole.isSystem ? (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t("rbac.systemRole")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -27,7 +27,15 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select.tsx";
|
} from "@/components/ui/select.tsx";
|
||||||
import { Badge } from "@/components/ui/badge.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 { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
@@ -43,6 +51,19 @@ import {
|
|||||||
type Role,
|
type Role,
|
||||||
type UserRole,
|
type UserRole,
|
||||||
} from "@/ui/main-axios.ts";
|
} 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 {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -73,6 +94,10 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
);
|
);
|
||||||
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
|
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
|
||||||
|
|
||||||
|
// Combobox states
|
||||||
|
const [userComboOpen, setUserComboOpen] = React.useState(false);
|
||||||
|
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
|
||||||
|
|
||||||
// Load roles
|
// Load roles
|
||||||
const loadRoles = React.useCallback(async () => {
|
const loadRoles = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -221,7 +246,11 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await assignRoleToUser(selectedUserId, selectedRoleId);
|
await assignRoleToUser(selectedUserId, selectedRoleId);
|
||||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
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);
|
setSelectedRoleId(null);
|
||||||
handleUserSelect(selectedUserId);
|
handleUserSelect(selectedUserId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -236,7 +265,11 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
try {
|
try {
|
||||||
await removeRoleFromUser(selectedUserId, roleId);
|
await removeRoleFromUser(selectedUserId, roleId);
|
||||||
const selectedUser = users.find((u) => u.id === selectedUserId);
|
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);
|
handleUserSelect(selectedUserId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("rbac.failedToRemoveRole"));
|
toast.error(t("rbac.failedToRemoveRole"));
|
||||||
@@ -265,19 +298,27 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
<TableHead>{t("rbac.displayName")}</TableHead>
|
<TableHead>{t("rbac.displayName")}</TableHead>
|
||||||
<TableHead>{t("rbac.description")}</TableHead>
|
<TableHead>{t("rbac.description")}</TableHead>
|
||||||
<TableHead>{t("rbac.type")}</TableHead>
|
<TableHead>{t("rbac.type")}</TableHead>
|
||||||
<TableHead className="text-right">{t("common.actions")}</TableHead>
|
<TableHead className="text-right">
|
||||||
|
{t("common.actions")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : roles.length === 0 ? (
|
) : roles.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
{t("rbac.noRoles")}
|
{t("rbac.noRoles")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -408,25 +449,65 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
|
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t("rbac.assignRoles")}</DialogTitle>
|
<DialogTitle>{t("rbac.assignRoles")}</DialogTitle>
|
||||||
<DialogDescription>{t("rbac.assignRolesDescription")}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{t("rbac.assignRolesDescription")}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* User Selection */}
|
{/* User Selection */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
|
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
|
||||||
<Select value={selectedUserId || ""} onValueChange={handleUserSelect}>
|
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||||
<SelectTrigger id="user-select">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder={t("rbac.selectUserPlaceholder")} />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{users.map((user) => (
|
aria-expanded={userComboOpen}
|
||||||
<SelectItem key={user.id} value={user.id}>
|
className="w-full justify-between"
|
||||||
{user.username}{user.is_admin ? " (Admin)" : ""}
|
>
|
||||||
</SelectItem>
|
{selectedUserId
|
||||||
))}
|
? users.find((u) => u.id === selectedUserId)?.username +
|
||||||
</SelectContent>
|
(users.find((u) => u.id === selectedUserId)?.is_admin
|
||||||
</Select>
|
? " (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>
|
</div>
|
||||||
|
|
||||||
{/* Current User Roles */}
|
{/* Current User Roles */}
|
||||||
@@ -438,14 +519,16 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
{t("rbac.noRolesAssigned")}
|
{t("rbac.noRolesAssigned")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-2">
|
||||||
{userRoles.map((userRole, index) => (
|
{userRoles.map((userRole, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-center justify-between p-2 border rounded"
|
className="flex items-center justify-between p-2 border rounded"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t(userRole.roleDisplayName)}</p>
|
<p className="font-medium">
|
||||||
|
{t(userRole.roleDisplayName)}
|
||||||
|
</p>
|
||||||
{userRole.roleDisplayName && (
|
{userRole.roleDisplayName && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{userRole.roleName}
|
{userRole.roleName}
|
||||||
@@ -453,13 +536,21 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
{userRole.isSystem ? (
|
||||||
size="sm"
|
<Badge variant="secondary" className="text-xs">
|
||||||
variant="ghost"
|
{t("rbac.systemRole")}
|
||||||
onClick={() => handleRemoveUserRole(userRole.roleId)}
|
</Badge>
|
||||||
>
|
) : (
|
||||||
<Trash2 className="h-4 w-4" />
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveUserRole(userRole.roleId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -473,29 +564,68 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role-select">{t("rbac.assignNewRole")}</Label>
|
<Label htmlFor="role-select">{t("rbac.assignNewRole")}</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select
|
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
|
||||||
value={selectedRoleId !== null ? selectedRoleId.toString() : ""}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(value) => {
|
<Button
|
||||||
const parsed = parseInt(value, 10);
|
variant="outline"
|
||||||
setSelectedRoleId(isNaN(parsed) ? null : parsed);
|
role="combobox"
|
||||||
}}
|
aria-expanded={roleComboOpen}
|
||||||
>
|
className="flex-1 justify-between"
|
||||||
<SelectTrigger id="role-select">
|
>
|
||||||
<SelectValue placeholder={t("rbac.selectRolePlaceholder")} />
|
{selectedRoleId !== null
|
||||||
</SelectTrigger>
|
? (() => {
|
||||||
<SelectContent>
|
const role = roles.find(
|
||||||
{roles
|
(r) => r.id === selectedRoleId,
|
||||||
.filter(
|
);
|
||||||
(role) =>
|
return role
|
||||||
!userRoles.some((ur) => ur.roleId === role.id),
|
? `${t(role.displayName)}${role.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
|
||||||
)
|
: t("rbac.selectRolePlaceholder");
|
||||||
.map((role) => (
|
})()
|
||||||
<SelectItem key={role.id} value={role.id.toString()}>
|
: t("rbac.selectRolePlaceholder")}
|
||||||
{t(role.displayName)}{role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</SelectItem>
|
</Button>
|
||||||
))}
|
</PopoverTrigger>
|
||||||
</SelectContent>
|
<PopoverContent
|
||||||
</Select>
|
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}>
|
<Button onClick={handleAssignRole} disabled={!selectedRoleId}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
{t("rbac.assign")}
|
{t("rbac.assign")}
|
||||||
@@ -506,7 +636,10 @@ export function RoleManagement(): React.ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setAssignDialogOpen(false)}
|
||||||
|
>
|
||||||
{t("common.close")}
|
{t("common.close")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -26,9 +26,16 @@ import {
|
|||||||
Shield,
|
Shield,
|
||||||
Clock,
|
Clock,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
|
Check,
|
||||||
|
ChevronsUpDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
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 { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
@@ -44,6 +51,19 @@ import {
|
|||||||
type AccessRecord,
|
type AccessRecord,
|
||||||
type SSHHost,
|
type SSHHost,
|
||||||
} from "@/ui/main-axios.ts";
|
} 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 {
|
interface HostSharingTabProps {
|
||||||
hostId: number | undefined;
|
hostId: number | undefined;
|
||||||
@@ -83,6 +103,9 @@ export function HostSharingTab({
|
|||||||
const [currentUserId, setCurrentUserId] = React.useState<string>("");
|
const [currentUserId, setCurrentUserId] = React.useState<string>("");
|
||||||
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
|
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
|
||||||
|
|
||||||
|
const [userComboOpen, setUserComboOpen] = React.useState(false);
|
||||||
|
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
|
||||||
|
|
||||||
// Load roles
|
// Load roles
|
||||||
const loadRoles = React.useCallback(async () => {
|
const loadRoles = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -191,7 +214,9 @@ export function HostSharingTab({
|
|||||||
targetUserId: shareType === "user" ? selectedUserId : undefined,
|
targetUserId: shareType === "user" ? selectedUserId : undefined,
|
||||||
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
|
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
|
||||||
permissionLevel,
|
permissionLevel,
|
||||||
durationHours: expiresInHours ? parseInt(expiresInHours, 10) : undefined,
|
durationHours: expiresInHours
|
||||||
|
? parseInt(expiresInHours, 10)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(t("rbac.sharedSuccessfully"));
|
toast.success(t("rbac.sharedSuccessfully"));
|
||||||
@@ -238,6 +263,14 @@ export function HostSharingTab({
|
|||||||
return new Date(expiresAt) < new Date();
|
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) {
|
if (isNewHost) {
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
@@ -271,7 +304,10 @@ export function HostSharingTab({
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Share Type Selection */}
|
{/* 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">
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
<TabsTrigger value="user" className="flex items-center gap-2">
|
<TabsTrigger value="user" className="flex items-center gap-2">
|
||||||
<UserCircle className="h-4 w-4" />
|
<UserCircle className="h-4 w-4" />
|
||||||
@@ -286,42 +322,106 @@ export function HostSharingTab({
|
|||||||
<TabsContent value="user" className="space-y-4">
|
<TabsContent value="user" className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
|
<Label htmlFor="user-select">{t("rbac.selectUser")}</Label>
|
||||||
<Select value={selectedUserId || ""} onValueChange={setSelectedUserId}>
|
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||||
<SelectTrigger id="user-select">
|
<PopoverTrigger asChild>
|
||||||
<SelectValue placeholder={t("rbac.selectUserPlaceholder")} />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
role="combobox"
|
||||||
{users.map((user) => (
|
aria-expanded={userComboOpen}
|
||||||
<SelectItem key={user.id} value={user.id}>
|
className="w-full justify-between"
|
||||||
{user.username}{user.is_admin ? " (Admin)" : ""}
|
>
|
||||||
</SelectItem>
|
{selectedUser
|
||||||
))}
|
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
|
||||||
</SelectContent>
|
: t("rbac.selectUserPlaceholder")}
|
||||||
</Select>
|
<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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="role" className="space-y-4">
|
<TabsContent value="role" className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role-select">{t("rbac.selectRole")}</Label>
|
<Label htmlFor="role-select">{t("rbac.selectRole")}</Label>
|
||||||
<Select
|
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
|
||||||
value={selectedRoleId !== null ? selectedRoleId.toString() : ""}
|
<PopoverTrigger asChild>
|
||||||
onValueChange={(v) => {
|
<Button
|
||||||
const parsed = parseInt(v, 10);
|
variant="outline"
|
||||||
setSelectedRoleId(isNaN(parsed) ? null : parsed);
|
role="combobox"
|
||||||
}}
|
aria-expanded={roleComboOpen}
|
||||||
>
|
className="w-full justify-between"
|
||||||
<SelectTrigger id="role-select">
|
>
|
||||||
<SelectValue placeholder={t("rbac.selectRolePlaceholder")} />
|
{selectedRole
|
||||||
</SelectTrigger>
|
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
|
||||||
<SelectContent>
|
: t("rbac.selectRolePlaceholder")}
|
||||||
{roles.map((role) => (
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
<SelectItem key={role.id} value={role.id.toString()}>
|
</Button>
|
||||||
{t(role.displayName)}{role.isSystem ? ` (${t("rbac.systemRole")})` : ""}
|
</PopoverTrigger>
|
||||||
</SelectItem>
|
<PopoverContent
|
||||||
))}
|
className="p-0"
|
||||||
</SelectContent>
|
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||||
</Select>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -329,7 +429,10 @@ export function HostSharingTab({
|
|||||||
{/* Permission Level */}
|
{/* Permission Level */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="permission-level">{t("rbac.permissionLevel")}</Label>
|
<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">
|
<SelectTrigger id="permission-level">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -345,9 +448,7 @@ export function HostSharingTab({
|
|||||||
|
|
||||||
{/* Expiration */}
|
{/* Expiration */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="expires-in">
|
<Label htmlFor="expires-in">{t("rbac.durationHours")}</Label>
|
||||||
{t("rbac.durationHours")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="expires-in"
|
id="expires-in"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -385,19 +486,27 @@ export function HostSharingTab({
|
|||||||
<TableHead>{t("rbac.grantedBy")}</TableHead>
|
<TableHead>{t("rbac.grantedBy")}</TableHead>
|
||||||
<TableHead>{t("rbac.expires")}</TableHead>
|
<TableHead>{t("rbac.expires")}</TableHead>
|
||||||
<TableHead>{t("rbac.accessCount")}</TableHead>
|
<TableHead>{t("rbac.accessCount")}</TableHead>
|
||||||
<TableHead className="text-right">{t("common.actions")}</TableHead>
|
<TableHead className="text-right">
|
||||||
|
{t("common.actions")}
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
{t("common.loading")}
|
{t("common.loading")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : accessList.length === 0 ? (
|
) : accessList.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={7}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
{t("rbac.noAccessRecords")}
|
{t("rbac.noAccessRecords")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -409,12 +518,18 @@ export function HostSharingTab({
|
|||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{access.targetType === "user" ? (
|
{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" />
|
<UserCircle className="h-3 w-3" />
|
||||||
{t("rbac.user")}
|
{t("rbac.user")}
|
||||||
</Badge>
|
</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" />
|
<Shield className="h-3 w-3" />
|
||||||
{t("rbac.role")}
|
{t("rbac.role")}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -433,7 +548,11 @@ export function HostSharingTab({
|
|||||||
{access.expiresAt ? (
|
{access.expiresAt ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-3 w-3" />
|
<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)}
|
{formatDate(access.expiresAt)}
|
||||||
{isExpired(access.expiresAt) && (
|
{isExpired(access.expiresAt) && (
|
||||||
<span className="ml-2">({t("rbac.expired")})</span>
|
<span className="ml-2">({t("rbac.expired")})</span>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
deleteAccount,
|
deleteAccount,
|
||||||
logoutUser,
|
logoutUser,
|
||||||
isElectron,
|
isElectron,
|
||||||
|
getUserRoles,
|
||||||
|
type UserRole,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { PasswordReset } from "@/ui/desktop/user/PasswordReset.tsx";
|
import { PasswordReset } from "@/ui/desktop/user/PasswordReset.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -105,6 +107,7 @@ export function UserProfile({
|
|||||||
useState<boolean>(
|
useState<boolean>(
|
||||||
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||||
);
|
);
|
||||||
|
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUserInfo();
|
fetchUserInfo();
|
||||||
@@ -133,6 +136,15 @@ export function UserProfile({
|
|||||||
is_dual_auth: info.is_dual_auth || false,
|
is_dual_auth: info.is_dual_auth || false,
|
||||||
totp_enabled: info.totp_enabled || 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) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { error?: string } } };
|
const error = err as { response?: { data?: { error?: string } } };
|
||||||
setError(error?.response?.data?.error || t("errors.loadFailed"));
|
setError(error?.response?.data?.error || t("errors.loadFailed"));
|
||||||
@@ -304,11 +316,26 @@ export function UserProfile({
|
|||||||
<Label className="text-gray-300">
|
<Label className="text-gray-300">
|
||||||
{t("profile.role")}
|
{t("profile.role")}
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-lg font-medium mt-1 text-white">
|
<div className="mt-1">
|
||||||
{userInfo.is_admin
|
{userRoles.length > 0 ? (
|
||||||
? t("interface.administrator")
|
<div className="flex flex-wrap gap-2">
|
||||||
: t("interface.user")}
|
{userRoles.map((role) => (
|
||||||
</p>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-gray-300">
|
<Label className="text-gray-300">
|
||||||
|
|||||||
Reference in New Issue
Block a user