diff --git a/src/auto-imports.d.ts b/src/auto-imports.d.ts index 24c2c506fc..0bc5d8c39a 100644 --- a/src/auto-imports.d.ts +++ b/src/auto-imports.d.ts @@ -45,12 +45,14 @@ declare global { const ignorableWatch: typeof import('@vueuse/core').ignorableWatch const inject: typeof import('vue').inject const injectLocal: typeof import('@vueuse/core').injectLocal + const isAdminRole: typeof import('./stores/organization').isAdminRole const isDefined: typeof import('@vueuse/core').isDefined const isProxy: typeof import('vue').isProxy const isReactive: typeof import('vue').isReactive const isReadonly: typeof import('vue').isReadonly const isRef: typeof import('vue').isRef const isShallow: typeof import('vue').isShallow + const isSuperAdminRole: typeof import('./stores/organization').isSuperAdminRole const makeDestructurable: typeof import('@vueuse/core').makeDestructurable const manualResetRef: typeof import('@vueuse/core').manualResetRef const markRaw: typeof import('vue').markRaw @@ -96,6 +98,7 @@ declare global { const resolveComponent: typeof import('vue').resolveComponent const resolveRef: typeof import('@vueuse/core').resolveRef const resolveUnref: typeof import('@vueuse/core').resolveUnref + const roleHasLegacyMinRight: typeof import('./stores/organization').roleHasLegacyMinRight const shallowReactive: typeof import('vue').shallowReactive const shallowReadonly: typeof import('vue').shallowReadonly const shallowRef: typeof import('vue').shallowRef @@ -389,12 +392,14 @@ declare module 'vue' { readonly ignorableWatch: UnwrapRef readonly inject: UnwrapRef readonly injectLocal: UnwrapRef + readonly isAdminRole: UnwrapRef readonly isDefined: UnwrapRef readonly isProxy: UnwrapRef readonly isReactive: UnwrapRef readonly isReadonly: UnwrapRef readonly isRef: UnwrapRef readonly isShallow: UnwrapRef + readonly isSuperAdminRole: UnwrapRef readonly makeDestructurable: UnwrapRef readonly manualResetRef: UnwrapRef readonly markRaw: UnwrapRef @@ -439,6 +444,7 @@ declare module 'vue' { readonly refWithControl: UnwrapRef readonly resolveComponent: UnwrapRef readonly resolveRef: UnwrapRef + readonly roleHasLegacyMinRight: UnwrapRef readonly shallowReactive: UnwrapRef readonly shallowReadonly: UnwrapRef readonly shallowRef: UnwrapRef @@ -660,4 +666,4 @@ declare module 'vue' { readonly watchWithFilter: UnwrapRef readonly whenever: UnwrapRef } -} +} \ No newline at end of file diff --git a/src/components.d.ts b/src/components.d.ts index c863223144..ff67977960 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -53,7 +53,6 @@ declare module 'vue' { FailedCard: typeof import('./components/FailedCard.vue')['default'] GroupsRbacManager: typeof import('./components/organization/GroupsRbacManager.vue')['default'] HistoryTable: typeof import('./components/tables/HistoryTable.vue')['default'] - IHeroiconsXMark: typeof import('~icons/heroicons/x-mark')['default'] IIonCopyOutline: typeof import('~icons/ion/copy-outline')['default'] InfoRow: typeof import('./components/package/InfoRow.vue')['default'] InviteTeammateModal: typeof import('./components/dashboard/InviteTeammateModal.vue')['default'] @@ -136,7 +135,6 @@ declare global { const FailedCard: typeof import('./components/FailedCard.vue')['default'] const GroupsRbacManager: typeof import('./components/organization/GroupsRbacManager.vue')['default'] const HistoryTable: typeof import('./components/tables/HistoryTable.vue')['default'] - const IHeroiconsXMark: typeof import('~icons/heroicons/x-mark')['default'] const IIonCopyOutline: typeof import('~icons/ion/copy-outline')['default'] const InfoRow: typeof import('./components/package/InfoRow.vue')['default'] const InviteTeammateModal: typeof import('./components/dashboard/InviteTeammateModal.vue')['default'] diff --git a/src/route-map.d.ts b/src/route-map.d.ts index 6d0f2e6521..0ac03394e9 100644 --- a/src/route-map.d.ts +++ b/src/route-map.d.ts @@ -17,7 +17,8 @@ import type { declare module 'vue-router' { interface TypesConfig { - ParamParsers: never + ParamParsers: + | never } } @@ -924,4 +925,4 @@ declare module 'vue-router/auto-routes' { : keyof RouteNamedMap } -export {} +export {} \ No newline at end of file diff --git a/supabase/functions/_backend/private/sso/provision-user.ts b/supabase/functions/_backend/private/sso/provision-user.ts index 170a4b2d02..60da067aec 100644 --- a/supabase/functions/_backend/private/sso/provision-user.ts +++ b/supabase/functions/_backend/private/sso/provision-user.ts @@ -86,6 +86,34 @@ async function setAuthUserSsoOnly(pgClient: ReturnType, user ) } +async function ensurePublicUserProfileFromAuth(pgClient: ReturnType, userId: string): Promise { + await pgClient.query( + ` + insert into public.users ( + id, + email, + first_name, + last_name, + country, + enable_notifications, + opt_for_newsletters + ) + select + au.id, + coalesce(au.email, ''), + coalesce(au.raw_user_meta_data ->> 'first_name', ''), + coalesce(au.raw_user_meta_data ->> 'last_name', ''), + null, + true, + true + from auth.users au + where au.id = $1 + on conflict (id) do nothing + `, + [userId], + ) +} + app.post('/', async (c: Context) => { const auth = c.get('auth') if (!auth) { @@ -135,6 +163,14 @@ app.post('/', async (c: Context) => { return quickError(400, 'no_email', 'User has no email address') } + try { + await ensurePublicUserProfileFromAuth(getSharedPgClient(), userId) + } + catch (profileEnsureError) { + cloudlogErr({ requestId, message: 'Failed to ensure public.users profile for SSO user', userId, error: profileEnsureError }) + return quickError(500, 'user_profile_sync_failed', 'Failed to provision user profile') + } + const userDomain = userEmail.split('@')[1]?.toLowerCase().trim() if (!userDomain) { return quickError(400, 'invalid_email', 'User email has no domain') @@ -201,6 +237,14 @@ app.post('/', async (c: Context) => { } if (!mergeProviderError && mergeProvider) { + try { + await ensurePublicUserProfileFromAuth(getSharedPgClient(), originalUserId) + } + catch (profileEnsureError) { + cloudlogErr({ requestId, message: 'Failed to ensure public.users profile for original user during SSO merge', originalUserId, error: profileEnsureError }) + return quickError(500, 'user_profile_sync_failed', 'Failed to provision merged user profile') + } + const { data: existingMembership } = await (admin as any) .from('org_users') .select('id') diff --git a/tests/app.test.ts b/tests/app.test.ts index 61991da2b2..99ca4a9c14 100644 --- a/tests/app.test.ts +++ b/tests/app.test.ts @@ -371,4 +371,4 @@ describe('[POST] /app operations with non-owner user', () => { const responseData = await createApp.json() expect(responseData).toHaveProperty('app_id', APPNAME) }) -}) +}) \ No newline at end of file diff --git a/tests/sso.test.ts b/tests/sso.test.ts index 917fbb8fa9..e52af6082f 100644 --- a/tests/sso.test.ts +++ b/tests/sso.test.ts @@ -489,6 +489,126 @@ describe('[POST] /private/sso/provision-user', () => { } }) + it('creates missing public.users profile before assigning org membership', async () => { + const managedOrgId = randomUUID() + const managedCustomerId = `cus_sso_missing_profile_${randomUUID()}` + const providerId = randomUUID() + const domain = `${randomUUID()}.sso.test` + const email = `missing-profile-${randomUUID()}@${domain}` + const password = 'testtest' + const identityProvider = `sso:${providerId}` + const identityProviderId = `nameid-${randomUUID()}` + const pool = new Pool({ connectionString: POSTGRES_URL }) + + const { data: createdUser, error: createUserError } = await getSupabaseClient().auth.admin.createUser({ + email, + password, + email_confirm: true, + user_metadata: { + first_name: 'Missing', + last_name: 'Profile', + }, + }) + if (createUserError || !createdUser.user) { + await pool.end() + throw createUserError ?? new Error('Failed to create SSO auth user for missing profile provisioning test') + } + + try { + const ssoAuthHeaders = await getAuthHeadersForCredentials(email, password) + + const { error: stripeError } = await getSupabaseClient().from('stripe_info').insert({ + customer_id: managedCustomerId, + status: 'succeeded', + product_id: 'prod_LQIregjtNduh4q', + subscription_id: `sub_sso_missing_profile_${randomUUID()}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + const { error: orgError } = await getSupabaseClient().from('orgs').insert({ + id: managedOrgId, + name: `SSO Missing Profile Org ${managedOrgId}`, + management_email: `sso-missing-profile-${managedOrgId}@capgo.app`, + created_by: USER_ID, + customer_id: managedCustomerId, + sso_enabled: true, + }) + if (orgError) + throw orgError + + const { error: providerError } = await (getSupabaseClient().from as any)('sso_providers').insert({ + id: providerId, + org_id: managedOrgId, + domain, + provider_id: randomUUID(), + status: 'active', + enforce_sso: false, + dns_verification_token: `dns-${randomUUID()}`, + }) + if (providerError) + throw providerError + + const { error: providerMetadataError } = await getSupabaseClient().auth.admin.updateUserById(createdUser.user.id, { + app_metadata: { + provider: identityProvider, + }, + }) + if (providerMetadataError) + throw providerMetadataError + + await pool.query( + 'update auth.identities set provider = $1, provider_id = $2, identity_data = jsonb_build_object($$sub$$, $2::text, $$email$$, $3::text, $$email_verified$$, true) where user_id = $4', + [identityProvider, identityProviderId, email, createdUser.user.id], + ) + + // Ensure the test really covers the missing-profile path. + await getSupabaseClient().from('users').delete().eq('id', createdUser.user.id) + + const response = await fetchWithRetry(getEndpointUrl('/private/sso/provision-user'), { + method: 'POST', + headers: ssoAuthHeaders, + body: JSON.stringify({}), + }) + + expect(response.status).toBe(200) + const responseBody = await response.json() + expect(responseBody).toMatchObject({ success: true }) + + const { data: publicUser, error: publicUserError } = await getSupabaseClient() + .from('users') + .select('id, email') + .eq('id', createdUser.user.id) + .maybeSingle() + + expect(publicUserError).toBeNull() + expect(publicUser?.id).toBe(createdUser.user.id) + expect(publicUser?.email).toBe(email) + + const { data: membership, error: membershipError } = await getSupabaseClient() + .from('org_users') + .select('id, org_id, user_id') + .eq('org_id', managedOrgId) + .eq('user_id', createdUser.user.id) + .maybeSingle() + + expect(membershipError).toBeNull() + expect(membership?.org_id).toBe(managedOrgId) + expect(membership?.user_id).toBe(createdUser.user.id) + } + finally { + await Promise.allSettled([ + getSupabaseClient().auth.admin.deleteUser(createdUser.user.id), + (getSupabaseClient().from as any)('sso_providers').delete().eq('id', providerId), + getSupabaseClient().from('orgs').delete().eq('id', managedOrgId), + getSupabaseClient().from('stripe_info').delete().eq('customer_id', managedCustomerId), + pool.end(), + ]) + } + }) + it('merges an existing password account when a new SSO auth user arrives with the same email', async () => { const managedOrgId = randomUUID() const managedCustomerId = `cus_sso_merge_${randomUUID()}`