Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/calm-maps-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/shared': minor
'@clerk/ui': minor
---

Surface organization creation defaults with prefilled form fields and advisory warnings
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type {
OrganizationCreationAdvisorySeverity,
OrganizationCreationAdvisoryType,
OrganizationCreationDefaultsJSON,
OrganizationCreationDefaultsJSONSnapshot,
OrganizationCreationDefaultsResource,
} from '@clerk/shared/types';

import { BaseResource } from './internal';

export class OrganizationCreationDefaults extends BaseResource implements OrganizationCreationDefaultsResource {
advisory: {
code: OrganizationCreationAdvisoryType;
severity: OrganizationCreationAdvisorySeverity;
meta: Record<string, string>;
} | null = null;
form: {
name: string;
slug: string;
logo: string | null;
blurHash: string | null;
} = {
name: '',
slug: '',
logo: null,
blurHash: null,
};

public constructor(data: OrganizationCreationDefaultsJSON | OrganizationCreationDefaultsJSONSnapshot | null = null) {
super();
this.fromJSON(data);
}

protected fromJSON(data: OrganizationCreationDefaultsJSON | OrganizationCreationDefaultsJSONSnapshot | null): this {
if (!data) {
return this;
}

if (data.advisory) {
this.advisory = this.withDefault(data.advisory, this.advisory ?? null);
}

if (data.form) {
this.form.name = this.withDefault(data.form.name, this.form.name);
this.form.slug = this.withDefault(data.form.slug, this.form.slug);
this.form.logo = this.withDefault(data.form.logo, this.form.logo);
this.form.blurHash = this.withDefault(data.form.blur_hash, this.form.blurHash);
}

return this;
}

static async retrieve(): Promise<OrganizationCreationDefaultsResource> {
return await BaseResource._fetch({
path: '/me/organization_creation_defaults',
method: 'GET',
}).then(res => {
const data = res?.response as unknown as OrganizationCreationDefaultsJSON;
return new OrganizationCreationDefaults(data);
});
}
Comment on lines +53 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Unsafe type casting bypasses validation and could cause runtime errors.

Line 55 uses as unknown as OrganizationCreationDefaultsJSON to force-cast the API response without any validation. If the API returns malformed data or the response structure changes, this will bypass type safety and could cause runtime errors when the resource properties are accessed.

🔎 Proposed fix with runtime validation
   static async retrieve(): Promise<OrganizationCreationDefaultsResource> {
     return await BaseResource._fetch({
       path: '/me/organization_creation_defaults',
       method: 'GET',
     }).then(res => {
-      const data = res?.response as unknown as OrganizationCreationDefaultsJSON;
+      const data = res?.response;
+      // Basic runtime validation
+      if (!data || typeof data !== 'object') {
+        throw new Error('Invalid organization creation defaults response');
+      }
       return new OrganizationCreationDefaults(data);
     });
   }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts around
lines 50 to 58, the code force-casts the API response using "as unknown as
OrganizationCreationDefaultsJSON" which bypasses runtime validation; replace the
cast with a proper runtime check: extract res.response, validate required fields
and types (either with a small type-guard function or an existing JSON/schema
validator), throw or return a clear error if validation fails, and only then
construct and return new OrganizationCreationDefaults(validatedData); do not use
"as unknown as" to silence type checking.


public __internal_toSnapshot(): OrganizationCreationDefaultsJSONSnapshot {
return {
advisory: this.advisory
? {
code: this.advisory.code,
meta: this.advisory.meta,
severity: this.advisory.severity,
}
: null,
} as unknown as OrganizationCreationDefaultsJSONSnapshot;
}
Comment on lines +63 to +73
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Incomplete snapshot: missing form property.

The __internal_toSnapshot method only serializes advisory but omits form. Per OrganizationCreationDefaultsJSONSnapshot (alias for OrganizationCreationDefaultsJSON), both properties are required. The as unknown as cast at line 72 masks this type error. This will cause incomplete state restoration when snapshots are consumed.

🐛 Proposed fix
   public __internal_toSnapshot(): OrganizationCreationDefaultsJSONSnapshot {
     return {
       advisory: this.advisory
         ? {
             code: this.advisory.code,
             meta: this.advisory.meta,
             severity: this.advisory.severity,
           }
         : null,
-    } as unknown as OrganizationCreationDefaultsJSONSnapshot;
+      form: {
+        name: this.form.name,
+        slug: this.form.slug,
+        logo: this.form.logo,
+        blur_hash: this.form.blurHash,
+      },
+    };
   }
🤖 Prompt for AI Agents
In @packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts around
lines 63 - 73, The __internal_toSnapshot method on OrganizationCreationDefaults
currently only serializes advisory and omits the required form field; update
OrganizationCreationDefaults.__internal_toSnapshot to also include a serialized
form property (mirror how advisory is serialized: map the relevant fields from
this.form into the expected OrganizationCreationDefaultsJSONSnapshot structure)
so the returned object contains both advisory and form, and remove or avoid
relying on the unsafe "as unknown as" mask if possible so the return type
properly matches OrganizationCreationDefaultsJSONSnapshot.

}
12 changes: 12 additions & 0 deletions packages/clerk-js/src/core/resources/OrganizationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe
} = {
disabled: false,
};
organizationCreationDefaults: {
enabled: boolean;
} = {
enabled: false,
};
enabled: boolean = false;
maxAllowedMemberships: number = 1;
forceOrganizationSelection!: boolean;
Expand Down Expand Up @@ -51,6 +56,13 @@ export class OrganizationSettings extends BaseResource implements OrganizationSe
this.slug.disabled = this.withDefault(data.slug.disabled, this.slug.disabled);
}

if (data.organization_creation_defaults) {
this.organizationCreationDefaults.enabled = this.withDefault(
data.organization_creation_defaults.enabled,
this.organizationCreationDefaults.enabled,
);
}

this.enabled = this.withDefault(data.enabled, this.enabled);
this.maxAllowedMemberships = this.withDefault(data.max_allowed_memberships, this.maxAllowedMemberships);
this.forceOrganizationSelection = this.withDefault(
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
UserOrganizationInvitation,
Web3Wallet,
} from './internal';
import { OrganizationCreationDefaults } from './OrganizationCreationDefaults';

export class User extends BaseResource implements UserResource {
pathRoot = '/me';
Expand Down Expand Up @@ -275,6 +276,8 @@ export class User extends BaseResource implements UserResource {
getOrganizationMemberships: GetOrganizationMemberships = retrieveMembership =>
OrganizationMembership.retrieve(retrieveMembership);

getOrganizationCreationDefaults = () => OrganizationCreationDefaults.retrieve();

leaveOrganization = async (organizationId: string): Promise<DeletedObjectResource> => {
const json = (
await BaseResource._fetch<DeletedObjectJSON>({
Expand Down
4 changes: 4 additions & 0 deletions packages/clerk-js/src/test/fixture-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON)
const withOrganizationSlug = (enabled = false) => {
os.slug.disabled = !enabled;
};
const withOrganizationCreationDefaults = (enabled = false) => {
os.organization_creation_defaults.enabled = enabled;
};

const withOrganizationDomains = (modes?: OrganizationEnrollmentMode[], defaultRole?: string) => {
os.domains.enabled = true;
Expand All @@ -356,6 +359,7 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON)
withOrganizationDomains,
withForceOrganizationSelection,
withOrganizationSlug,
withOrganizationCreationDefaults,
};
};

Expand Down
4 changes: 4 additions & 0 deletions packages/localizations/src/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,10 @@ export const enUS: LocalizationResource = {
actionLink: 'Sign out',
actionText: 'Signed in as {{identifier}}',
},
alerts: {
organizationAlreadyExists:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also include the company name:

An organization already exists for the detected company name (Clerk) and domain (clerk.dev). Join by invitation

'An organization already exists for the detected company name and {{email}}. Join by invitation.',
},
},
taskResetPassword: {
formButtonPrimary: 'Reset Password',
Expand Down
3 changes: 2 additions & 1 deletion packages/shared/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type * from './customPages';
export type * from './deletedObject';
export type * from './devtools';
export type * from './displayConfig';
export type * from './elementIds';
export type * from './emailAddress';
export type * from './enterpriseAccount';
export type * from './environment';
Expand All @@ -32,6 +33,7 @@ export type * from './localization';
export type * from './multiDomain';
export type * from './oauth';
export type * from './organization';
export type * from './organizationCreationDefaults';
export type * from './organizationDomain';
export type * from './organizationInvitation';
export type * from './organizationMembership';
Expand All @@ -49,7 +51,6 @@ export type * from './protectConfig';
export type * from './redirects';
export type * from './resource';
export type * from './role';
export type * from './elementIds';
export type * from './router';
/**
* TODO @revamp-hooks: Drop this in the next major release.
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1314,6 +1314,9 @@ export type __internal_LocalizationResource = {
title: LocalizationValue;
subtitle: LocalizationValue;
};
alerts: {
organizationAlreadyExists: LocalizationValue<'email'>;
};
};
taskResetPassword: {
title: LocalizationValue;
Expand Down
34 changes: 34 additions & 0 deletions packages/shared/src/types/organizationCreationDefaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { ClerkResourceJSON } from './json';
import type { ClerkResource } from './resource';

export type OrganizationCreationAdvisoryType = 'organization_already_exists';

export type OrganizationCreationAdvisorySeverity = 'warning';

export interface OrganizationCreationDefaultsJSON extends ClerkResourceJSON {
advisory: {
code: OrganizationCreationAdvisoryType;
severity: OrganizationCreationAdvisorySeverity;
meta: Record<string, string>;
} | null;
form: {
name: string;
slug: string;
logo: string | null;
blur_hash: string | null;
};
}

export interface OrganizationCreationDefaultsResource extends ClerkResource {
advisory: {
code: OrganizationCreationAdvisoryType;
severity: OrganizationCreationAdvisorySeverity;
meta: Record<string, string>;
} | null;
form: {
name: string;
slug: string;
logo: string | null;
blurHash: string | null;
};
}
6 changes: 6 additions & 0 deletions packages/shared/src/types/organizationSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export interface OrganizationSettingsJSON extends ClerkResourceJSON {
slug: {
disabled: boolean;
};
organization_creation_defaults: {
enabled: boolean;
};
}

export interface OrganizationSettingsResource extends ClerkResource {
Expand All @@ -37,5 +40,8 @@ export interface OrganizationSettingsResource extends ClerkResource {
slug: {
disabled: boolean;
};
organizationCreationDefaults: {
enabled: boolean;
};
__internal_toSnapshot: () => OrganizationSettingsJSONSnapshot;
}
3 changes: 3 additions & 0 deletions packages/shared/src/types/snapshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
VerificationJSON,
Web3WalletJSON,
} from './json';
import type { OrganizationCreationDefaultsJSON } from './organizationCreationDefaults';
import type { OrganizationSettingsJSON } from './organizationSettings';
import type { ProtectConfigJSON } from './protectConfig';
import type { SignInJSON } from './signIn';
Expand Down Expand Up @@ -143,6 +144,8 @@ export type OrganizationMembershipJSONSnapshot = OrganizationMembershipJSON;

export type OrganizationSettingsJSONSnapshot = OrganizationSettingsJSON;

export type OrganizationCreationDefaultsJSONSnapshot = OrganizationCreationDefaultsJSON;

export type PasskeyJSONSnapshot = Override<PasskeyJSON, { verification: VerificationJSONSnapshot | null }>;

export type PhoneNumberJSONSnapshot = Override<
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { ExternalAccountResource } from './externalAccount';
import type { ImageResource } from './image';
import type { UserJSON } from './json';
import type { OAuthScope } from './oauth';
import type { OrganizationCreationDefaultsResource } from './organizationCreationDefaults';
import type { OrganizationInvitationStatus } from './organizationInvitation';
import type { OrganizationMembershipResource } from './organizationMembership';
import type { OrganizationSuggestionResource, OrganizationSuggestionStatus } from './organizationSuggestion';
Expand Down Expand Up @@ -115,6 +116,7 @@ export interface UserResource extends ClerkResource, BillingPayerMethods {
getOrganizationSuggestions: (
params?: GetUserOrganizationSuggestionsParams,
) => Promise<ClerkPaginatedResponse<OrganizationSuggestionResource>>;
getOrganizationCreationDefaults: () => Promise<OrganizationCreationDefaultsResource>;
leaveOrganization: (organizationId: string) => Promise<DeletedObjectResource>;
createTOTP: () => Promise<TOTPResource>;
verifyTOTP: (params: VerifyTOTPParams) => Promise<TOTPResource>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@ import { Col, descriptors, Text } from '../../customizables';
import { localizationKeys } from '../../localization';

export const OrganizationProfileAvatarUploader = (
props: Omit<AvatarUploaderProps, 'avatarPreview' | 'title'> & { organization: Partial<OrganizationResource> },
props: Omit<AvatarUploaderProps, 'avatarPreview' | 'title'> & {
organization: Partial<OrganizationResource>;
/** Shows a loading spinner while the image is loading */
showLoadingSpinner?: boolean;
},
) => {
const { organization, ...rest } = props;
const { organization, showLoadingSpinner, ...rest } = props;

return (
<Col elementDescriptor={descriptors.organizationAvatarUploaderContainer}>
Expand All @@ -28,6 +32,7 @@ export const OrganizationProfileAvatarUploader = (
avatarPreview={
<OrganizationAvatar
size={theme => theme.sizes.$16}
showLoadingSpinner={showLoadingSpinner}
{...organization}
/>
}
Expand Down
Loading
Loading