0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

feat(console): invite members dialog (#5531)

This commit is contained in:
Charles Zhao 2024-03-26 15:43:48 +08:00 committed by GitHub
parent bca0ce98aa
commit c7a23dfe92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 940 additions and 56 deletions

View file

@ -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<Option<TenantRole>> = 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);
}
}}
/>
</FormField>

View file

@ -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 (
<>
@ -62,7 +107,7 @@ function Invitations() {
description="tenant_members.invitation_empty_placeholder.description"
action={
<Button
title="tenant_members.invite_member"
title="tenant_members.invite_members"
type="primary"
size="large"
icon={<Plus />}
@ -124,29 +169,65 @@ function Invitations() {
{
dataIndex: 'actions',
title: null,
render: (invitation) => (
<ActionsButton
deleteConfirmation="tenant_members.delete_user_confirm"
fieldName="tenant_members.user"
textOverrides={{
edit: 'tenant_members.menu_options.resend_invite',
delete: 'tenant_members.menu_options.revoke',
deleteConfirmation: 'general.remove',
}}
onDelete={async () => {
await cloudApi.delete(`/api/tenants/:tenantId/invitations/:invitationId`, {
params: { tenantId: currentTenantId, invitationId: invitation.id },
});
void mutate();
}}
/>
render: ({ id, status }) => (
<ActionMenu
icon={<More />}
iconSize="small"
title={<DynamicT forKey="general.more_options" />}
>
{status !== OrganizationInvitationStatus.Accepted && (
<ActionMenuItem
icon={<Invite />}
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')}
</ActionMenuItem>
)}
{status === OrganizationInvitationStatus.Pending && (
<ActionMenuItem
icon={<Redo />}
type="danger"
onClick={() => {
void handleRevoke(id);
}}
>
{t('menu_options.revoke')}
</ActionMenuItem>
)}
{status !== OrganizationInvitationStatus.Pending && (
<ActionMenuItem
icon={<Delete />}
type="danger"
onClick={() => {
void handleDelete(id);
}}
>
{t('menu_options.delete_invitation_record')}
</ActionMenuItem>
)}
</ActionMenu>
),
},
]}
rowIndexKey="id"
/>
{/* TODO: Implemented in the follow-up PR */}
{/* {showInviteModal && <InviteModal isOpen={showInviteModal} />} */}
{showInviteModal && (
<InviteMemberModal
isOpen={showInviteModal}
onClose={() => {
setShowInviteModal(false);
void mutate();
}}
/>
)}
</>
);
}

View file

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

View file

@ -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<HTMLInputElement>(null);
const [focusedValueId, setFocusedValueId] = useState<Nullable<string>>(null);
const [currentValue, setCurrentValue] = useState('');
const { setError, clearErrors } = useFormContext<InviteMemberForm>();
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 (
<>
<div
className={classNames(
styles.input,
styles.multiple,
Boolean(error) && styles.error,
className
)}
role="button"
tabIndex={0}
onKeyDown={onKeyDownHandler(() => {
ref.current?.focus();
})}
onClick={() => {
ref.current?.focus();
}}
>
{values.map((option) => (
<Tag
key={option.id}
variant="cell"
className={classNames(
styles.tag,
option.status && styles[option.status],
option.id === focusedValueId && styles.focused
)}
onClick={() => {
ref.current?.focus();
}}
>
{option.value}
<IconButton
className={styles.delete}
size="small"
onClick={() => {
handleDelete(option);
}}
onKeyDown={onKeyDownHandler(() => {
handleDelete(option);
})}
>
<Close className={styles.close} />
</IconButton>
</Tag>
))}
<input
ref={ref}
placeholder={conditional(values.length === 0 && placeholder)}
value={currentValue}
onKeyDown={(event) => {
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);
}}
/>
</div>
{Boolean(error) && typeof error === 'string' && (
<div className={styles.errorMessage}>{error}</div>
)}
</>
);
}
export default InviteEmailsInput;

View file

@ -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<string>();
const invalidEmails = new Set<string>();
const validEmails = new Set<string>();
for (const email of emails) {
if (!emailRegEx.test(email)) {
invalidEmails.add(email);
}
if (validEmails.has(email)) {
duplicatedEmails.add(email);
}
}
return {
duplicatedEmails,
invalidEmails,
};
};

View file

@ -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<InviteMemberForm>({
defaultValues: {
emails: [],
role: TenantRole.Member,
},
});
const {
control,
handleSubmit,
setError,
formState: { errors },
} = formMethods;
const roleOptions: Array<Option<TenantRole>> = 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 (
<ReactModal
isOpen={isOpen}
className={modalStyles.content}
overlayClassName={modalStyles.overlay}
onRequestClose={() => {
onClose();
}}
>
<ModalLayout
title="tenant_members.invite_modal.title"
footer={
<Button
size="large"
type="primary"
title="tenant_members.invite_members"
isLoading={isLoading}
onClick={onSubmit}
/>
}
onClose={onClose}
>
<FormProvider {...formMethods}>
<FormField isRequired title="tenant_members.invite_modal.to">
<Controller
name="emails"
control={control}
rules={{
validate: (value) => {
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 } }) => (
<InviteEmailsInput
values={value}
error={errors.emails?.message}
placeholder={t('invite_modal.email_input_placeholder')}
onChange={onChange}
/>
)}
/>
</FormField>
<FormField title="tenant_members.roles">
<Controller
name="role"
control={control}
render={({ field: { onChange, value } }) => (
<Select options={roleOptions} value={value} onChange={onChange} />
)}
/>
</FormField>
</FormProvider>
</ModalLayout>
</ReactModal>
);
}
export default InviteMemberModal;

View file

@ -1,4 +1,5 @@
import classNames from 'classnames';
import { useState } from 'react';
import { Route, Routes } from 'react-router-dom';
import InvitationIcon from '@/assets/icons/invitation.svg';
@ -11,6 +12,7 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
import NotFound from '@/pages/NotFound';
import Invitations from './Invitations';
import InviteMemberModal from './InviteMemberModal';
import Members from './Members';
import * as styles from './index.module.scss';
@ -18,6 +20,7 @@ const invitationsRoute = 'invitations';
function TenantMembers() {
const { navigate, match } = useTenantPathname();
const [showInviteModal, setShowInviteModal] = useState(false);
const isInvitationTab = match(
`/tenant-settings/${TenantSettingsTabs.Members}/${invitationsRoute}`
@ -47,7 +50,10 @@ function TenantMembers() {
type="primary"
size="large"
icon={<PlusIcon />}
title="tenant_members.invite_member"
title="tenant_members.invite_members"
onClick={() => {
setShowInviteModal(true);
}}
/>
</div>
<Routes>
@ -55,6 +61,17 @@ function TenantMembers() {
<Route index element={<Members />} />
<Route path={invitationsRoute} element={<Invitations />} />
</Routes>
{showInviteModal && (
<InviteMemberModal
isOpen={showInviteModal}
onClose={(isSuccessful) => {
setShowInviteModal(false);
if (isSuccessful) {
navigate('invitations');
}
}}
/>
)}
</div>
);
}

View file

@ -0,0 +1,23 @@
import { type TenantRole } from '@logto/schemas';
import { type Props as TagProps } from '@/ds-components/Tag';
export type InviteeEmailItem = {
/**
* Generate a random unique id for each option to handle deletion.
* Sometimes we may have options with the same value, which is allowed when inputting but prohibited when submitting.
*/
id: string;
value: string;
/**
* The `status` is used to indicate the input status of the email item (could fall into following categories):
* - undefined: valid email
* - 'info': duplicated email or invalid email format.
*/
status?: Extract<TagProps['status'], 'info'>;
};
export type InviteMemberForm = {
emails: InviteeEmailItem[];
role: TenantRole;
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -1,7 +1,7 @@
const tenant_members = {
members: 'Members',
invitations: 'Invitations',
invite_member: 'Invite member',
invite_members: 'Invite members',
user: 'User',
roles: 'Roles',
admin: 'Admin',
@ -14,6 +14,7 @@ const tenant_members = {
subtitle: 'To invite members to an organization, they must accept the invitation.',
to: 'To',
added_as: 'Added as roles',
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
pending: 'Pending',
@ -39,9 +40,19 @@ const tenant_members = {
delete_user_confirm: 'Are you sure you want to remove this user from this tenant?',
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
invitation_sent: 'Invitation sent.',
invitation_revoked: 'Invitation revoked.',
invitation_resend: 'Invitation resent.',
invitation_deleted: 'Invitation record deleted.',
},
errors: {
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
user_exists: 'This user is already in this organization.',
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};

View file

@ -4,7 +4,7 @@ const tenant_members = {
/** UNTRANSLATED */
invitations: 'Invitations',
/** UNTRANSLATED */
invite_member: 'Invite member',
invite_members: 'Invite members',
/** UNTRANSLATED */
user: 'User',
/** UNTRANSLATED */
@ -28,6 +28,8 @@ const tenant_members = {
to: 'To',
/** UNTRANSLATED */
added_as: 'Added as roles',
/** UNTRANSLATED */
email_input_placeholder: 'johndoe@example.com',
},
invitation_statuses: {
/** UNTRANSLATED */
@ -67,11 +69,29 @@ const tenant_members = {
/** UNTRANSLATED */
assign_admin_confirm:
'Are you sure you want to make the selected user(s) admin? Granting admin access will give the user(s) the following permissions.<ul><li>Change the tenant billing plan</li><li>Add or remove collaborators</li><li>Delete the tenant</li></ul>',
/** UNTRANSLATED */
revoke_invitation_confirm: 'Are you sure you want to revoke this invitation?',
/** UNTRANSLATED */
delete_invitation_confirm: 'Are you sure you want to delete this invitation record?',
messages: {
/** UNTRANSLATED */
invitation_sent: 'Invitation sent.',
/** UNTRANSLATED */
invitation_revoked: 'Invitation revoked.',
/** UNTRANSLATED */
invitation_resend: 'Invitation resent.',
/** UNTRANSLATED */
invitation_deleted: 'Invitation record deleted.',
},
errors: {
/** UNTRANSLATED */
user_exists: 'This user is already in this organization',
email_required: 'Invitee email is required.',
/** UNTRANSLATED */
user_exists: 'This user is already in this organization.',
/** UNTRANSLATED */
invalid_email: 'Email address is invalid. Please make sure it is in the right format.',
/** UNTRANSLATED */
max_member_limit: 'You have reached the maximum number of members ({{limit}}) for this tenant.',
},
};