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 {
roles: UserRole[];
assignableRoles: UserRole[];
}
interface RolesProviderProps {
@ -11,18 +12,26 @@ interface RolesProviderProps {
}
const RolesContext = createContext<RolesContextProps>({
roles: []
roles: [],
assignableRoles: []
});
const RolesProvider: React.FC<RolesProviderProps> = ({children}) => {
const {api} = useContext(ServicesContext);
const [roles, setRoles] = useState <UserRole[]> ([]);
const [assignableRoles, setAssignableRoles] = useState <UserRole[]> ([]);
useEffect(() => {
const fetchRoles = async (): Promise<void> => {
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<RolesProviderProps> = ({children}) => {
return (
<RolesContext.Provider value={{
roles
roles,
assignableRoles
}}>
{children}
</RolesContext.Provider>

View file

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

View file

@ -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<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(() => {
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 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 (
<Modal
<Modal
cancelLabel=''
okLabel='Send invitation now'
okLabel={okLabel}
size={540}
title='Invite a new staff user'
onOk={() => {
// Handle invite user
}}
onOk={handleSendInvitation}
>
<div className='flex flex-col gap-6 py-4'>
<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.
</p>
<TextField
<TextField
clearBg={true}
error={!!errors.email}
hint={errors.email}
inputRef={focusRef}
placeholder='jamie@example.com'
title='Email address'
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
<div>
<Radio
defaultSelectedOption={'contributor'}
id='role'
options={[
{
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'
}
]}
options={allowedRoleOptions}
separator={true}
title="Role"
onSelect={() => {}}
onSelect={(value) => {
setRole(value as RoleType);
}}
/>
</div>
</div>

View file

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

View file

@ -14,6 +14,7 @@ export type UsersHook = {
contributorUsers: User[];
currentUser: User|null;
updateUser?: (user: User) => Promise<void>;
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
};
};

View file

@ -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<PasswordUpdateResponseType>;
};
roles: {
browse: () => Promise<RolesResponseType>;
browse: (options?: BrowseRoleOptions) => Promise<RolesResponseType>;
};
site: {
browse: () => Promise<SiteResponseType>;
@ -97,6 +103,13 @@ interface API {
};
invites: {
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: {
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;
}
}
};