0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-01-20 22:42:53 -05:00

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.
This commit is contained in:
Rishabh 2023-06-04 15:49:34 +05:30
parent 8bf113930f
commit 2b0a6bc454
6 changed files with 180 additions and 41 deletions

View file

@ -4,6 +4,7 @@ import {UserRole} from '../../types/api';
interface RolesContextProps { interface RolesContextProps {
roles: UserRole[]; roles: UserRole[];
assignableRoles: UserRole[];
} }
interface RolesProviderProps { interface RolesProviderProps {
@ -11,18 +12,26 @@ interface RolesProviderProps {
} }
const RolesContext = createContext<RolesContextProps>({ const RolesContext = createContext<RolesContextProps>({
roles: [] roles: [],
assignableRoles: []
}); });
const RolesProvider: React.FC<RolesProviderProps> = ({children}) => { const RolesProvider: React.FC<RolesProviderProps> = ({children}) => {
const {api} = useContext(ServicesContext); const {api} = useContext(ServicesContext);
const [roles, setRoles] = useState <UserRole[]> ([]); const [roles, setRoles] = useState <UserRole[]> ([]);
const [assignableRoles, setAssignableRoles] = useState <UserRole[]> ([]);
useEffect(() => { useEffect(() => {
const fetchRoles = async (): Promise<void> => { const fetchRoles = async (): Promise<void> => {
try { try {
const rolesData = await api.roles.browse(); const rolesData = await api.roles.browse();
const assignableRolesData = await api.roles.browse({
queryParams: {
permissions: 'assign'
}
});
setRoles(rolesData.roles); setRoles(rolesData.roles);
setAssignableRoles(assignableRolesData.roles);
} catch (error) { } catch (error) {
// Log error in API // Log error in API
} }
@ -33,7 +42,8 @@ const RolesProvider: React.FC<RolesProviderProps> = ({children}) => {
return ( return (
<RolesContext.Provider value={{ <RolesContext.Provider value={{
roles roles,
assignableRoles
}}> }}>
{children} {children}
</RolesContext.Provider> </RolesContext.Provider>

View file

@ -8,6 +8,7 @@ interface UsersContextProps {
invites: UserInvite[]; invites: UserInvite[];
currentUser: User|null; currentUser: User|null;
updateUser?: (user: User) => Promise<void>; updateUser?: (user: User) => Promise<void>;
setInvites: (invites: UserInvite[]) => void;
} }
interface UsersProviderProps { interface UsersProviderProps {
@ -17,7 +18,8 @@ interface UsersProviderProps {
const UsersContext = createContext<UsersContextProps>({ const UsersContext = createContext<UsersContextProps>({
users: [], users: [],
invites: [], invites: [],
currentUser: null currentUser: null,
setInvites: () => {}
}); });
const UsersProvider: React.FC<UsersProviderProps> = ({children}) => { const UsersProvider: React.FC<UsersProviderProps> = ({children}) => {
@ -66,7 +68,8 @@ const UsersProvider: React.FC<UsersProviderProps> = ({children}) => {
users, users,
invites, invites,
currentUser, currentUser,
updateUser updateUser,
setInvites
}}> }}>
{children} {children}
</UsersContext.Provider> </UsersContext.Provider>

View file

@ -2,66 +2,141 @@ import Modal from '../../../../admin-x-ds/global/Modal';
import NiceModal from '@ebay/nice-modal-react'; import NiceModal from '@ebay/nice-modal-react';
import Radio from '../../../../admin-x-ds/global/Radio'; import Radio from '../../../../admin-x-ds/global/Radio';
import TextField from '../../../../admin-x-ds/global/TextField'; 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 InviteUserModal = NiceModal.create(() => {
const {api} = useContext(ServicesContext);
const {roles, assignableRoles, getRoleId} = useRoles();
const {invites, setInvites} = useStaffUsers();
const focusRef = useRef<HTMLInputElement>(null); const focusRef = useRef<HTMLInputElement>(null);
const [email, setEmail] = useState<string>('');
const [saveState, setSaveState] = useState<'saving' | 'saved' | 'error' | ''>('');
const [role, setRole] = useState<RoleType>('contributor');
const [errors, setErrors] = useState<{
email?: string;
}>({});
useEffect(() => { useEffect(() => {
if (focusRef.current) { if (focusRef.current) {
focusRef.current.focus(); 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 cant 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 ( return (
<Modal <Modal
cancelLabel='' cancelLabel=''
okLabel='Send invitation now' okLabel={okLabel}
size={540} size={540}
title='Invite a new staff user' title='Invite a new staff user'
onOk={() => { onOk={handleSendInvitation}
// Handle invite user
}}
> >
<div className='flex flex-col gap-6 py-4'> <div className='flex flex-col gap-6 py-4'>
<p> <p>
Send an invitation for a new person to create a staff account on your site, and select a role that matches what youd like them to be able to do. Send an invitation for a new person to create a staff account on your site, and select a role that matches what youd like them to be able to do.
</p> </p>
<TextField <TextField
clearBg={true} clearBg={true}
error={!!errors.email}
hint={errors.email}
inputRef={focusRef} inputRef={focusRef}
placeholder='jamie@example.com' placeholder='jamie@example.com'
title='Email address' title='Email address'
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/> />
<div> <div>
<Radio <Radio
defaultSelectedOption={'contributor'} defaultSelectedOption={'contributor'}
id='role' id='role'
options={[ options={allowedRoleOptions}
{
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 cant 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'
}
]}
separator={true} separator={true}
title="Role" title="Role"
onSelect={() => {}} onSelect={(value) => {
setRole(value as RoleType);
}}
/> />
</div> </div>
</div> </div>

View file

@ -4,13 +4,25 @@ import {useContext} from 'react';
export type RolesHook = { export type RolesHook = {
roles: UserRole[]; 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 useRoles = (): RolesHook => {
const {roles} = useContext(RolesContext); const {roles, assignableRoles} = useContext(RolesContext);
return { return {
roles roles,
assignableRoles,
getRoleId
}; };
}; };

View file

@ -14,6 +14,7 @@ export type UsersHook = {
contributorUsers: User[]; contributorUsers: User[];
currentUser: User|null; currentUser: User|null;
updateUser?: (user: User) => Promise<void>; updateUser?: (user: User) => Promise<void>;
setInvites: (invites: UserInvite[]) => void;
}; };
function getUsersByRole(users: User[], role: string): User[] { function getUsersByRole(users: User[], role: string): User[] {
@ -29,7 +30,7 @@ function getOwnerUser(users: User[]): User {
} }
const useStaffUsers = (): UsersHook => { const useStaffUsers = (): UsersHook => {
const {users, currentUser, updateUser, invites} = useContext(UsersContext); const {users, currentUser, updateUser, invites, setInvites} = useContext(UsersContext);
const {roles} = useContext(RolesContext); const {roles} = useContext(RolesContext);
const ownerUser = getOwnerUser(users); const ownerUser = getOwnerUser(users);
const adminUsers = getUsersByRole(users, 'Administrator'); const adminUsers = getUsersByRole(users, 'Administrator');
@ -45,6 +46,7 @@ const useStaffUsers = (): UsersHook => {
role: role?.name role: role?.name
}; };
}); });
return { return {
users, users,
ownerUser, ownerUser,
@ -54,7 +56,8 @@ const useStaffUsers = (): UsersHook => {
contributorUsers, contributorUsers,
currentUser, currentUser,
invites: mappedInvites, invites: mappedInvites,
updateUser updateUser,
setInvites
}; };
}; };

View file

@ -68,6 +68,12 @@ interface RequestOptions {
}; };
} }
interface BrowseRoleOptions {
queryParams: {
[key: string]: string;
}
}
interface UpdatePasswordOptions { interface UpdatePasswordOptions {
newPassword: string; newPassword: string;
confirmNewPassword: string; confirmNewPassword: string;
@ -87,7 +93,7 @@ interface API {
updatePassword: (options: UpdatePasswordOptions) => Promise<PasswordUpdateResponseType>; updatePassword: (options: UpdatePasswordOptions) => Promise<PasswordUpdateResponseType>;
}; };
roles: { roles: {
browse: () => Promise<RolesResponseType>; browse: (options?: BrowseRoleOptions) => Promise<RolesResponseType>;
}; };
site: { site: {
browse: () => Promise<SiteResponseType>; browse: () => Promise<SiteResponseType>;
@ -97,6 +103,13 @@ interface API {
}; };
invites: { invites: {
browse: () => Promise<InvitesResponseType>; browse: () => Promise<InvitesResponseType>;
add: ({email, roleId} : {
email: string;
roleId: string;
expires?: number;
status?: string;
token?: string;
}) => Promise<InvitesResponseType>;
} }
} }
@ -195,8 +208,14 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API {
} }
}, },
roles: { roles: {
browse: async () => { browse: async (options?: BrowseRoleOptions) => {
const response = await fetcher(`/roles/?limit=all`, {}); 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(); const data: RolesResponseType = await response.json();
return data; return data;
} }
@ -228,6 +247,23 @@ function setupGhostApi({ghostVersion}: GhostApiOptions): API {
const response = await fetcher(`/invites/`, {}); const response = await fetcher(`/invites/`, {});
const data: InvitesResponseType = await response.json(); const data: InvitesResponseType = await response.json();
return data; 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;
} }
} }
}; };