Update settings for roles in AdminX (#18147)

refs. https://github.com/TryGhost/Product/issues/3349

- updated user modal for various roles
This commit is contained in:
Peter Zimon 2023-09-14 20:20:50 +03:00 committed by GitHub
parent 32eb4635cf
commit bcb543d039
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 149 additions and 130 deletions

View File

@ -40,7 +40,7 @@ const MainContent: React.FC = () => {
<Page>
<div className='w-full'>
<Heading className='mb-10'>Settings</Heading>
<Users keywords={[]} />
<Users highlight={false} keywords={[]} />
</div>
</Page>
);

View File

@ -57,7 +57,7 @@ const Heading: React.FC<Heading1to5Props | Heading6Props | HeadingLabelProps> =
if (!useLabelTag) {
switch (level) {
case 1:
styles += ' md:text-5xl';
styles += ' md:text-5xl leading-tight';
break;
case 2:
styles += ' md:text-3xl';

View File

@ -22,6 +22,7 @@ interface ImageUploadProps {
editButtonClassName?: string;
editButtonContent?: React.ReactNode;
editButtonUnstyled?: boolean;
buttonContainerClassName?: string;
/**
* Removes all the classnames from all elements so you can set a completely custom styling
@ -61,7 +62,8 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
pintura,
editButtonClassName,
editButtonContent,
editButtonUnstyled = false
editButtonUnstyled = false,
buttonContainerClassName
}) => {
if (!unstyled) {
imageContainerClassName = clsx(
@ -112,6 +114,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
width: (unstyled ? '' : width || '100%'),
height: (unstyled ? '' : height || 'auto')
}} onClick={onImageClick} />
<div className={buttonContainerClassName}>
{
pintura?.isEnabled && pintura?.openEditor &&
<button className={editButtonClassName} type='button' onClick={pintura.openEditor}>
@ -122,6 +125,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
{deleteButtonContent}
</button>
</div>
</div>
);
if (imageBWCheckedBg) {
@ -146,6 +150,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
return image;
} else {
return (
<div className={buttonContainerClassName}>
<FileUpload className={fileUploadClassName} id={id} style={
{
width: (unstyled ? '' : width),
@ -154,6 +159,7 @@ const ImageUpload: React.FC<ImageUploadProps> = ({
} unstyled={unstyled} onUpload={onUpload}>
<span className='text-center'>{children}</span>
</FileUpload>
</div>
);
}
};

View File

@ -146,7 +146,8 @@ const Modal: React.FC<ModalProps> = ({
}
let modalClasses = clsx(
'relative z-50 mx-auto flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden rounded bg-white dark:bg-black',
'relative z-50 mx-auto flex max-h-[100%] w-full flex-col justify-between overflow-x-hidden bg-white dark:bg-black',
size !== 'bleed' && 'rounded',
formSheet ? 'shadow-md' : 'shadow-xl',
(animate && !formSheet && !animationFinished) && 'animate-modal-in',
(formSheet && !animationFinished) && 'animate-modal-in-reverse',

View File

@ -22,6 +22,11 @@ interface SettingGroupProps {
hideEditButton?: boolean;
alwaysShowSaveButton?: boolean;
/**
* Show a green outline in case the modal that's been triggered from the group is closed
*/
highlightOnModalClose?: boolean;
/**
* Remove borders and paddings
*/
@ -50,6 +55,7 @@ const SettingGroup: React.FC<SettingGroupProps> = ({
hideEditButton,
alwaysShowSaveButton = true,
border = true,
highlightOnModalClose = true,
styles,
onEditingChange,
onSave,
@ -146,7 +152,7 @@ const SettingGroup: React.FC<SettingGroupProps> = ({
'relative flex-col gap-6 rounded',
border && 'border p-5 md:p-7',
!checkVisible(keywords) ? 'hidden' : 'flex',
highlight && 'before:pointer-events-none before:absolute before:inset-[1px] before:animate-setting-highlight-fade-out before:rounded before:shadow-[0_0_0_3px_rgba(48,207,67,0.45)]',
(highlight && highlightOnModalClose) && 'before:pointer-events-none before:absolute before:inset-[1px] before:animate-setting-highlight-fade-out before:rounded before:shadow-[0_0_0_3px_rgba(48,207,67,0.45)]',
!isEditing && 'is-not-editing group/setting-group',
styles
);

View File

@ -1,8 +1,9 @@
import Button from '../../../../admin-x-ds/global/Button';
import React, {ReactNode, useState} from 'react';
import clsx from 'clsx';
export interface APIKeyFieldProps {
label: string;
label?: string;
text?: string;
hint?: ReactNode;
onRegenerate?: () => void;
@ -17,9 +18,14 @@ const APIKeyField: React.FC<APIKeyFieldProps> = ({label, text = '', hint, onRege
setTimeout(() => setCopied(false), 2000);
};
const containerClasses = clsx(
'group/api-keys relative mb-3 w-full overflow-hidden rounded py-1 text-sm hover:bg-grey-50 dark:hover:bg-black md:mb-0',
label ? 'md:p-1' : 'md:px-0'
);
return <>
<div className='p-0 pr-4 text-sm text-grey-600 md:py-1'>{label}</div>
<div className='group/api-keys relative mb-3 overflow-hidden rounded py-1 text-sm hover:bg-grey-50 dark:hover:bg-grey-900 md:mb-0 md:p-1'>
{label && <div className='p-0 pr-4 text-sm text-grey-600 md:py-1'>{label}</div>}
<div className={containerClasses}>
{text}
{hint}
<div className='visible absolute right-0 top-[50%] flex translate-y-[-50%] gap-1 bg-white pl-1 text-sm group-hover/api-keys:visible dark:bg-black md:invisible'>
@ -30,9 +36,9 @@ const APIKeyField: React.FC<APIKeyFieldProps> = ({label, text = '', hint, onRege
</>;
};
const APIKeys: React.FC<{keys: APIKeyFieldProps[]}> = ({keys}) => {
const APIKeys: React.FC<{hasLabel?: boolean; keys: APIKeyFieldProps[];}> = ({hasLabel = true, keys}) => {
return (
<div className='grid grid-cols-1 md:grid-cols-[max-content_1fr]'>
<div className={hasLabel ? 'grid grid-cols-1 md:grid-cols-[max-content_1fr]' : ''}>
{keys.map(key => <APIKeyField key={key.label} {...key} />)}
</div>
);

View File

@ -0,0 +1,51 @@
import React from 'react';
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
import TextArea from '../../../admin-x-ds/global/form/TextArea';
import TextField from '../../../admin-x-ds/global/form/TextField';
import {UserDetailProps} from './UserDetailModal';
export const DetailsInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
return (
<SettingGroupContent>
<TextField
hint="Where in the world do you live?"
title="Location"
value={user.location}
onChange={(e) => {
setUserData?.({...user, location: e.target.value});
}} />
<TextField
error={!!errors?.url}
hint={errors?.url || 'Have a website or blog other than this one? Link it!'}
title="Website"
value={user.website}
onBlur={(e) => {
validators?.url(e.target.value);
}}
onChange={(e) => {
setUserData?.({...user, website: e.target.value});
}} />
<TextField
hint='URL of your personal Facebook Profile'
title="Facebook profile"
value={user.facebook}
onChange={(e) => {
setUserData?.({...user, facebook: e.target.value});
}} />
<TextField
hint='URL of your personal Twitter profile'
title="Twitter profile"
value={user.twitter}
onChange={(e) => {
setUserData?.({...user, twitter: e.target.value});
}} />
<TextArea
hint={<>Recommended: 200 characters. You&lsquo;ve used <span className='font-bold'>{user.bio?.length || 0}</span></>}
title="Bio"
value={user.bio || ''}
onChange={(e) => {
setUserData?.({...user, bio: e.target.value});
}} />
</SettingGroupContent>
);
};

View File

@ -1,3 +1,4 @@
import APIKeys from '../advanced/integrations/APIKeys';
import Button from '../../../admin-x-ds/global/Button';
import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModal';
import Heading from '../../../admin-x-ds/global/Heading';
@ -11,14 +12,15 @@ import Radio from '../../../admin-x-ds/global/form/Radio';
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';
import TextField from '../../../admin-x-ds/global/form/TextField';
import Toggle from '../../../admin-x-ds/global/form/Toggle';
import clsx from 'clsx';
import useFeatureFlag from '../../../hooks/useFeatureFlag';
import usePinturaEditor from '../../../hooks/usePinturaEditor';
import useRouting from '../../../hooks/useRouting';
import useStaffUsers from '../../../hooks/useStaffUsers';
import validator from 'validator';
import {DetailsInputs} from './DetailsInputs';
import {HostLimitError, useLimiter} from '../../../hooks/useLimiter';
import {RoutingModalProps} from '../../providers/RoutingProvider';
import {User, canAccessSettings, hasAdminAccess, isAdminUser, isOwnerUser, useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword} from '../../../api/users';
@ -34,7 +36,7 @@ interface CustomHeadingProps {
children?: React.ReactNode;
}
interface UserDetailProps {
export interface UserDetailProps {
user: User;
setUserData?: (user: User) => void;
errors?: {
@ -136,6 +138,14 @@ const BasicInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUs
setUserData?.({...user, email: e.target.value});
}}
/>
<TextField
hint="https://example.com/author"
title="Slug"
value={user.slug}
onChange={(e) => {
setUserData?.({...user, slug: e.target.value});
}}
/>
{hasAdminAccess(currentUser) && <RoleSelector setUserData={setUserData} user={user} />}
</SettingGroupContent>
);
@ -153,68 +163,6 @@ const Basic: React.FC<UserDetailProps> = ({errors, validators, user, setUserData
);
};
const DetailsInputs: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
return (
<SettingGroupContent>
<TextField
hint="https://example.com/author"
title="Slug"
value={user.slug}
onChange={(e) => {
setUserData?.({...user, slug: e.target.value});
}}
/>
<TextField
hint="Where in the world do you live?"
title="Location"
value={user.location}
onChange={(e) => {
setUserData?.({...user, location: e.target.value});
}}
/>
<TextField
error={!!errors?.url}
hint={errors?.url || 'Have a website or blog other than this one? Link it!'}
placeholder='https://example.com'
title="Website"
value={user.website}
onBlur={(e) => {
validators?.url(e.target.value);
}}
onChange={(e) => {
setUserData?.({...user, website: e.target.value});
}}
/>
<TextField
hint='URL of your personal Facebook Profile'
placeholder='https://www.facebook.com/ghost'
title="Facebook profile"
value={user.facebook}
onChange={(e) => {
setUserData?.({...user, facebook: e.target.value});
}}
/>
<TextField
hint='URL of your personal Twitter profile'
placeholder='https://twitter.com/ghost'
title="Twitter profile"
value={user.twitter}
onChange={(e) => {
setUserData?.({...user, twitter: e.target.value});
}}
/>
<TextArea
hint={<>Recommended: 200 characters. You&lsquo;ve used <span className='font-bold'>{user.bio?.length || 0}</span></>}
title="Bio"
value={user.bio || ''}
onChange={(e) => {
setUserData?.({...user, bio: e.target.value});
}}
/>
</SettingGroupContent>
);
};
const Details: React.FC<UserDetailProps> = ({errors, validators, user, setUserData}) => {
return (
<SettingGroup
@ -445,15 +393,6 @@ const StaffToken: React.FC<UserDetailProps> = () => {
});
const [token, setToken] = useState('');
const {mutateAsync: newApiKey} = genStaffToken();
const [copied, setCopied] = useState(false);
const copyToClipboard = () => {
navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
};
useEffect(() => {
const getApiKey = async () => {
@ -479,24 +418,14 @@ const StaffToken: React.FC<UserDetailProps> = () => {
});
};
return (
<SettingGroup
border={false}
customHeader={<CustomHeader>Staff access token</CustomHeader>}
title='Staff access token'
>
<TextField
readOnly={true}
rightPlaceholder={
<div className='flex'>
<Button className='mt-2' color='white' label='Regenerate' size='sm' onClick={genConfirmation} />
<Button className='mt-2' color={copied ? 'green' : 'white'} label={copied ? 'Copied' : 'Copy'} size='sm' onClick={copyToClipboard} />
<div>
<Heading className='mb-2' level={6} grey>Staff access token</Heading>
<APIKeys hasLabel={false} keys={[
{
text: token || '',
onRegenerate: genConfirmation
}]} />
</div>
}
title="Staff access token"
type="text"
value={token || ''}
/>
</SettingGroup>
);
};
@ -731,11 +660,27 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
okLabel = 'Saved';
}
const fileUploadButtonClasses = 'absolute left-12 md:left-auto md:right-[104px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
const coverButtonContainerClassName = clsx(
canAccessSettings(currentUser) ? (
userData.cover_image ? 'relative ml-10 mr-[106px] flex translate-y-[-80px] gap-3 md:ml-0 md:justify-end' : 'relative -mb-8 ml-10 mr-[106px] flex translate-y-[358px] md:ml-0 md:translate-y-[268px] md:justify-end'
) : (
userData.cover_image ? 'relative ml-10 flex max-w-4xl translate-y-[-80px] gap-3 md:mx-auto md:justify-end' : 'relative -mb-8 ml-10 flex max-w-4xl translate-y-[358px] md:mx-auto md:translate-y-[268px] md:justify-end'
)
);
const deleteButtonClasses = 'absolute left-12 md:left-auto md:right-[152px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
const coverEditButtonBaseClasses = 'bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition-all cursor-pointer font-medium';
const editButtonClasses = 'absolute left-12 md:left-auto md:right-[102px] bottom-12 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white flex items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 transition cursor-pointer font-medium z-10';
const fileUploadButtonClasses = clsx(
coverEditButtonBaseClasses
);
const deleteButtonClasses = clsx(
coverEditButtonBaseClasses
);
const editButtonClasses = clsx(
coverEditButtonBaseClasses
);
const suspendedText = userData.status === 'inactive' ? ' (Suspended)' : '';
@ -769,7 +714,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
backDrop={canAccessSettings(currentUser)}
dirty={saveState === 'unsaved'}
okLabel={okLabel}
size={canAccessSettings(currentUser) ? 'lg' : 'full'}
size={canAccessSettings(currentUser) ? 'lg' : 'bleed'}
stickyFooter={true}
testId='user-detail-modal'
onOk={async () => {
@ -795,8 +740,9 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
}}
>
<div>
<div className={`relative -mx-12 -mt-12 rounded-t bg-gradient-to-tr from-grey-900 to-black`}>
<div className={`relative -mx-10 -mt-10 ${canAccessSettings(currentUser) && 'rounded-t'} bg-gradient-to-tr from-grey-900 to-black`}>
<ImageUpload
buttonContainerClassName={coverButtonContainerClassName}
deleteButtonClassName={deleteButtonClasses}
deleteButtonContent='Delete cover image'
editButtonClassName={editButtonClasses}
@ -804,7 +750,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
height={userData.cover_image ? '100%' : '32px'}
id='cover-image'
imageClassName='w-full h-full object-cover'
imageContainerClassName='absolute inset-0 bg-cover group bg-center rounded-t overflow-hidden'
imageContainerClassName={`absolute inset-0 bg-cover group bg-center ${canAccessSettings(currentUser) && 'rounded-t'} overflow-hidden`}
imageURL={userData.cover_image || ''}
pintura={
{
@ -825,18 +771,18 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
handleImageUpload('cover_image', file);
}}
>Upload cover image</ImageUpload>
<div className="absolute bottom-12 right-12 z-10">
{canAccessSettings(currentUser) && <div className="absolute bottom-12 right-12 z-10">
<Menu items={menuItems} position='right' trigger={<UserMenuTrigger />}></Menu>
</div>
<div className='relative flex flex-col items-start gap-4 px-12 pb-60 pt-10 md:flex-row md:items-center md:pb-7 md:pt-60'>
</div>}
<div className={`${!canAccessSettings(currentUser) ? 'mx-10 pl-0 md:max-w-[50%] min-[920px]:ml-[calc((100vw-920px)/2)] min-[920px]:max-w-[460px]' : 'max-w-[50%] pl-12'} relative flex flex-col items-start gap-4 pb-60 pt-10 md:flex-row md:items-center md:pb-7 md:pt-60`}>
<ImageUpload
deleteButtonClassName='md:invisible absolute pr-3 -right-2 -top-2 flex h-8 w-16 cursor-pointer items-center justify-end rounded-full bg-[rgba(0,0,0,0.75)] text-white group-hover:!visible'
deleteButtonContent={<Icon colorClass='text-white' name='trash' size='sm' />}
editButtonClassName='md:invisible absolute right-[22px] -top-2 flex h-8 w-8 cursor-pointer items-center justify-center text-white group-hover:!visible z-20'
fileUploadClassName='rounded-full bg-black flex items-center justify-center opacity-80 transition hover:opacity-100 -ml-2 cursor-pointer h-[80px] w-[80px]'
id='avatar'
imageClassName='w-full h-full object-cover rounded-full'
imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px]'
imageClassName='w-full h-full object-cover rounded-full shrink-0'
imageContainerClassName='relative group bg-cover bg-center -ml-2 h-[80px] w-[80px] shrink-0'
imageURL={userData.profile_image}
pintura={
{
@ -866,12 +812,14 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => {
</div>
</div>
</div>
<div className='mt-10 grid grid-cols-1 gap-x-12 gap-y-20 md:grid-cols-2'>
<div className={`${!canAccessSettings(currentUser) && 'mx-auto max-w-4xl'} mt-10 grid grid-cols-1 gap-x-12 gap-y-20 md:grid-cols-2`}>
<Basic errors={errors} setUserData={setUserData} user={userData} validators={validators} />
<div className='flex flex-col justify-between gap-10'>
<Details errors={errors} setUserData={setUserData} user={userData} validators={validators} />
<StaffToken user={userData} />
</div>
<EmailNotifications setUserData={setUserData} user={userData} />
<Password user={userData} />
<StaffToken user={userData} />
</div>
</div>
</Modal>

View File

@ -192,7 +192,7 @@ const InvitesUserList: React.FC<InviteListProps> = ({users}) => {
);
};
const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
const Users: React.FC<{ keywords: string[], highlight?: boolean }> = ({keywords, highlight = true}) => {
const {
ownerUser,
adminUsers,
@ -246,6 +246,7 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
return (
<SettingGroup
customButtons={buttons}
highlightOnModalClose={highlight}
keywords={keywords}
navid='users'
testId='users'