diff --git a/.changeset/mighty-boxes-reply.md b/.changeset/mighty-boxes-reply.md new file mode 100644 index 0000000000..bf9e6b1b3e --- /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 diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 430e32a356..3278964a60 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, @@ -16,7 +20,9 @@ import type { } from '@clerk/types'; import { unixEpochToDate } from '../../utils/date'; +import { convertPageToOffset } from '../../utils/pagesToOffset'; import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal'; +import { OrganizationDomain } from './OrganizationDomain'; export class Organization extends BaseResource implements OrganizationResource { pathRoot = '/organizations'; @@ -75,6 +81,43 @@ export class Organization extends BaseResource implements OrganizationResource { }); }; + getDomains = async ( + getDomainParams?: GetDomainsParams, + ): Promise> => { + 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); + }; + + 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.test.ts b/packages/clerk-js/src/core/resources/OrganizationDomain.test.ts new file mode 100644 index 0000000000..0aad2e5d17 --- /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/OrganizationDomain.ts b/packages/clerk-js/src/core/resources/OrganizationDomain.ts new file mode 100644 index 0000000000..a63a70fe25 --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationDomain.ts @@ -0,0 +1,94 @@ +import type { + AttemptAffiliationVerificationParams, + OrganizationDomainJSON, + OrganizationDomainResource, + OrganizationDomainVerification, + OrganizationEnrollmentMode, + PrepareAffiliationVerificationParams, + 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); + } + + 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', + path: `/organizations/${this.organizationId}/domains/${this.id}`, + body: params, + }); + }; + + delete = (): Promise => { + return this._baseDelete({ + path: `/organizations/${this.organizationId}/domains/${this.id}`, + }); + }; + + 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/UserOrganizationInvitation.ts b/packages/clerk-js/src/core/resources/UserOrganizationInvitation.ts index 52e7f42df0..40e4fa7fc0 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/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/Organization.test.ts.snap index 1c50c75187..567c23b6ba 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__/OrganizationDomain.test.ts.snap b/packages/clerk-js/src/core/resources/__snapshots__/OrganizationDomain.test.ts.snap new file mode 100644 index 0000000000..a8083943b8 --- /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", + }, +} +`; 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 fabc1c46c0..3f2e398859 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, diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index 442b2930be..89916282c4 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/shared/src/hooks/useOrganization.tsx b/packages/shared/src/hooks/useOrganization.tsx index 49ede6ef2f..0110e00ac0 100644 --- a/packages/shared/src/hooks/useOrganization.tsx +++ b/packages/shared/src/hooks/useOrganization.tsx @@ -1,18 +1,30 @@ import type { ClerkPaginationParams, + GetDomainsParams, GetMembershipsParams, GetPendingInvitationsParams, + OrganizationDomainResource, OrganizationInvitationResource, OrganizationMembershipResource, OrganizationResource, } from '@clerk/types'; +import type { ClerkPaginatedResponse } from '@clerk/types'; +import { useRef } from 'react'; import useSWR from 'swr'; import { useClerkInstanceContext, useOrganizationContext, useSessionContext } from './contexts'; +import type { PaginatedResources, PaginatedResourcesWithDefault } from './types'; +import { usePagesOrInfinite } from './usePagesOrInfinite'; 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: PaginatedResourcesWithDefault; } | { isLoaded: true; @@ -29,6 +42,7 @@ type UseOrganizationReturn = invitationList: undefined; membershipList: undefined; membership: undefined; + domains: PaginatedResourcesWithDefault; } | { isLoaded: boolean; @@ -36,17 +50,70 @@ type UseOrganizationReturn = invitationList: OrganizationInvitationResource[] | null | undefined; membershipList: OrganizationMembershipResource[] | null | undefined; membership: OrganizationMembershipResource | null | undefined; + domains: PaginatedResources | null; }; 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; + + // Cache initialPage and initialPageSize until unmount + const initialPageRef = useRef(shouldUseDefaults ? 1 : domainListParams?.initialPage ?? 1); + const pageSizeRef = useRef(shouldUseDefaults ? 10 : domainListParams?.pageSize ?? 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: initialPageRef.current, + pageSize: pageSizeRef.current, + }; + + const { + 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, + }, + ); // Some gymnastics to adhere to the rules of hooks // We need to make sure useSWR is called on every render @@ -87,6 +154,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 +178,7 @@ export const useOrganization: UseOrganization = params => { invitationList: null, membershipList: null, membership: null, + domains: null, }; } @@ -108,6 +190,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 +217,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, + }, }; }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4b4008ad7f..d44bde2684 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 e3d423b192..f634f755d5 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 ba26334836..328480a851 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,14 @@ 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; destroy: () => Promise; setLogo: (params: SetOrganizationLogoParams) => Promise; } @@ -57,6 +61,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 + */ + pageSize?: 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 0000000000..f91ec4f372 --- /dev/null +++ b/packages/types/src/organizationDomain.ts @@ -0,0 +1,40 @@ +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; + prepareDomainAffiliationVerification: ( + params: PrepareAffiliationVerificationParams, + ) => Promise; + + attemptAffiliationVerification: (params: AttemptAffiliationVerificationParams) => Promise; + delete: () => Promise; + update: (params: UpdateOrganizationDomainParams) => Promise; +} + +export type PrepareAffiliationVerificationParams = { + affiliationEmailAddress: string; +}; + +export type AttemptAffiliationVerificationParams = { + code: string; +}; + +export type UpdateOrganizationDomainParams = Partial>;