Skip to content

Commit

Permalink
feat(console): invite collaborators during onboarding (#5938)
Browse files Browse the repository at this point in the history
  • Loading branch information
gao-sun authored May 29, 2024
1 parent a0bcc83 commit 3250163
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 35 deletions.
46 changes: 32 additions & 14 deletions packages/console/src/cloud/hooks/use-cloud-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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]
);
Expand Down
5 changes: 5 additions & 0 deletions packages/console/src/components/CreateTenantModal/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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'));
}
}}
/>
Expand Down
83 changes: 76 additions & 7 deletions packages/console/src/onboarding/pages/CreateTenant/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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));
})
);
Expand All @@ -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)}
/>
Expand Down Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,7 +23,6 @@
justify-content: flex-start;
flex-wrap: wrap;
gap: _.unit(2);
cursor: text;

.tag {
cursor: auto;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};

/**
Expand All @@ -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);

Expand All @@ -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) => {
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"!',
Expand Down

0 comments on commit 3250163

Please sign in to comment.