From 2b0a6bc454da27a0c793c85626cf1874ae4a49e1 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Sun, 4 Jun 2023 15:49:34 +0530 Subject: [PATCH] Wired user invite modal in admin-x refs https://github.com/TryGhost/Team/issues/3351 Allows logged in user to send invites to new users, with allowed roles that can be invited restricted based on the role of logged in user. --- .../components/providers/RolesProvider.tsx | 14 +- .../components/providers/UsersProvider.tsx | 7 +- .../general/modals/InviteUserModal.tsx | 135 ++++++++++++++---- ghost/admin-x-settings/src/hooks/useRoles.tsx | 16 ++- .../src/hooks/useStaffUsers.tsx | 7 +- ghost/admin-x-settings/src/utils/api.ts | 42 +++++- 6 files changed, 180 insertions(+), 41 deletions(-) diff --git a/ghost/admin-x-settings/src/components/providers/RolesProvider.tsx b/ghost/admin-x-settings/src/components/providers/RolesProvider.tsx index 5b6ba8085d..e7b8b840db 100644 --- a/ghost/admin-x-settings/src/components/providers/RolesProvider.tsx +++ b/ghost/admin-x-settings/src/components/providers/RolesProvider.tsx @@ -4,6 +4,7 @@ import {UserRole} from '../../types/api'; interface RolesContextProps { roles: UserRole[]; + assignableRoles: UserRole[]; } interface RolesProviderProps { @@ -11,18 +12,26 @@ interface RolesProviderProps { } const RolesContext = createContext({ - roles: [] + roles: [], + assignableRoles: [] }); const RolesProvider: React.FC = ({children}) => { const {api} = useContext(ServicesContext); const [roles, setRoles] = useState ([]); + const [assignableRoles, setAssignableRoles] = useState ([]); useEffect(() => { const fetchRoles = async (): Promise => { try { const rolesData = await api.roles.browse(); + const assignableRolesData = await api.roles.browse({ + queryParams: { + permissions: 'assign' + } + }); setRoles(rolesData.roles); + setAssignableRoles(assignableRolesData.roles); } catch (error) { // Log error in API } @@ -33,7 +42,8 @@ const RolesProvider: React.FC = ({children}) => { return ( {children} diff --git a/ghost/admin-x-settings/src/components/providers/UsersProvider.tsx b/ghost/admin-x-settings/src/components/providers/UsersProvider.tsx index e4bb614e31..373f55339f 100644 --- a/ghost/admin-x-settings/src/components/providers/UsersProvider.tsx +++ b/ghost/admin-x-settings/src/components/providers/UsersProvider.tsx @@ -8,6 +8,7 @@ interface UsersContextProps { invites: UserInvite[]; currentUser: User|null; updateUser?: (user: User) => Promise; + setInvites: (invites: UserInvite[]) => void; } interface UsersProviderProps { @@ -17,7 +18,8 @@ interface UsersProviderProps { const UsersContext = createContext({ users: [], invites: [], - currentUser: null + currentUser: null, + setInvites: () => {} }); const UsersProvider: React.FC = ({children}) => { @@ -66,7 +68,8 @@ const UsersProvider: React.FC = ({children}) => { users, invites, currentUser, - updateUser + updateUser, + setInvites }}> {children} diff --git a/ghost/admin-x-settings/src/components/settings/general/modals/InviteUserModal.tsx b/ghost/admin-x-settings/src/components/settings/general/modals/InviteUserModal.tsx index 635fde5deb..bbf19c4f2c 100644 --- a/ghost/admin-x-settings/src/components/settings/general/modals/InviteUserModal.tsx +++ b/ghost/admin-x-settings/src/components/settings/general/modals/InviteUserModal.tsx @@ -2,66 +2,141 @@ import Modal from '../../../../admin-x-ds/global/Modal'; import NiceModal from '@ebay/nice-modal-react'; import Radio from '../../../../admin-x-ds/global/Radio'; import TextField from '../../../../admin-x-ds/global/TextField'; -import {useEffect, useRef} from 'react'; +import useRoles from '../../../../hooks/useRoles'; +import useStaffUsers from '../../../../hooks/useStaffUsers'; +import validator from 'validator'; +import {ServicesContext} from '../../../providers/ServiceProvider'; +import {useContext, useEffect, useRef, useState} from 'react'; + +type RoleType = 'administrator' | 'editor' | 'author' | 'contributor'; const InviteUserModal = NiceModal.create(() => { + const {api} = useContext(ServicesContext); + const {roles, assignableRoles, getRoleId} = useRoles(); + const {invites, setInvites} = useStaffUsers(); + const focusRef = useRef(null); + const [email, setEmail] = useState(''); + const [saveState, setSaveState] = useState<'saving' | 'saved' | 'error' | ''>(''); + const [role, setRole] = useState('contributor'); + const [errors, setErrors] = useState<{ + email?: string; + }>({}); useEffect(() => { if (focusRef.current) { focusRef.current.focus(); } + }, []); + + useEffect(() => { + if (saveState === 'saved') { + setTimeout(() => { + setSaveState(''); + }, 2000); + } + }, [saveState]); + + let okLabel = 'Send invitation now'; + if (saveState === 'saving') { + okLabel = 'Sending...'; + } else if (saveState === 'saved') { + okLabel = 'Invite sent!'; + } else if (saveState === 'error') { + okLabel = 'Failed to send. Retry?'; + } + + const handleSendInvitation = async () => { + if (saveState === 'saving') { + return; + } + + if (!validator.isEmail(email)) { + setErrors({ + email: 'Please enter a valid email address.' + }); + return; + } + setSaveState('saving'); + try { + const res = await api.invites.add({ + email, + roleId: getRoleId(role, roles) + }); + + // Update invites list + setInvites([...invites, res.invites[0]]); + + setSaveState('saved'); + } catch (e: any) { + setSaveState('error'); + return; + } + }; + + const roleOptions = [ + { + hint: 'Can create and edit their own posts, but cannot publish. An Editor needs to approve and publish for them.', + label: 'Contributor', + value: 'contributor' + }, + { + hint: 'A trusted user who can create, edit and publish their own posts, but can’t modify others.', + label: 'Author', + value: 'author' + }, + { + hint: 'Can invite and manage other Authors and Contributors, as well as edit and publish any posts on the site.', + label: 'Editor', + value: 'editor' + }, + { + hint: 'Trusted staff user who should be able to manage all content and users, as well as site settings and options.', + label: 'Administrator', + value: 'administrator' + } + ]; + + const allowedRoleOptions = roleOptions.filter((option) => { + return assignableRoles.some((r) => { + return r.name === option.label; + }); }); return ( - { - // Handle invite user - }} + onOk={handleSendInvitation} >

Send an invitation for a new person to create a staff account on your site, and select a role that matches what you’d like them to be able to do.

- { + setEmail(event.target.value); + }} />
{}} + onSelect={(value) => { + setRole(value as RoleType); + }} />
diff --git a/ghost/admin-x-settings/src/hooks/useRoles.tsx b/ghost/admin-x-settings/src/hooks/useRoles.tsx index 14ba48a7c8..bf4899261b 100644 --- a/ghost/admin-x-settings/src/hooks/useRoles.tsx +++ b/ghost/admin-x-settings/src/hooks/useRoles.tsx @@ -4,13 +4,25 @@ import {useContext} from 'react'; export type RolesHook = { roles: UserRole[]; + assignableRoles: UserRole[]; + getRoleId: (roleName: string, roles: UserRole[]) => string; }; +function getRoleId(roleName: string, roles: UserRole[]): string { + const role = roles.find((r) => { + return r.name.toLowerCase() === roleName?.toLowerCase(); + }); + + return role?.id || ''; +} + const useRoles = (): RolesHook => { - const {roles} = useContext(RolesContext); + const {roles, assignableRoles} = useContext(RolesContext); return { - roles + roles, + assignableRoles, + getRoleId }; }; diff --git a/ghost/admin-x-settings/src/hooks/useStaffUsers.tsx b/ghost/admin-x-settings/src/hooks/useStaffUsers.tsx index e54e9fc57a..cecc5c32e4 100644 --- a/ghost/admin-x-settings/src/hooks/useStaffUsers.tsx +++ b/ghost/admin-x-settings/src/hooks/useStaffUsers.tsx @@ -14,6 +14,7 @@ export type UsersHook = { contributorUsers: User[]; currentUser: User|null; updateUser?: (user: User) => Promise; + setInvites: (invites: UserInvite[]) => void; }; function getUsersByRole(users: User[], role: string): User[] { @@ -29,7 +30,7 @@ function getOwnerUser(users: User[]): User { } const useStaffUsers = (): UsersHook => { - const {users, currentUser, updateUser, invites} = useContext(UsersContext); + const {users, currentUser, updateUser, invites, setInvites} = useContext(UsersContext); const {roles} = useContext(RolesContext); const ownerUser = getOwnerUser(users); const adminUsers = getUsersByRole(users, 'Administrator'); @@ -45,6 +46,7 @@ const useStaffUsers = (): UsersHook => { role: role?.name }; }); + return { users, ownerUser, @@ -54,7 +56,8 @@ const useStaffUsers = (): UsersHook => { contributorUsers, currentUser, invites: mappedInvites, - updateUser + updateUser, + setInvites }; }; diff --git a/ghost/admin-x-settings/src/utils/api.ts b/ghost/admin-x-settings/src/utils/api.ts index 449273099b..12d8dea7aa 100644 --- a/ghost/admin-x-settings/src/utils/api.ts +++ b/ghost/admin-x-settings/src/utils/api.ts @@ -68,6 +68,12 @@ interface RequestOptions { }; } +interface BrowseRoleOptions { + queryParams: { + [key: string]: string; + } +} + interface UpdatePasswordOptions { newPassword: string; confirmNewPassword: string; @@ -87,7 +93,7 @@ interface API { updatePassword: (options: UpdatePasswordOptions) => Promise; }; roles: { - browse: () => Promise; + browse: (options?: BrowseRoleOptions) => Promise; }; site: { browse: () => Promise; @@ -97,6 +103,13 @@ interface API { }; invites: { browse: () => Promise; + add: ({email, roleId} : { + email: string; + roleId: string; + expires?: number; + status?: string; + token?: string; + }) => Promise; } } @@ -195,8 +208,14 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API { } }, roles: { - browse: async () => { - const response = await fetcher(`/roles/?limit=all`, {}); + browse: async (options?: BrowseRoleOptions) => { + const queryParams = options?.queryParams || {}; + queryParams.limit = 'all'; + const queryString = Object.keys(options?.queryParams || {}) + .map(key => `${key}=${options?.queryParams[key]}`) + .join('&'); + + const response = await fetcher(`/roles/?${queryString}`, {}); const data: RolesResponseType = await response.json(); return data; } @@ -228,6 +247,23 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API { const response = await fetcher(`/invites/`, {}); const data: InvitesResponseType = await response.json(); return data; + }, + add: async ({email, roleId}) => { + const payload = JSON.stringify({ + invites: [{ + email: email, + role_id: roleId, + expires: null, + status: null, + token: null + }] + }); + const response = await fetcher(`/invites/`, { + method: 'POST', + body: payload + }); + const data: InvitesResponseType = await response.json(); + return data; } } };