feat: implement RBAC support in permission handling and update related tests

This commit is contained in:
twwu
2026-05-26 14:07:21 +08:00
parent 6813875f31
commit cba4df082f
15 changed files with 125 additions and 182 deletions

View File

@ -2,23 +2,8 @@ import type { Member } from '@/models/common'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import { DatasetPermission } from '@/models/datasets'
import { LicenseStatus } from '@/types/feature'
import PermissionSelector from '../index'
const mockConfig = vi.hoisted(() => ({
IS_CLOUD_EDITION: false,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
get IS_CLOUD_EDITION() {
return mockConfig.IS_CLOUD_EDITION
},
}
})
// Mock app-context
vi.mock('@/context/app-context', () => ({
useSelector: () => ({
@ -48,7 +33,6 @@ describe('PermissionSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.IS_CLOUD_EDITION = false
})
describe('Rendering', () => {
@ -424,23 +408,10 @@ describe('PermissionSelector', () => {
expect(triggerElement)!.toBeInTheDocument()
})
it('should show access config hint and remain closed in SaaS', () => {
mockConfig.IS_CLOUD_EDITION = true
renderWithSystemFeatures(<PermissionSelector {...defaultProps} />)
const trigger = screen.getByText(/form\.permissionsAccessConfig/)
fireEvent.click(trigger)
expect(screen.getByText(/form\.permissionsAccessConfig/))!.toBeInTheDocument()
expect(screen.queryByText(/form\.permissionsOnlyMe/))!.not.toBeInTheDocument()
})
it('should show access config hint and remain closed in enterprise edition', () => {
it('should show access config hint and remain closed when RBAC is enabled', () => {
renderWithSystemFeatures(<PermissionSelector {...defaultProps} />, {
systemFeatures: {
license: {
status: LicenseStatus.ACTIVE,
},
rbac_enabled: true,
},
})

View File

@ -11,11 +11,9 @@ import { useSuspenseQuery } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { DatasetPermission } from '@/models/datasets'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { LicenseStatus } from '@/types/feature'
import MemberItem from './member-item'
import Item from './permission-item'
@ -91,9 +89,8 @@ const PermissionSelector = ({
const isAllTeamMembers = permission === DatasetPermission.allTeamMembers
const isPartialMembers = permission === DatasetPermission.partialMembers
const selectedMemberNames = selectedMembers.map(member => member.name).join(', ')
const isEnterpriseEdition = systemFeatures.license.status !== LicenseStatus.NONE
const isEditionDisabled = IS_CLOUD_EDITION || isEnterpriseEdition
const isDisabled = disabled || isEditionDisabled
const isDisabledByRBAC = systemFeatures.rbac_enabled
const isDisabled = disabled || isDisabledByRBAC
return (
<Popover
@ -108,7 +105,7 @@ const PermissionSelector = ({
<PopoverTrigger
render={(
<div className={cn('group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt', isDisabled && 'cursor-not-allowed! bg-components-input-bg-disabled! hover:bg-components-input-bg-disabled!')}>
{isEditionDisabled && (
{isDisabledByRBAC && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<span className="i-ri-lock-2-line size-4 text-text-tertiary" />
@ -119,7 +116,7 @@ const PermissionSelector = ({
</>
)}
{
!isEditionDisabled && isOnlyMe && (
!isDisabledByRBAC && isOnlyMe && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xs" />
@ -131,7 +128,7 @@ const PermissionSelector = ({
)
}
{
!isEditionDisabled && isAllTeamMembers && (
!isDisabledByRBAC && isAllTeamMembers && (
<>
<div className="flex size-6 shrink-0 items-center justify-center">
<span className="i-ri-group-2-line size-4 text-text-secondary" />
@ -143,7 +140,7 @@ const PermissionSelector = ({
)
}
{
!isEditionDisabled && isPartialMembers && (
!isDisabledByRBAC && isPartialMembers && (
<>
<div className="relative flex size-6 shrink-0 items-center justify-center">
{

View File

@ -42,7 +42,7 @@ vi.mock('@/context/app-context', () => ({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0' },
workspacePermissionKeys: ['plugin.install', 'plugin.manage', 'plugin.preference.manage'],
workspacePermissionKeys: ['plugin.install', 'plugin.manage', 'plugin.debug'],
}),
}))

View File

@ -25,7 +25,7 @@ const createAppContext = (overrides: Partial<ReturnType<typeof useAppContext>> =
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
langGeniusVersionInfo: { current_version: '1.0.0' },
workspacePermissionKeys: ['plugin.install', 'plugin.manage'],
workspacePermissionKeys: ['plugin.install', 'plugin.manage', 'plugin.debug'],
...overrides,
}) as ReturnType<typeof useAppContext>
@ -54,115 +54,49 @@ describe('useReferenceSetting Hook', () => {
vi.mocked(useInvalidateReferenceSettings).mockReturnValue(vi.fn())
})
describe('hasPermission logic', () => {
it('should return false when permission is undefined', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: undefined,
debug_permission: undefined,
},
},
} as unknown as ReturnType<typeof useReferenceSettings>)
describe('workspace permission key logic', () => {
it('should return false when workspace permission keys are empty', () => {
vi.mocked(useAppContext).mockReturnValue(createAppContext({
workspacePermissionKeys: [],
}))
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canInstall).toBe(false)
expect(result.current.canUpdate).toBe(false)
expect(result.current.canViewInstalledPlugins).toBe(false)
expect(result.current.canManagePlugin).toBe(false)
expect(result.current.canUninstall).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
it('should return false when permission is noOne', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
it('should only allow debug with plugin.debug only', () => {
vi.mocked(useAppContext).mockReturnValue(createAppContext({
workspacePermissionKeys: ['plugin.debug'],
}))
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canInstall).toBe(false)
expect(result.current.canDebugger).toBe(false)
expect(result.current.canUpdate).toBe(false)
expect(result.current.canViewInstalledPlugins).toBe(false)
expect(result.current.canManagePlugin).toBe(false)
expect(result.current.canUninstall).toBe(false)
expect(result.current.canDebugger).toBe(true)
})
it('should return true when permission is everyone', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.everyone,
debug_permission: PermissionType.everyone,
},
},
} as ReturnType<typeof useReferenceSettings>)
it('should allow install and update but not manage or debug with plugin.install only', () => {
vi.mocked(useAppContext).mockReturnValue(createAppContext({
workspacePermissionKeys: ['plugin.install'],
}))
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canInstall).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return isAdmin when permission is admin and user is manager', () => {
vi.mocked(useAppContext).mockReturnValue(createAppContext({
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: false,
}))
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canInstall).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return isAdmin when permission is admin and user is owner', () => {
vi.mocked(useAppContext).mockReturnValue(createAppContext({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: true,
}))
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canInstall).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
it('should return false when permission is admin and user is not admin', () => {
vi.mocked(useAppContext).mockReturnValue(createAppContext({
isCurrentWorkspaceManager: false,
isCurrentWorkspaceOwner: false,
}))
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.admin,
debug_permission: PermissionType.admin,
},
},
} as ReturnType<typeof useReferenceSettings>)
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canInstall).toBe(false)
expect(result.current.canUpdate).toBe(true)
expect(result.current.canViewInstalledPlugins).toBe(true)
expect(result.current.canManagePlugin).toBe(false)
expect(result.current.canUninstall).toBe(false)
expect(result.current.canDebugger).toBe(false)
})
@ -178,11 +112,12 @@ describe('useReferenceSetting Hook', () => {
expect(result.current.canViewInstalledPlugins).toBe(true)
expect(result.current.canManagePlugin).toBe(true)
expect(result.current.canUninstall).toBe(true)
expect(result.current.canDebugger).toBe(false)
})
it('should allow install and update but not manage with plugin.install only', () => {
it('should allow install and debug when both plugin.install and plugin.debug are present', () => {
vi.mocked(useAppContext).mockReturnValue(createAppContext({
workspacePermissionKeys: ['plugin.install'],
workspacePermissionKeys: ['plugin.install', 'plugin.debug'],
}))
const { result } = renderHook(() => useReferenceSetting())
@ -191,6 +126,7 @@ describe('useReferenceSetting Hook', () => {
expect(result.current.canUpdate).toBe(true)
expect(result.current.canViewInstalledPlugins).toBe(true)
expect(result.current.canManagePlugin).toBe(false)
expect(result.current.canDebugger).toBe(true)
})
it('should not allow uninstall with legacy plugin.uninstall only', () => {
@ -316,8 +252,9 @@ describe('useReferenceSetting Hook', () => {
const { result } = renderHook(() => useReferenceSetting())
expect(result.current.canInstall).toBe(false)
expect(result.current.canDebugger).toBe(false)
expect(result.current.referenceSetting).toBeNull()
expect(result.current.canInstall).toBe(true)
expect(result.current.canDebugger).toBe(true)
})
})
})
@ -365,14 +302,9 @@ describe('useCanInstallPluginFromMarketplace Hook', () => {
})
it('should return false when canInstall is false', () => {
vi.mocked(useReferenceSettings).mockReturnValue({
data: {
permission: {
install_permission: PermissionType.noOne,
debug_permission: PermissionType.noOne,
},
},
} as ReturnType<typeof useReferenceSettings>)
vi.mocked(useAppContext).mockReturnValue(createAppContext({
workspacePermissionKeys: [],
}))
const { result } = renderHook(() => useCanInstallPluginFromMarketplace(), {
systemFeatures: { enable_marketplace: true },

View File

@ -2,10 +2,6 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import {
RiArrowRightUpLine,
RiBugLine,
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useDocLink } from '@/context/i18n'
@ -28,7 +24,7 @@ const DebugInfo: FC = () => {
if (!info) {
return (
<Button className="size-full p-2 text-components-button-secondary-text" disabled>
<RiBugLine className="size-4" />
<span className="i-ri-bug-line size-4" />
</Button>
)
}
@ -38,7 +34,7 @@ const DebugInfo: FC = () => {
<PopoverTrigger
render={(
<Button className="size-full p-2 text-components-button-secondary-text">
<RiBugLine className="size-4" />
<span className="i-ri-bug-line size-4" />
</Button>
)}
/>
@ -57,7 +53,7 @@ const DebugInfo: FC = () => {
className="flex cursor-pointer items-center gap-0.5 text-text-accent-light-mode-only"
>
<span className="system-xs-medium">{t(`${i18nPrefix}.viewDocs`, { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="size-3" />
<span className="i-ri-arrow-right-up-line size-3" />
</a>
</div>
<div className="space-y-0.5">

View File

@ -6,27 +6,12 @@ import { useAppContext } from '@/context/app-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInvalidateReferenceSettings, useMutationReferenceSettings, useReferenceSettings } from '@/service/use-plugins'
import { hasPermission } from '@/utils/permission'
import { PermissionType } from '../types'
const hasPluginPermission = (permission: PermissionType | undefined, isAdmin: boolean) => {
if (!permission)
return false
if (permission === PermissionType.noOne)
return false
if (permission === PermissionType.everyone)
return true
return isAdmin
}
const useReferenceSetting = () => {
const { t } = useTranslation()
const { isCurrentWorkspaceManager, isCurrentWorkspaceOwner, langGeniusVersionInfo, workspacePermissionKeys } = useAppContext()
const { langGeniusVersionInfo, workspacePermissionKeys } = useAppContext()
const { data } = useReferenceSettings()
const { permission: permissions } = data || {}
const invalidateReferenceSettings = useInvalidateReferenceSettings()
const { mutate: updateReferenceSetting, isPending: isUpdatePending } = useMutationReferenceSettings({
onSuccess: () => {
@ -34,13 +19,13 @@ const useReferenceSetting = () => {
toast.success(t('api.actionSuccess', { ns: 'common' }))
},
})
const isAdmin = isCurrentWorkspaceManager || isCurrentWorkspaceOwner
const canInstallPluginByPermissionKey = hasPermission(workspacePermissionKeys, 'plugin.install')
const canUpdatePlugin = hasPermission(workspacePermissionKeys, ['plugin.install', 'plugin.manage'])
const canViewInstalledPlugins = canUpdatePlugin
const canManagePlugin = hasPermission(workspacePermissionKeys, 'plugin.manage')
const canUninstall = canManagePlugin
const canDebugger = hasPermission(workspacePermissionKeys, 'plugin.debug')
const canSetPermissions = hasPermission(workspacePermissionKeys, 'plugin.install')
const canSetAutoUpdate = hasPermission(workspacePermissionKeys, 'plugin.install')
const canSetPreferences = canSetPermissions || canSetAutoUpdate
@ -49,11 +34,11 @@ const useReferenceSetting = () => {
referenceSetting: data,
setReferenceSettings: updateReferenceSetting,
canViewInstalledPlugins,
canInstall: canInstallPluginByPermissionKey && hasPluginPermission(permissions?.install_permission, isAdmin),
canInstall: canInstallPluginByPermissionKey,
canUpdate: canUpdatePlugin,
canManagePlugin,
canUninstall,
canDebugger: hasPluginPermission(permissions?.debug_permission, isAdmin),
canDebugger,
canSetPermissions,
canSetAutoUpdate,
canSetPreferences,

View File

@ -9,7 +9,10 @@ import { PermissionType } from '@/app/components/plugins/types'
import { AUTO_UPDATE_MODE, AUTO_UPDATE_STRATEGY } from '../auto-update-setting/types'
import ReferenceSettingModal from '../index'
const mockSystemFeatures = { enable_marketplace: true }
const mockSystemFeatures = {
enable_marketplace: true,
rbac_enabled: false,
}
const render = (ui: ReactElement) =>
renderWithSystemFeatures(ui, { systemFeatures: mockSystemFeatures })
@ -37,19 +40,26 @@ vi.mock('@langgenius/dify-ui/dialog', () => ({
// Mock OptionCard component
vi.mock('@/app/components/workflow/nodes/_base/components/option-card', () => ({
default: ({ title, onSelect, selected, className }: {
default: ({ title, onSelect, selected, className, disabled, tooltip }: {
title: string
onSelect: () => void
selected: boolean
className?: string
disabled?: boolean
tooltip?: string
}) => (
<button
data-testid={`option-card-${title.toLowerCase().replace(/\s+/g, '-')}`}
onClick={onSelect}
onClick={() => {
if (!disabled)
onSelect()
}}
aria-pressed={selected}
disabled={disabled}
className={className}
>
{title}
{tooltip && <span>{tooltip}</span>}
</button>
),
}))
@ -124,6 +134,7 @@ describe('reference-setting-modal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSystemFeatures.enable_marketplace = true
mockSystemFeatures.rbac_enabled = false
})
// Label component tests moved to label.spec.tsx
@ -259,6 +270,16 @@ describe('reference-setting-modal', () => {
// Assert
expect(screen.getByTestId('modal-close'))!.toBeInTheDocument()
})
it('should disable permission controls with settings permissions tooltip beside titles when RBAC is enabled', () => {
mockSystemFeatures.rbac_enabled = true
render(<ReferenceSettingModal {...defaultProps} />)
expect(screen.getAllByLabelText('plugin.privilege.configurePermissionsInSettings')).toHaveLength(2)
expect(screen.queryByText('plugin.privilege.configurePermissionsInSettings')).not.toBeInTheDocument()
expect(screen.getAllByTestId(/option-card/).every(option => option.hasAttribute('disabled'))).toBe(true)
})
})
describe('State Management', () => {
@ -296,6 +317,16 @@ describe('reference-setting-modal', () => {
expect(noOneOptions[0])!.toHaveAttribute('aria-pressed', 'true')
})
it('should not update permission when RBAC disables permission controls', () => {
mockSystemFeatures.rbac_enabled = true
render(<ReferenceSettingModal {...defaultProps} />)
const noOneOptions = screen.getAllByTestId('option-card-plugin.privilege.noone')
fireEvent.click(noOneOptions[0]!)
expect(noOneOptions[0])!.toHaveAttribute('aria-pressed', 'false')
})
it('should initialize with payload auto_upgrade values', () => {
// Arrange
const payload = createMockReferenceSetting({

View File

@ -35,18 +35,19 @@ const PluginSettingModal: FC<Props> = ({
const { auto_upgrade: autoUpdateConfig, permission: privilege } = payload || {}
const [tempPrivilege, setTempPrivilege] = useState<Permissions>(privilege)
const [tempAutoUpdateConfig, setTempAutoUpdateConfig] = useState<AutoUpdateConfig>(autoUpdateConfig || autoUpdateDefaultValue)
const { data: enable_marketplace } = useSuspenseQuery({
...systemFeaturesQueryOptions(),
select: s => s.enable_marketplace,
})
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isPermissionDisabledByRBAC = systemFeatures.rbac_enabled
const permissionDisabledTip = t(`${i18nPrefix}.configurePermissionsInSettings`, { ns: 'plugin' })
const handlePrivilegeChange = useCallback((key: string) => {
return (value: PermissionType) => {
if (isPermissionDisabledByRBAC)
return
setTempPrivilege({
...tempPrivilege,
[key]: value,
})
}
}, [tempPrivilege])
}, [isPermissionDisabledByRBAC, tempPrivilege])
const handleSave = useCallback(async () => {
await onSave({
@ -78,7 +79,10 @@ const PluginSettingModal: FC<Props> = ({
{ title: t(`${i18nPrefix}.whoCanDebug`, { ns: 'plugin' }), key: 'debug_permission', value: tempPrivilege?.debug_permission || PermissionType.noOne },
].map(({ title, key, value }) => (
<div key={key} className="flex flex-col items-start gap-1 self-stretch">
<Label label={title} />
<Label
label={title}
tooltip={isPermissionDisabledByRBAC ? permissionDisabledTip : undefined}
/>
<div className="flex w-full items-start justify-between gap-2">
{[PermissionType.everyone, PermissionType.admin, PermissionType.noOne].map(option => (
<OptionCard
@ -86,6 +90,7 @@ const PluginSettingModal: FC<Props> = ({
title={t(`${i18nPrefix}.${option}`, { ns: 'plugin' })}
onSelect={() => handlePrivilegeChange(key)(option)}
selected={value === option}
disabled={isPermissionDisabledByRBAC}
className="flex-1"
/>
))}
@ -95,7 +100,7 @@ const PluginSettingModal: FC<Props> = ({
</div>
)}
{
enable_marketplace && canSetAutoUpdate && (
systemFeatures.enable_marketplace && canSetAutoUpdate && (
<AutoUpdateSetting payload={tempAutoUpdateConfig} onChange={setTempAutoUpdateConfig} />
)
}

View File

@ -1,21 +1,41 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import * as React from 'react'
type Props = {
label: string
description?: string
tooltip?: string
}
const Label: FC<Props> = ({
label,
description,
tooltip,
}) => {
const tooltipIcon = (
<span
aria-label={tooltip}
className="ml-1 flex size-4 shrink-0 cursor-pointer items-center justify-center"
>
<span aria-hidden className="i-ri-question-line size-3.5 text-text-quaternary" />
</span>
)
return (
<div>
<div className={cn('flex h-6 items-center', description && 'h-4')}>
<span className="system-sm-semibold text-text-secondary">{label}</span>
{tooltip && (
<Tooltip>
<TooltipTrigger render={tooltipIcon} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)}
</div>
{description && (
<div className="mt-1 body-xs-regular text-text-tertiary">

View File

@ -37,6 +37,7 @@
"page.datasets.access": "Access Datasets page",
"page.explore.access": "Access Explore page",
"page.tool.access": "Access Tool page",
"plugin.debug": "Debug plugins",
"plugin.install": "Install and update plugins",
"plugin.manage": "Manage plugins",
"tool.manage": "Manage tools",

View File

@ -213,6 +213,7 @@
"pluginInfoModal.repository": "Repository",
"pluginInfoModal.title": "Plugin info",
"privilege.admins": "Admins",
"privilege.configurePermissionsInSettings": "Go to Settings > Permissions to configure plugin permissions.",
"privilege.everyone": "Everyone",
"privilege.noone": "No one",
"privilege.title": "Plugin Preferences",

View File

@ -37,6 +37,7 @@
"page.datasets.access": "Datasets ページにアクセス",
"page.explore.access": "Explore ページにアクセス",
"page.tool.access": "Tool ページにアクセス",
"plugin.debug": "プラグインをデバッグ",
"plugin.install": "プラグインのインストールと更新",
"plugin.manage": "プラグインを管理",
"tool.manage": "ツールを管理",

View File

@ -37,6 +37,7 @@
"page.datasets.access": "访问 Datasets 页面",
"page.explore.access": "访问 Explore 页面",
"page.tool.access": "访问 Tool 页面",
"plugin.debug": "调试插件",
"plugin.install": "安装与更新插件",
"plugin.manage": "管理插件",
"tool.manage": "管理工具",

View File

@ -213,6 +213,7 @@
"pluginInfoModal.repository": "仓库",
"pluginInfoModal.title": "插件信息",
"privilege.admins": "管理员",
"privilege.configurePermissionsInSettings": "前往设置 > 权限中配置插件权限。",
"privilege.everyone": "所有人",
"privilege.noone": "无人",
"privilege.title": "插件偏好",

View File

@ -37,6 +37,7 @@
"page.datasets.access": "訪問 Datasets 頁面",
"page.explore.access": "訪問 Explore 頁面",
"page.tool.access": "訪問 Tool 頁面",
"plugin.debug": "調試插件",
"plugin.install": "安裝與更新插件",
"plugin.manage": "管理插件",
"tool.manage": "管理工具",