From 42f8302fe7a3b8ef2478945e264d05e18cde040e Mon Sep 17 00:00:00 2001 From: an9xyz Date: Thu, 11 Jun 2026 10:30:59 +0800 Subject: [PATCH 1/2] fix(space): allow space owners to manage member roles in Space Admin console Space owners could see the member list and remove members in the Space self-service console, but role management was hidden because the panel only exposed role actions for the super-admin scope and called the manager API directly. - add user-side role update API in space-user.ts: PUT /v1/space/:space_id/members/:uid/role - expose updateMemberRole on SpaceScope.api, routing super-admin to the manager endpoint and space scope to the user endpoint - SpaceMembersPanel now calls scope.api.updateMemberRole instead of importing the manager API directly - canChangeRole stays enabled for super-admin and is enabled for space scope only when role is owner (role=2); admin/member see no role action - owner rows expose no role/remove action, so the current owner cannot be locally demoted; ownership transfer and backend rejections are surfaced via the backend error message Closes #89 --- src/api/space-user.ts | 6 ++++++ src/hooks/useSpaceScope.ts | 5 +++++ src/pages/Spaces/SpaceMembersPanel.tsx | 7 ++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/api/space-user.ts b/src/api/space-user.ts index 80a50bc..f559546 100644 --- a/src/api/space-user.ts +++ b/src/api/space-user.ts @@ -97,6 +97,12 @@ export const listSpaceUserMembers = ( export const removeSpaceUserMembers = (spaceId: string, uids: string[]) => api.post(`/v1/space/${spaceId}/members/remove`, { uids }) +export const updateSpaceUserMemberRole = ( + spaceId: string, + uid: string, + role: 0 | 1 | 2, +) => api.put(`/v1/space/${spaceId}/members/${uid}/role`, { role }) + export const createSpaceUserInvite = ( spaceId: string, data: { max_uses?: number; expires_at?: string } = {}, diff --git a/src/hooks/useSpaceScope.ts b/src/hooks/useSpaceScope.ts index 8a47164..fa586b2 100644 --- a/src/hooks/useSpaceScope.ts +++ b/src/hooks/useSpaceScope.ts @@ -85,6 +85,7 @@ export interface SpaceScope { listMembers: (spaceId: string, params: ScopedMemberParams) => Promise addMembers?: (spaceId: string, uids: string[]) => Promise removeMembers: (spaceId: string, uids: string[]) => Promise + updateMemberRole: (spaceId: string, uid: string, role: 0 | 1 | 2) => Promise listInvites: (spaceId: string, params: ScopedInviteParams) => Promise createInvite: ( spaceId: string, @@ -123,6 +124,8 @@ function buildSuperScope(): SpaceScope { }, addMembers: (spaceId, uids) => manager.addSpaceMembers(spaceId, uids), removeMembers: (spaceId, uids) => manager.removeSpaceMembers(spaceId, uids), + updateMemberRole: (spaceId, uid, role) => + manager.updateSpaceMemberRole(spaceId, uid, role), listInvites: async (spaceId, params) => { const res = await manager.listSpaceInvites(spaceId, { page_index: params.page_index, @@ -192,6 +195,8 @@ function buildUserScope(role: 0 | 1 | 2): SpaceScope { } }, removeMembers: (spaceId, uids) => user.removeSpaceUserMembers(spaceId, uids), + updateMemberRole: (spaceId, uid, role) => + user.updateSpaceUserMemberRole(spaceId, uid, role), listInvites: async (spaceId, params) => { const resp = await user.listSpaceUserInvites(spaceId, params) return { count: resp.count, list: resp.list } diff --git a/src/pages/Spaces/SpaceMembersPanel.tsx b/src/pages/Spaces/SpaceMembersPanel.tsx index 52e3ceb..c9eaafa 100644 --- a/src/pages/Spaces/SpaceMembersPanel.tsx +++ b/src/pages/Spaces/SpaceMembersPanel.tsx @@ -14,9 +14,10 @@ import type { ColumnsType } from 'antd/es/table' import { useTranslation } from 'react-i18next' const { Text } = Typography -import { updateSpaceMemberRole, type SpaceMemberRole } from '../../api/space' import type { MemberItem, SpaceScope } from '../../hooks/useSpaceScope' +type SpaceMemberRole = 0 | 1 | 2 + interface Props { spaceId: string scope: SpaceScope @@ -44,7 +45,7 @@ export default function SpaceMembersPanel({ spaceId, scope, readOnly = false }: const canAdd = !readOnly && scope.canAddMembers const canRemove = !readOnly && scope.canRemoveMembers - const canChangeRole = !readOnly && scope.kind === 'super' + const canChangeRole = !readOnly && (scope.kind === 'super' || scope.role === 2) const fetchData = async (nextPage = page, kw = keyword) => { setLoading(true) @@ -84,7 +85,7 @@ export default function SpaceMembersPanel({ spaceId, scope, readOnly = false }: : undefined, onOk: async () => { try { - await updateSpaceMemberRole(spaceId, uid, role) + await scope.api.updateMemberRole(spaceId, uid, role) message.success(t('members.changeRole.success')) fetchData() } catch (error) { From c0838c1819d5f45665cd38686257e71e0e824e17 Mon Sep 17 00:00:00 2001 From: an9xyz Date: Thu, 11 Jun 2026 22:55:30 +0800 Subject: [PATCH 2/2] refactor(space): address review nits on member role management Non-blocking follow-ups from PR #90 review: - refresh viewer scope after a role change: SpaceAdminLayout exposes a refreshDetail callback via Outlet context, wired to SpaceMembersPanel as onRoleChanged. After an owner transfers ownership and is demoted, the role action now disappears without a manual page reload. - guard the viewer's own row: role actions are hidden when record.uid === viewerUid, avoiding a doomed self-demotion/transfer request that the backend would reject anyway. - single-source the role type: export SpaceMemberRole from useSpaceScope and reuse it across the scope API, MemberItem, and the panel instead of redeclaring 0 | 1 | 2 in several places. - rename the shadowed role param in buildUserScope.updateMemberRole to nextRole for clarity. --- src/api/space-user.ts | 2 +- src/hooks/useSpaceScope.ts | 15 +++++++------- src/pages/SpaceAdmin/SpaceAdminLayout.tsx | 25 +++++++++++++++++++++-- src/pages/SpaceAdmin/tabs.tsx | 15 ++++++++++---- src/pages/Spaces/SpaceMembersPanel.tsx | 20 +++++++++++++----- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/api/space-user.ts b/src/api/space-user.ts index f559546..5983d39 100644 --- a/src/api/space-user.ts +++ b/src/api/space-user.ts @@ -100,7 +100,7 @@ export const removeSpaceUserMembers = (spaceId: string, uids: string[]) => export const updateSpaceUserMemberRole = ( spaceId: string, uid: string, - role: 0 | 1 | 2, + role: SpaceUserMember['role'], ) => api.put(`/v1/space/${spaceId}/members/${uid}/role`, { role }) export const createSpaceUserInvite = ( diff --git a/src/hooks/useSpaceScope.ts b/src/hooks/useSpaceScope.ts index fa586b2..f691f9e 100644 --- a/src/hooks/useSpaceScope.ts +++ b/src/hooks/useSpaceScope.ts @@ -4,7 +4,8 @@ import * as manager from '../api/space' import * as user from '../api/space-user' export type SpaceScopeKind = 'super' | 'space' -export type SpaceScopeRole = 'super' | 0 | 1 | 2 +export type SpaceMemberRole = 0 | 1 | 2 +export type SpaceScopeRole = 'super' | SpaceMemberRole export interface InviteListItem { invite_code: string @@ -25,7 +26,7 @@ export interface InviteListResp { export interface MemberItem { uid: string name: string - role: 0 | 1 | 2 + role: SpaceMemberRole status?: number robot?: 0 | 1 created_at?: string @@ -85,7 +86,7 @@ export interface SpaceScope { listMembers: (spaceId: string, params: ScopedMemberParams) => Promise addMembers?: (spaceId: string, uids: string[]) => Promise removeMembers: (spaceId: string, uids: string[]) => Promise - updateMemberRole: (spaceId: string, uid: string, role: 0 | 1 | 2) => Promise + updateMemberRole: (spaceId: string, uid: string, role: SpaceMemberRole) => Promise listInvites: (spaceId: string, params: ScopedInviteParams) => Promise createInvite: ( spaceId: string, @@ -156,7 +157,7 @@ function buildSuperScope(): SpaceScope { } } -function buildUserScope(role: 0 | 1 | 2): SpaceScope { +function buildUserScope(role: SpaceMemberRole): SpaceScope { const isManager = role >= 1 return { kind: 'space', @@ -195,8 +196,8 @@ function buildUserScope(role: 0 | 1 | 2): SpaceScope { } }, removeMembers: (spaceId, uids) => user.removeSpaceUserMembers(spaceId, uids), - updateMemberRole: (spaceId, uid, role) => - user.updateSpaceUserMemberRole(spaceId, uid, role), + updateMemberRole: (spaceId, uid, nextRole) => + user.updateSpaceUserMemberRole(spaceId, uid, nextRole), listInvites: async (spaceId, params) => { const resp = await user.listSpaceUserInvites(spaceId, params) return { count: resp.count, list: resp.list } @@ -235,7 +236,7 @@ function buildUserScope(role: 0 | 1 | 2): SpaceScope { } } -export function useSpaceScope(role?: 0 | 1 | 2): SpaceScope { +export function useSpaceScope(role?: SpaceMemberRole): SpaceScope { const scope = useAuthStore((s) => s.scope) return useMemo(() => { if (scope === 'super') return buildSuperScope() diff --git a/src/pages/SpaceAdmin/SpaceAdminLayout.tsx b/src/pages/SpaceAdmin/SpaceAdminLayout.tsx index 75ff005..e93517d 100644 --- a/src/pages/SpaceAdmin/SpaceAdminLayout.tsx +++ b/src/pages/SpaceAdmin/SpaceAdminLayout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Outlet, useNavigate, useParams, useLocation } from 'react-router-dom' import { @@ -156,6 +156,27 @@ export default function SpaceAdminLayout() { } }, [spaceId, currentSpaceId, setCurrentSpaceId, setMySpaces, navigate]) + // 角色变更等操作后重新校验当前用户在本空间的角色,避免 scope.role 过期 + // (例如 owner 转让所有权后被降级为 admin,角色操作应及时消失)。 + const refreshDetail = useCallback(async () => { + if (!spaceId) return + try { + const [list, d] = await Promise.all([ + getMySpaces().catch(() => [] as MySpace[]), + getSpaceUserDetail(spaceId), + ]) + const managed = (list || []).filter((s) => s.role >= 1) + setMySpaces(managed) + if (!managed.some((s) => s.space_id === spaceId)) { + navigate('/space', { replace: true }) + return + } + setDetail(d) + } catch (error) { + message.error((error as Error).message) + } + }, [spaceId, setMySpaces, navigate]) + const handleLogout = () => { logout() navigate('/login') @@ -465,7 +486,7 @@ export default function SpaceAdminLayout() { destroyInactiveTabPane tabBarStyle={{ marginBottom: 16 }} /> - + )} diff --git a/src/pages/SpaceAdmin/tabs.tsx b/src/pages/SpaceAdmin/tabs.tsx index 58f4a71..7ffd4cb 100644 --- a/src/pages/SpaceAdmin/tabs.tsx +++ b/src/pages/SpaceAdmin/tabs.tsx @@ -8,18 +8,25 @@ import AppBotsPage from '../AppBots' interface Ctx { detail: SpaceUserDetail + refreshDetail: () => void } function useSpaceCtx() { const { spaceId } = useParams<{ spaceId: string }>() - const { detail } = useOutletContext() + const { detail, refreshDetail } = useOutletContext() const scope = useSpaceScope(detail.role) - return { spaceId: spaceId!, detail, scope } + return { spaceId: spaceId!, detail, scope, refreshDetail } } export function MembersTab() { - const { spaceId, scope } = useSpaceCtx() - return + const { spaceId, scope, refreshDetail } = useSpaceCtx() + return ( + + ) } export function InvitesTab() { diff --git a/src/pages/Spaces/SpaceMembersPanel.tsx b/src/pages/Spaces/SpaceMembersPanel.tsx index c9eaafa..b33ba09 100644 --- a/src/pages/Spaces/SpaceMembersPanel.tsx +++ b/src/pages/Spaces/SpaceMembersPanel.tsx @@ -14,14 +14,18 @@ import type { ColumnsType } from 'antd/es/table' import { useTranslation } from 'react-i18next' const { Text } = Typography -import type { MemberItem, SpaceScope } from '../../hooks/useSpaceScope' - -type SpaceMemberRole = 0 | 1 | 2 +import { useAuthStore } from '../../store/auth' +import type { + MemberItem, + SpaceScope, + SpaceMemberRole, +} from '../../hooks/useSpaceScope' interface Props { spaceId: string scope: SpaceScope readOnly?: boolean + onRoleChanged?: () => void } const ROLE_LABEL: Record = { @@ -32,8 +36,9 @@ const ROLE_LABEL: Record s.uid) const [loading, setLoading] = useState(false) const [data, setData] = useState([]) const [total, setTotal] = useState(0) @@ -88,6 +93,9 @@ export default function SpaceMembersPanel({ spaceId, scope, readOnly = false }: await scope.api.updateMemberRole(spaceId, uid, role) message.success(t('members.changeRole.success')) fetchData() + // 角色变更可能改变当前用户自身权限(如转让所有权后被降级), + // 通知上层重新校验 scope,避免操作入口残留到刷新前。 + onRoleChanged?.() } catch (error) { message.error((error as Error).message) } @@ -149,7 +157,9 @@ export default function SpaceMembersPanel({ spaceId, scope, readOnly = false }: if (record.status !== undefined && record.status !== 1) return const items: MenuProps['items'] = [] - if (canChangeRole) { + // 不对当前登录用户自己的行展示角色操作:owner 无法自降级、 + // 也无需对自己做转让/升降级,这些请求注定被后端拒绝。 + if (canChangeRole && record.uid !== viewerUid) { if (record.role !== 2) { items.push({ key: 'owner',