diff --git a/README.md b/README.md index 3cbaaa3..96c629c 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,36 @@ localazy: { }, ``` +## πŸ” Access control (RBAC) + +The plugin registers four Strapi permission actions, all visible under +**Settings β†’ Administration Panel β†’ Roles β†’ Plugins β†’ Localazy** (grouped +by `General` and `Settings` sub-categories): + +| Action | Unlocks | +| ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Localazy β†’ Read` (`plugin::localazy.read`) | Localazy menu link, Overview, Activity Logs (list + detail), Content-Manager side panel & Localazy status column, read endpoints (identity, project, models, plugin settings, **content-transfer-setup**, sync cursor, activity logs, troubleshooting bundle, entry-exclusion state). | +| `Localazy β†’ Transfer` (`plugin::localazy.transfer`) | Upload, Download, Entry Exclusion mutations (incl. Content-Manager bulk actions), Activity Log session clearing. Reading the content-transfer-setup is bundled under `Read` above so transfer-only roles can still open the Upload/Download pages. | +| `Localazy β†’ Settings β†’ Read` (`plugin::localazy.settings.read`) | Adds the Localazy Settings menu links (Global Settings, Content Transfer Setup) to the admin sidebar. Form controls render read-only β€” Save, Cancel, the Content Transfer Setup tree, and the webhook setup buttons are disabled without `Settings β†’ Update`. | +| `Localazy β†’ Settings β†’ Update` (`plugin::localazy.settings.update`) | **Connecting / disconnecting the Localazy account**, webhook setup, updating Content Transfer Setup, and updating Global Settings. | + +> **Note:** the **Webhook author** picker in _Localazy Settings β†’ Global +> Settings_ populates from Strapi's core `/admin/users` endpoint, which is +> gated by Strapi's own `admin::users.read` permission β€” independent of the +> plugin's actions. A role with only the plugin's `Settings β†’ Read` will +> still see the rest of the page; the author dropdown silently falls back +> to an empty list. + +Server is the enforcement perimeter β€” all admin-typed plugin routes are +gated by `admin::hasPermissions`, so UI gates are convenience only. + +### Upgrade note + +Strapi grants new actions to the **Super Admin** role automatically. **Other +roles (Editor, Author, custom roles) keep no Localazy access until an admin +re-grants the actions** under _Settings β†’ Administration Panel β†’ Roles β†’ +Plugins β†’ Localazy_. Plan this step when upgrading from `<= 1.4.x`. + ## πŸ›Ÿ Support If you encounter any issues or have questions, feel free to contact us through whichever channel suits you best: diff --git a/admin/src/components/LocalazyPanel.tsx b/admin/src/components/LocalazyPanel.tsx index 1bb1edc..c186301 100644 --- a/admin/src/components/LocalazyPanel.tsx +++ b/admin/src/components/LocalazyPanel.tsx @@ -4,13 +4,17 @@ import { useTranslation } from 'react-i18next'; import type { PanelComponent } from '@strapi/content-manager/strapi-admin'; import { Typography, Toggle } from '@strapi/design-system'; +import { useRBAC } from '@strapi/strapi/admin'; import EntryExclusionService from '../modules/entry-exclusion/services/entry-exclusion-service'; +import { PERMISSIONS } from '../constants/permissions'; import '../i18n'; const LocalazyPanel: PanelComponent = () => { const { t } = useTranslation(); + const { allowedActions } = useRBAC([...PERMISSIONS.READ, ...PERMISSIONS.TRANSFER]); + const location = useLocation(); const [isExcluded, setIsExcluded] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -41,7 +45,7 @@ const LocalazyPanel: PanelComponent = () => { // Load the current exclusion state when we have the entry information useEffect(() => { const loadExclusionState = async () => { - if (!contentType || !documentId) { + if (!contentType || !documentId || !allowedActions.canRead) { setIsLoading(false); return; } @@ -59,7 +63,11 @@ const LocalazyPanel: PanelComponent = () => { }; void loadExclusionState(); - }, [contentType, documentId]); + }, [contentType, documentId, allowedActions.canRead]); + + if (!allowedActions.canRead) { + return null; + } // Handle toggle change const handleToggleChange = async (event: React.ChangeEvent) => { @@ -92,7 +100,7 @@ const LocalazyPanel: PanelComponent = () => { checked={isExcluded} onLabel='True' offLabel='False' - disabled={isLoading || !contentType || !documentId} + disabled={isLoading || !contentType || !documentId || !allowedActions.canTransfer} onChange={handleToggleChange} /> diff --git a/admin/src/components/LocalazyStatusColumn.tsx b/admin/src/components/LocalazyStatusColumn.tsx index b4b0da9..04f2720 100644 --- a/admin/src/components/LocalazyStatusColumn.tsx +++ b/admin/src/components/LocalazyStatusColumn.tsx @@ -1,20 +1,23 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useTheme } from 'styled-components'; +import { useRBAC } from '@strapi/strapi/admin'; import EntryExclusionService from '../modules/entry-exclusion/services/entry-exclusion-service'; +import { PERMISSIONS } from '../constants/permissions'; import '../i18n'; // TODO: define props interface const LocalazyStatusColumn = ({ data, model }: any) => { const { t } = useTranslation(); const theme = useTheme(); + const { allowedActions } = useRBAC(PERMISSIONS.READ); const [isExcluded, setIsExcluded] = useState(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { const checkStatus = async () => { - if (!data?.documentId || !model) { + if (!data?.documentId || !model || !allowedActions.canRead) { setIsLoading(false); return; } @@ -31,7 +34,11 @@ const LocalazyStatusColumn = ({ data, model }: any) => { }; void checkStatus(); - }, [data?.documentId, model]); + }, [data?.documentId, model, allowedActions.canRead]); + + if (!allowedActions.canRead) { + return null; + } if (isLoading) { return ...; diff --git a/admin/src/constants/permissions.ts b/admin/src/constants/permissions.ts new file mode 100644 index 0000000..bc64d51 --- /dev/null +++ b/admin/src/constants/permissions.ts @@ -0,0 +1,35 @@ +import { PLUGIN_ID } from '../pluginId'; + +/** + * Mirror of `server/src/constants/permissions.ts`. Admin code can't import from + * `server/`, so any rename must be applied in both files. + * + * `useRBAC` derives the boolean it returns under `allowedActions` from the LAST + * dotted segment of each UID, lowercased and prefixed with `can`: + * + * plugin::localazy.read β†’ canRead + * plugin::localazy.transfer β†’ canTransfer + * plugin::localazy.settings.read β†’ canRead (collides with READ above!) + * plugin::localazy.settings.update β†’ canUpdate + * + * Because `settings.read` and `read` both collapse to `canRead`, never pass + * both into the same `useRBAC` call β€” issue separate calls per distinct UID + * you need to check (one call returns one `canX` boolean per unique segment). + */ +export const PERMISSION_UIDS = { + READ: `plugin::${PLUGIN_ID}.read`, + TRANSFER: `plugin::${PLUGIN_ID}.transfer`, + SETTINGS_READ: `plugin::${PLUGIN_ID}.settings.read`, + SETTINGS_UPDATE: `plugin::${PLUGIN_ID}.settings.update`, +} as const; + +export type PermissionUid = (typeof PERMISSION_UIDS)[keyof typeof PERMISSION_UIDS]; + +type PermissionDescriptor = { action: PermissionUid; subject: null }; + +export const PERMISSIONS: Record = { + READ: [{ action: PERMISSION_UIDS.READ, subject: null }], + TRANSFER: [{ action: PERMISSION_UIDS.TRANSFER, subject: null }], + SETTINGS_READ: [{ action: PERMISSION_UIDS.SETTINGS_READ, subject: null }], + SETTINGS_UPDATE: [{ action: PERMISSION_UIDS.SETTINGS_UPDATE, subject: null }], +}; diff --git a/admin/src/index.ts b/admin/src/index.ts index 5738855..ed68625 100644 --- a/admin/src/index.ts +++ b/admin/src/index.ts @@ -1,6 +1,7 @@ import { PLUGIN_ID } from './pluginId'; import { Initializer } from './components/Initializer'; import { Localazy } from './modules/@common/components/Icons/Localazy'; +import { PERMISSIONS } from './constants/permissions'; import React from 'react'; export default { @@ -81,7 +82,7 @@ export default { }); if (contentManagerPlugin?.apis?.addBulkAction) { - const { useNotification } = await import('@strapi/strapi/admin'); + const { useNotification, useRBAC } = await import('@strapi/strapi/admin'); await import('./i18n'); const { useTranslation } = await import('react-i18next'); @@ -89,6 +90,11 @@ export default { const { documents, model } = props; const { toggleNotification } = useNotification(); const { t } = useTranslation(); + const { allowedActions } = useRBAC(PERMISSIONS.TRANSFER); + + if (!allowedActions.canTransfer) { + return null; + } return { label: t('plugin_settings.bulk_action_exclude_from_translation'), @@ -133,6 +139,11 @@ export default { const IncludeToTranslationAction = ({ documents, model }: any) => { const { toggleNotification } = useNotification(); const { t } = useTranslation(); + const { allowedActions } = useRBAC(PERMISSIONS.TRANSFER); + + if (!allowedActions.canTransfer) { + return null; + } return { label: t('plugin_settings.bulk_action_include_to_translation'), @@ -194,6 +205,7 @@ const addMenuLink = (app: any) => { defaultMessage: 'Localazy', }, Component: () => import('./pages/LocalazyApp'), + permissions: PERMISSIONS.READ, }); }; @@ -216,7 +228,7 @@ const addSettingsSection = (app: any) => { }, to: `${PLUGIN_ID}/global-settings`, Component: () => import('./pages/LocalazyGlobalSettings'), - permissions: [], + permissions: PERMISSIONS.SETTINGS_READ, }, // Content Transfer Setup { @@ -227,7 +239,7 @@ const addSettingsSection = (app: any) => { }, to: `${PLUGIN_ID}/content-transfer-setup`, Component: () => import('./pages/LocalazyContentTransferSetup'), - permissions: [], + permissions: PERMISSIONS.SETTINGS_READ, }, ] ); diff --git a/admin/src/modules/@common/components/LanguagesSelector.tsx b/admin/src/modules/@common/components/LanguagesSelector.tsx index d0349bb..39e76e7 100644 --- a/admin/src/modules/@common/components/LanguagesSelector.tsx +++ b/admin/src/modules/@common/components/LanguagesSelector.tsx @@ -11,6 +11,7 @@ interface LanguageSelectorProps { projectLanguages: any[]; preselectedLanguages: any[]; onChange: (values: any) => void; + disabled?: boolean; } const LanguagesSelector: React.FC = (props: LanguageSelectorProps) => { @@ -46,6 +47,7 @@ const LanguagesSelector: React.FC = (props: LanguageSelec onClear={() => setSelectedLanguages([])} value={selectedLanguages || []} onChange={(values: any) => onChange(values)} + disabled={props.disabled} multi withTags > diff --git a/admin/src/modules/login/components/LoginButton.tsx b/admin/src/modules/login/components/LoginButton.tsx index 5fbd22b..8ecc13a 100644 --- a/admin/src/modules/login/components/LoginButton.tsx +++ b/admin/src/modules/login/components/LoginButton.tsx @@ -1,13 +1,15 @@ import { useState } from 'react'; import PropTypes from 'prop-types'; -import { Button } from '@strapi/design-system'; +import { Button, Tooltip } from '@strapi/design-system'; import { useTranslation } from 'react-i18next'; +import { useRBAC } from '@strapi/strapi/admin'; import { getOAuthAuthorizationUrl } from '@localazy/generic-connector-client'; import LocalazyLoginService from '../services/localazy-login-service'; import { getStrapiDefaultLocale } from '../../@common/utils/get-default-locale'; import { isoStrapiToLocalazy } from '../../@common/utils/iso-locales-utils'; import config from '../../../config'; import { LocalazyIdentity } from '../../user/model/localazy-identity'; +import { PERMISSIONS } from '../../../constants/permissions'; import { Locales } from '@localazy/api-client'; interface LoginButtonProps { @@ -16,6 +18,8 @@ interface LoginButtonProps { const LoginButton = (props: LoginButtonProps) => { const { t } = useTranslation(); + const { allowedActions } = useRBAC(PERMISSIONS.SETTINGS_UPDATE); + const canConnect = !!allowedActions.canUpdate; const [isLoading, setIsLoading] = useState(false); const login = async () => { @@ -42,11 +46,21 @@ const LoginButton = (props: LoginButtonProps) => { props.onResultFetched(pollResult); }; + const button = ( + + ); + return (
- + {canConnect ? ( + button + ) : ( + + {button} + + )}
); }; diff --git a/admin/src/modules/login/components/LogoutButton.tsx b/admin/src/modules/login/components/LogoutButton.tsx index abe1df4..3bbd8ff 100644 --- a/admin/src/modules/login/components/LogoutButton.tsx +++ b/admin/src/modules/login/components/LogoutButton.tsx @@ -1,10 +1,12 @@ import { useState } from 'react'; -import { Button } from '@strapi/design-system'; +import { Button, Tooltip } from '@strapi/design-system'; import { useTranslation } from 'react-i18next'; import { SignOut } from '@strapi/icons'; +import { useRBAC } from '@strapi/strapi/admin'; import LocalazyUserService from '../../user/services/localazy-user-service'; import { useLocalazyIdentity } from '../../../state/localazy-identity'; import { emptyIdentity } from '../../user/model/localazy-identity'; +import { PERMISSIONS } from '../../../constants/permissions'; interface LogoutButtonProps { onResultFetched: () => void; @@ -14,6 +16,8 @@ const LogoutButton: React.FC = (props) => { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); const { setIdentity } = useLocalazyIdentity(); + const { allowedActions } = useRBAC(PERMISSIONS.SETTINGS_UPDATE); + const canDisconnect = !!allowedActions.canUpdate; const logout = async () => { setIsLoading(true); @@ -28,11 +32,21 @@ const LogoutButton: React.FC = (props) => { props.onResultFetched(); }; + const button = ( + + ); + return (
- + {canDisconnect ? ( + button + ) : ( + + {button} + + )}
); }; diff --git a/admin/src/modules/login/locale/en.ts b/admin/src/modules/login/locale/en.ts index 968de6f..2a72fcc 100644 --- a/admin/src/modules/login/locale/en.ts +++ b/admin/src/modules/login/locale/en.ts @@ -5,4 +5,6 @@ export default { 'You have to own a Localazy account for the plugin to work properly.', read_the_documentation: 'Read the documentation', logout_from_localazy: 'Disconnect from Localazy', + requires_settings_update_permission: + 'Requires Localazy "settings.update" permission. Ask an admin to grant it under Settings β†’ Roles β†’ Plugins β†’ Localazy.', }; diff --git a/admin/src/modules/plugin-settings/components/Tree.tsx b/admin/src/modules/plugin-settings/components/Tree.tsx index 7507dbc..072ce3e 100644 --- a/admin/src/modules/plugin-settings/components/Tree.tsx +++ b/admin/src/modules/plugin-settings/components/Tree.tsx @@ -14,9 +14,10 @@ interface TreeProps { objects: any[]; onTreeItemClick: (key: string[], currentValue: boolean) => void; initiallyExpanded: boolean; + disabled?: boolean; } -const Tree: React.FC = ({ objects, onTreeItemClick, initiallyExpanded = false }) => { +const Tree: React.FC = ({ objects, onTreeItemClick, initiallyExpanded = false, disabled = false }) => { const { t } = useTranslation(); const [, setIsExpanded] = useState(initiallyExpanded); @@ -82,7 +83,7 @@ const Tree: React.FC = ({ objects, onTreeItemClick, initiallyExpanded {isSubbranchObject && ( onTreeItemClick(flattenedKeys, hasTruthyValue)} > @@ -96,7 +97,7 @@ const Tree: React.FC = ({ objects, onTreeItemClick, initiallyExpanded )} - + {createTree(key, value, passedKey)} @@ -106,7 +107,16 @@ const Tree: React.FC = ({ objects, onTreeItemClick, initiallyExpanded ); } - return ; + return ( + + ); }; return ( @@ -127,6 +137,7 @@ const Tree: React.FC = ({ objects, onTreeItemClick, initiallyExpanded label={`switch_tree_${key}`} onCheckedChange={() => onTreeItemClick([`${key}.__model__`], value.__model__)} checked={!!value.__model__} + disabled={disabled} style={{ marginRight: '1rem' }} /> diff --git a/admin/src/modules/plugin-settings/components/TreeItem.tsx b/admin/src/modules/plugin-settings/components/TreeItem.tsx index 75dc021..db0825e 100644 --- a/admin/src/modules/plugin-settings/components/TreeItem.tsx +++ b/admin/src/modules/plugin-settings/components/TreeItem.tsx @@ -7,9 +7,17 @@ interface TreeItemProps { children?: any; passedKey: string; onTreeItemClick: (key: any, currentValue: any) => void; + disabled?: boolean; } -const TreeItem: React.FC = ({ label, value, children, passedKey = '', onTreeItemClick }) => { +const TreeItem: React.FC = ({ + label, + value, + children, + passedKey = '', + onTreeItemClick, + disabled = false, +}) => { const onChange = (key: any, currentValue: any) => { onTreeItemClick(key, currentValue); }; @@ -17,7 +25,7 @@ const TreeItem: React.FC = ({ label, value, children, passedKey = return ( <> {typeof value === 'boolean' && ( - onChange([passedKey], value)}> + onChange([passedKey], value)}> {label || '-'} )} diff --git a/admin/src/modules/plugin-settings/components/WebhookSetup.tsx b/admin/src/modules/plugin-settings/components/WebhookSetup.tsx index 71f4fd3..99baa48 100644 --- a/admin/src/modules/plugin-settings/components/WebhookSetup.tsx +++ b/admin/src/modules/plugin-settings/components/WebhookSetup.tsx @@ -9,7 +9,11 @@ const LOCALAZY_DOCS_URL = 'https://localazy.com/docs/general/webhooks'; type WebhookState = 'loading' | 'configured' | 'not_configured'; -function WebhookSetup() { +interface WebhookSetupProps { + disabled?: boolean; +} + +function WebhookSetup({ disabled = false }: WebhookSetupProps = {}) { const { t } = useTranslation(); const [state, setState] = useState('loading'); @@ -98,7 +102,7 @@ function WebhookSetup() { - @@ -117,7 +121,9 @@ function WebhookSetup() { {t('plugin_settings.webhook_setup_description')} - + }> {t('plugin_settings.webhook_setup_docs_link')} diff --git a/admin/src/modules/plugin-settings/locale/en.ts b/admin/src/modules/plugin-settings/locale/en.ts index 88779d8..a871291 100644 --- a/admin/src/modules/plugin-settings/locale/en.ts +++ b/admin/src/modules/plugin-settings/locale/en.ts @@ -32,6 +32,9 @@ export default { webhook_author_info: 'Select the author signed under the webhook actions (required by Strapi for certain actions, e.g. creating a locale).', webhook_author_placeholder: 'Select webhook actions author', + webhook_author_permission_required: + 'You need the Strapi β€œUsers and Roles β†’ Users β†’ Read” permission (admin::users.read) to view and pick a webhook author.', + webhook_author_unknown_user: 'User #{{id}}', webhook_languages: 'Allowed webhook languages', webhook_languages_info: 'Select the languages from Localazy. Translations of selected languages would be downloaded. If no language is selected, all translations will be downloaded.', diff --git a/admin/src/modules/plugin-settings/services/plugin-settings-service.ts b/admin/src/modules/plugin-settings/services/plugin-settings-service.ts index dd23c4a..0607457 100644 --- a/admin/src/modules/plugin-settings/services/plugin-settings-service.ts +++ b/admin/src/modules/plugin-settings/services/plugin-settings-service.ts @@ -46,6 +46,19 @@ export default class PluginSettingsService { } } + // Read-gated counterpart for per-user UI prefs (last visited route, sort + // prefs). Server filters the body to a fixed allowlist; sending anything else + // is silently dropped, so callers must only pass UI-pref fields here. + static async updatePluginSettingsUiPrefs(data: { defaultRoute?: string; activityLogsSort?: any }) { + try { + const result = await axiosInstance.put(`${BASE_PATH}/ui-prefs`, data); + + return result.data; + } catch (e) { + throw e; + } + } + static async getSyncCursor() { try { const result = await axiosInstance.get(`${BASE_PATH}/sync-cursor`); diff --git a/admin/src/pages/ActivityLogs.tsx b/admin/src/pages/ActivityLogs.tsx index 8b98d06..2e546fb 100644 --- a/admin/src/pages/ActivityLogs.tsx +++ b/admin/src/pages/ActivityLogs.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Layouts } from '@strapi/strapi/admin'; +import { Layouts, useRBAC } from '@strapi/strapi/admin'; import { useTranslation } from 'react-i18next'; import { Box, Flex, Button, Typography, Tabs, Dialog, Alert, Field, DatePicker } from '@strapi/design-system'; import { Trash, Download, Information } from '@strapi/icons'; @@ -11,6 +11,7 @@ import SessionsTable from '../modules/activity-logs/components/SessionsTable'; import PluginSettingsService from '../modules/plugin-settings/services/plugin-settings-service'; import { PLUGIN_ID } from '../pluginId'; import { PLUGIN_ROUTES } from '../modules/@common/utils/redirect-to-plugin-route'; +import { PERMISSIONS } from '../constants/permissions'; import useDebouncedSearch from '../modules/activity-logs/hooks/use-debounced-search'; import type { SessionItem, SortKey, SortDirection } from '../modules/activity-logs/models/activity-logs'; @@ -22,6 +23,9 @@ export type ActivityLogsProps = { const ActivityLogs: React.FC = (props) => { const { t } = useTranslation(); const navigate = useNavigate(); + const { + allowedActions: { canTransfer }, + } = useRBAC(PERMISSIONS.TRANSFER); const [isLoading, setIsLoading] = useState(true); const [sessions, setSessions] = useState([]); @@ -38,7 +42,7 @@ const ActivityLogs: React.FC = (props) => { const debouncedSaveSortPrefs = useCallback((prefs: Record) => { if (saveSortTimerRef.current) clearTimeout(saveSortTimerRef.current); saveSortTimerRef.current = setTimeout(() => { - void PluginSettingsService.updatePluginSettings({ activityLogsSort: prefs }); + void PluginSettingsService.updatePluginSettingsUiPrefs({ activityLogsSort: prefs }); }, 1000); }, []); @@ -102,7 +106,7 @@ const ActivityLogs: React.FC = (props) => { void fetchSessions('upload'); }; void init(); - void PluginSettingsService.updatePluginSettings({ defaultRoute: PLUGIN_ROUTES.ACTIVITY_LOGS }); + void PluginSettingsService.updatePluginSettingsUiPrefs({ defaultRoute: PLUGIN_ROUTES.ACTIVITY_LOGS }); }, []); return ( @@ -115,7 +119,12 @@ const ActivityLogs: React.FC = (props) => { - diff --git a/admin/src/pages/App.tsx b/admin/src/pages/App.tsx index 884ab1d..c16a727 100644 --- a/admin/src/pages/App.tsx +++ b/admin/src/pages/App.tsx @@ -8,13 +8,14 @@ import PluginSettingsService from '../modules/plugin-settings/services/plugin-se import { useHeaderTitle } from '../modules/@common/utils/use-header-title'; import { useHeaderSubtitle } from '../modules/@common/utils/use-header-subtitle'; import Login from './Login'; -import { Layouts, Page } from '@strapi/strapi/admin'; +import { Layouts, Page, useRBAC } from '@strapi/strapi/admin'; import SideNav from '../modules/@common/components/SideNav'; import Overview from './Overview'; import { getDefaultTheme } from '../modules/strapi/utils/get-default-theme'; import Loader from '../modules/@common/components/PluginPageLoader'; import { HeadingFixGlobalStyle } from '../modules/@common/styles/heading-fix'; import PluginErrorBoundary from '../components/PluginErrorBoundary'; +import { PERMISSIONS } from '../constants/permissions'; // import and load resources import '../i18n'; @@ -31,6 +32,11 @@ const App = () => { const headerTitle = useHeaderTitle(); const headerSubtitle = useHeaderSubtitle(); + const { + allowedActions: { canRead, canTransfer }, + isLoading: isLoadingPermissions, + } = useRBAC([...PERMISSIONS.READ, ...PERMISSIONS.TRANSFER]); + const normalizedPath = location.pathname.replace(/\/+$/, ''); const pluginRoot = `/plugins/${PLUGIN_ID}`; const isAtPluginRoot = normalizedPath === pluginRoot; @@ -56,6 +62,34 @@ const App = () => { void fetchData(); }, [isLoggedIn, isFetchingIdentity, normalizedPath]); + if (isLoadingPermissions) { + return ( + + + + + + + + + ); + } + + if (!canRead) { + return ( + + + + + + + + + ); + } + + const transferElement = (page: JSX.Element) => (canTransfer ? page : ); + return ( @@ -72,8 +106,14 @@ const App = () => { path={`overview`} element={} /> - } /> - } /> + )} + /> + )} + /> } /> { */ const { t } = useTranslation(); + /** + * Settings.update gate β€” read-only for users without it. + */ + const { + allowedActions: { canUpdate: canUpdateSettings }, + } = useRBAC(PERMISSIONS.SETTINGS_UPDATE); + /** * Component state */ @@ -175,12 +183,12 @@ const ContentTransferSetup: React.FC = () => { subtitle={t('plugin_settings.content_transfer_setup_description')} primaryAction={ -