diff --git a/backend/alembic/versions/add_user_group_permission_scope.py b/backend/alembic/versions/add_user_group_permission_scope.py new file mode 100644 index 000000000..74846d0df --- /dev/null +++ b/backend/alembic/versions/add_user_group_permission_scope.py @@ -0,0 +1,31 @@ +"""add_user_group_permission_scope + +Revision ID: add_user_group_scope +Revises: increase_api_key_length +Create Date: 2026-04-16 22:30:00.000000 + +This migration adds 'user_group' enum value for specifying multiple users with permissions. +'user' remains as "only creator" (backward compatible). +'user_group' is the new "specific users" option. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'add_user_group_scope' +down_revision: Union[str, None] = 'increase_api_key_length' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add 'user_group' to the permission_scope_enum enum type + op.execute("ALTER TYPE permission_scope_enum ADD VALUE IF NOT EXISTS 'user_group'") + + +def downgrade() -> None: + # Note: PostgreSQL doesn't support removing enum values easily + pass diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 0f8f1bc41..30dd75be2 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -169,6 +169,7 @@ async def list_agents( .where( (AgentPermission.scope_type == "company") | ((AgentPermission.scope_type == "user") & (AgentPermission.scope_id == current_user.id)) + | ((AgentPermission.scope_type == "user_group") & (AgentPermission.scope_id == current_user.id)) ) ) permitted = select(Agent).where(Agent.id.in_(permitted_ids), Agent.tenant_id == user_tenant) @@ -198,6 +199,11 @@ async def create_agent( db: AsyncSession = Depends(get_db), ): """Create a new digital employee (any authenticated user).""" + # Debug: log permission data + import logging + logger = logging.getLogger(__name__) + logger.info(f"[create_agent] Received permission data: scope_type={data.permission_scope_type}, scope_ids={data.permission_scope_ids}, access_level={data.permission_access_level}") + # Check agent creation quota from app.services.quota_guard import check_agent_creation_quota, QuotaExceeded try: @@ -270,17 +276,33 @@ async def create_agent( # Set permissions access_level = data.permission_access_level if data.permission_access_level in ("use", "manage") else "use" - if data.permission_scope_type not in ("company", "user"): + if data.permission_scope_type not in ("company", "user", "user_group"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported permission_scope_type") + + # Validate user_group requires at least one user + if data.permission_scope_type == "user_group" and not data.permission_scope_ids: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_group scope requires at least one user") + if data.permission_scope_type == "company": db.add(AgentPermission(agent_id=agent.id, scope_type="company", access_level=access_level)) elif data.permission_scope_type == "user": + # User: only creator can access + db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=current_user.id, access_level="manage")) + elif data.permission_scope_type == "user_group": if data.permission_scope_ids: - for scope_id in data.permission_scope_ids: - db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=scope_id, access_level=access_level)) - else: - # "仅自己" — insert creator as the only permitted user - db.add(AgentPermission(agent_id=agent.id, scope_type="user", scope_id=current_user.id, access_level="manage")) + # Check if we have per-user access levels + if data.user_permissions and isinstance(data.user_permissions, dict): + # Per-user permissions: {user_id: access_level} + for scope_id in data.permission_scope_ids: + user_access = data.user_permissions.get(str(scope_id), access_level) + # Validate: only allow 'use' or 'manage', default to 'use' for safety + if user_access not in ("use", "manage"): + user_access = "use" + db.add(AgentPermission(agent_id=agent.id, scope_type="user_group", scope_id=scope_id, access_level=user_access)) + else: + # Legacy: all users share the same access level + for scope_id in data.permission_scope_ids: + db.add(AgentPermission(agent_id=agent.id, scope_type="user_group", scope_id=scope_id, access_level=access_level)) await db.flush() @@ -401,16 +423,30 @@ async def get_agent_permissions( scope_type = perms[0].scope_type scope_ids = [str(p.scope_id) for p in perms if p.scope_id] - perm_access_level = perms[0].access_level or "use" - - # Resolve names for display + + # Determine access_level deterministically + if scope_type == "user_group": + # For user_group, check if all users have the same access level. + # If mixed, default to 'use' (least privilege) to prevent accidental over-permissioning. + levels = set(p.access_level or "use" for p in perms) + perm_access_level = levels.pop() if len(levels) == 1 else "use" + else: + perm_access_level = perms[0].access_level or "use" + + # Resolve names and access levels for display scope_names = [] - if scope_type == "user": - for sid in scope_ids: - r = await db.execute(select(User).where(User.id == uuid.UUID(sid))) - u = r.scalar_one_or_none() - if u: - scope_names.append({"id": sid, "name": u.display_name or u.username}) + if scope_type == "user_group": + for perm in perms: + if perm.scope_id: + sid = str(perm.scope_id) + r = await db.execute(select(User).where(User.id == perm.scope_id)) + u = r.scalar_one_or_none() + if u: + scope_names.append({ + "id": sid, + "name": u.display_name or u.username, + "access_level": perm.access_level or "use" + }) return { "scope_type": scope_type, @@ -418,6 +454,7 @@ async def get_agent_permissions( "scope_names": scope_names, "access_level": perm_access_level, "is_owner": is_agent_creator(current_user, agent), + "creator_id": str(agent.creator_id) if agent.creator_id else None, } @@ -428,18 +465,42 @@ async def update_agent_permissions( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): - """Update agent permission scope (owner or platform_admin only).""" - agent, _access = await check_agent_access(db, current_user, agent_id) - if not is_agent_creator(current_user, agent): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner or admin can change permissions") + """Update agent permission scope (owner, admin, or users with manage access).""" + # Check admin status first to bypass strict check_agent_access for org_admins + is_admin = current_user.role in ("platform_admin", "org_admin") + + if is_admin: + # Admins can manage permissions for any agent in their tenant + result = await db.execute(select(Agent).where(Agent.id == agent_id)) + agent = result.scalar_one_or_none() + if not agent: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found") + # Tenant isolation check for admins + if agent.tenant_id != current_user.tenant_id and current_user.role != "platform_admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this agent") + access_level = "manage" # Admins implicitly have manage access + else: + # For non-admins, use standard access check + agent, access_level = await check_agent_access(db, current_user, agent_id) + + # Check if user has permission to modify + is_owner = is_agent_creator(current_user, agent) + has_manage_access = access_level == "manage" + + if not is_owner and not is_admin and not has_manage_access: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only owner, admin, or users with manage access can change permissions") scope_type = data.get("scope_type", "company") scope_ids = data.get("scope_ids", []) access_level = data.get("access_level", "use") if access_level not in ("use", "manage"): access_level = "use" - if scope_type not in ("company", "user"): + if scope_type not in ("company", "user", "user_group"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported scope_type") + + # Validate user_group requires at least one user + if scope_type == "user_group" and not scope_ids: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="user_group scope requires at least one user") # Delete existing permissions from sqlalchemy import delete as sql_delete @@ -449,12 +510,24 @@ async def update_agent_permissions( if scope_type == "company": db.add(AgentPermission(agent_id=agent_id, scope_type="company", access_level=access_level)) elif scope_type == "user": + # User: only creator can access + db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=current_user.id, access_level="manage")) + elif scope_type == "user_group": if scope_ids: - for sid in scope_ids: - db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=uuid.UUID(sid), access_level=access_level)) - else: - # "仅自己" - db.add(AgentPermission(agent_id=agent_id, scope_type="user", scope_id=current_user.id, access_level="manage")) + # Check if we have per-user access levels + user_permissions = data.get('user_permissions', None) + if user_permissions and isinstance(user_permissions, dict): + # Per-user permissions: {user_id: access_level} + for sid in scope_ids: + user_access = user_permissions.get(str(sid), access_level) + # Validate: only allow 'use' or 'manage', default to 'use' for safety + if user_access not in ("use", "manage"): + user_access = "use" + db.add(AgentPermission(agent_id=agent_id, scope_type="user_group", scope_id=uuid.UUID(sid), access_level=user_access)) + else: + # Legacy: all users share the same access level + for sid in scope_ids: + db.add(AgentPermission(agent_id=agent_id, scope_type="user_group", scope_id=uuid.UUID(sid), access_level=access_level)) await db.commit() return {"status": "ok"} diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 63e551ad0..e51c3ba1b 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -20,7 +20,7 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) Access is granted if: 1. User is platform admin → manage 2. User is the agent creator → manage - 3. User has explicit permission (company/user scope) → from permission record + 3. User has explicit permission (company/user/private scope) → from permission record """ result = await db.execute(select(Agent).where(Agent.id == agent_id)) agent = result.scalar_one_or_none() @@ -47,6 +47,10 @@ async def check_agent_access(db: AsyncSession, user: User, agent_id: uuid.UUID) if perm.scope_type == "company": return agent, perm.access_level or "use" if perm.scope_type == "user" and perm.scope_id == user.id: + # User scope: only the creator can access + return agent, perm.access_level or "manage" + if perm.scope_type == "user_group" and perm.scope_id == user.id: + # User group scope: specific users can access return agent, perm.access_level or "use" raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this agent") diff --git a/backend/app/models/agent.py b/backend/app/models/agent.py index 8cb129f7a..990375c59 100644 --- a/backend/app/models/agent.py +++ b/backend/app/models/agent.py @@ -137,10 +137,10 @@ class AgentPermission(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) agent_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False) scope_type: Mapped[str] = mapped_column( - Enum("company", "department", "user", name="permission_scope_enum"), + Enum("company", "department", "user", "user_group", name="permission_scope_enum"), nullable=False, ) - # scope_id: null for company, user_id for user scope + # scope_id: null for company, user_id for user/user_group scope scope_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) # access_level: 'use' = task/chat/tool/skill/workspace only, 'manage' = full access access_level: Mapped[str] = mapped_column(String(20), default="use", nullable=False) diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index 3870392b9..a59afc617 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -216,9 +216,10 @@ class AgentCreate(BaseModel): primary_model_id: uuid.UUID | None = None fallback_model_id: uuid.UUID | None = None # Permissions - permission_scope_type: str = "company" # company | user + permission_scope_type: str = "company" # company | user (only me) | user_group (specific users) permission_scope_ids: list[uuid.UUID] = [] permission_access_level: str = "use" # use | manage + user_permissions: dict[str, str] | None = None # {user_id: access_level} for per-user permissions # Target tenant (admin-only override; otherwise ignored) tenant_id: uuid.UUID | None = None # Template diff --git a/backend/app/services/agent_manager.py b/backend/app/services/agent_manager.py index 9f4c4a73c..af2bffb23 100644 --- a/backend/app/services/agent_manager.py +++ b/backend/app/services/agent_manager.py @@ -25,9 +25,18 @@ class AgentManager: def __init__(self): try: - self.docker_client = docker.from_env() - except DockerException: - logger.warning("Docker not available — agent containers will not be managed") + # Set a timeout to avoid hanging if Docker daemon is unresponsive + import os + if os.getenv('SKIP_DOCKER', 'false').lower() == 'true': + logger.info("Docker skipped via SKIP_DOCKER environment variable") + self.docker_client = None + else: + self.docker_client = docker.from_env() + # Quick health check - ping Docker daemon + self.docker_client.ping() + logger.info("Docker connected successfully") + except Exception as e: + logger.warning(f"Docker not available ({e}) — agent containers will not be managed") self.docker_client = None def _agent_dir(self, agent_id: uuid.UUID) -> Path: diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index a06b0ea7b..20698da18 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -496,14 +496,26 @@ "description": "Control who can see and interact with this agent. Only the creator or admin can change this.", "companyWide": "Company-wide", "companyWideDesc": "All users in the organization can use this agent", - "onlyMe": "Only Me", + "specificUsers": "Specific Users", + "specificUsersDesc": "Only selected users can use this agent", + "onlyMe": "Private", "onlyMeDesc": "Only the creator can use this agent", + "selectUsers": "Select Users", + "selectedCount": "{{count}} users selected", "defaultAccess": "Default Access Level", "useAccess": "Use", "useAccessDesc": "Task, Chat, Tools, Skills, Workspace", "manageAccess": "Manage", - "manageAccessDesc": "Full access including Settings, Mind, Relationships" + "manageAccessDesc": "Full access including Settings, Mind, Relationships", + "selectAtLeastOneUser": "Please select at least one user before choosing \"Specific Users\" permission", + "userGroupAccessHint": "All selected users will have this access level", + "canUse": "Can Use", + "canManage": "Can Manage", + "creator": "(Creator)", + "cannotChangeCreator": "Cannot change creator permission" }, + "accessDenied": "Access Denied", + "accessDeniedDesc": "You do not have permission to view or modify this agent's settings. Please contact the agent creator or administrator.", "timezone": { "label": "Timezone", "description": "Set the timezone for scheduled tasks and time-based triggers", @@ -811,10 +823,19 @@ "title": "Permissions", "companyWide": "Company-wide", "companyWideDesc": "Everyone can use this digital employee", + "specificUsers": "Specific Users", + "specificUsersDesc": "Only selected users can use this agent", "department": "Department", "departmentDesc": "Only selected department members can use", - "selfOnly": "Self Only", - "selfOnlyDesc": "Only the creator can use" + "selfOnly": "Private", + "selfOnlyDesc": "Only the creator can use", + "selectUsers": "Select Users", + "selectedCount": "{{count}} users selected", + "accessLevel": "Default Access Level", + "useLevel": "Use", + "useDesc": "Can use Task, Chat, Tools, Skills, Workspace", + "manageLevel": "Manage", + "manageDesc": "Full access including Settings, Mind, Relationships" }, "step5": { "title": "Feishu Bot Configuration (Optional)", @@ -1463,6 +1484,8 @@ "confirm": "Confirm", "delete": "Delete", "search": "Search", + "searchPlaceholder": "Search by name or email...", + "noSearchResults": "No users found matching your search", "loading": "Loading...", "noData": "No data", "error": "Error", diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index 1d96737a2..6a68bfa2c 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -527,14 +527,26 @@ "description": "控制谁可以查看和与这个数字员工互动。只有创建者或管理员可以更改此设置。", "companyWide": "全公司", "companyWideDesc": "组织中的所有用户都可以使用此数字员工", - "onlyMe": "仅我可见", + "specificUsers": "指定用户", + "specificUsersDesc": "只有选定的用户可以使用此智能体", + "onlyMe": "仅自己", "onlyMeDesc": "只有创建者可以使用此数字员工", + "selectUsers": "选择用户", + "selectedCount": "已选择 {{count}} 个用户", "defaultAccess": "默认访问级别", "useAccess": "使用", "useAccessDesc": "任务、聊天、工具、技能、工作区", "manageAccess": "管理", - "manageAccessDesc": "完全访问权限,包括设置、思维、关系" + "manageAccessDesc": "完全访问权限,包括设置、思维、关系", + "selectAtLeastOneUser": "请选择至少一个用户后再选择“指定用户”权限", + "userGroupAccessHint": "所有选定的用户将拥有此访问级别", + "canUse": "可使用", + "canManage": "可管理", + "creator": "(创建人)", + "cannotChangeCreator": "无法修改创建人的权限" }, + "accessDenied": "无权访问", + "accessDeniedDesc": "您没有权限查看或修改此数字员工的设置。请联系数字员工创建人或管理员。", "tools": { "platformTools": "平台预置工具", "agentInstalled": "数字员工自行安装的工具", @@ -920,10 +932,14 @@ "title": "权限设置", "companyWide": "全公司可见", "companyWideDesc": "所有人都可以使用此数字员工", + "specificUsers": "指定用户", + "specificUsersDesc": "只有选定的用户可以使用此智能体", "department": "指定部门", "departmentDesc": "仅选定部门成员可使用", "selfOnly": "仅自己", "selfOnlyDesc": "仅创建者本人可使用", + "selectUsers": "选择用户", + "selectedCount": "已选择 {{count}} 个用户", "accessLevel": "默认访问级别", "useLevel": "使用", "useDesc": "可以使用任务、聊天、工具、技能、工作区", @@ -1553,6 +1569,8 @@ "confirm": "确认", "delete": "删除", "search": "搜索", + "searchPlaceholder": "按用户名或邮箱搜索...", + "noSearchResults": "未找到匹配的用户", "loading": "加载中...", "noData": "暂无数据", "error": "出错了", diff --git a/frontend/src/pages/AgentCreate.tsx b/frontend/src/pages/AgentCreate.tsx index 8816eb9a4..5b43c2cfa 100644 --- a/frontend/src/pages/AgentCreate.tsx +++ b/frontend/src/pages/AgentCreate.tsx @@ -81,6 +81,8 @@ export default function AgentCreate() { fallback_model_id: '' as string, permission_scope_type: 'company', permission_access_level: 'use', + // Store user permissions as a map: { userId: accessLevel } + permission_user_access: {} as Record, template_id: '' as string, max_tokens_per_day: '', max_tokens_per_month: '', @@ -106,6 +108,31 @@ export default function AgentCreate() { queryFn: skillApi.list, }); + // Fetch current user info + const { data: currentUser } = useQuery({ + queryKey: ['auth', 'me'], + queryFn: () => import('../services/api').then(m => m.authApi.me()), + }); + + // Fetch users for permission selection + const { data: users = [], isLoading, error: usersError } = useQuery({ + queryKey: ['users', currentTenant], + queryFn: () => { + console.log('[AgentCreate] Fetching users, currentTenant:', currentTenant); + return enterpriseApi.users(currentTenant || undefined); + }, + enabled: form.permission_scope_type === 'user_group', + }); + + // Debug: log when permission scope changes + useEffect(() => { + console.log('[AgentCreate] permission_scope_type changed to:', form.permission_scope_type); + console.log('[AgentCreate] currentTenant:', currentTenant); + }, [form.permission_scope_type, currentTenant]); + + // Search state for user selector + const [userSearchKeyword, setUserSearchKeyword] = useState(''); + // Auto-select default skills useEffect(() => { if (globalSkills.length > 0) { @@ -250,6 +277,16 @@ export default function AgentCreate() { if (step === 0 || agentType === 'openclaw') { if (!validateStep0()) return; } + + // Debug: log permission data + console.log('[AgentCreate] Creating agent with permissions:', { + permission_scope_type: form.permission_scope_type, + permission_user_access: form.permission_user_access, + }); + + // Convert permission_user_access to scope_ids for backend + const scopeIds = Object.keys(form.permission_user_access); + createMutation.mutate({ name: form.name, agent_type: agentType, @@ -260,10 +297,13 @@ export default function AgentCreate() { fallback_model_id: agentType === 'native' ? (form.fallback_model_id || undefined) : undefined, template_id: form.template_id || undefined, permission_scope_type: form.permission_scope_type, + permission_access_level: form.permission_scope_type === 'company' ? form.permission_access_level : undefined, + permission_scope_ids: scopeIds.length > 0 ? scopeIds : undefined, max_tokens_per_day: form.max_tokens_per_day ? Number(form.max_tokens_per_day) : undefined, max_tokens_per_month: form.max_tokens_per_month ? Number(form.max_tokens_per_month) : undefined, skill_ids: agentType === 'native' ? form.skill_ids : [], - permission_access_level: form.permission_access_level, + // Pass per-user access levels + user_permissions: scopeIds.length > 0 ? form.permission_user_access : undefined, tenant_id: currentTenant || undefined, }); }; @@ -357,7 +397,59 @@ For humans, the message is delivered via their available channel (e.g. Feishu).` ", "result": ""}\n\n### 3. Send a message to someone\nTo proactively contact a person or agent, make an HTTP POST request:\n- URL: ${window.location.origin}/api/gateway/send-message\n- Header: X-Api-Key: ${createdApiKey}\n- Header: Content-Type: application/json\n- Body: {"target": "", "content": ""}\n\nThe system auto-detects the best channel. For agents, the reply appears in your next poll.\nFor humans, the message is delivered via their available channel (e.g. Feishu).`} + textToCopy={`Please create a skill file at skills/clawith_sync.md with the following content, then add "Check Clawith inbox using the clawith_sync skill and process any pending messages" to your HEARTBEAT.md: + +--- +name: clawith_sync +description: Sync with Clawith platform — check inbox, submit results, and send messages. +--- + +# Clawith Sync + +## When to use +Check for new messages from the Clawith platform during every heartbeat cycle. +You can also proactively send messages to people and agents in your relationships. + +## Instructions + +### 1. Check inbox +Make an HTTP GET request: +- URL: ${window.location.origin}/api/gateway/poll +- Header: X-Api-Key: ${createdApiKey} + +The response contains a \`messages\` array. Each message includes: +- \`id\` — unique message ID (use this for reporting) +- \`content\` — the message text +- \`sender_user_name\` — name of the Clawith user who sent it +- \`sender_user_id\` — unique ID of the sender +- \`conversation_id\` — the conversation this message belongs to +- \`history\` — array of previous messages in this conversation for context + +The response also contains a \`relationships\` array describing your colleagues: +- \`name\` — the person or agent name +- \`type\` — "human" or "agent" +- \`role\` — relationship type (e.g. collaborator, supervisor) +- \`channels\` — available communication channels (e.g. ["feishu"], ["agent"]) + +**IMPORTANT**: Use the \`history\` array to understand conversation context before replying. +Different \`sender_user_name\` values mean different people — address them accordingly. + +### 2. Report results +For each completed message, make an HTTP POST request: +- URL: ${window.location.origin}/api/gateway/report +- Header: X-Api-Key: ${createdApiKey} +- Header: Content-Type: application/json +- Body: {"message_id": "", "result": ""} + +### 3. Send a message to someone +To proactively contact a person or agent, make an HTTP POST request: +- URL: ${window.location.origin}/api/gateway/send-message +- Header: X-Api-Key: ${createdApiKey} +- Header: Content-Type: application/json +- Body: {"target": "", "content": ""} + +The system auto-detects the best channel. For agents, the reply appears in your next poll. +For humans, the message is delivered via their available channel (e.g. Feishu).`} label={t('common.copy', 'Copy')} copiedLabel="Copied" /> @@ -761,6 +853,7 @@ For humans, the message is delivered via their available channel (e.g. Feishu).` {[ { value: 'company', label: t('wizard.step4.companyWide'), desc: t('wizard.step4.companyWideDesc') }, { value: 'user', label: t('wizard.step4.selfOnly'), desc: t('wizard.step4.selfOnlyDesc') }, + { value: 'user_group', label: t('wizard.step4.specificUsers'), desc: t('wizard.step4.specificUsersDesc') }, ].map((scope) => (