From 32a9776c50383a6ba4b8597c43b506cd6dd19fd3 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 8 Aug 2023 12:22:28 +0300 Subject: [PATCH 01/18] feat(localizations,types): Create localization keys for Organization domains --- packages/localizations/src/en-US.ts | 27 +++++++++++++++++++++++++++ packages/types/src/localization.ts | 25 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 1e9dfd68d9e..23b811c8dfd 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -29,11 +29,13 @@ export const enUS: LocalizationResource = { formFieldLabel__newPassword: 'New password', formFieldLabel__confirmPassword: 'Confirm password', formFieldLabel__signOutOfOtherSessions: 'Sign out of all other devices', + formFieldLabel__automaticInvitations: 'Enable automatic invitations for this domain', formFieldLabel__firstName: 'First name', formFieldLabel__lastName: 'Last name', formFieldLabel__backupCode: 'Backup code', formFieldLabel__organizationName: 'Organization name', formFieldLabel__organizationSlug: 'Slug URL', + formFieldLabel__organizationEmailDomain: 'Email domain', formFieldLabel__confirmDeletion: 'Confirmation', formFieldLabel__role: 'Role', formFieldInputPlaceholder__emailAddress: '', @@ -51,6 +53,7 @@ export const enUS: LocalizationResource = { formFieldInputPlaceholder__backupCode: '', formFieldInputPlaceholder__organizationName: '', formFieldInputPlaceholder__organizationSlug: '', + formFieldInputPlaceholder__organizationEmailDomain: '', formFieldError__notMatchingPasswords: `Passwords don't match.`, formFieldError__matchingPasswords: 'Passwords match.', formFieldAction__forgotPassword: 'Forgot password?', @@ -561,6 +564,30 @@ export const enUS: LocalizationResource = { successMessage: 'You have deleted the organization.', }, }, + domainSection: { + title: 'Verified Domains', + subtitle: + 'Setup a domain so everyone with email addresses at this domain can automatically join your organization.', + primaryButton: 'Add verified domain', + }, + }, + createDomainPage: { + title: 'Add verified domain', + subtitle: + 'Add a domain so new users signing up with email addresses at this domain can automatically join your organization as a member.', + }, + verifyDomainPage: { + title: 'Verify domain', + subtitle: 'Add an email address under {{domainName}} to receive a code and verify this domain.', + formTitle: 'Verification code', + formSubtitle: 'Enter the verification code sent to your email address', + resendButton: "Didn't receive a code? Resend", + }, + verifiedDomainPage: { + title: 'Verified domain', + actionLabel__remove: 'Remove verified domain', + formTitle: 'Automatic invitations', + formSubtitle: 'Users matching this domain are invited to join automatically.', }, invitePage: { title: 'Invite members', diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 6cedec83873..9c7edddb0d8 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -39,11 +39,13 @@ type _LocalizationResource = { formFieldLabel__newPassword: LocalizationValue; formFieldLabel__confirmPassword: LocalizationValue; formFieldLabel__signOutOfOtherSessions: LocalizationValue; + formFieldLabel__automaticInvitations: LocalizationValue; formFieldLabel__firstName: LocalizationValue; formFieldLabel__lastName: LocalizationValue; formFieldLabel__backupCode: LocalizationValue; formFieldLabel__organizationName: LocalizationValue; formFieldLabel__organizationSlug: LocalizationValue; + formFieldLabel__organizationEmailDomain: LocalizationValue; formFieldLabel__confirmDeletion: LocalizationValue; formFieldLabel__role: LocalizationValue; formFieldInputPlaceholder__emailAddress: LocalizationValue; @@ -69,6 +71,7 @@ type _LocalizationResource = { formFieldInputPlaceholder__backupCode: LocalizationValue; formFieldInputPlaceholder__organizationName: LocalizationValue; formFieldInputPlaceholder__organizationSlug: LocalizationValue; + formFieldInputPlaceholder__organizationEmailDomain: LocalizationValue; formFieldError__notMatchingPasswords: LocalizationValue; formFieldError__matchingPasswords: LocalizationValue; formFieldAction__forgotPassword: LocalizationValue; @@ -581,6 +584,28 @@ type _LocalizationResource = { successMessage: LocalizationValue; }; }; + domainSection: { + title: LocalizationValue; + subtitle: LocalizationValue; + primaryButton: LocalizationValue; + }; + }; + createDomainPage: { + title: LocalizationValue; + subtitle: LocalizationValue; + }; + verifyDomainPage: { + title: LocalizationValue; + subtitle: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; + resendButton: LocalizationValue; + }; + verifiedDomainPage: { + title: LocalizationValue; + actionLabel__remove: LocalizationValue; + formTitle: LocalizationValue; + formSubtitle: LocalizationValue; }; invitePage: { title: LocalizationValue; From 7d8d97b41481cbeb1964a85750d2799e0559d338 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 8 Aug 2023 12:22:37 +0300 Subject: [PATCH 02/18] feat(types): Update appearance element keys for Organization domains --- packages/types/src/appearance.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 7615e514d1a..3b484e4dea3 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -73,7 +73,8 @@ export type FieldId = | 'code' | 'role' | 'deleteConfirmation' - | 'deleteOrganizationConfirmation'; + | 'deleteOrganizationConfirmation' + | 'automaticInvitations'; export type ProfileSectionId = | 'profile' | 'username' @@ -87,7 +88,8 @@ export type ProfileSectionId = | 'danger' | 'activeDevices' | 'organizationProfile' - | 'organizationDanger'; + | 'organizationDanger' + | 'organizationDomains'; export type ProfilePageId = 'account' | 'security' | 'organizationSettings' | 'organizationMembers'; export type NavbarItemId = 'account' | 'security' | 'members' | 'settings'; From 15b8eb9f407a0133c2ef82bda849eff02c0959ea Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 8 Aug 2023 12:25:14 +0300 Subject: [PATCH 03/18] feat(clerk-js): Add Verified domains section and all routes to support it + New `/add-domain/` route + New `/verify-domain/:id` route + New `/verified-domain/:id` route --- .../OrganizationProfile/AddDomainPage.tsx | 70 +++++++ .../OrganizationProfileNavbar.tsx | 2 + .../OrganizationProfileRoutes.tsx | 35 ++++ .../OrganizationSettings.tsx | 114 ++++++++++- .../VerifiedDomainPage.tsx | 117 +++++++++++ .../OrganizationProfile/VerifyDomainPage.tsx | 190 ++++++++++++++++++ 6 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx create mode 100644 packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx create mode 100644 packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx new file mode 100644 index 00000000000..f4665587528 --- /dev/null +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/AddDomainPage.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +import { useCoreOrganization } from '../../contexts'; +import { localizationKeys } from '../../customizables'; +import { ContentPage, Form, FormButtons, useCardState, withCardStateProvider } from '../../elements'; +import { useRouter } from '../../router'; +import { handleError, useFormControl } from '../../utils'; +import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar'; + +export const AddDomainPage = withCardStateProvider(() => { + const title = localizationKeys('organizationProfile.createDomainPage.title'); + const subtitle = localizationKeys('organizationProfile.createDomainPage.subtitle'); + const card = useCardState(); + const { organization } = useCoreOrganization(); + const { navigate } = useRouter(); + + const nameField = useFormControl('name', '', { + type: 'text', + label: localizationKeys('formFieldLabel__organizationEmailDomain'), + placeholder: localizationKeys('formFieldInputPlaceholder__organizationName'), + }); + + if (!organization) { + return null; + } + + const dataChanged = organization.name !== nameField.value; + const canSubmit = dataChanged; + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault(); + return organization + .createDomain(nameField.value) + .then(res => { + if (res.verification && res.verification.status === 'verified') { + return navigate(`../domain/${res.id}`); + } + return navigate(`../domain/${res.id}/verify`); + }) + .catch(err => { + handleError(err, [nameField], card.setError); + }); + }; + + return ( + // + + + + + + + + + // + // + ); +}); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx index 5abac279b68..fff24f3b43a 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileNavbar.tsx @@ -51,6 +51,8 @@ export const OrganizationProfileNavbar = ( const pageToRootNavbarRouteMap = { 'invite-members': organizationProfileRoutes.find(r => r.id === 'members'), + 'add-domain': organizationProfileRoutes.find(r => r.id === 'settings'), + 'verify-domain/:id': organizationProfileRoutes.find(r => r.id === 'settings'), profile: organizationProfileRoutes.find(r => r.id === 'settings'), leave: organizationProfileRoutes.find(r => r.id === 'settings'), }; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index dc7fdb791bc..68c63fa30c9 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -2,10 +2,13 @@ import { ProfileCardContent } from '../../elements'; import { Route, Switch } from '../../router'; import type { PropsOfComponent } from '../../styledSystem'; import { DeleteOrganizationPage, LeaveOrganizationPage } from './ActionConfirmationPage'; +import { AddDomainPage } from './AddDomainPage'; import { InviteMembersPage } from './InviteMembersPage'; import { OrganizationMembers } from './OrganizationMembers'; import { OrganizationSettings } from './OrganizationSettings'; import { ProfileSettingsPage } from './ProfileSettingsPage'; +import { VerifiedDomainPage } from './VerifiedDomainPage'; +import { VerifyDomainPage } from './VerifyDomainPage'; export const OrganizationProfileRoutes = (props: PropsOfComponent) => { return ( @@ -18,6 +21,38 @@ export const OrganizationProfileRoutes = (props: PropsOfComponent + + + + + + + + + + + + + + {/**/} + {/* */} + {/**/} + {/**/} + {/* */} + {/**/} + + {/**/} + {/* */} + {/**/} { + @@ -57,6 +66,105 @@ const OrganizationProfileSection = () => { ); }; +const OrganizationDomainsSection = () => { + const { organization, membership, domains } = useCoreOrganization({ + domains: { + infinite: true, + }, + }); + + const { ref } = useInView({ + threshold: 0, + onChange: inView => { + if (inView) { + void domains?.fetchNext?.(); + } + }, + }); + + const { navigate } = useRouter(); + const isAdmin = membership?.role === 'admin'; + + if (!organization || !isAdmin) { + return null; + } + + return ( + + + {domains?.data?.map(d => ( + Verified + ) : ( + + Unverified + + ) + } + sx={t => ({ + padding: `${t.space.$3} ${t.space.$4}`, + minHeight: t.sizes.$10, + })} + onClick={() => { + d.verification && d.verification.status === 'verified' + ? void navigate(`domain/${d.id}`) + : void navigate(`domain/${d.id}/verify`); + }} + > + {d.name} + + ))} + {(domains?.hasNextPage || domains?.isFetching) && ( + ({ + width: '100%', + height: t.space.$10, + position: 'relative', + }), + ]} + > + + + + + )} + + + navigate('domain')} + /> + + ); +}; + const OrganizationDangerSection = () => { const { organization, diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx new file mode 100644 index 00000000000..4b7a5178bc6 --- /dev/null +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifiedDomainPage.tsx @@ -0,0 +1,117 @@ +import type { OrganizationDomainResource } from '@clerk/types'; +import React, { useEffect } from 'react'; + +import { useCoreOrganization } from '../../contexts'; +import { Badge, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; +import { ArrowBlockButton, ContentPage, Form, FormButtons, useCardState, withCardStateProvider } from '../../elements'; +import { useLoadingStatus } from '../../hooks'; +import { useRouter } from '../../router'; +import { handleError, useFormControl } from '../../utils'; +import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar'; + +export const VerifiedDomainPage = withCardStateProvider(() => { + const card = useCardState(); + const { organization } = useCoreOrganization(); + const { params, navigate } = useRouter(); + + const [domain, setDomain] = React.useState(null); + + const title = localizationKeys('organizationProfile.verifiedDomainPage.title'); + const domainStatus = useLoadingStatus({ + status: 'loading', + }); + + const automaticInvitationsField = useFormControl('automaticInvitations', '', { + type: 'checkbox', + label: localizationKeys('formFieldLabel__automaticInvitations'), + }); + + useEffect(() => { + domainStatus.setLoading(); + organization + ?.getDomain?.({ + domainId: params.id, + }) + .then(domain => { + domainStatus.setIdle(); + setDomain(domain); + automaticInvitationsField.setChecked(domain.enrollmentMode === 'automatic_invitation'); + }) + .catch(() => { + setDomain(null); + domainStatus.setError(); + }); + }, [params.id]); + + const updateEnrollmentMode = async () => { + if (!domain || !organization) { + return; + } + + try { + await domain.update({ + enrollmentMode: automaticInvitationsField.checked ? 'automatic_invitation' : 'manual_invitation', + }); + + await navigate('../../'); + } catch (e) { + handleError(e, [automaticInvitationsField], card.setError); + } + }; + + if (!organization) { + return null; + } + + if (domainStatus.isLoading || !domain) { + return ( + + + + ); + } + + return ( + + Verified} + sx={t => ({ + backgroundColor: t.colors.$blackAlpha50, + padding: `${t.space.$3} ${t.space.$4}`, + minHeight: t.sizes.$10, + })} + rightIconSx={{ + visibility: 'hidden', + }} + > + {domain.name} + + + + + + + + + + ); +}); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx new file mode 100644 index 00000000000..b6cf98fca17 --- /dev/null +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/VerifyDomainPage.tsx @@ -0,0 +1,190 @@ +import type { OrganizationDomainResource } from '@clerk/types'; +import React, { useEffect } from 'react'; + +import { useWizard, Wizard } from '../../common'; +import { useCoreOrganization } from '../../contexts'; +import { Button, descriptors, Flex, localizationKeys, Spinner } from '../../customizables'; +import type { VerificationCodeCardProps } from '../../elements'; +import { + ContentPage, + Form, + FormButtonContainer, + FormButtons, + useCardState, + useCodeControl, + withCardStateProvider, +} from '../../elements'; +import { CodeForm } from '../../elements/CodeForm'; +import { useLoadingStatus } from '../../hooks'; +import { useRouter } from '../../router'; +import { handleError, sleep, useFormControl } from '../../utils'; +import { OrganizationProfileBreadcrumbs } from './OrganizationProfileNavbar'; + +export const VerifyDomainPage = withCardStateProvider(() => { + const card = useCardState(); + const { organization } = useCoreOrganization(); + const { navigate, params } = useRouter(); + + const [success, setSuccess] = React.useState(false); + const [domain, setDomain] = React.useState(null); + + const title = localizationKeys('organizationProfile.verifyDomainPage.title'); + const subtitle = localizationKeys('organizationProfile.verifyDomainPage.subtitle', { + domainName: domain?.name ?? '', + }); + const status = useLoadingStatus(); + const domainStatus = useLoadingStatus({ + status: 'loading', + }); + const codeControlState = useFormControl('code', ''); + const codeControl = useCodeControl(codeControlState); + + const wizard = useWizard({ onNextStep: () => card.setError(undefined) }); + + const emailField = useFormControl('emailAddress', '', { + type: 'email', + label: localizationKeys('formFieldLabel__organizationEmailDomain'), + placeholder: localizationKeys('formFieldInputPlaceholder__organizationName'), + }); + + useEffect(() => { + domainStatus.setLoading(); + organization + ?.getDomain?.({ + domainId: params.id, + }) + .then(domain => { + domainStatus.setIdle(); + setDomain(domain); + }) + .catch(() => { + setDomain(null); + domainStatus.setError(); + }); + }, [params.id]); + + const resolve = async () => { + setSuccess(true); + await sleep(750); + }; + + const action: VerificationCodeCardProps['onCodeEntryFinishedAction'] = (code, resolve, reject) => { + organization + ?.attemptDomainAffiliationVerification({ + domainId: params.id, + code, + }) + .then(async res => { + await resolve(); + if (res.verification?.status === 'verified') { + return navigate('../../'); + } + return; + }) + .catch(err => reject(err)); + }; + + const reject = async (err: any) => { + handleError(err, [codeControlState], card.setError); + status.setIdle(); + await sleep(750); + codeControl.reset(); + }; + + codeControl.onCodeEntryFinished(code => { + status.setLoading(); + codeControlState.setError(undefined); + action(code, resolve, reject); + }); + + if (!organization) { + return null; + } + const handleResend = () => { + codeControl.reset(); + }; + + const dataChanged = organization.name !== emailField.value; + const canSubmit = dataChanged; + + const onSubmitPrepare = (e: React.FormEvent) => { + e.preventDefault(); + return organization + .prepareDomainAffiliationVerification({ + domainId: params.id, + emailAddress: emailField.value, + }) + .then(wizard.nextStep) + .catch(err => { + handleError(err, [emailField], card.setError); + }); + }; + + if (domainStatus.isLoading) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + + + +