Fix RBAC role system bugs and improve UX #446

Merged
ZacharyZcR merged 7 commits from fix/rbac-improvements into dev-1.10.0 2025-12-20 02:13:36 +00:00
7 changed files with 283 additions and 58 deletions
Showing only changes of commit f4f1440991 - Show all commits
+5 -1
View File
@@ -405,7 +405,8 @@
"checkingDatabase": "Checking database connection...", "checkingDatabase": "Checking database connection...",
"actions": "Actions", "actions": "Actions",
"remove": "Remove", "remove": "Remove",
"revoke": "Revoke" "revoke": "Revoke",
"create": "Create"
}, },
"nav": { "nav": {
"home": "Home", "home": "Home",
@@ -1872,6 +1873,8 @@
"securityWarningMessage": "Sharing credentials gives the user full access to perform any operations on the server, including changing passwords and deleting files. Only share with trusted users.", "securityWarningMessage": "Sharing credentials gives the user full access to perform any operations on the server, including changing passwords and deleting files. Only share with trusted users.",
"tempUserRecommended": "We recommend enabling 'Create Temporary User' for better security.", "tempUserRecommended": "We recommend enabling 'Create Temporary User' for better security.",
"roleManagement": "Role Management", "roleManagement": "Role Management",
"manageRoles": "Manage Roles",
"manageRolesFor": "Manage roles for {{username}}",
"assignRole": "Assign Role", "assignRole": "Assign Role",
"removeRole": "Remove Role", "removeRole": "Remove Role",
"userRoles": "User Roles", "userRoles": "User Roles",
@@ -1883,6 +1886,7 @@
"roleRemovedSuccessfully": "Role removed from {{username}} successfully", "roleRemovedSuccessfully": "Role removed from {{username}} successfully",
"failedToRemoveRole": "Failed to remove role", "failedToRemoveRole": "Failed to remove role",
"cannotRemoveSystemRole": "Cannot remove system role", "cannotRemoveSystemRole": "Cannot remove system role",
"cannotShareWithSelf": "Cannot share host with yourself",
"auditLogs": "Audit Logs", "auditLogs": "Audit Logs",
"viewAuditLogs": "View Audit Logs", "viewAuditLogs": "View Audit Logs",
"action": "Action", "action": "Action",
+5 -1
View File
@@ -385,7 +385,8 @@
"checkingDatabase": "正在检查数据库连接...", "checkingDatabase": "正在检查数据库连接...",
"actions": "操作", "actions": "操作",
"remove": "移除", "remove": "移除",
"revoke": "撤销" "revoke": "撤销",
"create": "创建"
}, },
"nav": { "nav": {
"home": "首页", "home": "首页",
@@ -1733,6 +1734,8 @@
"securityWarningMessage": "分享凭据会让用户完全访问服务器并执行任何操作,包括更改密码和删除文件。仅与受信任的用户共享。", "securityWarningMessage": "分享凭据会让用户完全访问服务器并执行任何操作,包括更改密码和删除文件。仅与受信任的用户共享。",
"tempUserRecommended": "我们建议启用'创建临时用户'以获得更好的安全性。", "tempUserRecommended": "我们建议启用'创建临时用户'以获得更好的安全性。",
"roleManagement": "角色管理", "roleManagement": "角色管理",
"manageRoles": "管理角色",
"manageRolesFor": "管理 {{username}} 的角色",
"assignRole": "分配角色", "assignRole": "分配角色",
"removeRole": "移除角色", "removeRole": "移除角色",
"userRoles": "用户角色", "userRoles": "用户角色",
@@ -1744,6 +1747,7 @@
"roleRemovedSuccessfully": "已成功从{{username}}移除角色", "roleRemovedSuccessfully": "已成功从{{username}}移除角色",
"failedToRemoveRole": "移除角色失败", "failedToRemoveRole": "移除角色失败",
"cannotRemoveSystemRole": "无法移除系统角色", "cannotRemoveSystemRole": "无法移除系统角色",
"cannotShareWithSelf": "不能与自己共享主机",
"auditLogs": "审计日志", "auditLogs": "审计日志",
"viewAuditLogs": "查看审计日志", "viewAuditLogs": "查看审计日志",
"action": "操作", "action": "操作",
+5
View File
@@ -48,6 +48,11 @@ export interface SSHHost {
terminalConfig?: TerminalConfig; terminalConfig?: TerminalConfig;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
// Shared access metadata
isShared?: boolean;
permissionLevel?: "view" | "manage";
sharedExpiresAt?: string;
} }
export interface JumpHostData { export interface JumpHostData {
+205 -24
View File
@@ -42,6 +42,7 @@ import {
Smartphone, Smartphone,
Globe, Globe,
Clock, Clock,
UserCog,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -66,6 +67,12 @@ import {
revokeAllUserSessions, revokeAllUserSessions,
linkOIDCToPasswordAccount, linkOIDCToPasswordAccount,
unlinkOIDCFromPasswordAccount, unlinkOIDCFromPasswordAccount,
getUserRoles,
assignRoleToUser,
removeRoleFromUser,
getRoles,
type UserRole,
type Role,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { RoleManagement } from "./RoleManagement.tsx"; import { RoleManagement } from "./RoleManagement.tsx";
@@ -120,6 +127,16 @@ export function AdminSettings({
null, null,
); );
// Role management states
const [rolesDialogOpen, setRolesDialogOpen] = React.useState(false);
const [selectedUser, setSelectedUser] = React.useState<{
id: string;
username: string;
} | null>(null);
const [userRoles, setUserRoles] = React.useState<UserRole[]>([]);
const [availableRoles, setAvailableRoles] = React.useState<Role[]>([]);
const [rolesLoading, setRolesLoading] = React.useState(false);
const [securityInitialized, setSecurityInitialized] = React.useState(true); const [securityInitialized, setSecurityInitialized] = React.useState(true);
const [currentUser, setCurrentUser] = React.useState<{ const [currentUser, setCurrentUser] = React.useState<{
id: string; id: string;
@@ -268,6 +285,65 @@ export function AdminSettings({
} }
}; };
// Role management functions
const handleOpenRolesDialog = async (user: { id: string; username: string }) => {
setSelectedUser(user);
setRolesDialogOpen(true);
setRolesLoading(true);
try {
// Load user's current roles
const rolesResponse = await getUserRoles(user.id);
setUserRoles(rolesResponse.roles || []);
// Load all available roles
const allRolesResponse = await getRoles();
setAvailableRoles(allRolesResponse.roles || []);
} catch (error) {
console.error("Failed to load roles:", error);
toast.error(t("rbac.failedToLoadRoles"));
} finally {
setRolesLoading(false);
}
};
const handleAssignRole = async (roleId: number) => {
if (!selectedUser) return;
try {
await assignRoleToUser(selectedUser.id, roleId);
toast.success(t("rbac.roleAssignedSuccessfully", { username: selectedUser.username }));
// Reload user roles
const rolesResponse = await getUserRoles(selectedUser.id);
setUserRoles(rolesResponse.roles || []);
} catch (error) {
toast.error(t("rbac.failedToAssignRole"));
}
};
const handleRemoveRole = async (roleId: number) => {
if (!selectedUser) return;
// Prevent removal of system roles
const roleToRemove = userRoles.find((r) => r.roleId === roleId);
if (roleToRemove && (roleToRemove.roleName === "admin" || roleToRemove.roleName === "user")) {
toast.error(t("rbac.cannotRemoveSystemRole"));
return;
}
try {
await removeRoleFromUser(selectedUser.id, roleId);
toast.success(t("rbac.roleRemovedSuccessfully", { username: selectedUser.username }));
// Reload user roles
const rolesResponse = await getUserRoles(selectedUser.id);
setUserRoles(rolesResponse.roles || []);
} catch (error) {
toast.error(t("rbac.failedToRemoveRole"));
}
};
const handleToggleRegistration = async (checked: boolean) => { const handleToggleRegistration = async (checked: boolean) => {
setRegLoading(true); setRegLoading(true);
try { try {
@@ -1090,13 +1166,13 @@ export function AdminSettings({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4"> <TableHead>
{t("admin.username")} {t("admin.username")}
</TableHead> </TableHead>
<TableHead className="px-4"> <TableHead>
{t("admin.type")} {t("admin.type")}
</TableHead> </TableHead>
<TableHead className="px-4"> <TableHead>
{t("admin.actions")} {t("admin.actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -1104,7 +1180,7 @@ export function AdminSettings({
<TableBody> <TableBody>
{users.map((user) => ( {users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell className="px-4 font-medium"> <TableCell className="font-medium">
{user.username} {user.username}
{user.is_admin && ( {user.is_admin && (
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border"> <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">
@@ -1112,14 +1188,14 @@ export function AdminSettings({
</span> </span>
)} )}
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell>
{user.is_oidc && user.password_hash {user.is_oidc && user.password_hash
? "Dual Auth" ? "Dual Auth"
: user.is_oidc : user.is_oidc
? t("admin.external") ? t("admin.external")
: t("admin.local")} : t("admin.local")}
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell>
<div className="flex gap-2"> <div className="flex gap-2">
{user.is_oidc && !user.password_hash && ( {user.is_oidc && !user.password_hash && (
<Button <Button
@@ -1150,6 +1226,20 @@ export function AdminSettings({
<Unlink className="h-4 w-4" /> <Unlink className="h-4 w-4" />
</Button> </Button>
)} )}
<Button
variant="ghost"
size="sm"
onClick={() =>
handleOpenRolesDialog({
id: user.id,
username: user.username,
})
}
className="text-purple-600 hover:text-purple-700 hover:bg-purple-50"
title={t("rbac.manageRoles")}
>
<UserCog className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1199,12 +1289,12 @@ export function AdminSettings({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4">Device</TableHead> <TableHead>Device</TableHead>
<TableHead className="px-4">User</TableHead> <TableHead>User</TableHead>
<TableHead className="px-4">Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="px-4">Last Active</TableHead> <TableHead>Last Active</TableHead>
<TableHead className="px-4">Expires</TableHead> <TableHead>Expires</TableHead>
<TableHead className="px-4"> <TableHead>
{t("admin.actions")} {t("admin.actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -1239,7 +1329,7 @@ export function AdminSettings({
session.isRevoked ? "opacity-50" : undefined session.isRevoked ? "opacity-50" : undefined
} }
> >
<TableCell className="px-4"> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DeviceIcon className="h-4 w-4" /> <DeviceIcon className="h-4 w-4" />
<div className="flex flex-col"> <div className="flex flex-col">
@@ -1254,19 +1344,19 @@ export function AdminSettings({
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell>
{session.username || session.userId} {session.username || session.userId}
</TableCell> </TableCell>
<TableCell className="px-4 text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{formatDate(createdDate)} {formatDate(createdDate)}
</TableCell> </TableCell>
<TableCell className="px-4 text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{formatDate(lastActiveDate)} {formatDate(lastActiveDate)}
</TableCell> </TableCell>
<TableCell className="px-4 text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{formatDate(expiresDate)} {formatDate(expiresDate)}
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="ghost" variant="ghost"
@@ -1354,13 +1444,13 @@ export function AdminSettings({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="px-4"> <TableHead>
{t("admin.username")} {t("admin.username")}
</TableHead> </TableHead>
<TableHead className="px-4"> <TableHead>
{t("admin.type")} {t("admin.type")}
</TableHead> </TableHead>
<TableHead className="px-4"> <TableHead>
{t("admin.actions")} {t("admin.actions")}
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -1370,18 +1460,18 @@ export function AdminSettings({
.filter((u) => u.is_admin) .filter((u) => u.is_admin)
.map((admin) => ( .map((admin) => (
<TableRow key={admin.id}> <TableRow key={admin.id}>
<TableCell className="px-4 font-medium"> <TableCell className="font-medium">
{admin.username} {admin.username}
<span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border"> <span className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
{t("admin.adminBadge")} {t("admin.adminBadge")}
</span> </span>
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell>
{admin.is_oidc {admin.is_oidc
? t("admin.external") ? t("admin.external")
: t("admin.local")} : t("admin.local")}
</TableCell> </TableCell>
<TableCell className="px-4"> <TableCell>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1622,6 +1712,97 @@ export function AdminSettings({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
)} )}
{/* Role Management Dialog */}
<Dialog open={rolesDialogOpen} onOpenChange={setRolesDialogOpen}>
<DialogContent className="max-w-2xl bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle>{t("rbac.manageRoles")}</DialogTitle>
<DialogDescription>
{t("rbac.manageRolesFor", { username: selectedUser?.username || "" })}
</DialogDescription>
</DialogHeader>
{rolesLoading ? (
<div className="text-center py-8 text-muted-foreground">
{t("common.loading")}
</div>
) : (
<div className="space-y-6 py-4">
{/* Current Roles */}
<div className="space-y-3">
<Label>{t("rbac.currentRoles")}</Label>
{userRoles.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("rbac.noRolesAssigned")}
</p>
) : (
<div className="space-y-2">
{userRoles.map((userRole) => (
<div
key={userRole.roleId}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<p className="font-medium">{t(userRole.roleDisplayName)}</p>
<p className="text-xs text-muted-foreground">
{userRole.roleName}
</p>
</div>
{userRole.roleName !== "admin" && userRole.roleName !== "user" && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => handleRemoveRole(userRole.roleId)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Assign New Role */}
<div className="space-y-3">
<Label>{t("rbac.assignNewRole")}</Label>
<div className="flex gap-2">
{availableRoles
.filter(
(role) =>
!role.isSystem &&
!userRoles.some((ur) => ur.roleId === role.id),
)
.map((role) => (
<Button
key={role.id}
type="button"
size="sm"
variant="outline"
onClick={() => handleAssignRole(role.id)}
>
{t(role.displayName)}
</Button>
))}
</div>
</div>
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setRolesDialogOpen(false)}
>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+7 -5
View File
@@ -298,6 +298,8 @@ export function RoleManagement(): React.ReactElement {
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{!role.isSystem && (
<>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -305,7 +307,6 @@ export function RoleManagement(): React.ReactElement {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
{!role.isSystem && (
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -313,6 +314,7 @@ export function RoleManagement(): React.ReactElement {
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</>
)} )}
</div> </div>
</TableCell> </TableCell>
@@ -339,7 +341,7 @@ export function RoleManagement(): React.ReactElement {
{/* Create/Edit Role Dialog */} {/* Create/Edit Role Dialog */}
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}> <Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
<DialogContent> <DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editingRole ? t("rbac.editRole") : t("rbac.createRole")} {editingRole ? t("rbac.editRole") : t("rbac.createRole")}
@@ -351,7 +353,7 @@ export function RoleManagement(): React.ReactElement {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-6 py-4">
{!editingRole && ( {!editingRole && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="role-name">{t("rbac.roleName")}</Label> <Label htmlFor="role-name">{t("rbac.roleName")}</Label>
@@ -403,13 +405,13 @@ export function RoleManagement(): React.ReactElement {
{/* Assign Role Dialog */} {/* Assign Role Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}> <Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent className="max-w-2xl"> <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-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>
@@ -1183,9 +1183,11 @@ export function HostManagerEditor({
<TabsTrigger value="statistics"> <TabsTrigger value="statistics">
{t("hosts.statistics")} {t("hosts.statistics")}
</TabsTrigger> </TabsTrigger>
{!editingHost?.isShared && (
<TabsTrigger value="sharing"> <TabsTrigger value="sharing">
{t("rbac.sharing")} {t("rbac.sharing")}
</TabsTrigger> </TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="general" className="pt-2"> <TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold"> <FormLabel className="mb-3 font-bold">
@@ -3316,6 +3318,7 @@ export function HostManagerEditor({
</ScrollArea> </ScrollArea>
<footer className="shrink-0 w-full pb-0"> <footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" /> <Separator className="p-0.25" />
{!(editingHost?.permissionLevel === "view") && (
<Button className="translate-y-2" type="submit" variant="outline"> <Button className="translate-y-2" type="submit" variant="outline">
{editingHost {editingHost
? editingHost.id ? editingHost.id
@@ -3323,6 +3326,7 @@ export function HostManagerEditor({
: t("hosts.cloneHost") : t("hosts.cloneHost")
: t("hosts.addHost")} : t("hosts.addHost")}
</Button> </Button>
)}
</footer> </footer>
</form> </form>
</Form> </Form>
@@ -35,6 +35,7 @@ import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { import {
getRoles, getRoles,
getUserList, getUserList,
getUserInfo,
shareHost, shareHost,
getHostAccess, getHostAccess,
revokeHostAccess, revokeHostAccess,
@@ -55,7 +56,6 @@ interface User {
const PERMISSION_LEVELS = [ const PERMISSION_LEVELS = [
{ value: "view", labelKey: "rbac.view" }, { value: "view", labelKey: "rbac.view" },
{ value: "use", labelKey: "rbac.use" },
{ value: "manage", labelKey: "rbac.manage" }, { value: "manage", labelKey: "rbac.manage" },
]; ];
@@ -71,13 +71,14 @@ export function HostSharingTab({
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>( const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
null, null,
); );
const [permissionLevel, setPermissionLevel] = React.useState("use"); const [permissionLevel, setPermissionLevel] = React.useState("view");
const [expiresInHours, setExpiresInHours] = React.useState<string>(""); const [expiresInHours, setExpiresInHours] = React.useState<string>("");
const [roles, setRoles] = React.useState<Role[]>([]); const [roles, setRoles] = React.useState<Role[]>([]);
const [users, setUsers] = React.useState<User[]>([]); const [users, setUsers] = React.useState<User[]>([]);
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]); const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const [currentUserId, setCurrentUserId] = React.useState<string>("");
// Load roles // Load roles
const loadRoles = React.useCallback(async () => { const loadRoles = React.useCallback(async () => {
@@ -131,6 +132,19 @@ export function HostSharingTab({
} }
}, [loadRoles, loadUsers, loadAccessList, isNewHost]); }, [loadRoles, loadUsers, loadAccessList, isNewHost]);
// Load current user ID
React.useEffect(() => {
const fetchCurrentUser = async () => {
try {
const userInfo = await getUserInfo();
setCurrentUserId(userInfo.userId);
} catch (error) {
console.error("Failed to load current user:", error);
}
};
fetchCurrentUser();
}, []);
// Share host // Share host
const handleShare = async () => { const handleShare = async () => {
if (!hostId) { if (!hostId) {
@@ -148,6 +162,12 @@ export function HostSharingTab({
return; return;
} }
// Prevent sharing with self
if (shareType === "user" && selectedUserId === currentUserId) {
toast.error(t("rbac.cannotShareWithSelf"));
return;
}
try { try {
await shareHost(hostId, { await shareHost(hostId, {
targetType: shareType, targetType: shareType,
@@ -298,13 +318,18 @@ 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.expiresIn")} ({t("rbac.hours")}) {t("rbac.durationHours")}
</Label> </Label>
<Input <Input
id="expires-in" id="expires-in"
type="number" type="number"
value={expiresInHours} value={expiresInHours}
onChange={(e) => setExpiresInHours(e.target.value)} onChange={(e) => {
const value = e.target.value;
if (value === "" || /^\d+$/.test(value)) {
setExpiresInHours(value);
}
}}
placeholder={t("rbac.neverExpires")} placeholder={t("rbac.neverExpires")}
min="1" min="1"
/> />