Skip to content
Merged
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: 7 additions & 1 deletion src/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -389,12 +392,14 @@ declare module 'vue' {
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly isAdminRole: UnwrapRef<typeof import('./stores/organization')['isAdminRole']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
readonly isReactive: UnwrapRef<typeof import('vue')['isReactive']>
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly isShallow: UnwrapRef<typeof import('vue')['isShallow']>
readonly isSuperAdminRole: UnwrapRef<typeof import('./stores/organization')['isSuperAdminRole']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
readonly manualResetRef: UnwrapRef<typeof import('@vueuse/core')['manualResetRef']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
Expand Down Expand Up @@ -439,6 +444,7 @@ declare module 'vue' {
readonly refWithControl: UnwrapRef<typeof import('@vueuse/core')['refWithControl']>
readonly resolveComponent: UnwrapRef<typeof import('vue')['resolveComponent']>
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly roleHasLegacyMinRight: UnwrapRef<typeof import('./stores/organization')['roleHasLegacyMinRight']>
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
Expand Down Expand Up @@ -660,4 +666,4 @@ declare module 'vue' {
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}
}
2 changes: 0 additions & 2 deletions src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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']
Expand Down
5 changes: 3 additions & 2 deletions src/route-map.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import type {

declare module 'vue-router' {
interface TypesConfig {
ParamParsers: never
ParamParsers:
| never
}
}

Expand Down Expand Up @@ -924,4 +925,4 @@ declare module 'vue-router/auto-routes' {
: keyof RouteNamedMap
}

export {}
export {}
44 changes: 44 additions & 0 deletions supabase/functions/_backend/private/sso/provision-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,34 @@ async function setAuthUserSsoOnly(pgClient: ReturnType<typeof getPgClient>, user
)
}

async function ensurePublicUserProfileFromAuth(pgClient: ReturnType<typeof getPgClient>, userId: string): Promise<void> {
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<MiddlewareKeyVariables>) => {
const auth = c.get('auth')
if (!auth) {
Expand Down Expand Up @@ -135,6 +163,14 @@ app.post('/', async (c: Context<MiddlewareKeyVariables>) => {
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')
Expand Down Expand Up @@ -201,6 +237,14 @@ app.post('/', async (c: Context<MiddlewareKeyVariables>) => {
}

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')
Expand Down
2 changes: 1 addition & 1 deletion tests/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,4 +371,4 @@ describe('[POST] /app operations with non-owner user', () => {
const responseData = await createApp.json()
expect(responseData).toHaveProperty('app_id', APPNAME)
})
})
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix EOF newline to satisfy lint.

Line 365 is missing a trailing newline, which currently fails style/eol-last.

🔧 Proposed fix
 })
+
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
})
})
🧰 Tools
🪛 ESLint

[error] 365-365: Newline required at end of file but not found.

(style/eol-last)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/app.test.ts` at line 365, The file ends with the closing test block
token "})" and is missing a trailing newline; add a single newline character
after the final "})" in tests/app.test.ts so the file ends with an EOF newline
and satisfies the style/eol-last lint rule.

120 changes: 120 additions & 0 deletions tests/sso.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`
Expand Down
Loading