TryGhost/Ghost

View on GitHub
apps/admin-x-settings/src/components/settings/general/Users.tsx

Summary

Maintainability
C
1 day
Test Coverage
import React, {useEffect, useState} from 'react';
import TopLevelGroup from '../../TopLevelGroup';
import clsx from 'clsx';
import useQueryParams from '../../../hooks/useQueryParams';
import useStaffUsers from '../../../hooks/useStaffUsers';
import {Avatar, Button, List, ListItem, NoValueLabel, TabView, showToast, withErrorBoundary} from '@tryghost/admin-x-design-system';
import {User, hasAdminAccess, isContributorUser, isEditorUser} from '@tryghost/admin-x-framework/api/users';
import {UserInvite, useAddInvite, useDeleteInvite} from '@tryghost/admin-x-framework/api/invites';
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
import {useGlobalData} from '../../providers/GlobalDataProvider';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
import {useRouting} from '@tryghost/admin-x-framework/routing';

interface OwnerProps {
    user: User;
    updateUser?: (user: User) => void;
}

interface UsersListProps {
    users: User[];
    groupname?: string;
    updateUser?: (user: User) => void;
}

interface InviteListProps {
    users: UserInvite[];
    updateUser?: (user: User) => void;
}

const Owner: React.FC<OwnerProps> = ({user}) => {
    const {updateRoute} = useRouting();
    const {currentUser} = useGlobalData();

    const showDetailModal = () => {
        if (hasAdminAccess(currentUser)) {
            updateRoute({route: `staff/${user.slug}`});
        }
    };

    if (!user) {
        return null;
    }

    return (
        <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 ?? undefined} label={getInitials(user.name)} labelColor='white' size='lg' />
            <div className='flex flex-col'>
                <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>
    );
};

const UsersList: React.FC<UsersListProps> = ({users, groupname}) => {
    const {updateRoute} = useRouting();
    const {currentUser} = useGlobalData();

    const showDetailModal = (user: User) => {
        updateRoute({route: `staff/${user.slug}`});
    };

    if (!users || !users.length) {
        return (
            <NoValueLabel icon='single-user-block'>
                No {groupname} found.
            </NoValueLabel>
        );
    }

    return (
        <List titleSeparator={false}>
            {users.map((user) => {
                let title = user.name || '';
                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={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 ?? undefined} label={getInitials(user.name)} labelColor='white' />)}
                        bgOnHover={canEdit}
                        className='min-h-[64px]'
                        detail={user.email}
                        hideActions={true}
                        id={`list-item-${user.id}`}
                        separator={false}
                        testId='user-list-item'
                        title={title}
                        onClick={() => canEdit && showDetailModal(user)} />
                );
            })}
        </List>
    );
};

const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
    const [revokeState, setRevokeState] = useState<'progress'|''>('');
    const [resendState, setResendState] = useState<'progress'|''>('');

    const {mutateAsync: deleteInvite} = useDeleteInvite();
    const {mutateAsync: addInvite} = useAddInvite();
    const handleError = useHandleError();

    let revokeActionLabel = 'Revoke';
    if (revokeState === 'progress') {
        revokeActionLabel = 'Revoking...';
    }
    let resendActionLabel = 'Resend';
    if (resendState === 'progress') {
        resendActionLabel = 'Resending...';
    }
    return (
        <div className='flex gap-2'>
            <Button
                color='red'
                label={revokeActionLabel}
                link={true}
                onClick={async () => {
                    try {
                        setRevokeState('progress');
                        await deleteInvite(invite.id);
                        showToast({
                            title: `Invitation revoked`,
                            message: invite.email,
                            type: 'success'
                        });
                    } catch (e) {
                        handleError(e);
                    } finally {
                        setRevokeState('');
                    }
                }}
            />
            <Button
                className='ml-2'
                color='green'
                label={resendActionLabel}
                link={true}
                onClick={async () => {
                    try {
                        setResendState('progress');
                        await deleteInvite(invite.id);
                        await addInvite({
                            email: invite.email,
                            roleId: invite.role_id
                        });
                        showToast({
                            title: `Invitation resent`,
                            message: invite.email,
                            type: 'success'
                        });
                    } catch (e) {
                        handleError(e);
                    } finally {
                        setResendState('');
                    }
                }}
            />
        </div>
    );
};

const InvitesUserList: React.FC<InviteListProps> = ({users}) => {
    if (!users || !users.length) {
        return (
            <NoValueLabel icon='single-user-block'>
                No invitations found.
            </NoValueLabel>
        );
    }

    return (
        <List>
            {users.map((user) => {
                return (
                    <ListItem
                        key={user.id}
                        action={<UserInviteActions invite={user} />}
                        avatar={(<Avatar bgColor={generateAvatarColor((user.email))} image={''} label={''} labelColor='white' />)}
                        className='min-h-[64px]'
                        detail={user.role}
                        hideActions={true}
                        id={`list-item-${user.id}`}
                        separator={false}
                        testId='user-invite'
                        title={user.email}
                        onClick={() => {
                            // do nothing
                        }}
                    />
                );
            })}
        </List>
    );
};

const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, highlight = true}) => {
    const {
        totalUsers,
        users,
        ownerUser,
        adminUsers,
        editorUsers,
        authorUsers,
        contributorUsers,
        invites,
        hasNextPage,
        fetchNextPage
    } = useStaffUsers();
    const {updateRoute} = useRouting();

    const showInviteModal = () => {
        updateRoute('staff/invite');
    };

    const buttons = (
        <Button className='mt-[-5px]' color='clear' label='Invite people' size='sm' linkWithPadding onClick={() => {
            showInviteModal();
        }} />
    );

    const tabParam = useQueryParams().getParam('tab');
    const defaultTab = tabParam || 'administrators';
    const [selectedTab, setSelectedTab] = useState(defaultTab);

    useEffect(() => {
        if (tabParam) {
            setSelectedTab(tabParam);
        }
    }, [tabParam]);

    const updateSelectedTab = (newTab: string) => {
        updateRoute(`staff?tab=${newTab}`);
        setSelectedTab(newTab);
    };

    const tabs = [
        {
            id: 'administrators',
            title: 'Administrators',
            contents: (<UsersList groupname='administrators' users={adminUsers} />),
            counter: adminUsers.length ? adminUsers.length : undefined
        },
        {
            id: 'editors',
            title: 'Editors',
            contents: (<UsersList groupname='editors' users={editorUsers} />),
            counter: editorUsers.length ? editorUsers.length : undefined
        },
        {
            id: 'authors',
            title: 'Authors',
            contents: (<UsersList groupname='authors' users={authorUsers} />),
            counter: authorUsers.length ? authorUsers.length : undefined
        },
        {
            id: 'contributors',
            title: 'Contributors',
            contents: (<UsersList groupname='contributors' users={contributorUsers} />),
            counter: contributorUsers.length ? contributorUsers.length : undefined
        },
        {
            id: 'invited',
            title: 'Invited',
            contents: (<InvitesUserList users={invites} />),
            counter: invites.length ? invites.length : undefined
        }
    ];

    return (
        <TopLevelGroup
            customButtons={buttons}
            highlightOnModalClose={highlight}
            keywords={keywords}
            navid='staff'
            testId='users'
            title='Staff'
        >
            <Owner user={ownerUser} />
            {/* if there are no users besides the owner user, hide the tabs*/}
            {(users.length > 1 || invites.length > 0) && <TabView selectedTab={selectedTab} tabs={tabs} testId='user-tabview' onTabChange={updateSelectedTab} />}
            {hasNextPage && <Button
                label={`Load more (showing ${users.length}/${totalUsers} users)`}
                link
                onClick={() => fetchNextPage()}
            />}
        </TopLevelGroup>
    );
};

export default withErrorBoundary(Users, 'Staff');