mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(console): invite collaborators during onboarding (#5938)
This commit is contained in:
parent
a0bcc8340f
commit
3250163b69
8 changed files with 144 additions and 35 deletions
|
@ -61,6 +61,33 @@ export const useCloudApi = ({ hideErrorToast = false }: UseCloudApiProps = {}):
|
|||
return api;
|
||||
};
|
||||
|
||||
type CreateTenantOptions = UseCloudApiProps &
|
||||
Pick<ReturnType<typeof useLogto>, 'isAuthenticated' | 'getOrganizationToken'> & {
|
||||
tenantId: string;
|
||||
};
|
||||
|
||||
export const createTenantApi = ({
|
||||
hideErrorToast = false,
|
||||
isAuthenticated,
|
||||
getOrganizationToken,
|
||||
tenantId,
|
||||
}: CreateTenantOptions) =>
|
||||
new Client<typeof tenantAuthRouter>({
|
||||
baseUrl: window.location.origin,
|
||||
headers: async () => {
|
||||
if (isAuthenticated) {
|
||||
return {
|
||||
Authorization: `Bearer ${
|
||||
(await getOrganizationToken(getTenantOrganizationId(tenantId))) ?? ''
|
||||
}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
before: {
|
||||
...conditional(!hideErrorToast && { error: toastResponseError }),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* This hook is used to request the cloud `tenantAuthRouter` endpoints, with an organization token.
|
||||
*/
|
||||
|
@ -71,20 +98,11 @@ export const useAuthedCloudApi = ({ hideErrorToast = false }: UseCloudApiProps =
|
|||
const { isAuthenticated, getOrganizationToken } = useLogto();
|
||||
const api = useMemo(
|
||||
() =>
|
||||
new Client<typeof tenantAuthRouter>({
|
||||
baseUrl: window.location.origin,
|
||||
headers: async () => {
|
||||
if (isAuthenticated) {
|
||||
return {
|
||||
Authorization: `Bearer ${
|
||||
(await getOrganizationToken(getTenantOrganizationId(currentTenantId))) ?? ''
|
||||
}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
before: {
|
||||
...conditional(!hideErrorToast && { error: toastResponseError }),
|
||||
},
|
||||
createTenantApi({
|
||||
hideErrorToast,
|
||||
isAuthenticated,
|
||||
getOrganizationToken,
|
||||
tenantId: currentTenantId,
|
||||
}),
|
||||
[currentTenantId, getOrganizationToken, hideErrorToast, isAuthenticated]
|
||||
);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { Theme, TenantTag } from '@logto/schemas';
|
||||
import { useState } from 'react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import CreateTenantHeaderIconDark from '@/assets/icons/create-tenant-header-dark.svg';
|
||||
|
@ -53,11 +55,13 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
|||
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag, regionName } });
|
||||
onClose(newTenant);
|
||||
};
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const onCreateClick = handleSubmit(async (data: CreateTenantData) => {
|
||||
const { tag } = data;
|
||||
if (tag === TenantTag.Development) {
|
||||
await createTenant(data);
|
||||
toast.success(t('tenants.create_modal.tenant_created'));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -168,6 +172,7 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
|||
* Note: only close the create tenant modal when tenant is created successfully
|
||||
*/
|
||||
onClose(tenant);
|
||||
toast.success(t('tenants.create_modal.tenant_created'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -1,12 +1,15 @@
|
|||
import { Theme } from '@logto/schemas';
|
||||
import { emailRegEx } from '@logto/core-kit';
|
||||
import { useLogto } from '@logto/react';
|
||||
import { TenantRole, Theme } from '@logto/schemas';
|
||||
import { joinPath } from '@silverhand/essentials';
|
||||
import { useContext } from 'react';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CreateTenantHeaderIconDark from '@/assets/icons/create-tenant-header-dark.svg';
|
||||
import CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg';
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { createTenantApi, useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import ActionBar from '@/components/ActionBar';
|
||||
import { type CreateTenantData } from '@/components/CreateTenantModal/types';
|
||||
import PageMeta from '@/components/PageMeta';
|
||||
|
@ -23,12 +26,16 @@ import useTenantPathname from '@/hooks/use-tenant-pathname';
|
|||
import useTheme from '@/hooks/use-theme';
|
||||
import * as pageLayout from '@/onboarding/scss/layout.module.scss';
|
||||
import { OnboardingPage, OnboardingRoute } from '@/onboarding/types';
|
||||
import InviteEmailsInput from '@/pages/TenantSettings/TenantMembers/InviteEmailsInput';
|
||||
import { type InviteeEmailItem } from '@/pages/TenantSettings/TenantMembers/types';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
||||
type CreateTenantForm = Omit<CreateTenantData, 'tag'>;
|
||||
type CreateTenantForm = Omit<CreateTenantData, 'tag'> & { collaboratorEmails: InviteeEmailItem[] };
|
||||
|
||||
function CreateTenant() {
|
||||
const methods = useForm<CreateTenantForm>({ defaultValues: { regionName: RegionName.EU } });
|
||||
const methods = useForm<CreateTenantForm>({
|
||||
defaultValues: { regionName: RegionName.EU, collaboratorEmails: [] },
|
||||
});
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
|
@ -39,13 +46,51 @@ function CreateTenant() {
|
|||
const { prependTenant } = useContext(TenantsContext);
|
||||
const theme = useTheme();
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const parseEmailOptions = useCallback(
|
||||
(values: InviteeEmailItem[]) => {
|
||||
const validEmails = values.filter(({ value }) => emailRegEx.test(value));
|
||||
|
||||
return {
|
||||
values: validEmails,
|
||||
errorMessage:
|
||||
values.length === validEmails.length
|
||||
? undefined
|
||||
: t('tenant_members.errors.invalid_email'),
|
||||
};
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const { isAuthenticated, getOrganizationToken } = useLogto();
|
||||
const cloudApi = useCloudApi();
|
||||
|
||||
const onCreateClick = handleSubmit(
|
||||
trySubmitSafe(async ({ name, regionName }: CreateTenantForm) => {
|
||||
trySubmitSafe(async ({ name, regionName, collaboratorEmails }: CreateTenantForm) => {
|
||||
const newTenant = await cloudApi.post('/api/tenants', { body: { name, regionName } });
|
||||
prependTenant(newTenant);
|
||||
toast.success(t('tenants.create_modal.tenant_created'));
|
||||
|
||||
const tenantCloudApi = createTenantApi({
|
||||
hideErrorToast: true,
|
||||
isAuthenticated,
|
||||
getOrganizationToken,
|
||||
tenantId: newTenant.id,
|
||||
});
|
||||
|
||||
// Should not block the onboarding flow if the invitation fails.
|
||||
try {
|
||||
await Promise.all(
|
||||
collaboratorEmails.map(async (email) =>
|
||||
tenantCloudApi.post('/api/tenants/:tenantId/invitations', {
|
||||
params: { tenantId: newTenant.id },
|
||||
body: { invitee: email.value, roleName: TenantRole.Collaborator },
|
||||
})
|
||||
)
|
||||
);
|
||||
toast.success(t('tenant_members.messages.invitation_sent'));
|
||||
} catch {
|
||||
toast.error(t('tenants.create_modal.invitation_failed', { duration: 5 }));
|
||||
}
|
||||
navigate(joinPath(OnboardingRoute.Onboarding, newTenant.id, OnboardingPage.SignInExperience));
|
||||
})
|
||||
);
|
||||
|
@ -64,6 +109,7 @@ function CreateTenant() {
|
|||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
placeholder="My project"
|
||||
disabled={isSubmitting}
|
||||
{...register('name', { required: true })}
|
||||
error={Boolean(errors.name)}
|
||||
/>
|
||||
|
@ -91,13 +137,36 @@ function CreateTenant() {
|
|||
</DangerousRaw>
|
||||
}
|
||||
value={region}
|
||||
isDisabled={!isDevFeaturesEnabled && region !== RegionName.EU}
|
||||
isDisabled={
|
||||
isSubmitting || (!isDevFeaturesEnabled && region !== RegionName.EU)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField title="cloud.create_tenant.invite_collaborators">
|
||||
<Controller
|
||||
name="collaboratorEmails"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value): string | true => {
|
||||
return parseEmailOptions(value).errorMessage ?? true;
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InviteEmailsInput
|
||||
formName="collaboratorEmails"
|
||||
values={value}
|
||||
error={errors.collaboratorEmails?.message}
|
||||
placeholder={t('tenant_members.invite_modal.email_input_placeholder')}
|
||||
parseEmailOptions={parseEmailOptions}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormProvider>
|
||||
</div>
|
||||
</OverlayScrollbar>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
transition-timing-function: ease-in-out;
|
||||
transition-duration: 0.2s;
|
||||
font: var(--font-body-2);
|
||||
cursor: pointer;
|
||||
cursor: text;
|
||||
position: relative;
|
||||
|
||||
.wrapper {
|
||||
|
@ -23,7 +23,6 @@
|
|||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: _.unit(2);
|
||||
cursor: text;
|
||||
|
||||
.tag {
|
||||
cursor: auto;
|
||||
|
|
|
@ -10,17 +10,25 @@ import IconButton from '@/ds-components/IconButton';
|
|||
import Tag from '@/ds-components/Tag';
|
||||
import { onKeyDownHandler } from '@/utils/a11y';
|
||||
|
||||
import type { InviteeEmailItem, InviteMemberForm } from '../types';
|
||||
import type { InviteeEmailItem } from '../types';
|
||||
|
||||
import useEmailInputUtils from './hooks';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
readonly formName?: string;
|
||||
readonly className?: string;
|
||||
readonly values: InviteeEmailItem[];
|
||||
readonly onChange: (values: InviteeEmailItem[]) => void;
|
||||
readonly error?: string | boolean;
|
||||
readonly placeholder?: string;
|
||||
/**
|
||||
* Function to check for duplicated or invalid email addresses. It should return valid email addresses
|
||||
* and an error message if any.
|
||||
*/
|
||||
readonly parseEmailOptions: (values: InviteeEmailItem[]) => {
|
||||
values: InviteeEmailItem[];
|
||||
errorMessage?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -31,17 +39,18 @@ const fontBody2 =
|
|||
'400 14px / 20px -apple-system, system-ui, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Helvetica, Arial, sans-serif, Apple Color Emoji';
|
||||
|
||||
function InviteEmailsInput({
|
||||
formName = 'emails',
|
||||
className,
|
||||
values,
|
||||
onChange: rawOnChange,
|
||||
error,
|
||||
placeholder,
|
||||
parseEmailOptions,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const [focusedValueId, setFocusedValueId] = useState<Nullable<string>>(null);
|
||||
const [currentValue, setCurrentValue] = useState('');
|
||||
const { setError, clearErrors } = useFormContext<InviteMemberForm>();
|
||||
const { parseEmailOptions } = useEmailInputUtils();
|
||||
const { setError, clearErrors } = useFormContext();
|
||||
const [minInputWidth, setMinInputWidth] = useState<number>(0);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
|
@ -55,14 +64,17 @@ function InviteEmailsInput({
|
|||
setMinInputWidth(ctx.measureText(currentValue).width);
|
||||
}, [currentValue]);
|
||||
|
||||
const onChange = (values: InviteeEmailItem[]) => {
|
||||
const onChange = (values: InviteeEmailItem[]): boolean => {
|
||||
const { values: parsedValues, errorMessage } = parseEmailOptions(values);
|
||||
|
||||
if (errorMessage) {
|
||||
setError('emails', { type: 'custom', message: errorMessage });
|
||||
} else {
|
||||
clearErrors('emails');
|
||||
setError(formName, { type: 'custom', message: errorMessage });
|
||||
return false;
|
||||
}
|
||||
|
||||
clearErrors(formName);
|
||||
rawOnChange(parsedValues);
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAdd = (value: string) => {
|
||||
|
@ -74,9 +86,10 @@ function InviteEmailsInput({
|
|||
...conditional(!emailRegEx.test(value) && { status: 'error' }),
|
||||
},
|
||||
];
|
||||
onChange(newValues);
|
||||
setCurrentValue('');
|
||||
ref.current?.focus();
|
||||
if (onChange(newValues)) {
|
||||
setCurrentValue('');
|
||||
ref.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (option: InviteeEmailItem) => {
|
||||
|
|
|
@ -124,7 +124,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
|
|||
name="emails"
|
||||
control={control}
|
||||
rules={{
|
||||
validate: (value) => {
|
||||
validate: (value): string | true => {
|
||||
if (value.length === 0) {
|
||||
return t('errors.email_required');
|
||||
}
|
||||
|
@ -138,6 +138,7 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
|
|||
values={value}
|
||||
error={errors.emails?.message}
|
||||
placeholder={t('invite_modal.email_input_placeholder')}
|
||||
parseEmailOptions={parseEmailOptions}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -38,6 +38,7 @@ const cloud = {
|
|||
title: 'Create your first tenant',
|
||||
description:
|
||||
'A tenant is an isolated environment where you can manage user identities, applications, and all other Logto resources.',
|
||||
invite_collaborators: 'Invite your collaborators by email',
|
||||
},
|
||||
sie: {
|
||||
page_title: 'Customize sign-in experience',
|
||||
|
|
|
@ -56,6 +56,9 @@ const tenants = {
|
|||
available_plan: 'Available plan:',
|
||||
create_button: 'Create tenant',
|
||||
tenant_name_placeholder: 'My tenant',
|
||||
tenant_created: 'Tenant created successfully.',
|
||||
invitation_failed:
|
||||
'Some invitation failed to send. Please try again in Settings -> Members later.',
|
||||
},
|
||||
dev_tenant_migration: {
|
||||
title: 'You can now try our Pro features for free by creating a new "Development tenant"!',
|
||||
|
|
Loading…
Add table
Reference in a new issue