From 00e598b3658417f9fbaaf137e84b64a79dc4bb33 Mon Sep 17 00:00:00 2001 From: Jono M Date: Thu, 14 Sep 2023 08:48:07 +0100 Subject: [PATCH] Updated AdminX features for different roles (#18131) refs https://github.com/TryGhost/Product/issues/3832 --- This pull request introduces a new `MainContent` component that handles the role-based access and rendering of the settings page and the sidebar. It also refactors and improves the UI and logic of the `UserDetailModal` and the `Users` components, and updates the footer component to use the new settings page and profile modal for editors. Additionally, it removes unused code and adds new helper functions for checking the user's roles and permissions. --- apps/admin-x-settings/src/App.tsx | 27 +--- apps/admin-x-settings/src/MainContent.tsx | 67 ++++++++++ apps/admin-x-settings/src/api/users.ts | 16 +++ .../src/components/Settings.tsx | 19 +-- .../src/components/Sidebar.tsx | 8 +- .../settings/general/UserDetailModal.tsx | 122 ++++++++++-------- .../src/components/settings/general/Users.tsx | 25 +++- ghost/admin/.lint-todo | 1 + .../app/components/gh-nav-menu/footer.hbs | 17 ++- 9 files changed, 189 insertions(+), 113 deletions(-) create mode 100644 apps/admin-x-settings/src/MainContent.tsx diff --git a/apps/admin-x-settings/src/App.tsx b/apps/admin-x-settings/src/App.tsx index 8668dc5db0..0f1d39811c 100644 --- a/apps/admin-x-settings/src/App.tsx +++ b/apps/admin-x-settings/src/App.tsx @@ -1,10 +1,7 @@ -import ExitSettingsButton from './components/ExitSettingsButton'; import GlobalDataProvider from './components/providers/GlobalDataProvider'; -import Heading from './admin-x-ds/global/Heading'; +import MainContent from './MainContent'; import NiceModal from '@ebay/nice-modal-react'; import RoutingProvider, {ExternalLink} from './components/providers/RoutingProvider'; -import Settings from './components/Settings'; -import Sidebar from './components/Sidebar'; import clsx from 'clsx'; import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState'; import {OfficialTheme, ServicesProvider} from './components/providers/ServiceProvider'; @@ -49,27 +46,7 @@ function App({ghostVersion, officialThemes, zapierTemplates, externalNavigate, d > -
- -
- - {/* Main container */} -
- - {/* Sidebar */} -
-
- Settings -
-
- -
-
-
- {/*
*/} - -
-
+
diff --git a/apps/admin-x-settings/src/MainContent.tsx b/apps/admin-x-settings/src/MainContent.tsx new file mode 100644 index 0000000000..428fdb1673 --- /dev/null +++ b/apps/admin-x-settings/src/MainContent.tsx @@ -0,0 +1,67 @@ +import ExitSettingsButton from './components/ExitSettingsButton'; +import Heading from './admin-x-ds/global/Heading'; +import Settings from './components/Settings'; +import Sidebar from './components/Sidebar'; +import Users from './components/settings/general/Users'; +import useRouting from './hooks/useRouting'; +import {ReactNode, useEffect} from 'react'; +import {canAccessSettings, isEditorUser} from './api/users'; +import {useGlobalData} from './components/providers/GlobalDataProvider'; + +const Page: React.FC<{children: ReactNode}> = ({children}) => { + return <> +
+ +
+ +
+ {children} +
+ ; +}; + +const MainContent: React.FC = () => { + const {currentUser} = useGlobalData(); + const {route, updateRoute} = useRouting(); + + useEffect(() => { + if (!canAccessSettings(currentUser) && route !== `users/show/${currentUser.slug}`) { + updateRoute(`users/show/${currentUser.slug}`); + } + }, [currentUser, route, updateRoute]); + + if (!canAccessSettings(currentUser)) { + return null; + } + + if (isEditorUser(currentUser)) { + return ( + +
+ Settings + +
+
+ ); + } + + return ( + + {/* Sidebar */} +
+
+ Settings +
+
+ +
+
+
+ {/*
*/} + +
+
+ ); +}; + +export default MainContent; diff --git a/apps/admin-x-settings/src/api/users.ts b/apps/admin-x-settings/src/api/users.ts index 0aedc76b9c..00b294bfca 100644 --- a/apps/admin-x-settings/src/api/users.ts +++ b/apps/admin-x-settings/src/api/users.ts @@ -145,3 +145,19 @@ export function isAdminUser(user: User) { export function isEditorUser(user: User) { return user.roles.some(role => role.name === 'Editor'); } + +export function isAuthorUser(user: User) { + return user.roles.some(role => role.name === 'Author'); +} + +export function isContributorUser(user: User) { + return user.roles.some(role => role.name === 'Contributor'); +} + +export function canAccessSettings(user: User) { + return isOwnerUser(user) || isAdminUser(user) || isEditorUser(user); +} + +export function hasAdminAccess(user: User) { + return isOwnerUser(user) || isAdminUser(user); +} diff --git a/apps/admin-x-settings/src/components/Settings.tsx b/apps/admin-x-settings/src/components/Settings.tsx index f15cc391b9..d3c711dc84 100644 --- a/apps/admin-x-settings/src/components/Settings.tsx +++ b/apps/admin-x-settings/src/components/Settings.tsx @@ -5,24 +5,15 @@ import EmailSettings from './settings/email/EmailSettings'; import GeneralSettings from './settings/general/GeneralSettings'; import MembershipSettings from './settings/membership/MembershipSettings'; import SiteSettings from './settings/site/SiteSettings'; -import Users from './settings/general/Users'; -import {isEditorUser} from '../api/users'; -import {useGlobalData} from './providers/GlobalDataProvider'; const Settings: React.FC = () => { - const {currentUser} = useGlobalData(); - return (
- {isEditorUser(currentUser) ? - - : <> - - - - - - } + + + + +
Click here to open the original Admin settings.
diff --git a/apps/admin-x-settings/src/components/Sidebar.tsx b/apps/admin-x-settings/src/components/Sidebar.tsx index 26270ca56c..354f5eb3b7 100644 --- a/apps/admin-x-settings/src/components/Sidebar.tsx +++ b/apps/admin-x-settings/src/components/Sidebar.tsx @@ -9,7 +9,6 @@ import {searchKeywords as advancedSearchKeywords} from './settings/advanced/Adva import {searchKeywords as emailSearchKeywords} from './settings/email/EmailSettings'; import {searchKeywords as generalSearchKeywords} from './settings/general/GeneralSettings'; import {getSettingValues} from '../api/settings'; -import {isEditorUser} from '../api/users'; import {searchKeywords as membershipSearchKeywords} from './settings/membership/MembershipSettings'; import {searchKeywords as siteSearchKeywords} from './settings/site/SiteSettings'; import {useGlobalData} from './providers/GlobalDataProvider'; @@ -19,7 +18,7 @@ const Sidebar: React.FC = () => { const {filter, setFilter} = useSearch(); const {updateRoute} = useRouting(); - const {settings, config, currentUser} = useGlobalData(); + const {settings, config} = useGlobalData(); const [newslettersEnabled] = getSettingValues(settings, ['editor_default_email_recipients']) as [string]; const handleSectionClick = (e: React.MouseEvent) => { @@ -38,11 +37,6 @@ const Sidebar: React.FC = () => { } }; - // Editors can only see staff settings, so no point in showing navigation - if (isEditorUser(currentUser)) { - return null; - } - return (
diff --git a/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx b/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx index 4fcc120d74..feb9a7a0b3 100644 --- a/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx +++ b/apps/admin-x-settings/src/components/settings/general/UserDetailModal.tsx @@ -8,7 +8,7 @@ import Menu, {MenuItem} from '../../../admin-x-ds/global/Menu'; import Modal from '../../../admin-x-ds/global/modal/Modal'; import NiceModal, {useModal} from '@ebay/nice-modal-react'; import Radio from '../../../admin-x-ds/global/form/Radio'; -import React, {useEffect, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent'; import TextArea from '../../../admin-x-ds/global/form/TextArea'; @@ -21,7 +21,7 @@ import useStaffUsers from '../../../hooks/useStaffUsers'; import validator from 'validator'; import {HostLimitError, useLimiter} from '../../../hooks/useLimiter'; import {RoutingModalProps} from '../../providers/RoutingProvider'; -import {User, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword} from '../../../api/users'; +import {User, canAccessSettings, hasAdminAccess, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword} from '../../../api/users'; import {genStaffToken, getStaffToken} from '../../../api/staffToken'; import {getImageUrl, useUploadImage} from '../../../api/images'; import {getSettingValues} from '../../../api/settings'; @@ -108,6 +108,8 @@ const RoleSelector: React.FC = ({user, setUserData}) => { }; const BasicInputs: React.FC = ({errors, validators, user, setUserData}) => { + const {currentUser} = useGlobalData(); + return ( = ({errors, validators, user, setUs setUserData?.({...user, email: e.target.value}); }} /> - + {hasAdminAccess(currentUser) && } ); }; @@ -227,6 +229,7 @@ const Details: React.FC = ({errors, validators, user, setUserDa const EmailNotificationsInputs: React.FC = ({user, setUserData}) => { const hasWebmentions = useFeatureFlag('webmentions'); + const {currentUser} = useGlobalData(); return ( @@ -239,51 +242,53 @@ const EmailNotificationsInputs: React.FC = ({user, setUserData} setUserData?.({...user, comment_notifications: e.target.checked}); }} /> - {hasWebmentions && { - setUserData?.({...user, mention_notifications: e.target.checked}); - }} - />} - { - setUserData?.({...user, free_member_signup_notification: e.target.checked}); - }} - /> - { - setUserData?.({...user, paid_subscription_started_notification: e.target.checked}); - }} - /> - { - setUserData?.({...user, paid_subscription_canceled_notification: e.target.checked}); - }} - /> - { - setUserData?.({...user, milestone_notifications: e.target.checked}); - }} - /> + {hasAdminAccess(currentUser) && <> + {hasWebmentions && { + setUserData?.({...user, mention_notifications: e.target.checked}); + }} + />} + { + setUserData?.({...user, free_member_signup_notification: e.target.checked}); + }} + /> + { + setUserData?.({...user, paid_subscription_started_notification: e.target.checked}); + }} + /> + { + setUserData?.({...user, paid_subscription_canceled_notification: e.target.checked}); + }} + /> + { + setUserData?.({...user, milestone_notifications: e.target.checked}); + }} + /> + } ); }; @@ -439,7 +444,7 @@ const StaffToken: React.FC = () => { const [token, setToken] = useState(''); const {mutateAsync: newApiKey} = genStaffToken(); const [copied, setCopied] = useState(false); - + const copyToClipboard = () => { navigator.clipboard.writeText(token); setCopied(true); @@ -496,6 +501,7 @@ const UserMenuTrigger = () => ( const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { const {updateRoute} = useRouting(); const {ownerUser} = useStaffUsers(); + const {currentUser} = useGlobalData(); const [userData, _setUserData] = useState(user); const [saveState, setSaveState] = useState<'' | 'unsaved' | 'saving' | 'saved'>(''); const [errors, setErrors] = useState<{ @@ -531,14 +537,22 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { disabled: !pinturaEnabled} ); + const navigateOnClose = useCallback(() => { + if (canAccessSettings(currentUser)) { + updateRoute('users'); + } else { + updateRoute({isExternal: true, route: 'dashboard'}); + } + }, [currentUser, updateRoute]); + useEffect(() => { if (saveState === 'saved') { setTimeout(() => { mainModal.remove(); - updateRoute('users'); + navigateOnClose(); }, 300); } - }, [mainModal, saveState, updateRoute]); + }, [mainModal, navigateOnClose, saveState, updateRoute]); const confirmSuspend = async (_user: User) => { if (_user.status === 'inactive' && _user.roles[0].name !== 'Contributor') { @@ -741,10 +755,12 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { return ( updateRoute('users')} + afterClose={navigateOnClose} + animate={canAccessSettings(currentUser)} + backDrop={canAccessSettings(currentUser)} dirty={saveState === 'unsaved'} okLabel={okLabel} - size='lg' + size={canAccessSettings(currentUser) ? 'lg' : 'full'} stickyFooter={true} testId='user-detail-modal' onOk={async () => { diff --git a/apps/admin-x-settings/src/components/settings/general/Users.tsx b/apps/admin-x-settings/src/components/settings/general/Users.tsx index b0b0e35ab4..0f43419461 100644 --- a/apps/admin-x-settings/src/components/settings/general/Users.tsx +++ b/apps/admin-x-settings/src/components/settings/general/Users.tsx @@ -6,12 +6,14 @@ import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel'; import React, {useState} from 'react'; import SettingGroup from '../../../admin-x-ds/settings/SettingGroup'; import TabView from '../../../admin-x-ds/global/TabView'; +import clsx from 'clsx'; import useRouting from '../../../hooks/useRouting'; import useStaffUsers from '../../../hooks/useStaffUsers'; -import {User} from '../../../api/users'; +import {User, hasAdminAccess, isContributorUser, isEditorUser} from '../../../api/users'; import {UserInvite, useAddInvite, useDeleteInvite} from '../../../api/invites'; import {generateAvatarColor, getInitials} from '../../../utils/helpers'; import {showToast} from '../../../admin-x-ds/global/Toast'; +import {useGlobalData} from '../../providers/GlobalDataProvider'; interface OwnerProps { user: User; @@ -31,9 +33,12 @@ interface InviteListProps { const Owner: React.FC = ({user}) => { const {updateRoute} = useRouting(); + const {currentUser} = useGlobalData(); const showDetailModal = () => { - updateRoute({route: `users/show/${user.slug}`}); + if (hasAdminAccess(currentUser)) { + updateRoute({route: `users/show/${user.slug}`}); + } }; if (!user) { @@ -41,10 +46,10 @@ const Owner: React.FC = ({user}) => { } return ( -
+
- {user.name} — Owner + {user.name} — Owner {hasAdminAccess(currentUser) && } {user.email}
@@ -53,6 +58,7 @@ const Owner: React.FC = ({user}) => { const UsersList: React.FC = ({users, groupname}) => { const {updateRoute} = useRouting(); + const {currentUser} = useGlobalData(); const showDetailModal = (user: User) => { updateRoute({route: `users/show/${user.slug}`}); @@ -73,11 +79,17 @@ const UsersList: React.FC = ({users, groupname}) => { if (user.status === 'inactive') { title = `${title} (Suspended)`; } + + const canEdit = hasAdminAccess(currentUser) || + (isEditorUser(currentUser) && isContributorUser(user)) || + currentUser.id === user.id; + return ( showDetailModal(user)}/>} + action={canEdit &&
- {{#if (gh-user-can-admin this.session.user)}} + {{#if (or (gh-user-can-admin this.session.user) this.session.user.isEditor)}} {{#if (feature "adminXSettings")}} {{svg-jar "settings"}} {{else}} {{svg-jar "settings"}} {{/if}} {{/if}} - {{#if this.session.user.isEditor}} - {{svg-jar "settings"}} - {{/if}}
{{svg-jar "sun"}}