diff --git a/.changeset/calm-maps-work.md b/.changeset/calm-maps-work.md new file mode 100644 index 00000000000..9583e7cb076 --- /dev/null +++ b/.changeset/calm-maps-work.md @@ -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 diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index cbc3539426c..834dd41db10 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,8 +1,8 @@ { "files": [ { "path": "./dist/clerk.js", "maxSize": "538KB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "63KB" }, - { "path": "./dist/clerk.chips.browser.js", "maxSize": "63KB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "66KB" }, + { "path": "./dist/clerk.chips.browser.js", "maxSize": "66KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "105KB" }, { "path": "./dist/clerk.no-rhc.js", "maxSize": "305KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "65KB" }, diff --git a/packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts b/packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts new file mode 100644 index 00000000000..94274a7a3ca --- /dev/null +++ b/packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts @@ -0,0 +1,80 @@ +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; + } | 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 { + 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); + }); + } + + public __internal_toSnapshot(): OrganizationCreationDefaultsJSONSnapshot { + return { + advisory: this.advisory + ? { + code: this.advisory.code, + meta: this.advisory.meta, + severity: this.advisory.severity, + } + : null, + form: { + name: this.form.name, + slug: this.form.slug, + logo: this.form.logo, + blur_hash: this.form.blurHash, + }, + } as unknown as OrganizationCreationDefaultsJSONSnapshot; + } +} diff --git a/packages/clerk-js/src/core/resources/OrganizationSettings.ts b/packages/clerk-js/src/core/resources/OrganizationSettings.ts index 8960b347d62..a9fa5873d49 100644 --- a/packages/clerk-js/src/core/resources/OrganizationSettings.ts +++ b/packages/clerk-js/src/core/resources/OrganizationSettings.ts @@ -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; @@ -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( diff --git a/packages/clerk-js/src/core/resources/User.ts b/packages/clerk-js/src/core/resources/User.ts index ae7ef203c63..079b7ae2f75 100644 --- a/packages/clerk-js/src/core/resources/User.ts +++ b/packages/clerk-js/src/core/resources/User.ts @@ -53,6 +53,7 @@ import { UserOrganizationInvitation, Web3Wallet, } from './internal'; +import { OrganizationCreationDefaults } from './OrganizationCreationDefaults'; export class User extends BaseResource implements UserResource { pathRoot = '/me'; @@ -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 => { const json = ( await BaseResource._fetch({ diff --git a/packages/clerk-js/src/test/fixture-helpers.ts b/packages/clerk-js/src/test/fixture-helpers.ts index 86547dae2c0..b1564bcd841 100644 --- a/packages/clerk-js/src/test/fixture-helpers.ts +++ b/packages/clerk-js/src/test/fixture-helpers.ts @@ -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; @@ -356,6 +359,7 @@ const createOrganizationSettingsFixtureHelpers = (environment: EnvironmentJSON) withOrganizationDomains, withForceOrganizationSelection, withOrganizationSlug, + withOrganizationCreationDefaults, }; }; diff --git a/packages/localizations/src/ar-SA.ts b/packages/localizations/src/ar-SA.ts index 50d917bb24c..7b236645588 100644 --- a/packages/localizations/src/ar-SA.ts +++ b/packages/localizations/src/ar-SA.ts @@ -882,6 +882,10 @@ export const arSA: LocalizationResource = { actionLink: 'تسجيل الخروج', actionText: 'تم تسجيل الدخول كـ {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'توجد منظمة بالفعل لاسم الشركة المكتشف ({{organizationName}}) و {{organizationDomain}}. انضم عن طريق الدعوة.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/be-BY.ts b/packages/localizations/src/be-BY.ts index 2fc534100a6..eeda34ac12d 100644 --- a/packages/localizations/src/be-BY.ts +++ b/packages/localizations/src/be-BY.ts @@ -890,6 +890,10 @@ export const beBY: LocalizationResource = { actionLink: 'Выйсці', actionText: 'Увайшлі як {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Арганізацыя ўжо існуе для выяўленай назвы кампаніі ({{organizationName}}) і {{organizationDomain}}. Далучайцеся па запрашэнні.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/bg-BG.ts b/packages/localizations/src/bg-BG.ts index bfb2dd00f07..7688d81eb0e 100644 --- a/packages/localizations/src/bg-BG.ts +++ b/packages/localizations/src/bg-BG.ts @@ -886,6 +886,10 @@ export const bgBG: LocalizationResource = { actionLink: 'Изход', actionText: 'Влязъл като {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Организация вече съществува за откритото име на компанията ({{organizationName}}) и {{organizationDomain}}. Присъединете се чрез покана.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/bn-IN.ts b/packages/localizations/src/bn-IN.ts index b1dcf6952f4..048968c279c 100644 --- a/packages/localizations/src/bn-IN.ts +++ b/packages/localizations/src/bn-IN.ts @@ -890,6 +890,10 @@ export const bnIN: LocalizationResource = { actionLink: 'সাইন আউট', actionText: '{{identifier}} হিসাবে সাইন ইন করা হয়েছে', }, + alerts: { + organizationAlreadyExists: + 'শনাক্ত করা কোম্পানির নাম ({{organizationName}}) এবং {{organizationDomain}}-এর জন্য একটি সংস্থা ইতিমধ্যেই বিদ্যমান। আমন্ত্রণের মাধ্যমে যোগ দিন।', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/ca-ES.ts b/packages/localizations/src/ca-ES.ts index 5d37a50d8d0..8b1c69e0ef4 100644 --- a/packages/localizations/src/ca-ES.ts +++ b/packages/localizations/src/ca-ES.ts @@ -885,6 +885,10 @@ export const caES: LocalizationResource = { actionLink: 'Tancar sessió', actionText: 'Sessió iniciada com a {{identifier}}', }, + alerts: { + organizationAlreadyExists: + "Ja existeix una organització per al nom d'empresa detectat ({{organizationName}}) i {{organizationDomain}}. Uneix-te per invitació.", + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/cs-CZ.ts b/packages/localizations/src/cs-CZ.ts index 51bcab2c0a3..d077e4fcf69 100644 --- a/packages/localizations/src/cs-CZ.ts +++ b/packages/localizations/src/cs-CZ.ts @@ -896,6 +896,10 @@ export const csCZ: LocalizationResource = { actionLink: 'Odhlásit se', actionText: 'Přihlášen jako {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organizace již existuje pro detekovaný název společnosti ({{organizationName}}) a {{organizationDomain}}. Připojte se prostřednictvím pozvánky.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/da-DK.ts b/packages/localizations/src/da-DK.ts index 912a16e1961..6f9372f75f6 100644 --- a/packages/localizations/src/da-DK.ts +++ b/packages/localizations/src/da-DK.ts @@ -883,6 +883,10 @@ export const daDK: LocalizationResource = { actionLink: 'Log ud', actionText: 'Logget ind som {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Der findes allerede en organisation for det registrerede firmanavn ({{organizationName}}) og {{organizationDomain}}. Tilmeld dig via invitation.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/de-DE.ts b/packages/localizations/src/de-DE.ts index c94e023a3ec..6052ff0ef37 100644 --- a/packages/localizations/src/de-DE.ts +++ b/packages/localizations/src/de-DE.ts @@ -901,6 +901,10 @@ export const deDE: LocalizationResource = { actionLink: 'Abmelden', actionText: 'Angemeldet als {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Für den erkannten Firmennamen ({{organizationName}}) und {{organizationDomain}} existiert bereits eine Organisation. Treten Sie per Einladung bei.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/el-GR.ts b/packages/localizations/src/el-GR.ts index 88d9194fed1..77002e95e97 100644 --- a/packages/localizations/src/el-GR.ts +++ b/packages/localizations/src/el-GR.ts @@ -888,6 +888,10 @@ export const elGR: LocalizationResource = { actionLink: 'Αποσύνδεση', actionText: 'Συνδεδεμένος ως {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Υπάρχει ήδη οργανισμός για το ανιχνευμένο όνομα εταιρείας ({{organizationName}}) και {{organizationDomain}}. Εγγραφείτε μέσω πρόσκλησης.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/en-GB.ts b/packages/localizations/src/en-GB.ts index a0223170508..e3cf2a2eb18 100644 --- a/packages/localizations/src/en-GB.ts +++ b/packages/localizations/src/en-GB.ts @@ -887,6 +887,10 @@ export const enGB: LocalizationResource = { actionLink: 'Sign out', actionText: 'Signed in as {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'An organisation already exists for the detected company name ({{organizationName}}) and {{organizationDomain}}. Join by invitation.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 8f8cba4e707..15256e3e1c2 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -884,6 +884,10 @@ export const enUS: LocalizationResource = { actionLink: 'Sign out', actionText: 'Signed in as {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'An organization already exists for the detected company name ({{organizationName}}) and {{organizationDomain}}. Join by invitation.', + }, }, taskResetPassword: { formButtonPrimary: 'Reset Password', diff --git a/packages/localizations/src/es-CR.ts b/packages/localizations/src/es-CR.ts index b704d982755..342cec4458a 100644 --- a/packages/localizations/src/es-CR.ts +++ b/packages/localizations/src/es-CR.ts @@ -893,6 +893,10 @@ export const esCR: LocalizationResource = { actionLink: 'Cerrar sesión', actionText: 'Conectado como {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Ya existe una organización para el nombre de empresa detectado ({{organizationName}}) y {{organizationDomain}}. Únete por invitación.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/es-ES.ts b/packages/localizations/src/es-ES.ts index 10b8b3b4a79..88449ca3e49 100644 --- a/packages/localizations/src/es-ES.ts +++ b/packages/localizations/src/es-ES.ts @@ -887,6 +887,10 @@ export const esES: LocalizationResource = { actionLink: 'Cerrar sesión', actionText: 'Sesión iniciada como {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Ya existe una organización para el nombre de empresa detectado ({{organizationName}}) y {{organizationDomain}}. Únete por invitación.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/es-MX.ts b/packages/localizations/src/es-MX.ts index 51aa523cc4e..b17ad42dee4 100644 --- a/packages/localizations/src/es-MX.ts +++ b/packages/localizations/src/es-MX.ts @@ -894,6 +894,10 @@ export const esMX: LocalizationResource = { actionLink: 'Cerrar sesión', actionText: 'Registrado como {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Ya existe una organización para el nombre de empresa detectado ({{organizationName}}) y {{organizationDomain}}. Únete por invitación.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/es-UY.ts b/packages/localizations/src/es-UY.ts index 94df1a845bb..fc90d0c1824 100644 --- a/packages/localizations/src/es-UY.ts +++ b/packages/localizations/src/es-UY.ts @@ -893,6 +893,10 @@ export const esUY: LocalizationResource = { actionLink: 'Cerrar sesión', actionText: 'Logueado como {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Ya existe una organización para el nombre de empresa detectado ({{organizationName}}) y {{organizationDomain}}. Únete por invitación.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/fa-IR.ts b/packages/localizations/src/fa-IR.ts index a38bf43bdd1..b33f584be12 100644 --- a/packages/localizations/src/fa-IR.ts +++ b/packages/localizations/src/fa-IR.ts @@ -897,6 +897,10 @@ export const faIR: LocalizationResource = { actionLink: 'خروج از همه حساب‌ها', actionText: 'می‌خواهید خارج شوید؟', }, + alerts: { + organizationAlreadyExists: + 'سازمانی برای نام شرکت شناسایی شده ({{organizationName}}) و {{organizationDomain}} از قبل وجود دارد. از طریق دعوتنامه بپیوندید.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/fi-FI.ts b/packages/localizations/src/fi-FI.ts index 0c78950e678..5e75f899d20 100644 --- a/packages/localizations/src/fi-FI.ts +++ b/packages/localizations/src/fi-FI.ts @@ -886,6 +886,10 @@ export const fiFI: LocalizationResource = { actionLink: 'Kirjaudu ulos', actionText: 'Kirjautuneena käyttäjänä {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organisaatio on jo olemassa havaitulle yrityksen nimelle ({{organizationName}}) ja {{organizationDomain}}. Liity kutsulla.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/fr-FR.ts b/packages/localizations/src/fr-FR.ts index ff468c1eb32..413f1ca25ee 100644 --- a/packages/localizations/src/fr-FR.ts +++ b/packages/localizations/src/fr-FR.ts @@ -902,6 +902,10 @@ export const frFR: LocalizationResource = { actionLink: 'Se déconnecter', actionText: 'Connecté en tant que {{identifier}}', }, + alerts: { + organizationAlreadyExists: + "Une organisation existe déjà pour le nom d'entreprise détecté ({{organizationName}}) et {{organizationDomain}}. Rejoignez par invitation.", + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/he-IL.ts b/packages/localizations/src/he-IL.ts index 42564db8c6f..20c1cb0b225 100644 --- a/packages/localizations/src/he-IL.ts +++ b/packages/localizations/src/he-IL.ts @@ -876,6 +876,10 @@ export const heIL: LocalizationResource = { actionLink: 'התנתק', actionText: 'מחובר כ-{{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'ארגון כבר קיים עבור שם החברה שזוהה ({{organizationName}}) ו-{{organizationDomain}}. הצטרף באמצעות הזמנה.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/hi-IN.ts b/packages/localizations/src/hi-IN.ts index 6a9b336dd7c..3301b3847c2 100644 --- a/packages/localizations/src/hi-IN.ts +++ b/packages/localizations/src/hi-IN.ts @@ -891,6 +891,10 @@ export const hiIN: LocalizationResource = { actionLink: 'साइन आउट', actionText: '{{identifier}} के रूप में साइन इन किया गया', }, + alerts: { + organizationAlreadyExists: + 'पता लगाई गई कंपनी के नाम ({{organizationName}}) और {{organizationDomain}} के लिए एक संगठन पहले से मौजूद है। आमंत्रण द्वारा शामिल हों।', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/hr-HR.ts b/packages/localizations/src/hr-HR.ts index 529471130ae..67212f7440a 100644 --- a/packages/localizations/src/hr-HR.ts +++ b/packages/localizations/src/hr-HR.ts @@ -887,6 +887,10 @@ export const hrHR: LocalizationResource = { actionLink: 'Odjavi se', actionText: 'Prijavljen kao {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organizacija već postoji za otkriveni naziv tvrtke ({{organizationName}}) i {{organizationDomain}}. Pridružite se putem pozivnice.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/hu-HU.ts b/packages/localizations/src/hu-HU.ts index 5ac10d193f1..aeb1c042c98 100644 --- a/packages/localizations/src/hu-HU.ts +++ b/packages/localizations/src/hu-HU.ts @@ -884,6 +884,10 @@ export const huHU: LocalizationResource = { actionLink: 'Kijelentkezés', actionText: 'Bejelentkezve: {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Már létezik szervezet az észlelt cégnévhez ({{organizationName}}) és {{organizationDomain}}. Csatlakozz meghívással.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/id-ID.ts b/packages/localizations/src/id-ID.ts index 7708f997cc3..0aaa672bfd5 100644 --- a/packages/localizations/src/id-ID.ts +++ b/packages/localizations/src/id-ID.ts @@ -892,6 +892,10 @@ export const idID: LocalizationResource = { actionLink: 'Keluar', actionText: 'Masuk sebagai {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organisasi sudah ada untuk nama perusahaan yang terdeteksi ({{organizationName}}) dan {{organizationDomain}}. Bergabung melalui undangan.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/is-IS.ts b/packages/localizations/src/is-IS.ts index 0753fa95333..94c05eb6832 100644 --- a/packages/localizations/src/is-IS.ts +++ b/packages/localizations/src/is-IS.ts @@ -887,6 +887,10 @@ export const isIS: LocalizationResource = { actionLink: 'Skrá út', actionText: 'Skráður inn sem {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Fyrirtæki er þegar til fyrir uppgötvaða fyrirtækjanafnið ({{organizationName}}) og {{organizationDomain}}. Skráðu þig með boði.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/it-IT.ts b/packages/localizations/src/it-IT.ts index 59b78656282..c99073cde06 100644 --- a/packages/localizations/src/it-IT.ts +++ b/packages/localizations/src/it-IT.ts @@ -894,6 +894,10 @@ export const itIT: LocalizationResource = { actionLink: 'Esci', actionText: 'Accesso effettuato come {{identifier}}', }, + alerts: { + organizationAlreadyExists: + "Un'organizzazione esiste già per il nome dell'azienda rilevato ({{organizationName}}) e {{organizationDomain}}. Unisciti tramite invito.", + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/ja-JP.ts b/packages/localizations/src/ja-JP.ts index ec125d4eba5..c58c5197cb4 100644 --- a/packages/localizations/src/ja-JP.ts +++ b/packages/localizations/src/ja-JP.ts @@ -897,6 +897,10 @@ export const jaJP: LocalizationResource = { actionLink: 'サインアウト', actionText: '{{identifier}} としてサインイン中', }, + alerts: { + organizationAlreadyExists: + '検出された会社名 ({{organizationName}}) と {{organizationDomain}} の組織がすでに存在します。招待を通じて参加してください。', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/kk-KZ.ts b/packages/localizations/src/kk-KZ.ts index 91b13a17427..828a94456a9 100644 --- a/packages/localizations/src/kk-KZ.ts +++ b/packages/localizations/src/kk-KZ.ts @@ -877,6 +877,10 @@ export const kkKZ: LocalizationResource = { actionLink: 'Шығу', actionText: '{{identifier}} ретінде кірді', }, + alerts: { + organizationAlreadyExists: + 'Анықталған компания атауы ({{organizationName}}) және {{organizationDomain}} үшін ұйым бұрыннан бар. Шақыру арқылы қосылыңыз.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/ko-KR.ts b/packages/localizations/src/ko-KR.ts index 93110710f8e..a52bf6a1c22 100644 --- a/packages/localizations/src/ko-KR.ts +++ b/packages/localizations/src/ko-KR.ts @@ -878,6 +878,10 @@ export const koKR: LocalizationResource = { actionLink: '로그아웃', actionText: '{{identifier}}로 로그인됨', }, + alerts: { + organizationAlreadyExists: + '감지된 회사 이름 ({{organizationName}}) 및 {{organizationDomain}}에 대한 조직이 이미 존재합니다. 초대를 통해 가입하세요.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/mn-MN.ts b/packages/localizations/src/mn-MN.ts index 652a8c324c5..57ea7f8fdd1 100644 --- a/packages/localizations/src/mn-MN.ts +++ b/packages/localizations/src/mn-MN.ts @@ -886,6 +886,10 @@ export const mnMN: LocalizationResource = { actionLink: 'Гарах', actionText: '{{identifier}} гэж нэвтэрсэн', }, + alerts: { + organizationAlreadyExists: + 'Илрүүлсэн компанийн нэр ({{organizationName}}) болон {{organizationDomain}}-д байгууллага аль хэдийн байна. Урилгаар нэгдэнэ үү.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/ms-MY.ts b/packages/localizations/src/ms-MY.ts index 287fef9be36..7fc6ace2cbb 100644 --- a/packages/localizations/src/ms-MY.ts +++ b/packages/localizations/src/ms-MY.ts @@ -894,6 +894,10 @@ export const msMY: LocalizationResource = { actionLink: 'Daftar keluar', actionText: 'Log masuk sebagai {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organisasi sudah wujud untuk nama syarikat yang dikesan ({{organizationName}}) dan {{organizationDomain}}. Sertai melalui jemputan.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/nb-NO.ts b/packages/localizations/src/nb-NO.ts index fa1e75dd586..a7ae414e371 100644 --- a/packages/localizations/src/nb-NO.ts +++ b/packages/localizations/src/nb-NO.ts @@ -884,6 +884,10 @@ export const nbNO: LocalizationResource = { actionLink: 'Logg ut', actionText: 'Innlogget som {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'En organisasjon eksisterer allerede for det oppdagede firmanavnet ({{organizationName}}) og {{organizationDomain}}. Bli med via invitasjon.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/nl-BE.ts b/packages/localizations/src/nl-BE.ts index b3c4d8eb970..e614f1f3bcc 100644 --- a/packages/localizations/src/nl-BE.ts +++ b/packages/localizations/src/nl-BE.ts @@ -886,6 +886,10 @@ export const nlBE: LocalizationResource = { actionLink: 'Uitloggen', actionText: 'Ingelogd als {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Er bestaat al een organisatie voor de gedetecteerde bedrijfsnaam ({{organizationName}}) en {{organizationDomain}}. Word lid via uitnodiging.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/nl-NL.ts b/packages/localizations/src/nl-NL.ts index 978c9413aa1..a65897a463b 100644 --- a/packages/localizations/src/nl-NL.ts +++ b/packages/localizations/src/nl-NL.ts @@ -886,6 +886,10 @@ export const nlNL: LocalizationResource = { actionLink: 'Uitloggen', actionText: 'Ingelogd als {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Er bestaat al een organisatie voor de gedetecteerde bedrijfsnaam ({{organizationName}}) en {{organizationDomain}}. Word lid via uitnodiging.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/pl-PL.ts b/packages/localizations/src/pl-PL.ts index 30c915013b6..638d548ec93 100644 --- a/packages/localizations/src/pl-PL.ts +++ b/packages/localizations/src/pl-PL.ts @@ -890,6 +890,10 @@ export const plPL: LocalizationResource = { actionLink: 'Wyloguj', actionText: 'Zalogowano jako {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organizacja już istnieje dla wykrytej nazwy firmy ({{organizationName}}) i {{organizationDomain}}. Dołącz przez zaproszenie.', + }, }, taskResetPassword: { formButtonPrimary: 'Zresetuj hasło', diff --git a/packages/localizations/src/pt-BR.ts b/packages/localizations/src/pt-BR.ts index 07a8fedd0e6..2d5d3d87a63 100644 --- a/packages/localizations/src/pt-BR.ts +++ b/packages/localizations/src/pt-BR.ts @@ -898,6 +898,10 @@ export const ptBR: LocalizationResource = { actionLink: 'Sair', actionText: 'Conectado como {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Uma organização já existe para o nome da empresa detectado ({{organizationName}}) e {{organizationDomain}}. Entre por convite.', + }, }, taskResetPassword: { formButtonPrimary: 'Resetar Senha', diff --git a/packages/localizations/src/pt-PT.ts b/packages/localizations/src/pt-PT.ts index c08709211ee..ef173c90304 100644 --- a/packages/localizations/src/pt-PT.ts +++ b/packages/localizations/src/pt-PT.ts @@ -884,6 +884,10 @@ export const ptPT: LocalizationResource = { actionLink: 'Terminar sessão', actionText: 'Sessão iniciada como {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Já existe uma organização para o nome da empresa detetado ({{organizationName}}) e {{organizationDomain}}. Adira por convite.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/ro-RO.ts b/packages/localizations/src/ro-RO.ts index d42538d29b9..53f7145d945 100644 --- a/packages/localizations/src/ro-RO.ts +++ b/packages/localizations/src/ro-RO.ts @@ -899,6 +899,10 @@ export const roRO: LocalizationResource = { actionLink: 'Deconectează-te', actionText: 'Autentificat ca {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Există deja o organizație pentru numele companiei detectate ({{organizationName}}) și {{organizationDomain}}. Alătură-te prin invitație.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/ru-RU.ts b/packages/localizations/src/ru-RU.ts index 5da404c6162..b57a12b9079 100644 --- a/packages/localizations/src/ru-RU.ts +++ b/packages/localizations/src/ru-RU.ts @@ -897,6 +897,10 @@ export const ruRU: LocalizationResource = { actionLink: 'Выйти', actionText: 'Вошли как {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Организация уже существует для обнаруженного названия компании ({{organizationName}}) и {{organizationDomain}}. Присоединяйтесь по приглашению.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/sk-SK.ts b/packages/localizations/src/sk-SK.ts index 4e4c362d0b6..e48dc7a0afc 100644 --- a/packages/localizations/src/sk-SK.ts +++ b/packages/localizations/src/sk-SK.ts @@ -890,6 +890,10 @@ export const skSK: LocalizationResource = { actionLink: 'Odhlásiť sa', actionText: 'Prihlásený ako {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organizácia už existuje pre zistený názov spoločnosti ({{organizationName}}) a {{organizationDomain}}. Pripojte sa prostredníctvom pozvánky.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/sr-RS.ts b/packages/localizations/src/sr-RS.ts index a8f2efa6de2..f1719ff5b44 100644 --- a/packages/localizations/src/sr-RS.ts +++ b/packages/localizations/src/sr-RS.ts @@ -883,6 +883,10 @@ export const srRS: LocalizationResource = { actionLink: 'Odjavi se', actionText: 'Prijavljen kao {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Organizacija već postoji za otkriveno ime kompanije ({{organizationName}}) i {{organizationDomain}}. Pridružite se putem pozivnice.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/sv-SE.ts b/packages/localizations/src/sv-SE.ts index d2b076ce81d..9953b2b6dde 100644 --- a/packages/localizations/src/sv-SE.ts +++ b/packages/localizations/src/sv-SE.ts @@ -888,6 +888,10 @@ export const svSE: LocalizationResource = { actionLink: 'Logga ut', actionText: 'Inloggad som {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'En organisation finns redan för det upptäckta företagsnamnet ({{organizationName}}) och {{organizationDomain}}. Gå med via inbjudan.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/ta-IN.ts b/packages/localizations/src/ta-IN.ts index cde79eff01d..e2152864bc9 100644 --- a/packages/localizations/src/ta-IN.ts +++ b/packages/localizations/src/ta-IN.ts @@ -893,6 +893,10 @@ export const taIN: LocalizationResource = { actionLink: 'வெளியேறு', actionText: '{{identifier}} என உள்நுழைந்துள்ளீர்கள்', }, + alerts: { + organizationAlreadyExists: + 'கண்டறியப்பட்ட நிறுவன பெயர் ({{organizationName}}) மற்றும் {{organizationDomain}} க்கு ஒரு அமைப்பு ஏற்கனவே உள்ளது. அழைப்பின் மூலம் சேரவும்.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/te-IN.ts b/packages/localizations/src/te-IN.ts index f40b1f0a007..d603dab21b3 100644 --- a/packages/localizations/src/te-IN.ts +++ b/packages/localizations/src/te-IN.ts @@ -893,6 +893,10 @@ export const teIN: LocalizationResource = { actionLink: 'సైన్ అవుట్', actionText: '{{identifier}}గా సైన్ ఇన్ చేయబడింది', }, + alerts: { + organizationAlreadyExists: + 'గుర్తించిన కంపెనీ పేరు ({{organizationName}}) మరియు {{organizationDomain}} కోసం ఒక సంస్థ ఇప్పటికే ఉంది. ఆహ్వానం ద్వారా చేరండి.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/th-TH.ts b/packages/localizations/src/th-TH.ts index 87398b079cd..303b29ca368 100644 --- a/packages/localizations/src/th-TH.ts +++ b/packages/localizations/src/th-TH.ts @@ -886,6 +886,10 @@ export const thTH: LocalizationResource = { actionLink: 'ออกจากระบบ', actionText: 'เข้าสู่ระบบในนาม {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'องค์กรสำหรับชื่อบริษัทที่ตรวจพบ ({{organizationName}}) และ {{organizationDomain}} มีอยู่แล้ว เข้าร่วมโดยการเชิญ', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/tr-TR.ts b/packages/localizations/src/tr-TR.ts index 8fef9ca99f3..ab80cec37a7 100644 --- a/packages/localizations/src/tr-TR.ts +++ b/packages/localizations/src/tr-TR.ts @@ -886,6 +886,10 @@ export const trTR: LocalizationResource = { actionLink: 'Çıkış yap', actionText: '{{identifier}} olarak giriş yapıldı', }, + alerts: { + organizationAlreadyExists: + 'Tespit edilen şirket adı ({{organizationName}}) ve {{organizationDomain}} için bir organizasyon zaten mevcut. Davetiye ile katılın.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/uk-UA.ts b/packages/localizations/src/uk-UA.ts index 0bb8cd22a50..4f84903a806 100644 --- a/packages/localizations/src/uk-UA.ts +++ b/packages/localizations/src/uk-UA.ts @@ -882,6 +882,10 @@ export const ukUA: LocalizationResource = { actionLink: 'Вийти', actionText: 'Увійшли як {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Організація вже існує для виявленої назви компанії ({{organizationName}}) та {{organizationDomain}}. Приєднуйтесь за запрошенням.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/vi-VN.ts b/packages/localizations/src/vi-VN.ts index a50dacd425c..3120325bfcd 100644 --- a/packages/localizations/src/vi-VN.ts +++ b/packages/localizations/src/vi-VN.ts @@ -893,6 +893,10 @@ export const viVN: LocalizationResource = { actionLink: 'Đăng xuất', actionText: 'Đã đăng nhập với {{identifier}}', }, + alerts: { + organizationAlreadyExists: + 'Một tổ chức đã tồn tại cho tên công ty được phát hiện ({{organizationName}}) và {{organizationDomain}}. Tham gia bằng lời mời.', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/zh-CN.ts b/packages/localizations/src/zh-CN.ts index ffd83ee89e5..2eef007f398 100644 --- a/packages/localizations/src/zh-CN.ts +++ b/packages/localizations/src/zh-CN.ts @@ -872,6 +872,10 @@ export const zhCN: LocalizationResource = { actionLink: '退出', actionText: '已登录为 {{identifier}}', }, + alerts: { + organizationAlreadyExists: + '检测到的公司名称 ({{organizationName}}) 和 {{organizationDomain}} 已存在一个组织。请通过邀请加入。', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/localizations/src/zh-TW.ts b/packages/localizations/src/zh-TW.ts index 8c91fae57a7..72694a19896 100644 --- a/packages/localizations/src/zh-TW.ts +++ b/packages/localizations/src/zh-TW.ts @@ -873,6 +873,10 @@ export const zhTW: LocalizationResource = { actionLink: '登出', actionText: '已登入為 {{identifier}}', }, + alerts: { + organizationAlreadyExists: + '偵測到的公司名稱 ({{organizationName}}) 和 {{organizationDomain}} 已存在一個組織。請透過邀請加入。', + }, }, taskResetPassword: { formButtonPrimary: undefined, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index d1f50cfde7c..068e5728cae 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -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'; @@ -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'; @@ -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. diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts index 806826143d8..7c3b5ae0fc0 100644 --- a/packages/shared/src/types/localization.ts +++ b/packages/shared/src/types/localization.ts @@ -1314,6 +1314,9 @@ export type __internal_LocalizationResource = { title: LocalizationValue; subtitle: LocalizationValue; }; + alerts: { + organizationAlreadyExists: LocalizationValue<'organizationDomain' | 'organizationName'>; + }; }; taskResetPassword: { title: LocalizationValue; diff --git a/packages/shared/src/types/organizationCreationDefaults.ts b/packages/shared/src/types/organizationCreationDefaults.ts new file mode 100644 index 00000000000..95d3211110a --- /dev/null +++ b/packages/shared/src/types/organizationCreationDefaults.ts @@ -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; + } | 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; + } | null; + form: { + name: string; + slug: string; + logo: string | null; + blurHash: string | null; + }; +} diff --git a/packages/shared/src/types/organizationSettings.ts b/packages/shared/src/types/organizationSettings.ts index ab9e0704e1e..e9a24b8e0f0 100644 --- a/packages/shared/src/types/organizationSettings.ts +++ b/packages/shared/src/types/organizationSettings.ts @@ -20,6 +20,9 @@ export interface OrganizationSettingsJSON extends ClerkResourceJSON { slug: { disabled: boolean; }; + organization_creation_defaults: { + enabled: boolean; + }; } export interface OrganizationSettingsResource extends ClerkResource { @@ -37,5 +40,8 @@ export interface OrganizationSettingsResource extends ClerkResource { slug: { disabled: boolean; }; + organizationCreationDefaults: { + enabled: boolean; + }; __internal_toSnapshot: () => OrganizationSettingsJSONSnapshot; } diff --git a/packages/shared/src/types/snapshots.ts b/packages/shared/src/types/snapshots.ts index 6db74374c44..a1d239c329f 100644 --- a/packages/shared/src/types/snapshots.ts +++ b/packages/shared/src/types/snapshots.ts @@ -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'; @@ -143,6 +144,8 @@ export type OrganizationMembershipJSONSnapshot = OrganizationMembershipJSON; export type OrganizationSettingsJSONSnapshot = OrganizationSettingsJSON; +export type OrganizationCreationDefaultsJSONSnapshot = OrganizationCreationDefaultsJSON; + export type PasskeyJSONSnapshot = Override; export type PhoneNumberJSONSnapshot = Override< diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index 93d7a9ef4a4..ac1c40b2fbd 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -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'; @@ -115,6 +116,7 @@ export interface UserResource extends ClerkResource, BillingPayerMethods { getOrganizationSuggestions: ( params?: GetUserOrganizationSuggestionsParams, ) => Promise>; + getOrganizationCreationDefaults: () => Promise; leaveOrganization: (organizationId: string) => Promise; createTOTP: () => Promise; verifyTOTP: (params: VerifyTOTPParams) => Promise; diff --git a/packages/ui/src/components/OrganizationProfile/OrganizationProfileAvatarUploader.tsx b/packages/ui/src/components/OrganizationProfile/OrganizationProfileAvatarUploader.tsx index 6c146a20312..fa2c5133528 100644 --- a/packages/ui/src/components/OrganizationProfile/OrganizationProfileAvatarUploader.tsx +++ b/packages/ui/src/components/OrganizationProfile/OrganizationProfileAvatarUploader.tsx @@ -8,9 +8,13 @@ import { Col, descriptors, Text } from '../../customizables'; import { localizationKeys } from '../../localization'; export const OrganizationProfileAvatarUploader = ( - props: Omit & { organization: Partial }, + props: Omit & { + organization: Partial; + /** Shows a loading spinner while the image is loading */ + showLoadingSpinner?: boolean; + }, ) => { - const { organization, ...rest } = props; + const { organization, showLoadingSpinner, ...rest } = props; return ( @@ -28,6 +32,7 @@ export const OrganizationProfileAvatarUploader = ( avatarPreview={ theme.sizes.$16} + showLoadingSpinner={showLoadingSpinner} {...organization} /> } diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx index 2cf177d11a4..f2a8106f967 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx @@ -1,22 +1,28 @@ import { useOrganizationList } from '@clerk/shared/react'; -import type { CreateOrganizationParams } from '@clerk/shared/types'; +import type { CreateOrganizationParams, OrganizationCreationDefaultsResource } from '@clerk/shared/types'; +import { useState } from 'react'; import { useEnvironment } from '@/ui/contexts'; import { useSessionTasksContext, useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks'; -import { localizationKeys } from '@/ui/customizables'; +import { Icon, localizationKeys } from '@/ui/customizables'; import { useCardState } from '@/ui/elements/contexts'; import { Form } from '@/ui/elements/Form'; import { FormButtonContainer } from '@/ui/elements/FormButtons'; import { FormContainer } from '@/ui/elements/FormContainer'; import { Header } from '@/ui/elements/Header'; +import { IconButton } from '@/ui/elements/IconButton'; +import { Upload } from '@/ui/icons'; import { createSlug } from '@/ui/utils/createSlug'; import { handleError } from '@/ui/utils/errorHandler'; import { useFormControl } from '@/ui/utils/useFormControl'; +import { OrganizationProfileAvatarUploader } from '../../../OrganizationProfile/OrganizationProfileAvatarUploader'; import { organizationListParams } from '../../../OrganizationSwitcher/utils'; +import { OrganizationCreationDefaultsAlert } from './OrganizationCreationDefaultsAlert'; type CreateOrganizationScreenProps = { onCancel?: () => void; + organizationCreationDefaults?: OrganizationCreationDefaultsResource; }; export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) => { @@ -27,13 +33,14 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = userMemberships: organizationListParams.userMemberships, }); const { organizationSettings } = useEnvironment(); + const [file, setFile] = useState(); - const nameField = useFormControl('name', '', { + const nameField = useFormControl('name', props.organizationCreationDefaults?.form.name ?? '', { type: 'text', label: localizationKeys('taskChooseOrganization.createOrganization.formFieldLabel__name'), placeholder: localizationKeys('taskChooseOrganization.createOrganization.formFieldInputPlaceholder__name'), }); - const slugField = useFormControl('slug', '', { + const slugField = useFormControl('slug', props.organizationCreationDefaults?.form.slug ?? '', { type: 'text', label: localizationKeys('taskChooseOrganization.createOrganization.formFieldLabel__slug'), placeholder: localizationKeys('taskChooseOrganization.createOrganization.formFieldInputPlaceholder__slug'), @@ -57,6 +64,15 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = const organization = await createOrganization(createOrgParams); + if (file) { + await organization.setLogo({ file }); + } else if (defaultLogoUrl) { + const response = await fetch(defaultLogoUrl); + const blob = await response.blob(); + const logoFile = new File([blob], 'logo', { type: blob.type }); + await organization.setLogo({ file: logoFile }); + } + await setActive({ organization, navigate: async ({ session }) => { @@ -77,7 +93,13 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = slugField.setValue(val); }; + const onAvatarRemove = () => { + card.setIdle(); + return setFile(null); + }; + const isSubmitButtonDisabled = !nameField.value || !isLoaded; + const defaultLogoUrl = file === undefined ? props.organizationCreationDefaults?.form.logo : undefined; return ( <> @@ -88,8 +110,47 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) = + ({ padding: `${t.space.$none} ${t.space.$10} ${t.space.$8}` })}> + + await setFile(file)} + onAvatarRemove={file || defaultLogoUrl ? onAvatarRemove : null} + showLoadingSpinner={!!defaultLogoUrl} + avatarPreviewPlaceholder={ + ({ + color: t.colors.$colorMutedForeground, + transitionDuration: t.transitionDuration.$controls, + })} + /> + } + sx={t => ({ + width: t.sizes.$16, + height: t.sizes.$16, + borderRadius: t.radii.$md, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$dashed, + borderColor: t.colors.$borderAlpha200, + backgroundColor: t.colors.$neutralAlpha50, + ':hover': { + backgroundColor: t.colors.$neutralAlpha50, + svg: { + transform: 'scale(1.2)', + }, + }, + })} + /> + } + /> + + + ); +} + +const advisoryToLocalizationKey = (advisory?: OrganizationCreationDefaultsResource['advisory']) => { + if (!advisory) { + return null; + } + + switch (advisory.code) { + case 'organization_already_exists': + return localizationKeys('taskChooseOrganization.alerts.organizationAlreadyExists', { + organizationDomain: advisory.meta.organization_domain, + organizationName: advisory.meta.organization_name, + }); + default: + return null; + } +}; diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx index dbe835f3540..e3618dd7060 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/__tests__/TaskChooseOrganization.test.tsx @@ -9,7 +9,6 @@ import { } from '@/ui/components/OrganizationSwitcher/__tests__/test-utils'; import { TaskChooseOrganization } from '..'; -import { findByText } from '@testing-library/react'; const { createFixtures } = bindCreateFixtures('TaskChooseOrganization'); @@ -353,4 +352,84 @@ describe('TaskChooseOrganization', () => { expect(await findByText('Existing Org')).toBeInTheDocument(); }); }); + + describe('with organization creation defaults', () => { + describe('when enabled on environment', () => { + it('displays warning when organization already exists for user email domain', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withOrganizationCreationDefaults(true); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + fixtures.clerk.user?.getOrganizationCreationDefaults.mockReturnValueOnce( + Promise.resolve({ + advisory: { + code: 'organization_already_exists', + severity: 'warning', + meta: { organizationDomain: 'test@clerk.com', organizationName: 'Clerk' }, + }, + }), + ); + + const { findByText } = render(, { wrapper }); + + expect( + await findByText(/an organization already exists for the detected company name (Clerk) and test@clerk\.com/i), + ).toBeInTheDocument(); + }); + + it('prefills create organization form with defaults', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withOrganizationCreationDefaults(true); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + fixtures.clerk.user?.getOrganizationCreationDefaults.mockReturnValueOnce( + Promise.resolve({ + form: { + name: 'Test Org', + slug: 'test-org', + logo: null, + }, + }), + ); + + const { findByText } = render(, { wrapper }); + + expect(await findByText('Test Org')).toBeInTheDocument(); + expect(await findByText('test-org')).toBeInTheDocument(); + }); + }); + + describe('when disabled on environment', () => { + it('does not fetch for creation defaults', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withOrganizations(); + f.withForceOrganizationSelection(); + f.withOrganizationCreationDefaults(false); + f.withUser({ + email_addresses: ['test@clerk.com'], + create_organization_enabled: true, + tasks: [{ key: 'choose-organization' }], + }); + }); + + render(, { wrapper }); + + expect(fixtures.clerk.user?.getOrganizationCreationDefaults).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx index dc3375e0d0b..6862e1fc7c2 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/index.tsx @@ -1,7 +1,9 @@ import { useClerk, useSession, useUser } from '@clerk/shared/react'; +import type { OrganizationCreationDefaultsResource } from '@clerk/shared/types'; import { useState } from 'react'; -import { useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts'; +import { useFetch } from '@/hooks'; +import { useEnvironment, useSignOutContext, withCoreSessionSwitchGuard } from '@/ui/contexts'; import { descriptors, Flex, Flow, localizationKeys, Spinner } from '@/ui/customizables'; import { Card } from '@/ui/elements/Card'; import { withCardStateProvider } from '@/ui/elements/contexts'; @@ -16,8 +18,17 @@ import { CreateOrganizationScreen } from './CreateOrganizationScreen'; const TaskChooseOrganizationInternal = () => { const { user } = useUser(); const { userMemberships, userSuggestions, userInvitations } = useOrganizationListInView(); + const { organizationSettings } = useEnvironment(); + const organizationCreationDefaults = useFetch( + organizationSettings.organizationCreationDefaults?.enabled ? user?.getOrganizationCreationDefaults : undefined, + 'organization-creation-defaults', + ); - const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading; + const isLoading = + userMemberships?.isLoading || + userInvitations?.isLoading || + userSuggestions?.isLoading || + organizationCreationDefaults?.isLoading; const hasExistingResources = !!(userMemberships?.count || userInvitations?.count || userSuggestions?.count); const isOrganizationCreationDisabled = !isLoading && !user?.createOrganizationEnabled && !hasExistingResources; @@ -47,7 +58,10 @@ const TaskChooseOrganizationInternal = () => { /> ) : ( - + )} @@ -103,6 +117,7 @@ const TaskChooseOrganizationCardFooter = () => { type TaskChooseOrganizationFlowsProps = { initialFlow: 'create' | 'choose'; + organizationCreationDefaults?: OrganizationCreationDefaultsResource | null; }; const TaskChooseOrganizationFlows = withCardStateProvider((props: TaskChooseOrganizationFlowsProps) => { @@ -112,6 +127,7 @@ const TaskChooseOrganizationFlows = withCardStateProvider((props: TaskChooseOrga return ( setCurrentFlow('choose') : undefined} + organizationCreationDefaults={props.organizationCreationDefaults} /> ); } diff --git a/packages/ui/src/elements/Avatar.tsx b/packages/ui/src/elements/Avatar.tsx index 7f691fa032e..958df6b1eb2 100644 --- a/packages/ui/src/elements/Avatar.tsx +++ b/packages/ui/src/elements/Avatar.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Box, descriptors, Flex, Image, Text } from '../customizables'; +import { Box, descriptors, Flex, Image, Spinner, Text } from '../customizables'; import type { ElementDescriptor } from '../customizables/elementDescriptors'; import type { InternalTheme } from '../foundations'; import type { PropsOfComponent } from '../styledSystem'; @@ -15,8 +15,13 @@ type AvatarProps = PropsOfComponent & { rounded?: boolean; boxElementDescriptor?: ElementDescriptor; imageElementDescriptor?: ElementDescriptor; + /** Shows a loading spinner while the image is loading */ + showLoadingSpinner?: boolean; }; +const SPINNER_DELAY_MS = 150; +const SPINNER_MIN_DURATION_MS = 400; + export const Avatar = (props: AvatarProps) => { const { size = () => 26, @@ -28,8 +33,60 @@ export const Avatar = (props: AvatarProps) => { sx, boxElementDescriptor, imageElementDescriptor, + showLoadingSpinner = false, } = props; const [error, setError] = React.useState(false); + const [loaded, setLoaded] = React.useState(false); + const [spinnerVisible, setSpinnerVisible] = React.useState(false); + const spinnerShownAtRef = React.useRef(null); + const loadTimerRef = React.useRef | null>(null); + + React.useEffect(() => { + setLoaded(false); + setError(false); + setSpinnerVisible(false); + spinnerShownAtRef.current = null; + + return () => { + if (loadTimerRef.current) { + clearTimeout(loadTimerRef.current); + loadTimerRef.current = null; + } + }; + }, [imageUrl]); + + React.useEffect(() => { + if (!showLoadingSpinner || !imageUrl || loaded || error) { + return; + } + + const timer = setTimeout(() => { + setSpinnerVisible(true); + spinnerShownAtRef.current = Date.now(); + }, SPINNER_DELAY_MS); + + return () => clearTimeout(timer); + }, [showLoadingSpinner, imageUrl, loaded, error]); + + /** + * Prevents the loading spinner from appearing and disappearing too quickly + */ + const handleImageLoad = React.useCallback(() => { + if (spinnerShownAtRef.current) { + const elapsed = Date.now() - spinnerShownAtRef.current; + const remaining = SPINNER_MIN_DURATION_MS - elapsed; + if (remaining > 0) { + loadTimerRef.current = setTimeout(() => { + loadTimerRef.current = null; + setLoaded(true); + }, remaining); + return; + } + } + setLoaded(true); + }, []); + + const isLoading = showLoadingSpinner && spinnerVisible && imageUrl && !loaded && !error; const ImgOrFallback = initials && (!imageUrl || error) ? ( @@ -40,8 +97,15 @@ export const Avatar = (props: AvatarProps) => { title={title} alt={`${title}'s logo`} src={imageUrl || ''} - sx={{ objectFit: 'cover', width: '100%', height: '100%' }} + sx={{ + objectFit: 'cover', + width: '100%', + height: '100%', + opacity: showLoadingSpinner ? (loaded ? 1 : 0) : 1, + transition: 'opacity 0.2s ease-in-out', + }} onError={() => setError(true)} + onLoad={handleImageLoad} size={imageFetchSize} /> ); @@ -67,6 +131,24 @@ export const Avatar = (props: AvatarProps) => { > {ImgOrFallback} + {isLoading && ( + ({ + position: 'absolute', + inset: 0, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: t.colors.$avatarBackground, + })} + > + + + )} + {/* /** * This Box is the "shimmer" effect for the avatar. * The ":after" selector is responsible for the border shimmer animation. diff --git a/packages/ui/src/elements/AvatarUploader.tsx b/packages/ui/src/elements/AvatarUploader.tsx index f1ae367f2c2..8a7f99b7a0d 100644 --- a/packages/ui/src/elements/AvatarUploader.tsx +++ b/packages/ui/src/elements/AvatarUploader.tsx @@ -90,9 +90,10 @@ export const AvatarUploader = (props: AvatarUploaderProps) => { await handleFileDrop(f); }; + const hasExistingImage = !!(avatarPreview.props as { imageUrl?: string })?.imageUrl; const previewElement = objectUrl ? React.cloneElement(avatarPreview, { imageUrl: objectUrl }) - : avatarPreviewPlaceholder + : avatarPreviewPlaceholder && !hasExistingImage ? React.cloneElement(avatarPreviewPlaceholder, { onClick: openDialog }) : avatarPreview; diff --git a/packages/ui/src/elements/OrganizationAvatar.tsx b/packages/ui/src/elements/OrganizationAvatar.tsx index 7f449455955..ad7a078f664 100644 --- a/packages/ui/src/elements/OrganizationAvatar.tsx +++ b/packages/ui/src/elements/OrganizationAvatar.tsx @@ -4,15 +4,19 @@ import type { PropsOfComponent } from '../styledSystem'; import { Avatar } from './Avatar'; type OrganizationAvatarProps = PropsOfComponent & - Partial>; + Partial> & { + /** Shows a loading spinner while the image is loading */ + showLoadingSpinner?: boolean; + }; export const OrganizationAvatar = (props: OrganizationAvatarProps) => { - const { name = '', imageUrl, ...rest } = props; + const { name = '', imageUrl, showLoadingSpinner, ...rest } = props; return (