Skip to content

Commit

Permalink
feat(clerk-js,types,localizations): Introduces domains and invitation…
Browse files Browse the repository at this point in the history
…s in <OrganizationProfile /> (#1560)

* feat(localizations,types): Create localization keys for Organization domains

* feat(types): Update appearance element keys for Organization domains

* 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

* feat(localizations,types): Update localization

* chore(clerk-js): Replace ArrowBlockButton with BlockWithAction

* feat(clerk-js): Share domain list between members and settings

* feat(clerk-js): Create RemoveDomainPage

* fix(clerk-js): Rename to `prepareAffiliationVerification`

* test(clerk-js): Update OrganizationDomain snapshot

* test(clerk-js): Update OrganizationMembers

* test(clerk-js): Update OrganizationSettings

* chore(clerk-js): Add changeset

* chore(clerk-js): Introduce useFetch

* chore(clerk-js): Move CalloutWithAction under common ui
  • Loading branch information
panteliselef authored Aug 10, 2023
1 parent 6fe022f commit 3158752
Show file tree
Hide file tree
Showing 25 changed files with 1,146 additions and 75 deletions.
10 changes: 10 additions & 0 deletions .changeset/nervous-lizards-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/types': patch
---

Introduces domains and invitations in <OrganizationProfile />

- The "Members" page now accommodates Domain and Individual invitations
- The "Settings" page allows for the addition, edit and removal of a domain
2 changes: 1 addition & 1 deletion packages/clerk-js/src/core/resources/OrganizationDomain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class OrganizationDomain extends BaseResource implements OrganizationDoma
return new OrganizationDomain(json);
}

prepareDomainAffiliationVerification = async (
prepareAffiliationVerification = async (
params: PrepareAffiliationVerificationParams,
): Promise<OrganizationDomainResource> => {
return this._basePost({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ OrganizationDomain {
"name": "clerk.dev",
"organizationId": "test_org_id",
"pathRoot": "",
"prepareDomainAffiliationVerification": [Function],
"prepareAffiliationVerification": [Function],
"update": [Function],
"verification": null,
}
Expand All @@ -26,7 +26,7 @@ OrganizationDomain {
"name": "clerk.dev",
"organizationId": "test_org_id",
"pathRoot": "",
"prepareDomainAffiliationVerification": [Function],
"prepareAffiliationVerification": [Function],
"update": [Function],
"verification": {
"attempts": 1,
Expand Down
47 changes: 47 additions & 0 deletions packages/clerk-js/src/ui/common/CalloutWithAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { MouseEvent } from 'react';

import { Col, Flex, Link, Text } from '../customizables';
import type { LocalizationKey } from '../localization';

type CalloutWithActionProps = {
text: LocalizationKey;
actionLabel?: LocalizationKey;
onClick?: (e: MouseEvent<HTMLAnchorElement>) => Promise<any>;
};
export const CalloutWithAction = (props: CalloutWithActionProps) => {
const { text, actionLabel, onClick: onClickProp } = props;

const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
if (onClickProp) {
void onClickProp?.(e);
}
};

return (
<Flex
sx={theme => ({
background: theme.colors.$blackAlpha50,
padding: theme.space.$4,
justifyContent: 'space-between',
alignItems: 'flex-start',
borderRadius: theme.radii.$md,
})}
>
<Col gap={4}>
<Text
sx={t => ({
lineHeight: t.lineHeights.$tall,
})}
localizationKey={text}
/>

<Link
colorScheme={'primary'}
variant='regularMedium'
localizationKey={actionLabel}
onClick={onClick}
/>
</Col>
</Flex>
);
};
2 changes: 2 additions & 0 deletions packages/clerk-js/src/ui/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './BlockButtons';
export * from './constants';
export * from './CalloutWithAction';
export * from './forms';
export * from './redirects';
export * from './verification';
Expand All @@ -10,5 +11,6 @@ export * from './EmailLinkStatusCard';
export * from './Wizard';
export * from './RemoveResourcePage';
export * from './PrintableComponent';
export * from './RemoveResourcePage';
export * from './withOrganizationsEnabledGuard';
export * from './QRCode';
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 canSubmit = organization.name !== nameField.value;

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 (
<ContentPage
headerTitle={title}
headerSubtitle={subtitle}
Breadcrumbs={OrganizationProfileBreadcrumbs}
>
<Form.Root onSubmit={onSubmit}>
<Form.ControlRow elementId={nameField.id}>
<Form.Control
{...nameField.props}
autoFocus
required
/>
</Form.ControlRow>
<FormButtons isDisabled={!canSubmit} />
</Form.Root>
</ContentPage>
);
});
133 changes: 133 additions & 0 deletions packages/clerk-js/src/ui/components/OrganizationProfile/DomainList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { GetDomainsParams, OrganizationEnrollmentMode } from '@clerk/types';
import type { OrganizationDomainVerificationStatus } from '@clerk/types';
import React, { useMemo } from 'react';

import { useCoreOrganization } from '../../contexts';
import { Badge, Box, Col, descriptors, Spinner } from '../../customizables';
import { ArrowBlockButton } from '../../elements';
import { useInView } from '../../hooks';
import { useRouter } from '../../router';

type DomainListProps = GetDomainsParams & {
verificationStatus?: OrganizationDomainVerificationStatus;
enrollmentMode?: OrganizationEnrollmentMode;
/**
* Enables internal links to navigate to the correct page
* based on when this component is used
*/
redirectSubPath: string;
fallback?: React.ReactNode;
};

export const DomainList = (props: DomainListProps) => {
const { verificationStatus, enrollmentMode, redirectSubPath, fallback, ...rest } = props;
const { organization, membership, domains } = useCoreOrganization({
domains: {
infinite: true,
...rest,
},
});

const { ref } = useInView({
threshold: 0,
onChange: inView => {
if (inView) {
void domains?.fetchNext?.();
}
},
});
const { navigate } = useRouter();

const isAdmin = membership?.role === 'admin';

const domainList = useMemo(() => {
if (!domains?.data) {
return [];
}

return domains.data.filter(d => {
let matchesStatus = true;
let matchesMode = true;
if (verificationStatus) {
matchesStatus = !!d.verification && d.verification.status === verificationStatus;
}
if (enrollmentMode) {
matchesMode = d.enrollmentMode === enrollmentMode;
}

return matchesStatus && matchesMode;
});
}, [domains?.data]);

if (!organization || !isAdmin) {
return null;
}

// TODO: Split this to smaller components
return (
<Col>
{domainList.length === 0 && !domains?.isLoading && fallback}
{domainList.map(d => (
<ArrowBlockButton
key={d.id}
elementDescriptor={descriptors.accordionTriggerButton}
variant='ghost'
colorScheme='neutral'
badge={
!verificationStatus ? (
d.verification && d.verification.status === 'verified' ? (
<Badge textVariant={'extraSmallRegular'}>Verified</Badge>
) : (
<Badge
textVariant={'extraSmallRegular'}
colorScheme={'warning'}
>
Unverified
</Badge>
)
) : undefined
}
sx={t => ({
padding: `${t.space.$3} ${t.space.$4}`,
minHeight: t.sizes.$10,
})}
onClick={() => {
d.verification && d.verification.status === 'verified'
? void navigate(`${redirectSubPath}${d.id}`)
: void navigate(`${redirectSubPath}${d.id}/verify`);
}}
>
{d.name}
</ArrowBlockButton>
))}
{(domains?.hasNextPage || domains?.isFetching) && (
<Box
ref={domains?.isFetching ? undefined : ref}
sx={[
t => ({
width: '100%',
height: t.space.$10,
position: 'relative',
}),
]}
>
<Box
sx={{
display: 'flex',
margin: 'auto',
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translateY(-50%) translateX(-50%)',
}}
>
<Spinner
size='md'
colorScheme='primary'
/>
</Box>
</Box>
)}
</Col>
);
};
Loading

0 comments on commit 3158752

Please sign in to comment.