0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

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.
This commit is contained in:
Jono M 2023-09-14 08:48:07 +01:00 committed by GitHub
parent a1f056ee86
commit 00e598b365
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 189 additions and 113 deletions

View file

@ -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
>
<Toaster />
<NiceModal.Provider>
<div className='relative z-20 px-6 py-4 tablet:fixed'>
<ExitSettingsButton />
</div>
{/* Main container */}
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] tablet:flex-row tablet:items-start tablet:gap-x-10 tablet:py-[8vmin]" id="admin-x-settings-content">
{/* Sidebar */}
<div className="sticky top-[-42px] z-20 min-w-[260px] grow-0 md:top-[-52px] tablet:fixed tablet:top-[8vmin] tablet:basis-[260px]">
<div className='-mx-6 h-[84px] bg-white px-6 tablet:m-0 tablet:bg-transparent tablet:p-0'>
<Heading>Settings</Heading>
</div>
<div className="relative mt-[-32px] w-full overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:hidden after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-[''] dark:after:from-black tablet:w-[260px] tablet:after:!visible tablet:after:!block">
<Sidebar />
</div>
</div>
<div className="relative flex-auto pt-[3vmin] tablet:ml-[300px] tablet:pt-[85px]">
{/* <div className='pointer-events-none fixed inset-x-0 top-0 z-[5] hidden h-[80px] bg-gradient-to-t from-transparent to-white to-60% dark:to-black tablet:!visible tablet:!block'></div> */}
<Settings />
</div>
</div>
<MainContent />
</NiceModal.Provider>
</div>
</GlobalDirtyStateProvider>

View file

@ -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 <>
<div className='relative z-20 px-6 py-4 tablet:fixed'>
<ExitSettingsButton />
</div>
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] tablet:flex-row tablet:items-start tablet:gap-x-10 tablet:py-[8vmin]" id="admin-x-settings-content">
{children}
</div>
</>;
};
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 (
<Page>
<div className='w-full'>
<Heading className='mb-10'>Settings</Heading>
<Users keywords={[]} />
</div>
</Page>
);
}
return (
<Page>
{/* Sidebar */}
<div className="sticky top-[-42px] z-20 min-w-[260px] grow-0 md:top-[-52px] tablet:fixed tablet:top-[8vmin] tablet:basis-[260px]">
<div className='-mx-6 h-[84px] bg-white px-6 tablet:m-0 tablet:bg-transparent tablet:p-0'>
<Heading>Settings</Heading>
</div>
<div className="relative mt-[-32px] w-full overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:hidden after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-[''] dark:after:from-black tablet:w-[260px] tablet:after:!visible tablet:after:!block">
<Sidebar />
</div>
</div>
<div className="relative flex-auto pt-[3vmin] tablet:ml-[300px] tablet:pt-[85px]">
{/* <div className='pointer-events-none fixed inset-x-0 top-0 z-[5] hidden h-[80px] bg-gradient-to-t from-transparent to-white to-60% dark:to-black tablet:!visible tablet:!block'></div> */}
<Settings />
</div>
</Page>
);
};
export default MainContent;

View file

@ -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);
}

View file

@ -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 (
<div className='mb-[40vh]'>
{isEditorUser(currentUser) ?
<Users keywords={[]} />
: <>
<GeneralSettings />
<SiteSettings />
<MembershipSettings />
<EmailSettings />
<AdvancedSettings />
</>}
<div className='mt-40 text-sm'>
<a className='text-green' href="/ghost/#/settings">Click here</a> to open the original Admin settings.
</div>

View file

@ -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<HTMLButtonElement>) => {
@ -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 (
<div className='no-scrollbar tablet:h-[calc(100vh-5vmin-84px)] tablet:w-[240px] tablet:overflow-y-scroll'>
<div className='relative mb-10 md:pt-4 tablet:pt-[32px]'>

View file

@ -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<UserDetailProps> = ({user, setUserData}) => {
};
const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
const {currentUser} = useGlobalData();
return (
<SettingGroupContent>
<TextField
@ -134,7 +136,7 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUs
setUserData?.({...user, email: e.target.value});
}}
/>
<RoleSelector setUserData={setUserData} user={user} />
{hasAdminAccess(currentUser) && <RoleSelector setUserData={setUserData} user={user} />}
</SettingGroupContent>
);
};
@ -227,6 +229,7 @@ const Details: React.FC<UserDetailProps> = ({errors, validators, user, setUserDa
const EmailNotificationsInputs: React.FC<UserDetailProps> = ({user, setUserData}) => {
const hasWebmentions = useFeatureFlag('webmentions');
const {currentUser} = useGlobalData();
return (
<SettingGroupContent>
@ -239,6 +242,7 @@ const EmailNotificationsInputs: React.FC<UserDetailProps> = ({user, setUserData}
setUserData?.({...user, comment_notifications: e.target.checked});
}}
/>
{hasAdminAccess(currentUser) && <>
{hasWebmentions && <Toggle
checked={user.mention_notifications}
direction='rtl'
@ -284,6 +288,7 @@ const EmailNotificationsInputs: React.FC<UserDetailProps> = ({user, setUserData}
setUserData?.({...user, milestone_notifications: e.target.checked});
}}
/>
</>}
</SettingGroupContent>
);
};
@ -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 (
<Modal
afterClose={() => 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 () => {

View file

@ -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<OwnerProps> = ({user}) => {
const {updateRoute} = useRouting();
const {currentUser} = useGlobalData();
const showDetailModal = () => {
if (hasAdminAccess(currentUser)) {
updateRoute({route: `users/show/${user.slug}`});
}
};
if (!user) {
@ -41,10 +46,10 @@ const Owner: React.FC<OwnerProps> = ({user}) => {
}
return (
<div className='group flex gap-3 hover:cursor-pointer' data-testid='owner-user' onClick={showDetailModal}>
<div className={clsx('group flex gap-3', hasAdminAccess(currentUser) && 'cursor-pointer')} data-testid='owner-user' onClick={showDetailModal}>
<Avatar bgColor={generateAvatarColor((user.name ? user.name : user.email))} image={user.profile_image} label={getInitials(user.name)} labelColor='white' size='lg' />
<div className='flex flex-col'>
<span>{user.name} &mdash; <strong>Owner</strong> <button className='ml-2 inline-block text-sm font-bold text-green group-hover:visible md:invisible' type='button'>View profile</button></span>
<span>{user.name} &mdash; <strong>Owner</strong> {hasAdminAccess(currentUser) && <button className='ml-2 inline-block text-sm font-bold text-green group-hover:visible md:invisible' type='button'>View profile</button>}</span>
<span className='text-xs text-grey-700'>{user.email}</span>
</div>
</div>
@ -53,6 +58,7 @@ const Owner: React.FC<OwnerProps> = ({user}) => {
const UsersList: React.FC<UsersListProps> = ({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<UsersListProps> = ({users, groupname}) => {
if (user.status === 'inactive') {
title = `${title} (Suspended)`;
}
const canEdit = hasAdminAccess(currentUser) ||
(isEditorUser(currentUser) && isContributorUser(user)) ||
currentUser.id === user.id;
return (
<ListItem
key={user.id}
action={<Button color='green' label='Edit' link={true} onClick={() => showDetailModal(user)}/>}
action={canEdit && <Button color='green' label='Edit' link={true} onClick={() => showDetailModal(user)}/>}
avatar={(<Avatar bgColor={generateAvatarColor((user.name ? user.name : user.email))} image={user.profile_image} label={getInitials(user.name)} labelColor='white' />)}
bgOnHover={canEdit}
className='min-h-[64px]'
detail={user.email}
hideActions={true}
@ -85,7 +97,7 @@ const UsersList: React.FC<UsersListProps> = ({users, groupname}) => {
separator={false}
testId='user-list-item'
title={title}
onClick={() => showDetailModal(user)} />
onClick={() => canEdit && showDetailModal(user)} />
);
})}
</List>
@ -189,7 +201,6 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
contributorUsers,
invites
} = useStaffUsers();
const {updateRoute} = useRouting();
const showInviteModal = () => {

View file

@ -588,3 +588,4 @@ remove|ember-template-lint|no-action|465|46|465|46|f2f0f3f512f141fdd821333c873f5
remove|ember-template-lint|no-unused-block-params|1|0|1|0|e25f7866ab4ee682b08edf3b29a1351e4079538e|1688342400000|1698714000000|1703898000000|lib/koenig-editor/addon/components/koenig-card-header.hbs
remove|ember-template-lint|no-invalid-interactive|7|32|7|32|508e64575a985432d0588f3291a126c4b62e68d8|1688342400000|1698714000000|1703898000000|app/components/gh-nav-menu/design.hbs
add|ember-template-lint|no-invalid-interactive|7|32|7|32|2da5baf637c4f17f4acd498968b6022ffc0f3105|1692316800000|1702688400000|1707872400000|app/components/gh-nav-menu/design.hbs
add|ember-template-lint|no-unknown-arguments-for-builtin-components|99|93|99|93|156670ca427c49c51f0a94f862b286ccc9466d92|1694649600000|1705021200000|1710205200000|app/components/gh-nav-menu/footer.hbs

View file

@ -55,9 +55,15 @@
{{/if}}
<li>
{{#if (feature "adminXSettings")}}
<LinkTo @route="settings-x.settings-x" @model="users/show/{{this.session.user.slug}}" class="dropdown-item" @role="menuitem" tabindex="-1" data-test-nav="user-profile">
Your profile
</LinkTo>
{{else}}
<LinkTo @route="settings.staff.user" @model={{this.session.user.slug}} class="dropdown-item" @role="menuitem" tabindex="-1" data-test-nav="user-profile">
Your profile
</LinkTo>
{{/if}}
</li>
{{#unless this.session.user.isContributor}}
@ -99,16 +105,13 @@
</GhBasicDropdown>
</div>
<div class="flex items-center pe-all">
{{#if (gh-user-can-admin this.session.user)}}
{{#if (or (gh-user-can-admin this.session.user) this.session.user.isEditor)}}
{{#if (feature "adminXSettings")}}
<LinkTo class="gh-nav-bottom-tabicon" @route="settings-x" @current-when={{this.isSettingsRoute}} data-test-nav="settings">{{svg-jar "settings"}}</LinkTo>
{{else}}
<LinkTo class="gh-nav-bottom-tabicon" @route="settings" @current-when={{this.isSettingsRoute}} data-test-nav="settings">{{svg-jar "settings"}}</LinkTo>
{{/if}}
{{/if}}
{{#if this.session.user.isEditor}}
<LinkTo class="gh-nav-bottom-tabicon" @route="settings.staff" @current-when={{this.isSettingsRoute}} data-test-nav="settings">{{svg-jar "settings"}}</LinkTo>
{{/if}}
<div class="nightshift-toggle-container">
<div class="nightshift-toggle {{if this.feature.nightShift "on"}}" {{action (toggle "nightShift" this.feature)}}>
<div class="sun">{{svg-jar "sun"}}</div>