From 19d674bb3d0c77ed7d625a3c4b7255558c1a5790 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 7 Aug 2023 15:40:49 +0300 Subject: [PATCH 1/9] feat(clerk-js): Introduce OrganizationDomain --- .../src/core/resources/Organization.ts | 89 +++++++++++++++++++ .../src/core/resources/OrganizationDomain.ts | 88 ++++++++++++++++++ .../clerk-js/src/core/resources/internal.ts | 1 + packages/types/src/index.ts | 1 + packages/types/src/json.ts | 20 +++++ packages/types/src/organization.ts | 31 ++++++- packages/types/src/organizationDomain.ts | 27 ++++++ 7 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 packages/clerk-js/src/core/resources/OrganizationDomain.ts create mode 100644 packages/types/src/organizationDomain.ts diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 430e32a3565..fd0ba69d592 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -1,11 +1,15 @@ import type { AddMemberParams, + ClerkPaginatedResponse, ClerkResourceReloadParams, CreateOrganizationParams, + GetDomainsParams, GetMembershipsParams, GetPendingInvitationsParams, InviteMemberParams, InviteMembersParams, + OrganizationDomainJSON, + OrganizationDomainResource, OrganizationInvitationJSON, OrganizationJSON, OrganizationMembershipJSON, @@ -14,9 +18,11 @@ import type { UpdateMembershipParams, UpdateOrganizationParams, } from '@clerk/types'; +import type { ClerkPaginationParams, GetUserOrganizationInvitationsParams } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal'; +import { OrganizationDomain } from './OrganizationDomain'; export class Organization extends BaseResource implements OrganizationResource { pathRoot = '/organizations'; @@ -75,6 +81,89 @@ export class Organization extends BaseResource implements OrganizationResource { }); }; + getDomains = async ( + getDomainParams?: GetDomainsParams, + ): Promise> => { + function convertPageToOffset(pageParams: GetUserOrganizationInvitationsParams | undefined): ClerkPaginationParams { + const { initialPageSize, initialPage, ...restParams } = pageParams || {}; + const _initialPageSize = initialPageSize ?? 10; + const _initialPage = initialPage ?? 1; + + return { + ...restParams, + limit: initialPageSize, + offset: (_initialPage - 1) * _initialPageSize, + }; + } + + return await BaseResource._fetch({ + path: `/organizations/${this.id}/domains`, + method: 'GET', + search: convertPageToOffset(getDomainParams) as any, + }) + .then(res => { + const { data: invites, total_count } = + res?.response as unknown as ClerkPaginatedResponse; + + return { + total_count, + data: invites.map(domain => new OrganizationDomain(domain)), + }; + }) + .catch(() => ({ + total_count: 0, + data: [], + })); + }; + + getDomain = async ({ domainId }: { domainId: string }): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/domains/${domainId}`, + method: 'GET', + }) + )?.response as unknown as OrganizationDomainJSON; + return new OrganizationDomain(json); + }; + + prepareDomainAffiliationVerification = async ({ + domainId, + emailAddress, + }: { + domainId: string; + emailAddress: string; + }): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/domains/${domainId}/prepare_affiliation_verification`, + method: 'POST', + body: { affiliationEmailAddress: emailAddress } as any, + }) + )?.response as unknown as OrganizationDomainJSON; + return new OrganizationDomain(json); + }; + + attemptDomainAffiliationVerification = async ({ + domainId, + code, + }: { + domainId: string; + code: string; + }): Promise => { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${this.id}/domains/${domainId}/attempt_affiliation_verification`, + method: 'POST', + body: { code } as any, + }) + )?.response as unknown as OrganizationDomainJSON; + return new OrganizationDomain(json); + }; + + createDomain = async (name: string): Promise => { + return OrganizationDomain.create(this.id, { name }); + }; + getMemberships = async (getMemberhipsParams?: GetMembershipsParams): Promise => { return await BaseResource._fetch({ path: `/organizations/${this.id}/memberships`, diff --git a/packages/clerk-js/src/core/resources/OrganizationDomain.ts b/packages/clerk-js/src/core/resources/OrganizationDomain.ts new file mode 100644 index 00000000000..a72544feb40 --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationDomain.ts @@ -0,0 +1,88 @@ +import type { + OrganizationDomainJSON, + OrganizationDomainResource, + OrganizationDomainVerification, + OrganizationEnrollmentMode, + UpdateOrganizationDomainParams, +} from '@clerk/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './Base'; + +export class OrganizationDomain extends BaseResource implements OrganizationDomainResource { + id!: string; + name!: string; + organizationId!: string; + enrollmentMode!: OrganizationEnrollmentMode; + verification!: OrganizationDomainVerification | null; + affiliationEmailAddress!: string | null; + createdAt!: Date; + updatedAt!: Date; + + constructor(data: OrganizationDomainJSON) { + super(); + this.fromJSON(data); + } + + static async create(organizationId: string, { name }: { name: string }): Promise { + const json = ( + await BaseResource._fetch({ + path: `/organizations/${organizationId}/domains`, + method: 'POST', + body: { name } as any, + }) + )?.response as unknown as OrganizationDomainJSON; + return new OrganizationDomain(json); + } + + update = (params: UpdateOrganizationDomainParams): Promise => { + return this._basePatch({ + method: 'PATCH', + path: `/organizations/${this.organizationId}/domains/${this.id}`, + body: params, + }); + }; + + delete = (): Promise => { + return this._baseDelete({ + path: `/organizations/${this.organizationId}/domains/${this.id}`, + }); + }; + + // static async get({ + // organizationId, + // domainId, + // }: { + // organizationId: string; + // domainId: string; + // }): Promise { + // const json = ( + // await BaseResource._fetch({ + // path: `/organizations/${organizationId}/domains/${domainId}`, + // method: 'GET', + // }) + // )?.response as unknown as OrganizationDomainJSON; + // return new OrganizationDomain(json); + // } + + protected fromJSON(data: OrganizationDomainJSON | null): this { + if (data) { + this.id = data.id; + this.name = data.name; + this.organizationId = data.organization_id; + this.enrollmentMode = data.enrollment_mode; + this.affiliationEmailAddress = data.affiliation_email_address; + if (data.verification) { + this.verification = { + status: data.verification.status, + strategy: data.verification.strategy, + attempts: data.verification.attempts, + expiresAt: unixEpochToDate(data.verification.expires_at), + }; + } else { + this.verification = null; + } + } + return this; + } +} diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 442b2930bea..89916282c4e 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -13,6 +13,7 @@ export * from './IdentificationLink'; export * from './Image'; export * from './PhoneNumber'; export * from './Organization'; +export * from './OrganizationDomain'; export * from './OrganizationInvitation'; export * from './OrganizationMembership'; export * from './SamlAccount'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4b4008ad7f5..d44bde26842 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -22,6 +22,7 @@ export * from './jwtv2'; export * from './multiDomain'; export * from './oauth'; export * from './organization'; +export * from './organizationDomain'; export * from './organizationInvitation'; export * from './organizationMembership'; export * from './organizationSettings'; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index e3d423b1922..f634f755d52 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -6,6 +6,7 @@ import type { FontFamily } from './appearance'; import type { DisplayConfigJSON } from './displayConfig'; import type { ActJWTClaim } from './jwt'; import type { OAuthProvider } from './oauth'; +import type { OrganizationDomainVerificationStatus, OrganizationEnrollmentMode } from './organizationDomain'; import type { OrganizationInvitationStatus } from './organizationInvitation'; import type { MembershipRole } from './organizationMembership'; import type { OrganizationSettingsJSON } from './organizationSettings'; @@ -343,6 +344,25 @@ export interface OrganizationInvitationJSON extends ClerkResourceJSON { updated_at: number; } +interface OrganizationDomainVerificationJSON { + status: OrganizationDomainVerificationStatus; + strategy: 'email_code'; // only available value for now + attempts: number; + expires_at: number; +} + +export interface OrganizationDomainJSON extends ClerkResourceJSON { + object: 'organization_domain'; + id: string; + name: string; + organization_id: string; + enrollment_mode: OrganizationEnrollmentMode; + verification: OrganizationDomainVerificationJSON | null; + affiliation_email_address: string | null; + created_at: number; + updated_at: number; +} + export interface UserOrganizationInvitationJSON extends ClerkResourceJSON { object: 'organization_invitation'; id: string; diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts index ba26334836c..0c1536f2606 100644 --- a/packages/types/src/organization.ts +++ b/packages/types/src/organization.ts @@ -1,4 +1,5 @@ -import type { ClerkPaginationParams } from './api'; +import type { ClerkPaginatedResponse, ClerkPaginationParams } from './api'; +import type { OrganizationDomainResource } from './organizationDomain'; import type { OrganizationInvitationResource } from './organizationInvitation'; import type { MembershipRole, OrganizationMembershipResource } from './organizationMembership'; import type { ClerkResource } from './resource'; @@ -43,11 +44,29 @@ export interface OrganizationResource extends ClerkResource { update: (params: UpdateOrganizationParams) => Promise; getMemberships: (params?: GetMembershipsParams) => Promise; getPendingInvitations: (params?: GetPendingInvitationsParams) => Promise; + getDomains: (params?: GetDomainsParams) => Promise>; addMember: (params: AddMemberParams) => Promise; inviteMember: (params: InviteMemberParams) => Promise; inviteMembers: (params: InviteMembersParams) => Promise; updateMember: (params: UpdateMembershipParams) => Promise; removeMember: (userId: string) => Promise; + createDomain: (domainName: string) => Promise; + getDomain: ({ domainId }: { domainId: string }) => Promise; + prepareDomainAffiliationVerification: ({ + domainId, + emailAddress, + }: { + domainId: string; + emailAddress: string; + }) => Promise; + + attemptDomainAffiliationVerification: ({ + domainId, + code, + }: { + domainId: string; + code: string; + }) => Promise; destroy: () => Promise; setLogo: (params: SetOrganizationLogoParams) => Promise; } @@ -57,6 +76,16 @@ export type GetMembershipsParams = { } & ClerkPaginationParams; export type GetPendingInvitationsParams = ClerkPaginationParams; +export type GetDomainsParams = { + /** + * This the starting point for your fetched results. The initial value persists between re-renders + */ + initialPage?: number; + /** + * Maximum number of items returned per request. The initial value persists between re-renders + */ + initialPageSize?: number; +}; export interface AddMemberParams { userId: string; diff --git a/packages/types/src/organizationDomain.ts b/packages/types/src/organizationDomain.ts new file mode 100644 index 00000000000..be5cf6a9273 --- /dev/null +++ b/packages/types/src/organizationDomain.ts @@ -0,0 +1,27 @@ +import type { ClerkResource } from './resource'; + +export interface OrganizationDomainVerification { + status: OrganizationDomainVerificationStatus; + strategy: 'email_code'; // only available value for now + attempts: number; + expiresAt: Date; +} + +export type OrganizationDomainVerificationStatus = 'unverified' | 'verified'; + +export type OrganizationEnrollmentMode = 'manual_invitation' | 'automatic_invitation'; + +export interface OrganizationDomainResource extends ClerkResource { + id: string; + name: string; + organizationId: string; + enrollmentMode: OrganizationEnrollmentMode; + verification: OrganizationDomainVerification | null; + createdAt: Date; + updatedAt: Date; + affiliationEmailAddress: string | null; + delete: () => Promise; + update: (params: UpdateOrganizationDomainParams) => Promise; +} + +export type UpdateOrganizationDomainParams = Partial>; From c621767f4c73e3e0c82be199fd796525875a4b95 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 8 Aug 2023 12:19:19 +0300 Subject: [PATCH 2/9] feat(shared): Fetch domains within useOrganization --- packages/shared/src/hooks/useOrganization.tsx | 202 +++++++++++++++++- 1 file changed, 200 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/hooks/useOrganization.tsx b/packages/shared/src/hooks/useOrganization.tsx index 49ede6ef2f2..f9144477517 100644 --- a/packages/shared/src/hooks/useOrganization.tsx +++ b/packages/shared/src/hooks/useOrganization.tsx @@ -1,18 +1,30 @@ import type { + ClerkPaginatedResponse, ClerkPaginationParams, + GetDomainsParams, GetMembershipsParams, GetPendingInvitationsParams, + OrganizationDomainResource, OrganizationInvitationResource, OrganizationMembershipResource, OrganizationResource, } from '@clerk/types'; +import { useCallback, useMemo, useRef, useState } from 'react'; import useSWR from 'swr'; +import useSWRInfinite from 'swr/infinite'; import { useClerkInstanceContext, useOrganizationContext, useSessionContext } from './contexts'; +import type { PaginatedDataAPI, PaginatedDataAPIWithDefaults } from './useOrganizationList'; type UseOrganizationParams = { invitationList?: GetPendingInvitationsParams; membershipList?: GetMembershipsParams; + domains?: + | true + | (GetDomainsParams & { + infinite?: boolean; + keepPreviousData?: boolean; + }); }; type UseOrganizationReturn = @@ -22,6 +34,7 @@ type UseOrganizationReturn = invitationList: undefined; membershipList: undefined; membership: undefined; + domains: PaginatedDataAPIWithDefaults; } | { isLoaded: true; @@ -29,6 +42,7 @@ type UseOrganizationReturn = invitationList: undefined; membershipList: undefined; membership: undefined; + domains: PaginatedDataAPIWithDefaults; } | { isLoaded: boolean; @@ -36,17 +50,148 @@ type UseOrganizationReturn = invitationList: OrganizationInvitationResource[] | null | undefined; membershipList: OrganizationMembershipResource[] | null | undefined; membership: OrganizationMembershipResource | null | undefined; + domains: PaginatedDataAPI | null; }; +type CustomSetAction = (size: T | ((_size: T) => T)) => void; type UseOrganization = (params?: UseOrganizationParams) => UseOrganizationReturn; export const useOrganization: UseOrganization = params => { - const { invitationList: invitationListParams, membershipList: membershipListParams } = params || {}; + const { + invitationList: invitationListParams, + membershipList: membershipListParams, + domains: domainListParams, + } = params || {}; const { organization, lastOrganizationMember, lastOrganizationInvitation } = useOrganizationContext(); const session = useSessionContext(); + const shouldUseDefaults = typeof domainListParams === 'boolean' && domainListParams; + const [paginatedPage, setPaginatedPage] = useState(shouldUseDefaults ? 1 : domainListParams?.initialPage ?? 1); + + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef(shouldUseDefaults ? 1 : domainListParams?.initialPage ?? 1); + const initialPageSizeRef = useRef(shouldUseDefaults ? 10 : domainListParams?.initialPageSize ?? 10); + + const triggerInfinite = shouldUseDefaults ? false : !!domainListParams?.infinite; + const internalKeepPreviousData = shouldUseDefaults ? false : !!domainListParams?.keepPreviousData; + const clerk = useClerkInstanceContext(); - const shouldFetch = clerk.loaded && session && organization; + + const shouldFetch = !!(clerk.loaded && session && organization); + + const paginatedParams = + typeof domainListParams === 'undefined' + ? undefined + : { + initialPage: paginatedPage, + initialPageSize: initialPageSizeRef.current, + }; + + // Some gymnastics to adhere to the rules of hooks + // We need to make sure useSWR is called on every render + const fetchInvitations = !clerk.loaded + ? () => ({ data: [], total_count: 0 } as ClerkPaginatedResponse) + : () => organization?.getDomains(paginatedParams); + + const { + data: userInvitationsData, + isValidating: userInvitationsValidating, + isLoading: userInvitationsLoading, + error: userInvitationsError, + mutate: userInvitationsMutate, + } = useSWR( + !triggerInfinite && shouldFetch && paginatedParams + ? cacheKeyDomains('domains', organization, paginatedParams) + : null, + fetchInvitations, + { keepPreviousData: internalKeepPreviousData }, + ); + + const getInfiniteKey = ( + pageIndex: number, + previousPageData: ClerkPaginatedResponse | null, + ) => { + if (!shouldFetch || !paginatedParams || !triggerInfinite) { + return null; + } + + return cacheKeyDomains('domains', organization, { + initialPage: initialPageRef.current + pageIndex, + initialPageSize: initialPageSizeRef.current, + }); + }; + + const { + data: userInvitationsDataInfinite, + isLoading: userInvitationsLoadingInfinite, + isValidating: userInvitationsInfiniteValidating, + error: userInvitationsInfiniteError, + size, + setSize, + mutate: userInvitationsInfiniteMutate, + } = useSWRInfinite(getInfiniteKey, ({ initialPage, initialPageSize }) => { + return !clerk.loaded || !organization + ? ({ data: [], total_count: 0 } as ClerkPaginatedResponse) + : organization.getDomains({ + initialPage, + initialPageSize, + }); + }); + + const isomorphicPage = useMemo(() => { + if (triggerInfinite) { + return size; + } + return paginatedPage; + }, [triggerInfinite, size, paginatedPage]); + + const isomorphicSetPage: CustomSetAction = useCallback( + numberOrgFn => { + if (triggerInfinite) { + void setSize(numberOrgFn); + return; + } + return setPaginatedPage(numberOrgFn); + }, + [setSize], + ); + + const isomorphicData = useMemo(() => { + if (triggerInfinite) { + return userInvitationsDataInfinite?.map(a => a?.data).flat() ?? []; + } + return userInvitationsData?.data ?? []; + }, [triggerInfinite, userInvitationsDataInfinite, userInvitationsData]); + + const isomorphicCount = useMemo(() => { + if (triggerInfinite) { + return userInvitationsDataInfinite?.[userInvitationsDataInfinite?.length - 1]?.total_count || 0; + } + return userInvitationsData?.total_count ?? 0; + }, [triggerInfinite, userInvitationsDataInfinite, userInvitationsData]); + + const isomorphicIsLoading = triggerInfinite ? userInvitationsLoadingInfinite : userInvitationsLoading; + const isomorphicIsFetching = triggerInfinite ? userInvitationsInfiniteValidating : userInvitationsValidating; + const isomorphicIsError = !!(triggerInfinite ? userInvitationsInfiniteError : userInvitationsError); + /** + * Helpers + */ + const fetchNext = useCallback(() => { + isomorphicSetPage(n => n + 1); + }, [isomorphicSetPage]); + + const fetchPrevious = useCallback(() => { + isomorphicSetPage(n => n - 1); + }, [isomorphicSetPage]); + + const offsetCount = (initialPageRef.current - 1) * initialPageSizeRef.current; + + const pageCount = Math.ceil((isomorphicCount - offsetCount) / initialPageSizeRef.current); + const hasNextPage = + isomorphicCount - offsetCount * initialPageSizeRef.current > isomorphicPage * initialPageSizeRef.current; + const hasPreviousPage = (isomorphicPage - 1) * initialPageSizeRef.current > offsetCount * initialPageSizeRef.current; + + const unstable__mutate = triggerInfinite ? userInvitationsInfiniteMutate : userInvitationsMutate; // Some gymnastics to adhere to the rules of hooks // We need to make sure useSWR is called on every render @@ -87,6 +232,20 @@ export const useOrganization: UseOrganization = params => { invitationList: undefined, membershipList: undefined, membership: undefined, + domains: { + data: undefined, + count: undefined, + isLoading: false, + isFetching: false, + isError: false, + page: undefined, + pageCount: undefined, + fetchPage: undefined, + fetchNext: undefined, + fetchPrevious: undefined, + hasNextPage: false, + hasPreviousPage: false, + }, }; } @@ -97,6 +256,7 @@ export const useOrganization: UseOrganization = params => { invitationList: null, membershipList: null, membership: null, + domains: null, }; } @@ -108,6 +268,20 @@ export const useOrganization: UseOrganization = params => { invitationList: undefined, membershipList: undefined, membership: undefined, + domains: { + data: undefined, + count: undefined, + isLoading: false, + isFetching: false, + isError: false, + page: undefined, + pageCount: undefined, + fetchPage: undefined, + fetchNext: undefined, + fetchPrevious: undefined, + hasNextPage: false, + hasPreviousPage: false, + }, }; } @@ -121,6 +295,21 @@ export const useOrganization: UseOrganization = params => { void mutateMembershipList(); void mutateInvitationList(); }, + domains: { + data: isomorphicData, + count: isomorphicCount, + isLoading: isomorphicIsLoading, + isFetching: isomorphicIsFetching, + isError: isomorphicIsError, + page: isomorphicPage, + pageCount, + fetchPage: isomorphicSetPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + unstable__mutate, + }, }; }; @@ -143,3 +332,12 @@ function cacheKey( .filter(Boolean) .join('-'); } + +function cacheKeyDomains(type: 'domains', organization: OrganizationResource, pagination: GetDomainsParams) { + return { + type, + organizationId: organization.id, + initialPage: pagination.initialPage, + initialPageSize: pagination.initialPageSize, + }; +} From ae110ea904556c7f5c9041d3385a350747d8aaff Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Aug 2023 15:05:34 +0300 Subject: [PATCH 3/9] fix(shared): Fetch domains with usePageOrInfinite --- .../src/core/resources/Organization.ts | 8 +- .../resources/UserOrganizationInvitation.ts | 8 +- packages/shared/src/hooks/useOrganization.tsx | 159 ++++-------------- packages/types/src/organization.ts | 2 +- 4 files changed, 42 insertions(+), 135 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index fd0ba69d592..8f5dd81f37d 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -85,14 +85,14 @@ export class Organization extends BaseResource implements OrganizationResource { getDomainParams?: GetDomainsParams, ): Promise> => { function convertPageToOffset(pageParams: GetUserOrganizationInvitationsParams | undefined): ClerkPaginationParams { - const { initialPageSize, initialPage, ...restParams } = pageParams || {}; - const _initialPageSize = initialPageSize ?? 10; + const { pageSize, initialPage, ...restParams } = pageParams || {}; + const _pageSize = pageSize ?? 10; const _initialPage = initialPage ?? 1; return { ...restParams, - limit: initialPageSize, - offset: (_initialPage - 1) * _initialPageSize, + limit: _pageSize, + offset: (_initialPage - 1) * _pageSize, }; } diff --git a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index 52e7f42df02..40e4fa7fc06 100644 --- a/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts +++ b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts @@ -14,13 +14,7 @@ import { BaseResource } from './internal'; export class UserOrganizationInvitation extends BaseResource implements UserOrganizationInvitationResource { id!: string; emailAddress!: string; - publicOrganizationData!: { - hasImage: boolean; - imageUrl: string; - name: string; - id: string; - slug: string; - }; + publicOrganizationData!: UserOrganizationInvitationResource['publicOrganizationData']; publicMetadata: OrganizationInvitationPublicMetadata = {}; status!: OrganizationInvitationStatus; role!: MembershipRole; diff --git a/packages/shared/src/hooks/useOrganization.tsx b/packages/shared/src/hooks/useOrganization.tsx index f9144477517..0110e00ac0b 100644 --- a/packages/shared/src/hooks/useOrganization.tsx +++ b/packages/shared/src/hooks/useOrganization.tsx @@ -1,5 +1,4 @@ import type { - ClerkPaginatedResponse, ClerkPaginationParams, GetDomainsParams, GetMembershipsParams, @@ -9,12 +8,13 @@ import type { OrganizationMembershipResource, OrganizationResource, } from '@clerk/types'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import type { ClerkPaginatedResponse } from '@clerk/types'; +import { useRef } from 'react'; import useSWR from 'swr'; -import useSWRInfinite from 'swr/infinite'; import { useClerkInstanceContext, useOrganizationContext, useSessionContext } from './contexts'; -import type { PaginatedDataAPI, PaginatedDataAPIWithDefaults } from './useOrganizationList'; +import type { PaginatedResources, PaginatedResourcesWithDefault } from './types'; +import { usePagesOrInfinite } from './usePagesOrInfinite'; type UseOrganizationParams = { invitationList?: GetPendingInvitationsParams; @@ -34,7 +34,7 @@ type UseOrganizationReturn = invitationList: undefined; membershipList: undefined; membership: undefined; - domains: PaginatedDataAPIWithDefaults; + domains: PaginatedResourcesWithDefault; } | { isLoaded: true; @@ -42,7 +42,7 @@ type UseOrganizationReturn = invitationList: undefined; membershipList: undefined; membership: undefined; - domains: PaginatedDataAPIWithDefaults; + domains: PaginatedResourcesWithDefault; } | { isLoaded: boolean; @@ -50,9 +50,8 @@ type UseOrganizationReturn = invitationList: OrganizationInvitationResource[] | null | undefined; membershipList: OrganizationMembershipResource[] | null | undefined; membership: OrganizationMembershipResource | null | undefined; - domains: PaginatedDataAPI | null; + domains: PaginatedResources | null; }; -type CustomSetAction = (size: T | ((_size: T) => T)) => void; type UseOrganization = (params?: UseOrganizationParams) => UseOrganizationReturn; @@ -66,11 +65,10 @@ export const useOrganization: UseOrganization = params => { const session = useSessionContext(); const shouldUseDefaults = typeof domainListParams === 'boolean' && domainListParams; - const [paginatedPage, setPaginatedPage] = useState(shouldUseDefaults ? 1 : domainListParams?.initialPage ?? 1); // Cache initialPage and initialPageSize until unmount const initialPageRef = useRef(shouldUseDefaults ? 1 : domainListParams?.initialPage ?? 1); - const initialPageSizeRef = useRef(shouldUseDefaults ? 10 : domainListParams?.initialPageSize ?? 10); + const pageSizeRef = useRef(shouldUseDefaults ? 10 : domainListParams?.pageSize ?? 10); const triggerInfinite = shouldUseDefaults ? false : !!domainListParams?.infinite; const internalKeepPreviousData = shouldUseDefaults ? false : !!domainListParams?.keepPreviousData; @@ -83,116 +81,40 @@ export const useOrganization: UseOrganization = params => { typeof domainListParams === 'undefined' ? undefined : { - initialPage: paginatedPage, - initialPageSize: initialPageSizeRef.current, + initialPage: initialPageRef.current, + pageSize: pageSizeRef.current, }; - // Some gymnastics to adhere to the rules of hooks - // We need to make sure useSWR is called on every render - const fetchInvitations = !clerk.loaded - ? () => ({ data: [], total_count: 0 } as ClerkPaginatedResponse) - : () => organization?.getDomains(paginatedParams); - const { - data: userInvitationsData, - isValidating: userInvitationsValidating, - isLoading: userInvitationsLoading, - error: userInvitationsError, - mutate: userInvitationsMutate, - } = useSWR( - !triggerInfinite && shouldFetch && paginatedParams - ? cacheKeyDomains('domains', organization, paginatedParams) - : null, - fetchInvitations, - { keepPreviousData: internalKeepPreviousData }, - ); - - const getInfiniteKey = ( - pageIndex: number, - previousPageData: ClerkPaginatedResponse | null, - ) => { - if (!shouldFetch || !paginatedParams || !triggerInfinite) { - return null; - } - - return cacheKeyDomains('domains', organization, { - initialPage: initialPageRef.current + pageIndex, - initialPageSize: initialPageSizeRef.current, - }); - }; - - const { - data: userInvitationsDataInfinite, - isLoading: userInvitationsLoadingInfinite, - isValidating: userInvitationsInfiniteValidating, - error: userInvitationsInfiniteError, - size, - setSize, - mutate: userInvitationsInfiniteMutate, - } = useSWRInfinite(getInfiniteKey, ({ initialPage, initialPageSize }) => { - return !clerk.loaded || !organization - ? ({ data: [], total_count: 0 } as ClerkPaginatedResponse) - : organization.getDomains({ - initialPage, - initialPageSize, - }); - }); - - const isomorphicPage = useMemo(() => { - if (triggerInfinite) { - return size; - } - return paginatedPage; - }, [triggerInfinite, size, paginatedPage]); - - const isomorphicSetPage: CustomSetAction = useCallback( - numberOrgFn => { - if (triggerInfinite) { - void setSize(numberOrgFn); - return; - } - return setPaginatedPage(numberOrgFn); + data: isomorphicData, + count: isomorphicCount, + isLoading: isomorphicIsLoading, + isFetching: isomorphicIsFetching, + isError: isomorphicIsError, + page: isomorphicPage, + pageCount, + fetchPage: isomorphicSetPage, + fetchNext, + fetchPrevious, + hasNextPage, + hasPreviousPage, + unstable__mutate, + } = usePagesOrInfinite>( + { + ...paginatedParams, + }, + organization?.getDomains, + { + keepPreviousData: internalKeepPreviousData, + infinite: triggerInfinite, + enabled: !!paginatedParams, + }, + { + type: 'domains', + organizationId: organization?.id, }, - [setSize], ); - const isomorphicData = useMemo(() => { - if (triggerInfinite) { - return userInvitationsDataInfinite?.map(a => a?.data).flat() ?? []; - } - return userInvitationsData?.data ?? []; - }, [triggerInfinite, userInvitationsDataInfinite, userInvitationsData]); - - const isomorphicCount = useMemo(() => { - if (triggerInfinite) { - return userInvitationsDataInfinite?.[userInvitationsDataInfinite?.length - 1]?.total_count || 0; - } - return userInvitationsData?.total_count ?? 0; - }, [triggerInfinite, userInvitationsDataInfinite, userInvitationsData]); - - const isomorphicIsLoading = triggerInfinite ? userInvitationsLoadingInfinite : userInvitationsLoading; - const isomorphicIsFetching = triggerInfinite ? userInvitationsInfiniteValidating : userInvitationsValidating; - const isomorphicIsError = !!(triggerInfinite ? userInvitationsInfiniteError : userInvitationsError); - /** - * Helpers - */ - const fetchNext = useCallback(() => { - isomorphicSetPage(n => n + 1); - }, [isomorphicSetPage]); - - const fetchPrevious = useCallback(() => { - isomorphicSetPage(n => n - 1); - }, [isomorphicSetPage]); - - const offsetCount = (initialPageRef.current - 1) * initialPageSizeRef.current; - - const pageCount = Math.ceil((isomorphicCount - offsetCount) / initialPageSizeRef.current); - const hasNextPage = - isomorphicCount - offsetCount * initialPageSizeRef.current > isomorphicPage * initialPageSizeRef.current; - const hasPreviousPage = (isomorphicPage - 1) * initialPageSizeRef.current > offsetCount * initialPageSizeRef.current; - - const unstable__mutate = triggerInfinite ? userInvitationsInfiniteMutate : userInvitationsMutate; - // Some gymnastics to adhere to the rules of hooks // We need to make sure useSWR is called on every render const pendingInvitations = !clerk.loaded @@ -332,12 +254,3 @@ function cacheKey( .filter(Boolean) .join('-'); } - -function cacheKeyDomains(type: 'domains', organization: OrganizationResource, pagination: GetDomainsParams) { - return { - type, - organizationId: organization.id, - initialPage: pagination.initialPage, - initialPageSize: pagination.initialPageSize, - }; -} diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts index 0c1536f2606..392e47a1238 100644 --- a/packages/types/src/organization.ts +++ b/packages/types/src/organization.ts @@ -84,7 +84,7 @@ export type GetDomainsParams = { /** * Maximum number of items returned per request. The initial value persists between re-renders */ - initialPageSize?: number; + pageSize?: number; }; export interface AddMemberParams { From 0c14cb9ab5418b45f9253bf720d847ebe17fe0de Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Aug 2023 20:43:59 +0300 Subject: [PATCH 4/9] chore(shared): Add changeset --- .changeset/mighty-boxes-reply.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/mighty-boxes-reply.md diff --git a/.changeset/mighty-boxes-reply.md b/.changeset/mighty-boxes-reply.md new file mode 100644 index 00000000000..bf9e6b1b3e9 --- /dev/null +++ b/.changeset/mighty-boxes-reply.md @@ -0,0 +1,9 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/types': patch +--- + +Introduces a new resource called OrganizationDomain + ++ useOrganization has been updated in order to return a list of domain with the above type From bdc03edd06cf0b711bf5b943c61b145811bb28d0 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Aug 2023 20:53:49 +0300 Subject: [PATCH 5/9] chore(clerk-js): Move prepare and attempt verification to OrganizationDomain --- .../src/core/resources/Organization.ts | 34 ----------------- .../src/core/resources/OrganizationDomain.ts | 38 +++++++++++-------- packages/types/src/organization.ts | 15 -------- packages/types/src/organizationDomain.ts | 13 +++++++ 4 files changed, 35 insertions(+), 65 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 8f5dd81f37d..155081e9629 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -126,40 +126,6 @@ export class Organization extends BaseResource implements OrganizationResource { return new OrganizationDomain(json); }; - prepareDomainAffiliationVerification = async ({ - domainId, - emailAddress, - }: { - domainId: string; - emailAddress: string; - }): Promise => { - const json = ( - await BaseResource._fetch({ - path: `/organizations/${this.id}/domains/${domainId}/prepare_affiliation_verification`, - method: 'POST', - body: { affiliationEmailAddress: emailAddress } as any, - }) - )?.response as unknown as OrganizationDomainJSON; - return new OrganizationDomain(json); - }; - - attemptDomainAffiliationVerification = async ({ - domainId, - code, - }: { - domainId: string; - code: string; - }): Promise => { - const json = ( - await BaseResource._fetch({ - path: `/organizations/${this.id}/domains/${domainId}/attempt_affiliation_verification`, - method: 'POST', - body: { code } as any, - }) - )?.response as unknown as OrganizationDomainJSON; - return new OrganizationDomain(json); - }; - createDomain = async (name: string): Promise => { return OrganizationDomain.create(this.id, { name }); }; diff --git a/packages/clerk-js/src/core/resources/OrganizationDomain.ts b/packages/clerk-js/src/core/resources/OrganizationDomain.ts index a72544feb40..a63a70fe251 100644 --- a/packages/clerk-js/src/core/resources/OrganizationDomain.ts +++ b/packages/clerk-js/src/core/resources/OrganizationDomain.ts @@ -1,8 +1,10 @@ import type { + AttemptAffiliationVerificationParams, OrganizationDomainJSON, OrganizationDomainResource, OrganizationDomainVerification, OrganizationEnrollmentMode, + PrepareAffiliationVerificationParams, UpdateOrganizationDomainParams, } from '@clerk/types'; @@ -35,6 +37,26 @@ export class OrganizationDomain extends BaseResource implements OrganizationDoma return new OrganizationDomain(json); } + prepareDomainAffiliationVerification = async ( + params: PrepareAffiliationVerificationParams, + ): Promise => { + return this._basePost({ + path: `/organizations/${this.organizationId}/domains/${this.id}/prepare_affiliation_verification`, + method: 'POST', + body: params as any, + }); + }; + + attemptAffiliationVerification = async ( + params: AttemptAffiliationVerificationParams, + ): Promise => { + return this._basePost({ + path: `/organizations/${this.organizationId}/domains/${this.id}/attempt_affiliation_verification`, + method: 'POST', + body: params as any, + }); + }; + update = (params: UpdateOrganizationDomainParams): Promise => { return this._basePatch({ method: 'PATCH', @@ -49,22 +71,6 @@ export class OrganizationDomain extends BaseResource implements OrganizationDoma }); }; - // static async get({ - // organizationId, - // domainId, - // }: { - // organizationId: string; - // domainId: string; - // }): Promise { - // const json = ( - // await BaseResource._fetch({ - // path: `/organizations/${organizationId}/domains/${domainId}`, - // method: 'GET', - // }) - // )?.response as unknown as OrganizationDomainJSON; - // return new OrganizationDomain(json); - // } - protected fromJSON(data: OrganizationDomainJSON | null): this { if (data) { this.id = data.id; diff --git a/packages/types/src/organization.ts b/packages/types/src/organization.ts index 392e47a1238..328480a8519 100644 --- a/packages/types/src/organization.ts +++ b/packages/types/src/organization.ts @@ -52,21 +52,6 @@ export interface OrganizationResource extends ClerkResource { removeMember: (userId: string) => Promise; createDomain: (domainName: string) => Promise; getDomain: ({ domainId }: { domainId: string }) => Promise; - prepareDomainAffiliationVerification: ({ - domainId, - emailAddress, - }: { - domainId: string; - emailAddress: string; - }) => Promise; - - attemptDomainAffiliationVerification: ({ - domainId, - code, - }: { - domainId: string; - code: string; - }) => Promise; destroy: () => Promise; setLogo: (params: SetOrganizationLogoParams) => Promise; } diff --git a/packages/types/src/organizationDomain.ts b/packages/types/src/organizationDomain.ts index be5cf6a9273..b3d3f53335c 100644 --- a/packages/types/src/organizationDomain.ts +++ b/packages/types/src/organizationDomain.ts @@ -20,8 +20,21 @@ export interface OrganizationDomainResource extends ClerkResource { createdAt: Date; updatedAt: Date; affiliationEmailAddress: string | null; + prepareDomainAffiliationVerification: ( + params: PrepareAffiliationVerificationParams, + ) => Promise; + + attemptAffiliationVerification: (params: AttemptAffiliationVerificationParams) => Promise; delete: () => Promise; update: (params: UpdateOrganizationDomainParams) => Promise; } +export type PrepareAffiliationVerificationParams = { + affiliationEmailAddress: string; +}; + +export type AttemptAffiliationVerificationParams = { + emailAddress: string; +}; + export type UpdateOrganizationDomainParams = Partial>; From e65b5c5334c48c8b5d07446a322e1d0b7952b875 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Aug 2023 20:55:30 +0300 Subject: [PATCH 6/9] test(clerk-js): Update Organization class snapshots --- .../src/core/resources/__snapshots__/Organization.test.ts.snap | 3 +++ .../__snapshots__/OrganizationMembership.test.ts.snap | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap index 1c50c751877..567c23b6bad 100644 --- a/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap @@ -4,8 +4,11 @@ exports[`Organization has the same initial properties 1`] = ` Organization { "addMember": [Function], "adminDeleteEnabled": true, + "createDomain": [Function], "createdAt": 1970-01-01T00:00:12.345Z, "destroy": [Function], + "getDomain": [Function], + "getDomains": [Function], "getMemberships": [Function], "getPendingInvitations": [Function], "hasImage": true, diff --git a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap index fabc1c46c0b..3f2e3988593 100644 --- a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationMembership.test.ts.snap @@ -8,8 +8,11 @@ OrganizationMembership { "organization": Organization { "addMember": [Function], "adminDeleteEnabled": true, + "createDomain": [Function], "createdAt": 1970-01-01T00:00:12.345Z, "destroy": [Function], + "getDomain": [Function], + "getDomains": [Function], "getMemberships": [Function], "getPendingInvitations": [Function], "hasImage": true, From 6c55916921f372a4920db1617fb8c911d502d74f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 9 Aug 2023 21:01:14 +0300 Subject: [PATCH 7/9] test(clerk-js): Create snapshot test for OrganizationDomain --- .../core/resources/OrganizationDomain.test.ts | 40 +++++++++++++++++++ .../OrganizationDomain.test.ts.snap | 38 ++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 packages/clerk-js/src/core/resources/OrganizationDomain.test.ts create mode 100644 packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap diff --git a/packages/clerk-js/src/core/resources/OrganizationDomain.test.ts b/packages/clerk-js/src/core/resources/OrganizationDomain.test.ts new file mode 100644 index 00000000000..0aad2e5d174 --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationDomain.test.ts @@ -0,0 +1,40 @@ +import { OrganizationDomain } from './internal'; + +describe('OrganizationDomain', () => { + it('has the same initial properties', () => { + const organization = new OrganizationDomain({ + object: 'organization_domain', + id: 'test_domain_id', + name: 'clerk.dev', + organization_id: 'test_org_id', + enrollment_mode: 'manual_invitation', + verification: { + attempts: 1, + expires_at: 12345, + strategy: 'email_code', + status: 'verified', + }, + affiliation_email_address: 'some@clerk.dev', + created_at: 12345, + updated_at: 5678, + }); + + expect(organization).toMatchSnapshot(); + }); + + it('has the same initial nullable properties', () => { + const organization = new OrganizationDomain({ + object: 'organization_domain', + id: 'test_domain_id', + name: 'clerk.dev', + organization_id: 'test_org_id', + enrollment_mode: 'manual_invitation', + verification: null, + affiliation_email_address: null, + created_at: 12345, + updated_at: 5678, + }); + + expect(organization).toMatchSnapshot(); + }); +}); diff --git a/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap new file mode 100644 index 00000000000..a8083943b8b --- /dev/null +++ b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OrganizationDomain has the same initial nullable properties 1`] = ` +OrganizationDomain { + "affiliationEmailAddress": null, + "attemptAffiliationVerification": [Function], + "delete": [Function], + "enrollmentMode": "manual_invitation", + "id": "test_domain_id", + "name": "clerk.dev", + "organizationId": "test_org_id", + "pathRoot": "", + "prepareDomainAffiliationVerification": [Function], + "update": [Function], + "verification": null, +} +`; + +exports[`OrganizationDomain has the same initial properties 1`] = ` +OrganizationDomain { + "affiliationEmailAddress": "some@clerk.dev", + "attemptAffiliationVerification": [Function], + "delete": [Function], + "enrollmentMode": "manual_invitation", + "id": "test_domain_id", + "name": "clerk.dev", + "organizationId": "test_org_id", + "pathRoot": "", + "prepareDomainAffiliationVerification": [Function], + "update": [Function], + "verification": { + "attempts": 1, + "expiresAt": 1970-01-01T00:00:12.345Z, + "status": "verified", + "strategy": "email_code", + }, +} +`; From caff1314e5c7be7037429ad8119e3fa5c9e19104 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Aug 2023 10:55:41 +0300 Subject: [PATCH 8/9] fix(clerk-js): Replace emailAddress with code in AttemptAffiliationVerificationParams --- packages/types/src/organizationDomain.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/organizationDomain.ts b/packages/types/src/organizationDomain.ts index b3d3f53335c..f91ec4f3722 100644 --- a/packages/types/src/organizationDomain.ts +++ b/packages/types/src/organizationDomain.ts @@ -34,7 +34,7 @@ export type PrepareAffiliationVerificationParams = { }; export type AttemptAffiliationVerificationParams = { - emailAddress: string; + code: string; }; export type UpdateOrganizationDomainParams = Partial>; From 0a5725f6752130dfe8d892d6e6778503195da69f Mon Sep 17 00:00:00 2001 From: panteliselef Date: Thu, 10 Aug 2023 10:58:35 +0300 Subject: [PATCH 9/9] chore(clerk-js): Use convertPageToOffset util in Organization class --- .../clerk-js/src/core/resources/Organization.ts | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 155081e9629..3278964a600 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -18,9 +18,9 @@ import type { UpdateMembershipParams, UpdateOrganizationParams, } from '@clerk/types'; -import type { ClerkPaginationParams, GetUserOrganizationInvitationsParams } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; +import { convertPageToOffset } from '../../utils/pagesToOffset'; import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal'; import { OrganizationDomain } from './OrganizationDomain'; @@ -84,18 +84,6 @@ export class Organization extends BaseResource implements OrganizationResource { getDomains = async ( getDomainParams?: GetDomainsParams, ): Promise> => { - function convertPageToOffset(pageParams: GetUserOrganizationInvitationsParams | undefined): ClerkPaginationParams { - const { pageSize, initialPage, ...restParams } = pageParams || {}; - const _pageSize = pageSize ?? 10; - const _initialPage = initialPage ?? 1; - - return { - ...restParams, - limit: _pageSize, - offset: (_initialPage - 1) * _pageSize, - }; - } - return await BaseResource._fetch({ path: `/organizations/${this.id}/domains`, method: 'GET',