feat: Improve rbac UI and fixes some bugs

This commit is contained in:
LukeGus
2025-12-17 02:15:27 -06:00
parent 418fe67259
commit 3a0dbacfe1
9 changed files with 689 additions and 370 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "角色更新成功",

View File

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

View File

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

View File

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

View File

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