From 3e4c0770d0ded10e76a693e0912f09dc5d7e04c4 Mon Sep 17 00:00:00 2001 From: an9xyz Date: Mon, 15 Jun 2026 23:18:10 +0800 Subject: [PATCH 1/3] fix: gate manager UI with capabilities --- src/App.tsx | 87 ++++++++++++++++-- src/api/auth.ts | 7 ++ src/auth/capabilities.test.ts | 38 ++++++++ src/auth/capabilities.ts | 54 +++++++++++ src/hooks/useSpaceScope.ts | 24 +++-- src/i18n/locales/en-US/layout.json | 4 +- src/i18n/locales/zh-CN/layout.json | 4 +- src/layouts/MainLayout.tsx | 120 +++++++++++++++++++++---- src/pages/Dashboard/index.tsx | 37 ++++---- src/pages/Download/index.tsx | 60 ++++++++----- src/pages/Groups/index.tsx | 59 +++++++----- src/pages/Spaces/SpaceDetailDrawer.tsx | 3 +- src/pages/Spaces/SpaceInfoPanel.tsx | 5 +- src/pages/Spaces/SpaceMembersPanel.tsx | 2 +- src/pages/Spaces/index.tsx | 40 ++++++--- src/pages/Users/index.tsx | 46 ++++++---- src/store/auth.ts | 29 ++++++ 17 files changed, 492 insertions(+), 127 deletions(-) create mode 100644 src/auth/capabilities.test.ts create mode 100644 src/auth/capabilities.ts diff --git a/src/App.tsx b/src/App.tsx index c76c575..2791b21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,11 @@ import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom' import { useAuthStore } from './store/auth' import { useFeatureStore } from './store/feature' +import { + firstManagerPath, + hasManagerCapability, + type ManagerCapabilityKey, +} from './auth/capabilities' import MainLayout from './layouts/MainLayout' import AdminThemeProvider from './layouts/AdminThemeProvider' import Login from './pages/Login' @@ -24,6 +29,25 @@ function SuperOnlyRoute({ children }: { children: React.ReactNode }) { return <>{children} } +function CapabilityRoute({ + capability, + children, +}: { + capability: ManagerCapabilityKey + children: React.ReactNode +}) { + const { managerCapabilities, managerProfileStatus } = useAuthStore() + if ( + managerProfileStatus === 'idle' || + managerProfileStatus === 'loading' || + managerCapabilities === null + ) return null + if (!hasManagerCapability(managerCapabilities, capability)) { + return + } + return <>{children} +} + function SpaceOnlyRoute({ children }: { children: React.ReactNode }) { const { isLoggedIn, scope } = useAuthStore() if (!isLoggedIn || scope !== 'space') return @@ -108,13 +132,62 @@ function AdminRoutes() { } > } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> api.post('/v1/manager/login', params).then((res) => res.data) + +export const getManagerMe = () => + api.get('/v1/manager/me').then((res) => ({ + ...res.data, + capabilities: normalizeManagerCapabilities(res.data.capabilities), + })) diff --git a/src/auth/capabilities.test.ts b/src/auth/capabilities.test.ts new file mode 100644 index 0000000..e83d4ad --- /dev/null +++ b/src/auth/capabilities.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + firstManagerPath, + hasManagerCapability, + normalizeManagerCapabilities, +} from './capabilities' + +describe('manager capabilities', () => { + it('normalizes missing capability keys to false', () => { + const capabilities = normalizeManagerCapabilities({ + 'dashboard.read': true, + system_setting: false, + }) + + expect(capabilities['dashboard.read']).toBe(true) + expect(capabilities.system_setting).toBe(false) + expect(capabilities.backup).toBe(false) + }) + + it('checks capabilities strictly', () => { + const capabilities = normalizeManagerCapabilities({ + 'dashboard.read': 1, + system_setting: true, + }) + + expect(hasManagerCapability(capabilities, 'dashboard.read')).toBe(false) + expect(hasManagerCapability(capabilities, 'system_setting')).toBe(true) + }) + + it('selects the first readable manager path', () => { + const capabilities = normalizeManagerCapabilities({ + 'space.read': true, + 'appversion.read': true, + }) + + expect(firstManagerPath(capabilities)).toBe('/spaces') + }) +}) diff --git a/src/auth/capabilities.ts b/src/auth/capabilities.ts new file mode 100644 index 0000000..9f84bea --- /dev/null +++ b/src/auth/capabilities.ts @@ -0,0 +1,54 @@ +export const MANAGER_CAPABILITY_KEYS = [ + 'system_setting', + 'backup', + 'appversion.read', + 'appversion.write', + 'dashboard.read', + 'dashboard.trigger', + 'users.read', + 'users.write', + 'users.manage_admin', + 'groups.read', + 'groups.write', + 'space.read', + 'space.write', + 'space.destructive', +] as const + +export type ManagerCapabilityKey = (typeof MANAGER_CAPABILITY_KEYS)[number] + +export type ManagerCapabilities = Record + +export interface ManagerMe { + uid: string + name: string + role: string + capabilities: ManagerCapabilities +} + +export function normalizeManagerCapabilities( + capabilities?: Partial> | null, +): ManagerCapabilities { + return MANAGER_CAPABILITY_KEYS.reduce((acc, key) => { + acc[key] = capabilities?.[key] === true + return acc + }, {} as ManagerCapabilities) +} + +export function hasManagerCapability( + capabilities: ManagerCapabilities | null | undefined, + key: ManagerCapabilityKey, +): boolean { + return capabilities?.[key] === true +} + +export function firstManagerPath(capabilities: ManagerCapabilities | null | undefined): string { + if (hasManagerCapability(capabilities, 'dashboard.read')) return '/dashboard' + if (hasManagerCapability(capabilities, 'users.read')) return '/users' + if (hasManagerCapability(capabilities, 'groups.read')) return '/groups' + if (hasManagerCapability(capabilities, 'space.read')) return '/spaces' + if (hasManagerCapability(capabilities, 'system_setting')) return '/system-setting' + if (hasManagerCapability(capabilities, 'backup')) return '/backup' + if (hasManagerCapability(capabilities, 'appversion.read')) return '/download' + return '/dashboard' +} diff --git a/src/hooks/useSpaceScope.ts b/src/hooks/useSpaceScope.ts index f691f9e..947ddeb 100644 --- a/src/hooks/useSpaceScope.ts +++ b/src/hooks/useSpaceScope.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { useAuthStore } from '../store/auth' +import { hasManagerCapability, type ManagerCapabilities } from '../auth/capabilities' import * as manager from '../api/space' import * as user from '../api/space-user' @@ -78,6 +79,8 @@ export interface SpaceScope { canManageInvites: boolean canRemoveMembers: boolean canAddMembers: boolean + canChangeMemberRoles: boolean + canUpdateSpaceProfile: boolean canReviewApplies: boolean supportsMemberSearch: boolean supportsMemberPagination: boolean @@ -107,14 +110,18 @@ export interface SpaceScope { } } -function buildSuperScope(): SpaceScope { +function buildSuperScope(capabilities: ManagerCapabilities | null): SpaceScope { + const canWrite = hasManagerCapability(capabilities, 'space.write') + const canDestructive = hasManagerCapability(capabilities, 'space.destructive') return { kind: 'super', role: 'super', - canManageInvites: true, - canRemoveMembers: true, - canAddMembers: true, - canReviewApplies: true, + canManageInvites: canWrite, + canRemoveMembers: canDestructive, + canAddMembers: canWrite, + canChangeMemberRoles: canDestructive, + canUpdateSpaceProfile: canWrite, + canReviewApplies: canWrite, supportsMemberSearch: true, supportsMemberPagination: true, supportsApplyFilter: true, @@ -165,6 +172,8 @@ function buildUserScope(role: SpaceMemberRole): SpaceScope { canManageInvites: isManager, canRemoveMembers: isManager, canAddMembers: false, + canChangeMemberRoles: role === 2, + canUpdateSpaceProfile: isManager, canReviewApplies: isManager, supportsMemberSearch: false, supportsMemberPagination: true, @@ -238,8 +247,9 @@ function buildUserScope(role: SpaceMemberRole): SpaceScope { export function useSpaceScope(role?: SpaceMemberRole): SpaceScope { const scope = useAuthStore((s) => s.scope) + const capabilities = useAuthStore((s) => s.managerCapabilities) return useMemo(() => { - if (scope === 'super') return buildSuperScope() + if (scope === 'super') return buildSuperScope(capabilities) return buildUserScope(role ?? 0) - }, [scope, role]) + }, [capabilities, scope, role]) } diff --git a/src/i18n/locales/en-US/layout.json b/src/i18n/locales/en-US/layout.json index f3b72c0..a85c476 100644 --- a/src/i18n/locales/en-US/layout.json +++ b/src/i18n/locales/en-US/layout.json @@ -7,5 +7,7 @@ "theme.tooltip": "Theme: {{name}}", "theme.light": "Light", "theme.dark": "Dark", - "theme.auto": "Auto" + "theme.auto": "Auto", + "managerProfile.loadFailed": "Failed to load manager permissions", + "managerProfile.retry": "Retry" } diff --git a/src/i18n/locales/zh-CN/layout.json b/src/i18n/locales/zh-CN/layout.json index 75e9d88..cd28e5e 100644 --- a/src/i18n/locales/zh-CN/layout.json +++ b/src/i18n/locales/zh-CN/layout.json @@ -7,5 +7,7 @@ "theme.tooltip": "主题:{{name}}", "theme.light": "浅色", "theme.dark": "深色", - "theme.auto": "跟随系统" + "theme.auto": "跟随系统", + "managerProfile.loadFailed": "管理员权限加载失败", + "managerProfile.retry": "重试" } diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 142ca43..090b7f6 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { Outlet, useNavigate, useLocation } from 'react-router-dom' -import { Layout, Menu, Avatar, Dropdown, Tooltip, Breadcrumb } from 'antd' +import { Layout, Menu, Avatar, Dropdown, Tooltip, Breadcrumb, message, Spin, Button } from 'antd' import type { MenuProps } from 'antd' import { useTranslation } from 'react-i18next' import { @@ -21,6 +21,8 @@ import { } from '@ant-design/icons' import { useAuthStore } from '../store/auth' import { useFeatureStore } from '../store/feature' +import { getManagerMe } from '../api/auth' +import { firstManagerPath, hasManagerCapability } from '../auth/capabilities' import LanguageSwitcher from '../components/LanguageSwitcher' import { useTheme } from '../hooks/useTheme' import type { Theme } from '../hooks/useTheme' @@ -39,7 +41,17 @@ const MainLayout: React.FC = () => { const [collapsed, setCollapsed] = useState(false) const navigate = useNavigate() const location = useLocation() - const { name, logout } = useAuthStore() + const { + name, + token, + scope, + managerCapabilities, + managerProfileStatus, + setManagerProfileLoading, + setManagerMe, + setManagerProfileError, + logout, + } = useAuthStore() const { theme, effective, setTheme } = useTheme() const appBotsAvailable = useFeatureStore((s) => s.appBotsAvailable) const probeAppBots = useFeatureStore((s) => s.probeAppBots) @@ -49,6 +61,35 @@ const MainLayout: React.FC = () => { void probeAppBots() }, [probeAppBots]) + const loadManagerProfile = useCallback(async () => { + if (scope !== 'super' || !token) return + setManagerProfileLoading() + try { + setManagerMe(await getManagerMe()) + } catch (error) { + setManagerProfileError() + message.error((error as Error).message) + } + }, [ + scope, + setManagerMe, + setManagerProfileError, + setManagerProfileLoading, + token, + ]) + + useEffect(() => { + if (scope !== 'super' || !token) return + if (managerCapabilities !== null || managerProfileStatus !== 'idle') return + void loadManagerProfile() + }, [ + loadManagerProfile, + managerCapabilities, + managerProfileStatus, + scope, + token, + ]) + const themeLabel = useMemo>( () => ({ light: t('layout:theme.light'), @@ -59,21 +100,36 @@ const MainLayout: React.FC = () => { ) const menuItems = useMemo(() => { - const base: MenuItem[] = [ - { key: '/dashboard', icon: , label: t('nav:dashboard') }, - { key: '/users', icon: , label: t('nav:users') }, - { key: '/groups', icon: , label: t('nav:groups') }, - { key: '/spaces', icon: , label: t('nav:spaces') }, - ] - const tail: MenuItem[] = [ - { key: '/system-setting', icon: , label: t('nav:systemSetting') }, - { key: '/backup', icon: , label: t('nav:backup') }, - { key: '/download', icon: , label: t('nav:download') }, - ] + if (managerCapabilities === null) return [] + + const base: MenuItem[] = [] + if (hasManagerCapability(managerCapabilities, 'dashboard.read')) { + base.push({ key: '/dashboard', icon: , label: t('nav:dashboard') }) + } + if (hasManagerCapability(managerCapabilities, 'users.read')) { + base.push({ key: '/users', icon: , label: t('nav:users') }) + } + if (hasManagerCapability(managerCapabilities, 'groups.read')) { + base.push({ key: '/groups', icon: , label: t('nav:groups') }) + } + if (hasManagerCapability(managerCapabilities, 'space.read')) { + base.push({ key: '/spaces', icon: , label: t('nav:spaces') }) + } + + const tail: MenuItem[] = [] + if (hasManagerCapability(managerCapabilities, 'system_setting')) { + tail.push({ key: '/system-setting', icon: , label: t('nav:systemSetting') }) + } + if (hasManagerCapability(managerCapabilities, 'backup')) { + tail.push({ key: '/backup', icon: , label: t('nav:backup') }) + } + if (hasManagerCapability(managerCapabilities, 'appversion.read')) { + tail.push({ key: '/download', icon: , label: t('nav:download') }) + } return appBotsAvailable === true ? [...base, { key: '/app-bots', icon: , label: t('nav:appBots') }, ...tail] : [...base, ...tail] - }, [appBotsAvailable, t]) + }, [appBotsAvailable, managerCapabilities, t]) const handleMenuClick = ({ key }: { key: string }) => { navigate(key) @@ -85,6 +141,11 @@ const MainLayout: React.FC = () => { } const activeItem = menuItems.find((item) => item.key === location.pathname) + const homePath = firstManagerPath(managerCapabilities) + const managerProfilePending = + scope === 'super' && managerCapabilities === null && managerProfileStatus !== 'error' + const managerProfileFailed = + scope === 'super' && managerCapabilities === null && managerProfileStatus === 'error' const isDark = effective === 'dark' const surface = isDark ? '#14171f' : '#ffffff' @@ -169,9 +230,9 @@ const MainLayout: React.FC = () => { { e.preventDefault() - navigate('/dashboard') + navigate(homePath) }} - href="/dashboard" + href={homePath} > {t('layout:breadcrumb.admin')} @@ -264,7 +325,30 @@ const MainLayout: React.FC = () => { : '0 1px 2px rgba(16,24,40,0.05)', }} > - + {managerProfilePending ? ( +
+ +
+ ) : managerProfileFailed ? ( +
+ {t('layout:managerProfile.loadFailed')} + +
+ ) : ( + + )} diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index eb71cfe..a00a547 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -54,6 +54,8 @@ import { type DashboardTrendItem, } from '../../api/dashboard' import { ApiError } from '../../api' +import { hasManagerCapability } from '../../auth/capabilities' +import { useAuthStore } from '../../store/auth' const { RangePicker } = DatePicker const { Text } = Typography @@ -697,6 +699,9 @@ function LazyTablePlaceholder({ title, hint }: { title: string; hint: string }) export default function Dashboard() { const { t } = useTranslation(['dashboard', 'common']) + const canRunEtl = useAuthStore((s) => + hasManagerCapability(s.managerCapabilities, 'dashboard.trigger'), + ) const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>(defaultDateRange) const [selectedSpaceIds, setSelectedSpaceIds] = useState([]) const [spaceOptions, setSpaceOptions] = useState([]) @@ -1561,22 +1566,24 @@ export default function Dashboard() {

{t('title')}

{t('subtitle')}

- - - + + + )}