diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx index 060d0c87ec3..1e23f6640a1 100644 --- a/packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantMembers/EditMemberModal/index.tsx @@ -3,7 +3,6 @@ import { getUserDisplayName } from '@logto/shared/universal'; import { useContext, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import ReactModal from 'react-modal'; -import { z } from 'zod'; import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api'; import { type TenantMemberResponse } from '@/cloud/types/router'; @@ -11,7 +10,7 @@ import { TenantsContext } from '@/contexts/TenantsProvider'; import Button from '@/ds-components/Button'; import FormField from '@/ds-components/FormField'; import ModalLayout from '@/ds-components/ModalLayout'; -import Select from '@/ds-components/Select'; +import Select, { type Option } from '@/ds-components/Select'; import * as modalStyles from '@/scss/modal.module.scss'; type Props = { @@ -28,7 +27,7 @@ function EditMemberModal({ user, isOpen, onClose }: Props) { const [role, setRole] = useState(TenantRole.Member); const cloudApi = useAuthedCloudApi(); - const roleOptions = useMemo( + const roleOptions: Array> = useMemo( () => [ { value: TenantRole.Admin, title: t('admin') }, { value: TenantRole.Member, title: t('member') }, @@ -74,8 +73,9 @@ function EditMemberModal({ user, isOpen, onClose }: Props) { options={roleOptions} value={role} onChange={(value) => { - const guardResult = z.nativeEnum(TenantRole).safeParse(value); - setRole(guardResult.success ? guardResult.data : TenantRole.Member); + if (value) { + setRole(value); + } }} /> diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/Invitations/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/Invitations/index.tsx index c3d7b30744f..923f1c5fa4f 100644 --- a/packages/console/src/pages/TenantSettings/TenantMembers/Invitations/index.tsx +++ b/packages/console/src/pages/TenantSettings/TenantMembers/Invitations/index.tsx @@ -1,22 +1,31 @@ import { OrganizationInvitationStatus } from '@logto/schemas'; import { format } from 'date-fns'; import { useContext, useState } from 'react'; +import { toast } from 'react-hot-toast'; import { useTranslation } from 'react-i18next'; import useSWR from 'swr'; +import Delete from '@/assets/icons/delete.svg'; +import Invite from '@/assets/icons/invitation.svg'; +import More from '@/assets/icons/more.svg'; import Plus from '@/assets/icons/plus.svg'; +import Redo from '@/assets/icons/redo.svg'; import UsersEmptyDark from '@/assets/images/users-empty-dark.svg'; import UsersEmpty from '@/assets/images/users-empty.svg'; import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api'; import { type TenantInvitationResponse } from '@/cloud/types/router'; -import ActionsButton from '@/components/ActionsButton'; import { RoleOption } from '@/components/OrganizationRolesSelect'; import { TenantsContext } from '@/contexts/TenantsProvider'; +import ActionMenu, { ActionMenuItem } from '@/ds-components/ActionMenu'; import Button from '@/ds-components/Button'; +import DynamicT from '@/ds-components/DynamicT'; import Table from '@/ds-components/Table'; import TablePlaceholder from '@/ds-components/Table/TablePlaceholder'; import Tag, { type Props as TagProps } from '@/ds-components/Tag'; import { type RequestError } from '@/hooks/use-api'; +import { useConfirmModal } from '@/hooks/use-confirm-modal'; + +import InviteMemberModal from '../InviteMemberModal'; const convertInvitationStatusToTagStatus = ( status: OrganizationInvitationStatus @@ -49,6 +58,42 @@ function Invitations() { ); const [showInviteModal, setShowInviteModal] = useState(false); + const { show } = useConfirmModal(); + + const handleRevoke = async (invitationId: string) => { + const [result] = await show({ + ModalContent: t('revoke_invitation_confirm'), + confirmButtonText: 'general.confirm', + }); + + if (!result) { + return; + } + + await cloudApi.patch(`/api/tenants/:tenantId/invitations/:invitationId/status`, { + params: { tenantId: currentTenantId, invitationId }, + body: { status: OrganizationInvitationStatus.Revoked }, + }); + void mutate(); + toast.success(t('messages.invitation_revoked')); + }; + + const handleDelete = async (invitationId: string) => { + const [result] = await show({ + ModalContent: t('delete_user_confirm'), + confirmButtonText: 'general.delete', + }); + + if (!result) { + return; + } + + await cloudApi.delete(`/api/tenants/:tenantId/invitations/:invitationId`, { + params: { tenantId: currentTenantId, invitationId }, + }); + void mutate(); + toast.success(t('messages.invitation_deleted')); + }; return ( <> @@ -124,29 +169,65 @@ function Invitations() { { dataIndex: 'actions', title: null, - render: (invitation) => ( - { - await cloudApi.delete(`/api/tenants/:tenantId/invitations/:invitationId`, { - params: { tenantId: currentTenantId, invitationId: invitation.id }, - }); - void mutate(); - }} - /> + render: ({ id, status }) => ( + } + iconSize="small" + title={} + > + {status !== OrganizationInvitationStatus.Accepted && ( + } + onClick={async () => { + await cloudApi.post( + '/api/tenants/:tenantId/invitations/:invitationId/message', + { + params: { tenantId: currentTenantId, invitationId: id }, + } + ); + toast.success(t('messages.invitation_sent')); + }} + > + {t('menu_options.resend_invite')} + + )} + {status === OrganizationInvitationStatus.Pending && ( + } + type="danger" + onClick={() => { + void handleRevoke(id); + }} + > + {t('menu_options.revoke')} + + )} + {status !== OrganizationInvitationStatus.Pending && ( + } + type="danger" + onClick={() => { + void handleDelete(id); + }} + > + {t('menu_options.delete_invitation_record')} + + )} + ), }, ]} rowIndexKey="id" /> - {/* TODO: Implemented in the follow-up PR */} - {/* {showInviteModal && } */} + {showInviteModal && ( + { + setShowInviteModal(false); + void mutate(); + }} + /> + )} ); } diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.module.scss b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.module.scss new file mode 100644 index 00000000000..5ed7b320c4a --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.module.scss @@ -0,0 +1,88 @@ +@use '@/scss/underscore' as _; + +.input { + display: flex; + align-items: flex-start; + justify-content: space-between; + min-height: 96px; + padding: 0 _.unit(2) 0 _.unit(3); + background: var(--color-layer-1); + border: 1px solid var(--color-border); + border-radius: 8px; + outline: 3px solid transparent; + transition-property: outline, border; + transition-timing-function: ease-in-out; + transition-duration: 0.2s; + font: var(--font-body-2); + cursor: pointer; + position: relative; + + &.multiple { + justify-content: flex-start; + flex-wrap: wrap; + gap: _.unit(2); + padding: _.unit(1.5) _.unit(3); + cursor: text; + + .tag { + cursor: auto; + display: flex; + align-items: center; + gap: _.unit(1); + position: relative; + + &.focused::after { + content: ''; + position: absolute; + inset: 0; + background: var(--color-overlay-default-focused); + } + + &.info { + background: var(--color-error-container); + } + } + + .close { + width: 16px; + height: 16px; + } + + .delete { + width: 20px; + height: 20px; + margin-right: _.unit(-0.5); + } + + input { + color: var(--color-text); + font: var(--font-body-2); + background: transparent; + flex-grow: 1; + padding: _.unit(0.5); + + &::placeholder { + color: var(--color-placeholder); + } + } + } + + &:focus-within { + border-color: var(--color-primary); + outline-color: var(--color-focused-variant); + } + + &.error { + border-color: var(--color-error); + + &:focus-within { + outline-color: var(--color-danger-focused); + } + } +} + +.errorMessage { + font: var(--font-body-2); + color: var(--color-error); + margin-top: _.unit(1); +} diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.tsx new file mode 100644 index 00000000000..393e55035c1 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/index.tsx @@ -0,0 +1,157 @@ +import { emailRegEx } from '@logto/core-kit'; +import { generateStandardShortId } from '@logto/shared/universal'; +import { conditional, type Nullable } from '@silverhand/essentials'; +import classNames from 'classnames'; +import { useRef, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; + +import Close from '@/assets/icons/close.svg'; +import IconButton from '@/ds-components/IconButton'; +import Tag from '@/ds-components/Tag'; +import { onKeyDownHandler } from '@/utils/a11y'; + +import type { InviteeEmailItem, InviteMemberForm } from '../types'; + +import * as styles from './index.module.scss'; +import { emailOptionsParser } from './utils'; + +type Props = { + className?: string; + values: InviteeEmailItem[]; + onChange: (values: InviteeEmailItem[]) => void; + error?: string | boolean; + placeholder?: string; +}; + +function InviteEmailsInput({ + className, + values, + onChange: rawOnChange, + error, + placeholder, +}: Props) { + const ref = useRef(null); + const [focusedValueId, setFocusedValueId] = useState>(null); + const [currentValue, setCurrentValue] = useState(''); + const { setError, clearErrors } = useFormContext(); + + const onChange = (values: InviteeEmailItem[]) => { + const { values: parsedValues, errorMessage } = emailOptionsParser(values); + if (errorMessage) { + setError('emails', { type: 'custom', message: errorMessage }); + } else { + clearErrors('emails'); + } + rawOnChange(parsedValues); + }; + + const handleAdd = (value: string) => { + const newValues: InviteeEmailItem[] = [ + ...values, + { + value, + id: generateStandardShortId(), + ...conditional(!emailRegEx.test(value) && { status: 'info' }), + }, + ]; + onChange(newValues); + setCurrentValue(''); + ref.current?.focus(); + }; + + const handleDelete = (option: InviteeEmailItem) => { + onChange(values.filter(({ id }) => id !== option.id)); + }; + + return ( + <> +
{ + ref.current?.focus(); + })} + onClick={() => { + ref.current?.focus(); + }} + > + {values.map((option) => ( + { + ref.current?.focus(); + }} + > + {option.value} + { + handleDelete(option); + }} + onKeyDown={onKeyDownHandler(() => { + handleDelete(option); + })} + > + + + + ))} + { + if (event.key === 'Backspace' && currentValue === '') { + if (focusedValueId) { + onChange(values.filter(({ id }) => id !== focusedValueId)); + setFocusedValueId(null); + } else { + setFocusedValueId(values.at(-1)?.id ?? null); + } + ref.current?.focus(); + } + if (event.key === ' ' || event.code === 'Space' || event.key === 'Enter') { + // Focusing on input + if (currentValue !== '' && document.activeElement === ref.current) { + handleAdd(currentValue); + } + // Do not react to "Enter" + event.preventDefault(); + } + }} + onChange={({ currentTarget: { value } }) => { + setCurrentValue(value); + setFocusedValueId(null); + }} + onFocus={() => { + ref.current?.focus(); + }} + onBlur={() => { + if (currentValue !== '') { + handleAdd(currentValue); + } + setFocusedValueId(null); + }} + /> +
+ {Boolean(error) && typeof error === 'string' && ( +
{error}
+ )} + + ); +} + +export default InviteEmailsInput; diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/utils.ts b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/utils.ts new file mode 100644 index 00000000000..41edc4645de --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteEmailsInput/utils.ts @@ -0,0 +1,68 @@ +import { emailRegEx } from '@logto/core-kit'; +import { conditional, conditionalArray, conditionalString } from '@silverhand/essentials'; +import { t as globalTranslate } from 'i18next'; + +import { type InviteeEmailItem } from '../types'; + +export const emailOptionsParser = ( + inputValues: InviteeEmailItem[] +): { + values: InviteeEmailItem[]; + errorMessage?: string; +} => { + const { duplicatedEmails, invalidEmails } = findDuplicatedOrInvalidEmails( + inputValues.map((email) => email.value) + ); + // Show error message and update the inputs' status for error display. + if (duplicatedEmails.size > 0 || invalidEmails.size > 0) { + return { + values: inputValues.map(({ status, ...rest }) => ({ + ...rest, + ...conditional( + (duplicatedEmails.has(rest.value) || invalidEmails.has(rest.value)) && { + status: 'info', + } + ), + })), + errorMessage: conditionalArray( + conditionalString( + duplicatedEmails.size > 0 && + globalTranslate('admin_console.tenant_members.errors.user_exists') + ), + conditionalString( + invalidEmails.size > 0 && + globalTranslate('admin_console.tenant_members.errors.invalid_email') + ) + ).join(' '), + }; + } + + return { values: inputValues }; +}; + +/** + * Find duplicated and invalid formatted email addresses. + * + * @param emails Array of email emails. + * @returns + */ +const findDuplicatedOrInvalidEmails = (emails: string[] = []) => { + const duplicatedEmails = new Set(); + const invalidEmails = new Set(); + const validEmails = new Set(); + + for (const email of emails) { + if (!emailRegEx.test(email)) { + invalidEmails.add(email); + } + + if (validEmails.has(email)) { + duplicatedEmails.add(email); + } + } + + return { + duplicatedEmails, + invalidEmails, + }; +}; diff --git a/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx new file mode 100644 index 00000000000..275c297b360 --- /dev/null +++ b/packages/console/src/pages/TenantSettings/TenantMembers/InviteMemberModal/index.tsx @@ -0,0 +1,159 @@ +import { ReservedPlanId, TenantRole } from '@logto/schemas'; +import { useContext, useMemo, useState } from 'react'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; +import { toast } from 'react-hot-toast'; +import { useTranslation } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import { useAuthedCloudApi } from '@/cloud/hooks/use-cloud-api'; +import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider'; +import { TenantsContext } from '@/contexts/TenantsProvider'; +import Button from '@/ds-components/Button'; +import FormField from '@/ds-components/FormField'; +import ModalLayout from '@/ds-components/ModalLayout'; +import Select, { type Option } from '@/ds-components/Select'; +import * as modalStyles from '@/scss/modal.module.scss'; + +import InviteEmailsInput from '../InviteEmailsInput'; +import { emailOptionsParser } from '../InviteEmailsInput/utils'; +import { type InviteMemberForm } from '../types'; + +type Props = { + isOpen: boolean; + onClose: (isSuccessful?: boolean) => void; +}; + +function InviteMemberModal({ isOpen, onClose }: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.tenant_members' }); + const { currentPlan } = useContext(SubscriptionDataContext); + const { currentTenantId, isDevTenant } = useContext(TenantsContext); + const tenantMembersMaxLimit = useMemo(() => { + if (isDevTenant) { + return 10; + } + if (currentPlan.id === ReservedPlanId.Pro) { + return 3; + } + // Free plan can only have 1 admin, no other members allowed. + return 1; + }, [currentPlan.id, isDevTenant]); + + const [isLoading, setIsLoading] = useState(false); + const cloudApi = useAuthedCloudApi(); + + const formMethods = useForm({ + defaultValues: { + emails: [], + role: TenantRole.Member, + }, + }); + + const { + control, + handleSubmit, + setError, + formState: { errors }, + } = formMethods; + + const roleOptions: Array> = useMemo( + () => [ + { value: TenantRole.Admin, title: t('admin') }, + { value: TenantRole.Member, title: t('member') }, + ], + [t] + ); + + const onSubmit = handleSubmit(async ({ emails, role }) => { + setIsLoading(true); + try { + // Count the current tenant members + const members = await cloudApi.get(`/api/tenants/:tenantId/members`, { + params: { tenantId: currentTenantId }, + }); + // Check if it will exceed the tenant member limit + if (emails.length + members.length > tenantMembersMaxLimit) { + setError('emails', { + type: 'custom', + message: t('errors.max_member_limit', { limit: tenantMembersMaxLimit }), + }); + return; + } + + await Promise.all( + emails.map(async (email) => + cloudApi.post('/api/tenants/:tenantId/invitations', { + params: { tenantId: currentTenantId }, + body: { invitee: email.value, roleName: role }, + }) + ) + ); + toast.success(t('messages.invitation_sent')); + onClose(true); + } finally { + setIsLoading(false); + } + }); + + return ( + { + onClose(); + }} + > + + } + onClose={onClose} + > + + + { + if (value.length === 0) { + return t('errors.email_required'); + } + const { errorMessage } = emailOptionsParser(value); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + return errorMessage || true; + }, + }} + render={({ field: { onChange, value } }) => ( + + )} + /> + + + ( +