From 20def59a51db87f06baa575c354bc0a16d979af6 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Mon, 18 May 2026 18:51:08 +0200 Subject: [PATCH 1/5] fix(auth): require explicit pending invite choice --- cloudflare_workers/api/index.ts | 4 +- messages/en.json | 17 ++ src/modules/auth.ts | 45 +++ src/pages/onboarding/invitation.vue | 271 ++++++++++++++++++ src/pages/register.vue | 2 +- .../_backend/private/accept_invitation.ts | 117 +------- .../_backend/private/invitation_membership.ts | 131 +++++++++ .../private/invite_new_user_to_org.ts | 1 + .../_backend/private/pending_invitations.ts | 203 +++++++++++++ supabase/functions/private/index.ts | 2 + tests/private-pending-invitations.test.ts | 215 ++++++++++++++ 11 files changed, 890 insertions(+), 118 deletions(-) create mode 100644 src/pages/onboarding/invitation.vue create mode 100644 supabase/functions/_backend/private/invitation_membership.ts create mode 100644 supabase/functions/_backend/private/pending_invitations.ts create mode 100644 tests/private-pending-invitations.test.ts diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 0a36a4311f..fa7820f823 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -14,6 +14,7 @@ import { app as invite_existing_user_to_org } from '../../supabase/functions/_ba import { app as invite_new_user_to_org } from '../../supabase/functions/_backend/private/invite_new_user_to_org.ts' import { app as latency } from '../../supabase/functions/_backend/private/latency.ts' import { app as log_as } from '../../supabase/functions/_backend/private/log_as.ts' +import { app as pending_invitations } from '../../supabase/functions/_backend/private/pending_invitations.ts' import { app as plans } from '../../supabase/functions/_backend/private/plans.ts' import { app as publicStats } from '../../supabase/functions/_backend/private/public_stats.ts' import { app as set_org_email } from '../../supabase/functions/_backend/private/set_org_email.ts' @@ -39,8 +40,8 @@ import { app as channel } from '../../supabase/functions/_backend/public/channel import { app as check_cpu_usage } from '../../supabase/functions/_backend/public/check_cpu_usage.ts' import { app as device } from '../../supabase/functions/_backend/public/device/index.ts' import { app as ok } from '../../supabase/functions/_backend/public/ok.ts' -import { app as pluginRegions } from '../../supabase/functions/_backend/public/plugin_regions.ts' import { app as organization } from '../../supabase/functions/_backend/public/organization/index.ts' +import { app as pluginRegions } from '../../supabase/functions/_backend/public/plugin_regions.ts' import { app as replication } from '../../supabase/functions/_backend/public/replication.ts' import { app as statistics } from '../../supabase/functions/_backend/public/statistics/index.ts' import { app as translation } from '../../supabase/functions/_backend/public/translation.ts' @@ -104,6 +105,7 @@ appPrivate.route('/website_stats', publicStats) appPrivate.route('/config', config) appPrivate.route('/config/builder', configBuilder) appPrivate.route('/accept_invitation', accept_invitation) +appPrivate.route('/pending_invitations', pending_invitations) appPrivate.route('/devices', devices_priv) appPrivate.route('/log_as', log_as) appPrivate.route('/invite_new_user_to_org', invite_new_user_to_org) diff --git a/messages/en.json b/messages/en.json index a978cb2da9..3bfdceedb2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1504,6 +1504,23 @@ "personal-information": "Personal Information", "picture-delete-fail": "Cannot delete app picture, please check console log", "picture-uploaded": "Picture uploaded successfully", + "pending-invite-badge": "Organization invitation", + "pending-invite-card-copy": "This invitation matches the email on your Capgo account.", + "pending-invite-create-org": "Create my own organization", + "pending-invite-decline-failed": "Could not continue to organization creation", + "pending-invite-join": "Join organization", + "pending-invite-join-failed": "Could not join the organization", + "pending-invite-joined": "Organization joined", + "pending-invite-load-failed": "Could not load your organization invitation", + "pending-invite-logo-alt": "{name} logo", + "pending-invite-subtitle": "You can join the organization that invited you, or decline the invite and create your own organization.", + "pending-invite-subtitle-multiple": "You can join one of the organizations that invited you, or decline the invites and create your own organization.", + "pending-invite-summary": "Next step", + "pending-invite-summary-create": "Create a separate organization if this invitation is not for you.", + "pending-invite-summary-join": "Join the invited organization and continue to the dashboard.", + "pending-invite-summary-title": "Choose where this account belongs", + "pending-invite-title": "Join your organization", + "pending-invite-title-multiple": "Choose an organization", "plan": "Plan", "plan-bandwidth": "GB Bandwidth", "plan-desc": "Start building for free, then add a plan to go live.", diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 070de284e6..992443a31c 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -108,6 +108,25 @@ async function updateUser( } } +async function hasPendingInvitations(supabase: SupabaseClient) { + try { + const { data, error } = await supabase.functions.invoke('private/pending_invitations', { + method: 'GET', + }) + + if (error) { + console.error('Failed to load pending organization invitations', error) + return false + } + + return (data?.invitations?.length ?? 0) > 0 + } + catch (error) { + console.error('Failed to load pending organization invitations', error) + return false + } +} + async function maybeProvisionSsoMembership( supabase: SupabaseClient, session: Awaited>['data']['session'] | null, @@ -209,6 +228,14 @@ async function guard( return !organizationStore.organizations.some(org => org.gid === inviteOrgId && org.role.startsWith('invite')) } + async function shouldRedirectToPendingInviteOnboarding(organizationsLoaded: boolean) { + if (!organizationsLoaded || organizationStore.hasOrganizations) + return false + if (to.path.startsWith('/onboarding/invitation')) + return false + return await hasPendingInvitations(supabase) + } + if (hasAuth && sessionUser) { const authConfirmedAt = main.auth?.email_confirmed_at if (!main.auth || main.auth.id !== sessionUser.id || authConfirmedAt !== sessionUser.email_confirmed_at) { @@ -266,6 +293,15 @@ async function guard( } const organizationsLoaded = await tryLoadOrganizations(() => organizationStore.fetchOrganizations()) + if (await shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { + return next({ + path: '/onboarding/invitation', + query: { + to: to.path.startsWith('/onboarding/') ? '/dashboard' : to.fullPath, + }, + }) + } + if (organizationsLoaded && isAdminRoute) { try { main.isAdmin = await isPlatformAdmin() @@ -332,6 +368,15 @@ async function guard( } let organizationsLoaded = await tryLoadOrganizations(() => organizationStore.dedupFetchOrganizations()) + if (await shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { + return next({ + path: '/onboarding/invitation', + query: { + to: to.path.startsWith('/onboarding/') ? '/dashboard' : to.fullPath, + }, + }) + } + if (organizationsLoaded && !organizationStore.hasOrganizations && isSsoUser(sessionUser)) { const didProvisionSsoMembership = await maybeProvisionSsoMembership(supabase, sessionData?.session ?? null) if (didProvisionSsoMembership === 'redirect_login') { diff --git a/src/pages/onboarding/invitation.vue b/src/pages/onboarding/invitation.vue new file mode 100644 index 0000000000..1a9b3f93f6 --- /dev/null +++ b/src/pages/onboarding/invitation.vue @@ -0,0 +1,271 @@ + + + + + +meta: + middleware: auth + diff --git a/src/pages/register.vue b/src/pages/register.vue index 3b24485841..7a4dfd0662 100644 --- a/src/pages/register.vue +++ b/src/pages/register.vue @@ -73,7 +73,7 @@ async function submit(form: { first_name: string, last_name: string, password: s console.error('Failed to seed user profile after signup', profileError) } - router.push('/onboarding/organization') + router.push('/dashboard') } diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 642fd94eed..89df43298b 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -8,6 +8,7 @@ import { getEffectivePasswordMinLength, getPasswordPolicyValidationErrors } from import { emptySupabase, supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts' import { syncUserPreferenceTags } from '../utils/user_preferences.ts' import { getEnv } from '../utils/utils.ts' +import { ensureOrgMembership } from './invitation_membership.ts' interface AcceptInvitation { password: string @@ -24,13 +25,6 @@ interface PasswordPolicy { require_special: boolean } -const rbacRoleToLegacy: Record = { - org_member: 'read', - org_billing_admin: 'read', - org_admin: 'admin', - org_super_admin: 'super_admin', -} - // Default password policy (when org has no policy set) const DEFAULT_PASSWORD_POLICY: PasswordPolicy = { enabled: true, @@ -153,115 +147,6 @@ async function ensurePublicUserRowExists( } } -async function ensureOrgMembership( - supabaseAdmin: ReturnType, - userId: string, - invitation: any, - org: any, -) { - const rbacRoleName = invitation.rbac_role_name - const useRbacInvite = org?.use_new_rbac === true - - if (useRbacInvite && !rbacRoleName) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Missing RBAC role name' }) - } - - const rbacRoleNameValue = rbacRoleName ?? '' - const legacyRight = useRbacInvite - ? rbacRoleToLegacy[rbacRoleNameValue] ?? 'read' - : invitation.role - let rbacRoleId: string | null = null - - if (useRbacInvite) { - const { data: role, error: roleError } = await supabaseAdmin - .from('roles') - .select('id') - .eq('name', rbacRoleNameValue) - .eq('scope_type', 'org') - .single() - - if (roleError || !role) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: roleError?.message ?? 'Role not found' }) - } - - rbacRoleId = role.id - } - - // Avoid creating duplicates: org_users does not have a unique constraint on (org_id, user_id). - const { data: existingMembershipRows, error: existingMembershipError } = await supabaseAdmin - .from('org_users') - .select('id') - .eq('user_id', userId) - .eq('org_id', invitation.org_id) - .is('app_id', null) - .is('channel_id', null) - - if (existingMembershipError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to check existing org membership', { error: existingMembershipError.message }) - } - - if (existingMembershipRows && existingMembershipRows.length > 0) { - const { error: updateMembershipError } = await supabaseAdmin - .from('org_users') - .update({ - user_right: legacyRight, - rbac_role_name: useRbacInvite ? rbacRoleName : null, - }) - .eq('user_id', userId) - .eq('org_id', invitation.org_id) - .is('app_id', null) - .is('channel_id', null) - - if (updateMembershipError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to update org membership', { error: updateMembershipError.message }) - } - } - else { - const { error: insertIntoMainTableError } = await supabaseAdmin.from('org_users').insert({ - user_id: userId, - org_id: invitation.org_id, - user_right: legacyRight, - rbac_role_name: useRbacInvite ? rbacRoleName : null, - }) - - if (insertIntoMainTableError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation insert into org_users', { error: insertIntoMainTableError.message }) - } - } - - if (useRbacInvite) { - const { error: deleteBindingError } = await supabaseAdmin - .from('role_bindings') - .delete() - .eq('principal_type', 'user') - .eq('principal_id', userId) - .eq('scope_type', 'org') - .eq('org_id', invitation.org_id) - - if (deleteBindingError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to clear existing RBAC role bindings', { error: deleteBindingError.message }) - } - - const { error: insertBindingError } = await supabaseAdmin - .from('role_bindings') - .insert({ - principal_type: 'user', - principal_id: userId, - role_id: rbacRoleId as string, - scope_type: 'org', - org_id: invitation.org_id, - granted_by: userId, - granted_at: new Date().toISOString(), - reason: 'Accepted invitation', - is_direct: true, - }) - - if (insertBindingError) { - return quickError(500, 'failed_to_accept_invitation', 'Failed to create RBAC role binding', { error: insertBindingError.message }) - } - } -} - app.post('/', async (c) => { const rawBody = await parseBody(c) diff --git a/supabase/functions/_backend/private/invitation_membership.ts b/supabase/functions/_backend/private/invitation_membership.ts new file mode 100644 index 0000000000..cb8860e440 --- /dev/null +++ b/supabase/functions/_backend/private/invitation_membership.ts @@ -0,0 +1,131 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import type { Database } from '../utils/supabase.types.ts' +import { quickError } from '../utils/hono.ts' + +type AdminClient = SupabaseClient + +type InvitationRecord = Pick< + Database['public']['Tables']['tmp_users']['Row'], + 'org_id' | 'role' | 'rbac_role_name' +> + +type InvitationOrg = Pick< + Database['public']['Tables']['orgs']['Row'], + 'use_new_rbac' +> + +const rbacRoleToLegacy: Record = { + org_member: 'read', + org_billing_admin: 'read', + org_admin: 'admin', + org_super_admin: 'super_admin', +} + +export async function ensureOrgMembership( + supabaseAdmin: AdminClient, + userId: string, + invitation: InvitationRecord, + org: InvitationOrg, +) { + const rbacRoleName = invitation.rbac_role_name + const useRbacInvite = org?.use_new_rbac === true + + if (useRbacInvite && !rbacRoleName) { + return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: 'Missing RBAC role name' }) + } + + const rbacRoleNameValue = rbacRoleName ?? '' + const legacyRight = useRbacInvite + ? rbacRoleToLegacy[rbacRoleNameValue] ?? 'read' + : invitation.role + let rbacRoleId: string | null = null + + if (useRbacInvite) { + const { data: role, error: roleError } = await supabaseAdmin + .from('roles') + .select('id') + .eq('name', rbacRoleNameValue) + .eq('scope_type', 'org') + .single() + + if (roleError || !role) { + return quickError(500, 'failed_to_accept_invitation', 'Failed to resolve RBAC role', { error: roleError?.message ?? 'Role not found' }) + } + + rbacRoleId = role.id + } + + // Avoid creating duplicates: org_users does not have a unique constraint on (org_id, user_id). + const { data: existingMembershipRows, error: existingMembershipError } = await supabaseAdmin + .from('org_users') + .select('id') + .eq('user_id', userId) + .eq('org_id', invitation.org_id) + .is('app_id', null) + .is('channel_id', null) + + if (existingMembershipError) { + return quickError(500, 'failed_to_accept_invitation', 'Failed to check existing org membership', { error: existingMembershipError.message }) + } + + if (existingMembershipRows && existingMembershipRows.length > 0) { + const { error: updateMembershipError } = await supabaseAdmin + .from('org_users') + .update({ + user_right: legacyRight, + rbac_role_name: useRbacInvite ? rbacRoleName : null, + }) + .eq('user_id', userId) + .eq('org_id', invitation.org_id) + .is('app_id', null) + .is('channel_id', null) + + if (updateMembershipError) { + return quickError(500, 'failed_to_accept_invitation', 'Failed to update org membership', { error: updateMembershipError.message }) + } + } + else { + const { error: insertIntoMainTableError } = await supabaseAdmin.from('org_users').insert({ + user_id: userId, + org_id: invitation.org_id, + user_right: legacyRight, + rbac_role_name: useRbacInvite ? rbacRoleName : null, + }) + + if (insertIntoMainTableError) { + return quickError(500, 'failed_to_accept_invitation', 'Failed to accept invitation insert into org_users', { error: insertIntoMainTableError.message }) + } + } + + if (useRbacInvite) { + const { error: deleteBindingError } = await supabaseAdmin + .from('role_bindings') + .delete() + .eq('principal_type', 'user') + .eq('principal_id', userId) + .eq('scope_type', 'org') + .eq('org_id', invitation.org_id) + + if (deleteBindingError) { + return quickError(500, 'failed_to_accept_invitation', 'Failed to clear existing RBAC role bindings', { error: deleteBindingError.message }) + } + + const { error: insertBindingError } = await supabaseAdmin + .from('role_bindings') + .insert({ + principal_type: 'user', + principal_id: userId, + role_id: rbacRoleId as string, + scope_type: 'org', + org_id: invitation.org_id, + granted_by: userId, + granted_at: new Date().toISOString(), + reason: 'Accepted invitation', + is_direct: true, + }) + + if (insertBindingError) { + return quickError(500, 'failed_to_accept_invitation', 'Failed to create RBAC role binding', { error: insertBindingError.message }) + } + } +} diff --git a/supabase/functions/_backend/private/invite_new_user_to_org.ts b/supabase/functions/_backend/private/invite_new_user_to_org.ts index 786f8165ae..7f0380308b 100644 --- a/supabase/functions/_backend/private/invite_new_user_to_org.ts +++ b/supabase/functions/_backend/private/invite_new_user_to_org.ts @@ -85,6 +85,7 @@ async function validateInvite(c: Context, rawBody: any) { } const body = validationResult.data + body.email = body.email.trim().toLowerCase() cloudlog({ requestId: c.get('requestId'), context: 'invite_new_user_to_org validated body', body }) const authorization = c.get('authorization') diff --git a/supabase/functions/_backend/private/pending_invitations.ts b/supabase/functions/_backend/private/pending_invitations.ts new file mode 100644 index 0000000000..1b9d14eb29 --- /dev/null +++ b/supabase/functions/_backend/private/pending_invitations.ts @@ -0,0 +1,203 @@ +import type { Context } from 'hono' +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { Hono } from 'hono/tiny' +import { BRES, middlewareAuth, parseBody, quickError, useCors } from '../utils/hono.ts' +import { cloudlog } from '../utils/logging.ts' +import { closeClient, getPgClient } from '../utils/pg.ts' +import { supabaseAdmin as useSupabaseAdmin } from '../utils/supabase.ts' +import { ensureOrgMembership } from './invitation_membership.ts' + +export const app = new Hono() + +app.use('/', useCors) + +interface PendingInvitation { + id: number + org_id: string + org_name: string + org_logo: string | null + role: 'read' | 'upload' | 'write' | 'admin' | 'super_admin' + rbac_role_name: string | null + use_new_rbac: boolean +} + +interface PendingInvitationAction { + action?: 'accept' | 'decline' | 'decline_all' + invitation_id?: number +} + +async function getAuthenticatedEmail(supabaseAdmin: ReturnType, userId: string) { + const { data: authUserData, error: authUserError } = await supabaseAdmin.auth.admin.getUserById(userId) + if (authUserError) { + return quickError(500, 'failed_to_load_pending_invitations', 'Failed to load authenticated user', { error: authUserError.message }) + } + + return authUserData.user?.email?.trim().toLowerCase() ?? '' +} + +async function getPendingInvitations(c: Context, email: string, invitationId?: number) { + const pgClient = getPgClient(c) + try { + const params: Array = [email] + const idFilter = invitationId === undefined ? '' : 'AND tmp.id = $2' + if (invitationId !== undefined) + params.push(invitationId) + + const result = await pgClient.query( + `SELECT + tmp.id, + tmp.org_id, + tmp.role, + tmp.rbac_role_name, + orgs.name AS org_name, + orgs.logo AS org_logo, + orgs.use_new_rbac + FROM public.tmp_users tmp + JOIN public.orgs orgs ON orgs.id = tmp.org_id + WHERE lower(trim(tmp.email)) = $1 + ${idFilter} + AND tmp.cancelled_at IS NULL + AND GREATEST(tmp.updated_at, tmp.created_at) > now() - interval '7 days' + ORDER BY GREATEST(tmp.updated_at, tmp.created_at) DESC`, + params, + ) + + return result.rows + } + catch (error) { + return quickError(500, 'failed_to_load_pending_invitations', 'Failed to load pending invitations', { error }) + } + finally { + closeClient(c, pgClient) + } +} + +app.get('/', middlewareAuth, async (c) => { + const auth = c.get('auth') + if (!auth?.userId) + return quickError(401, 'not_authorized', 'Not authorized') + + const supabaseAdmin = useSupabaseAdmin(c) + const email = await getAuthenticatedEmail(supabaseAdmin, auth.userId) + if (!email) + return quickError(400, 'missing_email', 'Authenticated user has no email') + + const invitations = await getPendingInvitations(c, email) + + return c.json({ + ...BRES, + invitations: invitations.map(invitation => ({ + id: invitation.id, + org_id: invitation.org_id, + org_name: invitation.org_name, + org_logo: invitation.org_logo, + role: invitation.rbac_role_name ?? invitation.role, + })), + }) +}) + +app.post('/', middlewareAuth, async (c) => { + const auth = c.get('auth') + if (!auth?.userId) + return quickError(401, 'not_authorized', 'Not authorized') + + const body = await parseBody(c) + const action = body.action + if (action !== 'accept' && action !== 'decline' && action !== 'decline_all') + return quickError(400, 'invalid_action', 'Invalid invitation action') + + const supabaseAdmin = useSupabaseAdmin(c) + const email = await getAuthenticatedEmail(supabaseAdmin, auth.userId) + if (!email) + return quickError(400, 'missing_email', 'Authenticated user has no email') + + if (action === 'decline_all') { + const invitations = await getPendingInvitations(c, email) + const declinedOrgIds: string[] = [] + + for (const invitation of invitations) { + const { error: declineError } = await supabaseAdmin + .from('tmp_users') + .update({ cancelled_at: new Date().toISOString() }) + .eq('id', invitation.id) + + if (declineError) { + return quickError(500, 'failed_to_decline_pending_invitation', 'Failed to decline pending invitation', { error: declineError.message }) + } + + declinedOrgIds.push(invitation.org_id) + } + + return c.json({ + ...BRES, + declined_count: declinedOrgIds.length, + declined_org_ids: declinedOrgIds, + }) + } + + if (!body.invitation_id) + return quickError(400, 'missing_invitation_id', 'Missing invitation id') + + const invitations = await getPendingInvitations(c, email, body.invitation_id) + const invitation = invitations[0] + if (!invitation) + return quickError(404, 'pending_invitation_not_found', 'Pending invitation not found') + + if (action === 'decline') { + const { error: declineError } = await supabaseAdmin + .from('tmp_users') + .update({ cancelled_at: new Date().toISOString() }) + .eq('id', invitation.id) + + if (declineError) { + return quickError(500, 'failed_to_decline_pending_invitation', 'Failed to decline pending invitation', { error: declineError.message }) + } + + return c.json({ + ...BRES, + declined_org_id: invitation.org_id, + }) + } + + const { data: existingMembership, error: existingMembershipError } = await supabaseAdmin + .from('org_users') + .select('id, user_right') + .eq('user_id', auth.userId) + .eq('org_id', invitation.org_id) + .is('app_id', null) + .is('channel_id', null) + .maybeSingle() + + if (existingMembershipError) { + return quickError(500, 'failed_to_accept_pending_invitation', 'Failed to check existing org membership', { error: existingMembershipError.message }) + } + + const alreadyJoined = existingMembership?.user_right + && !existingMembership.user_right.startsWith('invite_') + if (!alreadyJoined) { + await ensureOrgMembership(supabaseAdmin, auth.userId, invitation, { + use_new_rbac: invitation.use_new_rbac, + }) + } + + const { error: tmpUserDeleteError } = await supabaseAdmin + .from('tmp_users') + .delete() + .eq('id', invitation.id) + + if (tmpUserDeleteError) { + return quickError(500, 'failed_to_accept_pending_invitation', 'Failed to delete accepted invitation', { error: tmpUserDeleteError.message }) + } + + cloudlog({ + requestId: c.get('requestId'), + message: 'Accepted pending organization invitation', + userId: auth.userId, + orgId: invitation.org_id, + }) + + return c.json({ + ...BRES, + accepted_org_id: invitation.org_id, + }) +}) diff --git a/supabase/functions/private/index.ts b/supabase/functions/private/index.ts index 91b10c4aa2..8db2e95506 100644 --- a/supabase/functions/private/index.ts +++ b/supabase/functions/private/index.ts @@ -15,6 +15,7 @@ import { app as invite_existing_user_to_org } from '../_backend/private/invite_e import { app as invite_new_user_to_org } from '../_backend/private/invite_new_user_to_org.ts' import { app as latency } from '../_backend/private/latency.ts' import { app as log_as } from '../_backend/private/log_as.ts' +import { app as pending_invitations } from '../_backend/private/pending_invitations.ts' // Webapps API import { app as plans } from '../_backend/private/plans.ts' import { app as publicStats } from '../_backend/private/public_stats.ts' @@ -72,6 +73,7 @@ appGlobal.route('/roles', roles) appGlobal.route('/invite_new_user_to_org', invite_new_user_to_org) appGlobal.route('/invite_existing_user_to_org', invite_existing_user_to_org) appGlobal.route('/accept_invitation', accept_invitation) +appGlobal.route('/pending_invitations', pending_invitations) appGlobal.route('/validate_password_compliance', validate_password_compliance) appGlobal.route('/verify_email_otp', verify_email_otp) appGlobal.route('/website_preview', website_preview) diff --git a/tests/private-pending-invitations.test.ts b/tests/private-pending-invitations.test.ts new file mode 100644 index 0000000000..5752602712 --- /dev/null +++ b/tests/private-pending-invitations.test.ts @@ -0,0 +1,215 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { getAuthHeadersForCredentials, getEndpointUrl, getSupabaseClient, PRODUCT_ID, USER_ID, USER_PASSWORD } from './test-utils.ts' + +const runId = randomUUID() +const invitedUserId = randomUUID() +const orgId = randomUUID() +const customerId = `cus_pending_invite_${runId}` +const invitedEmail = `pending-invite-${runId}@capgo.app` +const storedInviteEmail = invitedEmail.toUpperCase() +const orgEmail = `pending-invite-org-${runId}@capgo.app` + +async function cleanup() { + const supabase = getSupabaseClient() + await supabase.from('role_bindings').delete().eq('principal_id', invitedUserId) + await supabase.from('org_users').delete().eq('org_id', orgId) + await supabase.from('tmp_users').delete().eq('org_id', orgId) + await supabase.from('orgs').delete().eq('id', orgId) + await supabase.from('stripe_info').delete().eq('customer_id', customerId) + await supabase.from('users').delete().eq('id', invitedUserId) + await supabase.auth.admin.deleteUser(invitedUserId) +} + +async function seedPendingInvitation() { + const supabase = getSupabaseClient() + + const { data: authUser, error: authError } = await supabase.auth.admin.createUser({ + id: invitedUserId, + email: invitedEmail, + password: USER_PASSWORD, + email_confirm: true, + }) + if (authError || !authUser.user) + throw authError ?? new Error('Missing auth user') + + const { error: userError } = await supabase.from('users').insert({ + id: invitedUserId, + email: invitedEmail, + first_name: 'Pending', + last_name: 'Invite', + }) + if (userError) + throw userError + + const { error: stripeError } = await supabase.from('stripe_info').insert({ + customer_id: customerId, + status: 'succeeded', + product_id: PRODUCT_ID, + subscription_id: `sub_pending_invite_${runId}`, + trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), + is_good_plan: true, + }) + if (stripeError) + throw stripeError + + const { error: orgError } = await supabase.from('orgs').insert({ + id: orgId, + name: `Pending Invite Org ${runId}`, + management_email: orgEmail, + created_by: USER_ID, + customer_id: customerId, + use_new_rbac: true, + }) + if (orgError) + throw orgError + + const { data: invitation, error: tmpUserError } = await supabase.from('tmp_users').insert({ + email: storedInviteEmail, + org_id: orgId, + role: 'admin', + rbac_role_name: 'org_admin', + first_name: 'Pending', + last_name: 'Invite', + }).select('id').single() + if (tmpUserError || !invitation) + throw tmpUserError ?? new Error('Missing pending invitation') + + return invitation.id as number +} + +async function getInvitedUserHeaders() { + return await getAuthHeadersForCredentials(invitedEmail, USER_PASSWORD) +} + +afterEach(async () => { + await cleanup() +}) + +describe('/private/pending_invitations', () => { + it('lists pending invitations without joining the organization', async () => { + await cleanup() + await seedPendingInvitation() + + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { + method: 'GET', + headers: await getInvitedUserHeaders(), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { invitations: Array<{ org_id: string, org_name: string, role: string }> } + expect(data.invitations).toHaveLength(1) + expect(data.invitations[0]).toMatchObject({ + org_id: orgId, + role: 'org_admin', + }) + + const { data: membership, error: membershipError } = await getSupabaseClient() + .from('org_users') + .select('id') + .eq('org_id', orgId) + .eq('user_id', invitedUserId) + .maybeSingle() + + expect(membershipError).toBeNull() + expect(membership).toBeNull() + }) + + it('accepts a pending invitation only after an explicit join action', async () => { + await cleanup() + const invitationId = await seedPendingInvitation() + + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { + method: 'POST', + headers: await getInvitedUserHeaders(), + body: JSON.stringify({ + action: 'accept', + invitation_id: invitationId, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { status: string, accepted_org_id: string } + expect(data.status).toBe('ok') + expect(data.accepted_org_id).toBe(orgId) + + const supabase = getSupabaseClient() + const { data: membership, error: membershipError } = await supabase + .from('org_users') + .select('user_right, rbac_role_name') + .eq('org_id', orgId) + .eq('user_id', invitedUserId) + .maybeSingle() + + expect(membershipError).toBeNull() + expect(membership).toMatchObject({ + user_right: 'admin', + rbac_role_name: 'org_admin', + }) + + const { data: roleBinding, error: roleBindingError } = await supabase + .from('role_bindings') + .select('principal_type, principal_id, scope_type, org_id') + .eq('org_id', orgId) + .eq('principal_id', invitedUserId) + .maybeSingle() + + expect(roleBindingError).toBeNull() + expect(roleBinding).toMatchObject({ + principal_type: 'user', + principal_id: invitedUserId, + scope_type: 'org', + org_id: orgId, + }) + + const { data: pendingInvite, error: pendingInviteError } = await supabase + .from('tmp_users') + .select('id') + .eq('org_id', orgId) + .eq('email', storedInviteEmail) + .maybeSingle() + + expect(pendingInviteError).toBeNull() + expect(pendingInvite).toBeNull() + }) + + it('declines pending invitations before creating an organization', async () => { + await cleanup() + await seedPendingInvitation() + + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { + method: 'POST', + headers: await getInvitedUserHeaders(), + body: JSON.stringify({ + action: 'decline_all', + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { status: string, declined_count: number, declined_org_ids: string[] } + expect(data.status).toBe('ok') + expect(data.declined_count).toBe(1) + expect(data.declined_org_ids).toEqual([orgId]) + + const supabase = getSupabaseClient() + const { data: membership, error: membershipError } = await supabase + .from('org_users') + .select('id') + .eq('org_id', orgId) + .eq('user_id', invitedUserId) + .maybeSingle() + + expect(membershipError).toBeNull() + expect(membership).toBeNull() + + const { data: pendingInvite, error: pendingInviteError } = await supabase + .from('tmp_users') + .select('cancelled_at') + .eq('org_id', orgId) + .eq('email', storedInviteEmail) + .maybeSingle() + + expect(pendingInviteError).toBeNull() + expect(pendingInvite?.cancelled_at).toBeTruthy() + }) +}) From 6c59c7602639da61596fea70bbacd8efb68add06 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 20 May 2026 00:19:09 +0200 Subject: [PATCH 2/5] fix(auth): show pending invites before continuing --- docs/pr/pending-invite-choice.png | Bin 0 -> 89635 bytes messages/en.json | 14 +-- src/modules/auth.ts | 4 +- src/pages/onboarding/invitation.vue | 128 ++++++++++++++--------- src/route-map.d.ts | 13 +++ tests/auth-sso-provisioning.unit.test.ts | 97 +++++++++++++++++ 6 files changed, 202 insertions(+), 54 deletions(-) create mode 100644 docs/pr/pending-invite-choice.png diff --git a/docs/pr/pending-invite-choice.png b/docs/pr/pending-invite-choice.png new file mode 100644 index 0000000000000000000000000000000000000000..ddf1963b10544746c9d3693f514af763be029f33 GIT binary patch literal 89635 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 literal 0 HcmV?d00001 diff --git a/messages/en.json b/messages/en.json index 3bfdceedb2..61e7f9c375 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1506,18 +1506,20 @@ "picture-uploaded": "Picture uploaded successfully", "pending-invite-badge": "Organization invitation", "pending-invite-card-copy": "This invitation matches the email on your Capgo account.", - "pending-invite-create-org": "Create my own organization", - "pending-invite-decline-failed": "Could not continue to organization creation", + "pending-invite-create-org": "Decline all and continue", + "pending-invite-decline": "Decline", + "pending-invite-decline-failed": "Could not decline the invitation", + "pending-invite-declined": "Invitation declined", "pending-invite-join": "Join organization", "pending-invite-join-failed": "Could not join the organization", "pending-invite-joined": "Organization joined", "pending-invite-load-failed": "Could not load your organization invitation", "pending-invite-logo-alt": "{name} logo", - "pending-invite-subtitle": "You can join the organization that invited you, or decline the invite and create your own organization.", - "pending-invite-subtitle-multiple": "You can join one of the organizations that invited you, or decline the invites and create your own organization.", + "pending-invite-subtitle": "Review this invitation before continuing. You can join the organization or decline it.", + "pending-invite-subtitle-multiple": "Review these invitations before continuing. You can join or decline each organization.", "pending-invite-summary": "Next step", - "pending-invite-summary-create": "Create a separate organization if this invitation is not for you.", - "pending-invite-summary-join": "Join the invited organization and continue to the dashboard.", + "pending-invite-summary-create": "Decline invitations that are not for you, then continue.", + "pending-invite-summary-join": "Accept an invite to add that organization to your account.", "pending-invite-summary-title": "Choose where this account belongs", "pending-invite-title": "Join your organization", "pending-invite-title-multiple": "Choose an organization", diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 992443a31c..aea244007e 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -223,13 +223,15 @@ async function guard( function shouldRedirectToOrgOnboarding() { if (to.path.startsWith('/onboarding/organization')) return false + if (to.path.startsWith('/onboarding/invitation')) + return false if (!inviteOrgId) return true return !organizationStore.organizations.some(org => org.gid === inviteOrgId && org.role.startsWith('invite')) } async function shouldRedirectToPendingInviteOnboarding(organizationsLoaded: boolean) { - if (!organizationsLoaded || organizationStore.hasOrganizations) + if (!organizationsLoaded) return false if (to.path.startsWith('/onboarding/invitation')) return false diff --git a/src/pages/onboarding/invitation.vue b/src/pages/onboarding/invitation.vue index 1a9b3f93f6..cbe6be8621 100644 --- a/src/pages/onboarding/invitation.vue +++ b/src/pages/onboarding/invitation.vue @@ -6,8 +6,8 @@ import IconArrowRight from '~icons/lucide/arrow-right' import IconBuilding from '~icons/lucide/building-2' import IconCheck from '~icons/lucide/check' import IconLoader from '~icons/lucide/loader-2' -import IconPlus from '~icons/lucide/plus' import IconUserPlus from '~icons/lucide/user-plus' +import IconX from '~icons/lucide/x' import { useSupabase } from '~/services/supabase' import { useDisplayStore } from '~/stores/display' import { useOrganizationStore } from '~/stores/organization' @@ -29,11 +29,13 @@ const organizationStore = useOrganizationStore() const invitations = ref([]) const isLoading = ref(true) -const joiningInvitationId = ref(null) -const isCreatingOrg = ref(false) +const resolvingInvitationId = ref(null) +const resolvingInvitationAction = ref<'accept' | 'decline' | null>(null) +const isDecliningAll = ref(false) const errorMessage = ref('') const hasMultipleInvitations = computed(() => invitations.value.length > 1) +const isResolvingInvitation = computed(() => resolvingInvitationId.value !== null || isDecliningAll.value) const title = computed(() => hasMultipleInvitations.value ? t('pending-invite-title-multiple') : t('pending-invite-title')) @@ -47,6 +49,22 @@ const targetPath = computed(() => { return '/dashboard' }) +async function continueAfterInvitationsResolved() { + await organizationStore.dedupFetchOrganizations() + + if (organizationStore.hasOrganizations) { + await router.replace(targetPath.value) + return + } + + await router.replace({ + path: '/onboarding/organization', + query: { + to: targetPath.value, + }, + }) +} + function getInitials(name: string) { const parts = name.trim().split(/\s+/).filter(Boolean) const first = parts[0]?.[0] ?? '' @@ -67,12 +85,7 @@ async function loadPendingInvitations() { invitations.value = data?.invitations ?? [] if (invitations.value.length === 0) { - await router.replace({ - path: '/onboarding/organization', - query: { - to: targetPath.value, - }, - }) + await continueAfterInvitationsResolved() } } catch (error) { @@ -84,13 +97,14 @@ async function loadPendingInvitations() { } } -async function joinInvitation(invitation: PendingInvitation) { - joiningInvitationId.value = invitation.id +async function resolveInvitation(invitation: PendingInvitation, action: 'accept' | 'decline') { + resolvingInvitationId.value = invitation.id + resolvingInvitationAction.value = action errorMessage.value = '' try { const { data, error } = await supabase.functions.invoke('private/pending_invitations', { body: { - action: 'accept', + action, invitation_id: invitation.id, }, }) @@ -98,24 +112,33 @@ async function joinInvitation(invitation: PendingInvitation) { if (error) throw error - await organizationStore.fetchOrganizations() - if (data?.accepted_org_id) - organizationStore.setCurrentOrganization(data.accepted_org_id) + if (action === 'accept') { + await organizationStore.fetchOrganizations() + if (data?.accepted_org_id) + organizationStore.setCurrentOrganization(data.accepted_org_id) - toast.success(t('pending-invite-joined')) - await router.replace(targetPath.value) + toast.success(t('pending-invite-joined')) + } + else { + toast.success(t('pending-invite-declined')) + } + + invitations.value = invitations.value.filter(item => item.id !== invitation.id) + if (invitations.value.length === 0) + await continueAfterInvitationsResolved() } catch (error) { - console.error('Failed to join pending organization invitation', error) - errorMessage.value = t('pending-invite-join-failed') + console.error(`Failed to ${action} pending organization invitation`, error) + errorMessage.value = t(action === 'accept' ? 'pending-invite-join-failed' : 'pending-invite-decline-failed') } finally { - joiningInvitationId.value = null + resolvingInvitationId.value = null + resolvingInvitationAction.value = null } } -async function createOwnOrganization() { - isCreatingOrg.value = true +async function declineAllInvitations() { + isDecliningAll.value = true errorMessage.value = '' try { const { error } = await supabase.functions.invoke('private/pending_invitations', { @@ -127,19 +150,16 @@ async function createOwnOrganization() { if (error) throw error - await router.replace({ - path: '/onboarding/organization', - query: { - to: targetPath.value, - }, - }) + invitations.value = [] + toast.success(t('pending-invite-declined')) + await continueAfterInvitationsResolved() } catch (error) { console.error('Failed to decline pending organization invitations', error) errorMessage.value = t('pending-invite-decline-failed') } finally { - isCreatingOrg.value = false + isDecliningAll.value = false } } @@ -153,7 +173,7 @@ onMounted(async () => {
-
+
{{ t('pending-invite-badge') }} @@ -206,32 +226,46 @@ onMounted(async () => {
- +
+ + + +
-
+
diff --git a/src/route-map.d.ts b/src/route-map.d.ts index f2e42a3b3c..e1aca953d9 100644 --- a/src/route-map.d.ts +++ b/src/route-map.d.ts @@ -352,6 +352,13 @@ declare module 'vue-router/auto-routes' { Record, | never >, + '/onboarding/invitation': RouteRecordInfo< + '/onboarding/invitation', + '/onboarding/invitation', + Record, + Record, + | never + >, '/onboarding/organization': RouteRecordInfo< '/onboarding/organization', '/onboarding/organization', @@ -816,6 +823,12 @@ declare module 'vue-router/auto-routes' { views: | never } + 'src/pages/onboarding/invitation.vue': { + routes: + | '/onboarding/invitation' + views: + | never + } 'src/pages/onboarding/organization.vue': { routes: | '/onboarding/organization' diff --git a/tests/auth-sso-provisioning.unit.test.ts b/tests/auth-sso-provisioning.unit.test.ts index ad34f4e305..66e1f27830 100644 --- a/tests/auth-sso-provisioning.unit.test.ts +++ b/tests/auth-sso-provisioning.unit.test.ts @@ -90,6 +90,12 @@ function createTestContext() { const mockCreateSignedImageUrl = vi.fn(async (value: string) => value) const mockGetPlans = vi.fn(async () => []) const mockIsPlatformAdmin = vi.fn(async () => false) + const mockFunctionsInvoke = vi.fn<(...args: unknown[]) => Promise<{ data: { invitations: unknown[] }, error: null }>>(async () => ({ + data: { + invitations: [], + }, + error: null, + })) const mockFetch = vi.fn<(...args: unknown[]) => Promise>(async () => ({ ok: true, json: async () => ({ success: true }), @@ -101,6 +107,7 @@ function createTestContext() { mockCreateSignedImageUrl, mockFetch, mockFrom, + mockFunctionsInvoke, mockGetAuthenticatorAssuranceLevel, mockGetClaims, mockGetPlans, @@ -171,6 +178,9 @@ vi.mock('~/services/supabase', () => ({ }, rpc: context.mockRpc, from: context.mockFrom, + functions: { + invoke: context.mockFunctionsInvoke, + }, } }, defaultApiHost: 'https://api.capgo.test', @@ -268,6 +278,93 @@ describe('auth guard SSO provisioning', () => { }) }) + it.concurrent('shows pending invitations to users who already have an organization before continuing', async () => { + await withTestContext(async (context) => { + context.mockGetSession.mockResolvedValue({ + data: { + session: { + access_token: 'token-123', + user: { + id: 'user-123', + email: 'user@managed.test', + email_confirmed_at: '2026-04-15T10:00:00.000Z', + app_metadata: { + provider: 'email', + providers: ['email'], + }, + }, + }, + }, + }) + context.mockFunctionsInvoke.mockResolvedValueOnce({ + data: { + invitations: [ + { + id: 123, + org_id: 'invited-org-123', + org_name: 'Invited Org', + }, + ], + }, + error: null, + }) + + const guard = await getGuard() + const next = vi.fn() + + await guard( + { path: '/settings/account', fullPath: '/settings/account', meta: { middleware: 'auth' }, query: {} }, + { path: '/login', fullPath: '/login', meta: {}, query: {} }, + next, + ) + + expect(context.organizationStore.fetchOrganizations).toHaveBeenCalled() + expect(next).toHaveBeenCalledWith({ + path: '/onboarding/invitation', + query: { + to: '/settings/account', + }, + }) + }) + }) + + it.concurrent('keeps org-less users on the pending invitation page so they can decline before org creation', async () => { + await withTestContext(async (context) => { + context.mockGetSession.mockResolvedValue({ + data: { + session: { + access_token: 'token-123', + user: { + id: 'user-123', + email: 'user@managed.test', + email_confirmed_at: '2026-04-15T10:00:00.000Z', + app_metadata: { + provider: 'email', + providers: ['email'], + }, + }, + }, + }, + }) + context.organizationStore.fetchOrganizations = vi.fn(async () => { + context.organizationStore.organizations = [] + context.organizationStore.hasOrganizations = false + }) + + const guard = await getGuard() + const next = vi.fn() + + await guard( + { path: '/onboarding/invitation', fullPath: '/onboarding/invitation?to=/dashboard', meta: { middleware: 'auth' }, query: { to: '/dashboard' } }, + { path: '/login', fullPath: '/login', meta: {}, query: {} }, + next, + ) + + expect(next).toHaveBeenCalledWith() + expect(next).not.toHaveBeenCalledWith('/onboarding/organization') + }) + }) + it.concurrent('redirects accounts pending deletion to the recovery page instead of org onboarding', async () => { await withTestContext(async (context) => { context.mockRpc.mockResolvedValueOnce({ From 0ba733acf2e0f1da2a47b4a1f14bde258a10aba1 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 20 May 2026 11:12:52 +0200 Subject: [PATCH 3/5] fix(auth): address pending invite review feedback --- src/pages/onboarding/invitation.vue | 3 +- .../_backend/private/accept_invitation.ts | 24 +- .../_backend/private/pending_invitations.ts | 20 +- tests/private-pending-invitations.test.ts | 340 ++++++++++-------- 4 files changed, 226 insertions(+), 161 deletions(-) diff --git a/src/pages/onboarding/invitation.vue b/src/pages/onboarding/invitation.vue index cbe6be8621..eed4a57751 100644 --- a/src/pages/onboarding/invitation.vue +++ b/src/pages/onboarding/invitation.vue @@ -44,7 +44,8 @@ const subtitle = computed(() => hasMultipleInvitations.value : t('pending-invite-subtitle')) const targetPath = computed(() => { const target = typeof route.query.to === 'string' ? route.query.to : '' - if (target.startsWith('/') && !target.startsWith('/onboarding/')) + const isOnboardingTarget = /^\/onboarding(?:\/|\?|$)/.test(target) + if (target.startsWith('/') && !isOnboardingTarget) return target return '/dashboard' }) diff --git a/supabase/functions/_backend/private/accept_invitation.ts b/supabase/functions/_backend/private/accept_invitation.ts index 89df43298b..116f8a662a 100644 --- a/supabase/functions/_backend/private/accept_invitation.ts +++ b/supabase/functions/_backend/private/accept_invitation.ts @@ -34,6 +34,10 @@ const DEFAULT_PASSWORD_POLICY: PasswordPolicy = { require_special: true, } +function isErrorResponse(value: unknown): value is Response { + return value instanceof Response +} + // Base schema for initial validation (without password) const baseInvitationSchema = type({ 'password': 'string', @@ -218,7 +222,9 @@ app.post('/', async (c) => { } const userId = session.user?.id ?? existingUser.id - await ensureOrgMembership(supabaseAdmin, userId, invitation, org) + const membershipResult = await ensureOrgMembership(supabaseAdmin, userId, invitation, org) + if (isErrorResponse(membershipResult)) + return membershipResult // Remove the invite only after the org membership is created successfully. const { error: tmpUserDeleteError } = await supabaseAdmin.from('tmp_users').delete().eq('invite_magic_string', baseBody.magic_invite_string) @@ -282,8 +288,13 @@ app.post('/', async (c) => { }) if (!sessionError && session.user?.id) { - await ensurePublicUserRowExists(c, supabaseAdmin, session.user.id, invitation, body.opt_for_newsletters) - await ensureOrgMembership(supabaseAdmin, session.user.id, invitation, org) + const publicUserResult = await ensurePublicUserRowExists(c, supabaseAdmin, session.user.id, invitation, body.opt_for_newsletters) + if (isErrorResponse(publicUserResult)) + return publicUserResult + + const membershipResult = await ensureOrgMembership(supabaseAdmin, session.user.id, invitation, org) + if (isErrorResponse(membershipResult)) + return membershipResult const { error: tmpUserDeleteError } = await supabaseAdmin.from('tmp_users').delete().eq('invite_magic_string', body.magic_invite_string) if (tmpUserDeleteError) { @@ -366,7 +377,12 @@ app.post('/', async (c) => { return quickError(400, 'sign_in_failed', 'Sign in failed, please retry', { error: sessionError.message }) } - await ensureOrgMembership(supabaseAdmin, user.user.id, invitation, org) + const membershipResult = await ensureOrgMembership(supabaseAdmin, user.user.id, invitation, org) + if (isErrorResponse(membershipResult)) { + didRollback = true + await rollbackCreatedUser(c, user.user.id) + return membershipResult + } // Remove the invite only after the account + org membership are created successfully. const { error: tmpUserDeleteError } = await supabaseAdmin.from('tmp_users').delete().eq('invite_magic_string', body.magic_invite_string) diff --git a/supabase/functions/_backend/private/pending_invitations.ts b/supabase/functions/_backend/private/pending_invitations.ts index 1b9d14eb29..f2ddc8a938 100644 --- a/supabase/functions/_backend/private/pending_invitations.ts +++ b/supabase/functions/_backend/private/pending_invitations.ts @@ -26,6 +26,10 @@ interface PendingInvitationAction { invitation_id?: number } +function isErrorResponse(value: unknown): value is Response { + return value instanceof Response +} + async function getAuthenticatedEmail(supabaseAdmin: ReturnType, userId: string) { const { data: authUserData, error: authUserError } = await supabaseAdmin.auth.admin.getUserById(userId) if (authUserError) { @@ -79,10 +83,14 @@ app.get('/', middlewareAuth, async (c) => { const supabaseAdmin = useSupabaseAdmin(c) const email = await getAuthenticatedEmail(supabaseAdmin, auth.userId) + if (isErrorResponse(email)) + return email if (!email) return quickError(400, 'missing_email', 'Authenticated user has no email') const invitations = await getPendingInvitations(c, email) + if (isErrorResponse(invitations)) + return invitations return c.json({ ...BRES, @@ -108,11 +116,16 @@ app.post('/', middlewareAuth, async (c) => { const supabaseAdmin = useSupabaseAdmin(c) const email = await getAuthenticatedEmail(supabaseAdmin, auth.userId) + if (isErrorResponse(email)) + return email if (!email) return quickError(400, 'missing_email', 'Authenticated user has no email') if (action === 'decline_all') { const invitations = await getPendingInvitations(c, email) + if (isErrorResponse(invitations)) + return invitations + const declinedOrgIds: string[] = [] for (const invitation of invitations) { @@ -139,6 +152,9 @@ app.post('/', middlewareAuth, async (c) => { return quickError(400, 'missing_invitation_id', 'Missing invitation id') const invitations = await getPendingInvitations(c, email, body.invitation_id) + if (isErrorResponse(invitations)) + return invitations + const invitation = invitations[0] if (!invitation) return quickError(404, 'pending_invitation_not_found', 'Pending invitation not found') @@ -175,9 +191,11 @@ app.post('/', middlewareAuth, async (c) => { const alreadyJoined = existingMembership?.user_right && !existingMembership.user_right.startsWith('invite_') if (!alreadyJoined) { - await ensureOrgMembership(supabaseAdmin, auth.userId, invitation, { + const membershipResult = await ensureOrgMembership(supabaseAdmin, auth.userId, invitation, { use_new_rbac: invitation.use_new_rbac, }) + if (isErrorResponse(membershipResult)) + return membershipResult } const { error: tmpUserDeleteError } = await supabaseAdmin diff --git a/tests/private-pending-invitations.test.ts b/tests/private-pending-invitations.test.ts index 5752602712..b70cde52ef 100644 --- a/tests/private-pending-invitations.test.ts +++ b/tests/private-pending-invitations.test.ts @@ -1,32 +1,48 @@ import { randomUUID } from 'node:crypto' -import { afterEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { getAuthHeadersForCredentials, getEndpointUrl, getSupabaseClient, PRODUCT_ID, USER_ID, USER_PASSWORD } from './test-utils.ts' -const runId = randomUUID() -const invitedUserId = randomUUID() -const orgId = randomUUID() -const customerId = `cus_pending_invite_${runId}` -const invitedEmail = `pending-invite-${runId}@capgo.app` -const storedInviteEmail = invitedEmail.toUpperCase() -const orgEmail = `pending-invite-org-${runId}@capgo.app` +interface PendingInvitationFixture { + runId: string + invitedUserId: string + orgId: string + customerId: string + invitedEmail: string + storedInviteEmail: string + orgEmail: string +} + +function createFixture(): PendingInvitationFixture { + const runId = randomUUID() + const invitedEmail = `pending-invite-${runId}@capgo.app` + return { + runId, + invitedUserId: randomUUID(), + orgId: randomUUID(), + customerId: `cus_pending_invite_${runId}`, + invitedEmail, + storedInviteEmail: invitedEmail.toUpperCase(), + orgEmail: `pending-invite-org-${runId}@capgo.app`, + } +} -async function cleanup() { +async function cleanup(fixture: PendingInvitationFixture) { const supabase = getSupabaseClient() - await supabase.from('role_bindings').delete().eq('principal_id', invitedUserId) - await supabase.from('org_users').delete().eq('org_id', orgId) - await supabase.from('tmp_users').delete().eq('org_id', orgId) - await supabase.from('orgs').delete().eq('id', orgId) - await supabase.from('stripe_info').delete().eq('customer_id', customerId) - await supabase.from('users').delete().eq('id', invitedUserId) - await supabase.auth.admin.deleteUser(invitedUserId) + await supabase.from('role_bindings').delete().eq('principal_id', fixture.invitedUserId) + await supabase.from('org_users').delete().eq('org_id', fixture.orgId) + await supabase.from('tmp_users').delete().eq('org_id', fixture.orgId) + await supabase.from('orgs').delete().eq('id', fixture.orgId) + await supabase.from('stripe_info').delete().eq('customer_id', fixture.customerId) + await supabase.from('users').delete().eq('id', fixture.invitedUserId) + await supabase.auth.admin.deleteUser(fixture.invitedUserId) } -async function seedPendingInvitation() { +async function seedPendingInvitation(fixture: PendingInvitationFixture) { const supabase = getSupabaseClient() const { data: authUser, error: authError } = await supabase.auth.admin.createUser({ - id: invitedUserId, - email: invitedEmail, + id: fixture.invitedUserId, + email: fixture.invitedEmail, password: USER_PASSWORD, email_confirm: true, }) @@ -34,8 +50,8 @@ async function seedPendingInvitation() { throw authError ?? new Error('Missing auth user') const { error: userError } = await supabase.from('users').insert({ - id: invitedUserId, - email: invitedEmail, + id: fixture.invitedUserId, + email: fixture.invitedEmail, first_name: 'Pending', last_name: 'Invite', }) @@ -43,10 +59,10 @@ async function seedPendingInvitation() { throw userError const { error: stripeError } = await supabase.from('stripe_info').insert({ - customer_id: customerId, + customer_id: fixture.customerId, status: 'succeeded', product_id: PRODUCT_ID, - subscription_id: `sub_pending_invite_${runId}`, + subscription_id: `sub_pending_invite_${fixture.runId}`, trial_at: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), is_good_plan: true, }) @@ -54,19 +70,19 @@ async function seedPendingInvitation() { throw stripeError const { error: orgError } = await supabase.from('orgs').insert({ - id: orgId, - name: `Pending Invite Org ${runId}`, - management_email: orgEmail, + id: fixture.orgId, + name: `Pending Invite Org ${fixture.runId}`, + management_email: fixture.orgEmail, created_by: USER_ID, - customer_id: customerId, + customer_id: fixture.customerId, use_new_rbac: true, }) if (orgError) throw orgError const { data: invitation, error: tmpUserError } = await supabase.from('tmp_users').insert({ - email: storedInviteEmail, - org_id: orgId, + email: fixture.storedInviteEmail, + org_id: fixture.orgId, role: 'admin', rbac_role_name: 'org_admin', first_name: 'Pending', @@ -78,138 +94,152 @@ async function seedPendingInvitation() { return invitation.id as number } -async function getInvitedUserHeaders() { - return await getAuthHeadersForCredentials(invitedEmail, USER_PASSWORD) +async function getInvitedUserHeaders(fixture: PendingInvitationFixture) { + return await getAuthHeadersForCredentials(fixture.invitedEmail, USER_PASSWORD) } -afterEach(async () => { - await cleanup() -}) - describe('/private/pending_invitations', () => { - it('lists pending invitations without joining the organization', async () => { - await cleanup() - await seedPendingInvitation() - - const response = await fetch(getEndpointUrl('/private/pending_invitations'), { - method: 'GET', - headers: await getInvitedUserHeaders(), - }) - - expect(response.status).toBe(200) - const data = await response.json() as { invitations: Array<{ org_id: string, org_name: string, role: string }> } - expect(data.invitations).toHaveLength(1) - expect(data.invitations[0]).toMatchObject({ - org_id: orgId, - role: 'org_admin', - }) - - const { data: membership, error: membershipError } = await getSupabaseClient() - .from('org_users') - .select('id') - .eq('org_id', orgId) - .eq('user_id', invitedUserId) - .maybeSingle() - - expect(membershipError).toBeNull() - expect(membership).toBeNull() + it.concurrent('lists pending invitations without joining the organization', async () => { + const fixture = createFixture() + await cleanup(fixture) + await seedPendingInvitation(fixture) + + try { + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { + method: 'GET', + headers: await getInvitedUserHeaders(fixture), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { invitations: Array<{ org_id: string, org_name: string, role: string }> } + expect(data.invitations).toHaveLength(1) + expect(data.invitations[0]).toMatchObject({ + org_id: fixture.orgId, + role: 'org_admin', + }) + + const { data: membership, error: membershipError } = await getSupabaseClient() + .from('org_users') + .select('id') + .eq('org_id', fixture.orgId) + .eq('user_id', fixture.invitedUserId) + .maybeSingle() + + expect(membershipError).toBeNull() + expect(membership).toBeNull() + } + finally { + await cleanup(fixture) + } }) - it('accepts a pending invitation only after an explicit join action', async () => { - await cleanup() - const invitationId = await seedPendingInvitation() - - const response = await fetch(getEndpointUrl('/private/pending_invitations'), { - method: 'POST', - headers: await getInvitedUserHeaders(), - body: JSON.stringify({ - action: 'accept', - invitation_id: invitationId, - }), - }) - - expect(response.status).toBe(200) - const data = await response.json() as { status: string, accepted_org_id: string } - expect(data.status).toBe('ok') - expect(data.accepted_org_id).toBe(orgId) - - const supabase = getSupabaseClient() - const { data: membership, error: membershipError } = await supabase - .from('org_users') - .select('user_right, rbac_role_name') - .eq('org_id', orgId) - .eq('user_id', invitedUserId) - .maybeSingle() - - expect(membershipError).toBeNull() - expect(membership).toMatchObject({ - user_right: 'admin', - rbac_role_name: 'org_admin', - }) - - const { data: roleBinding, error: roleBindingError } = await supabase - .from('role_bindings') - .select('principal_type, principal_id, scope_type, org_id') - .eq('org_id', orgId) - .eq('principal_id', invitedUserId) - .maybeSingle() - - expect(roleBindingError).toBeNull() - expect(roleBinding).toMatchObject({ - principal_type: 'user', - principal_id: invitedUserId, - scope_type: 'org', - org_id: orgId, - }) - - const { data: pendingInvite, error: pendingInviteError } = await supabase - .from('tmp_users') - .select('id') - .eq('org_id', orgId) - .eq('email', storedInviteEmail) - .maybeSingle() - - expect(pendingInviteError).toBeNull() - expect(pendingInvite).toBeNull() + it.concurrent('accepts a pending invitation only after an explicit join action', async () => { + const fixture = createFixture() + await cleanup(fixture) + const invitationId = await seedPendingInvitation(fixture) + + try { + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { + method: 'POST', + headers: await getInvitedUserHeaders(fixture), + body: JSON.stringify({ + action: 'accept', + invitation_id: invitationId, + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { status: string, accepted_org_id: string } + expect(data.status).toBe('ok') + expect(data.accepted_org_id).toBe(fixture.orgId) + + const supabase = getSupabaseClient() + const { data: membership, error: membershipError } = await supabase + .from('org_users') + .select('user_right, rbac_role_name') + .eq('org_id', fixture.orgId) + .eq('user_id', fixture.invitedUserId) + .maybeSingle() + + expect(membershipError).toBeNull() + expect(membership).toMatchObject({ + user_right: 'admin', + rbac_role_name: 'org_admin', + }) + + const { data: roleBinding, error: roleBindingError } = await supabase + .from('role_bindings') + .select('principal_type, principal_id, scope_type, org_id') + .eq('org_id', fixture.orgId) + .eq('principal_id', fixture.invitedUserId) + .maybeSingle() + + expect(roleBindingError).toBeNull() + expect(roleBinding).toMatchObject({ + principal_type: 'user', + principal_id: fixture.invitedUserId, + scope_type: 'org', + org_id: fixture.orgId, + }) + + const { data: pendingInvite, error: pendingInviteError } = await supabase + .from('tmp_users') + .select('id') + .eq('org_id', fixture.orgId) + .eq('email', fixture.storedInviteEmail) + .maybeSingle() + + expect(pendingInviteError).toBeNull() + expect(pendingInvite).toBeNull() + } + finally { + await cleanup(fixture) + } }) - it('declines pending invitations before creating an organization', async () => { - await cleanup() - await seedPendingInvitation() - - const response = await fetch(getEndpointUrl('/private/pending_invitations'), { - method: 'POST', - headers: await getInvitedUserHeaders(), - body: JSON.stringify({ - action: 'decline_all', - }), - }) - - expect(response.status).toBe(200) - const data = await response.json() as { status: string, declined_count: number, declined_org_ids: string[] } - expect(data.status).toBe('ok') - expect(data.declined_count).toBe(1) - expect(data.declined_org_ids).toEqual([orgId]) - - const supabase = getSupabaseClient() - const { data: membership, error: membershipError } = await supabase - .from('org_users') - .select('id') - .eq('org_id', orgId) - .eq('user_id', invitedUserId) - .maybeSingle() - - expect(membershipError).toBeNull() - expect(membership).toBeNull() - - const { data: pendingInvite, error: pendingInviteError } = await supabase - .from('tmp_users') - .select('cancelled_at') - .eq('org_id', orgId) - .eq('email', storedInviteEmail) - .maybeSingle() - - expect(pendingInviteError).toBeNull() - expect(pendingInvite?.cancelled_at).toBeTruthy() + it.concurrent('declines pending invitations before creating an organization', async () => { + const fixture = createFixture() + await cleanup(fixture) + await seedPendingInvitation(fixture) + + try { + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { + method: 'POST', + headers: await getInvitedUserHeaders(fixture), + body: JSON.stringify({ + action: 'decline_all', + }), + }) + + expect(response.status).toBe(200) + const data = await response.json() as { status: string, declined_count: number, declined_org_ids: string[] } + expect(data.status).toBe('ok') + expect(data.declined_count).toBe(1) + expect(data.declined_org_ids).toEqual([fixture.orgId]) + + const supabase = getSupabaseClient() + const { data: membership, error: membershipError } = await supabase + .from('org_users') + .select('id') + .eq('org_id', fixture.orgId) + .eq('user_id', fixture.invitedUserId) + .maybeSingle() + + expect(membershipError).toBeNull() + expect(membership).toBeNull() + + const { data: pendingInvite, error: pendingInviteError } = await supabase + .from('tmp_users') + .select('cancelled_at') + .eq('org_id', fixture.orgId) + .eq('email', fixture.storedInviteEmail) + .maybeSingle() + + expect(pendingInviteError).toBeNull() + expect(pendingInvite?.cancelled_at).toBeTruthy() + } + finally { + await cleanup(fixture) + } }) }) From d73a4750a70661311a29a7a397c25600a2b2745b Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 20 May 2026 11:21:15 +0200 Subject: [PATCH 4/5] test(auth): guard pending invite fixture cleanup --- tests/private-pending-invitations.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/private-pending-invitations.test.ts b/tests/private-pending-invitations.test.ts index b70cde52ef..d875c5ed67 100644 --- a/tests/private-pending-invitations.test.ts +++ b/tests/private-pending-invitations.test.ts @@ -102,9 +102,10 @@ describe('/private/pending_invitations', () => { it.concurrent('lists pending invitations without joining the organization', async () => { const fixture = createFixture() await cleanup(fixture) - await seedPendingInvitation(fixture) try { + await seedPendingInvitation(fixture) + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { method: 'GET', headers: await getInvitedUserHeaders(fixture), @@ -136,9 +137,10 @@ describe('/private/pending_invitations', () => { it.concurrent('accepts a pending invitation only after an explicit join action', async () => { const fixture = createFixture() await cleanup(fixture) - const invitationId = await seedPendingInvitation(fixture) try { + const invitationId = await seedPendingInvitation(fixture) + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { method: 'POST', headers: await getInvitedUserHeaders(fixture), @@ -200,9 +202,10 @@ describe('/private/pending_invitations', () => { it.concurrent('declines pending invitations before creating an organization', async () => { const fixture = createFixture() await cleanup(fixture) - await seedPendingInvitation(fixture) try { + await seedPendingInvitation(fixture) + const response = await fetch(getEndpointUrl('/private/pending_invitations'), { method: 'POST', headers: await getInvitedUserHeaders(fixture), From bde8af0c206edf36206ebf77f9993960aef29bad Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 20 May 2026 12:20:58 +0200 Subject: [PATCH 5/5] fix(frontend): use existing invite onboarding flow --- cloudflare_workers/api/index.ts | 4 +- src/modules/auth.ts | 27 +- src/pages/onboarding/invitation.vue | 122 +++++---- .../_backend/private/accept_invitation.ts | 141 ++++++++-- .../_backend/private/invitation_membership.ts | 131 --------- .../private/invite_new_user_to_org.ts | 1 - .../_backend/private/pending_invitations.ts | 221 ---------------- supabase/functions/private/index.ts | 2 - tests/auth-sso-provisioning.unit.test.ts | 97 ------- tests/private-pending-invitations.test.ts | 248 ------------------ 10 files changed, 194 insertions(+), 800 deletions(-) delete mode 100644 supabase/functions/_backend/private/invitation_membership.ts delete mode 100644 supabase/functions/_backend/private/pending_invitations.ts delete mode 100644 tests/private-pending-invitations.test.ts diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index fa7820f823..0a36a4311f 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -14,7 +14,6 @@ import { app as invite_existing_user_to_org } from '../../supabase/functions/_ba import { app as invite_new_user_to_org } from '../../supabase/functions/_backend/private/invite_new_user_to_org.ts' import { app as latency } from '../../supabase/functions/_backend/private/latency.ts' import { app as log_as } from '../../supabase/functions/_backend/private/log_as.ts' -import { app as pending_invitations } from '../../supabase/functions/_backend/private/pending_invitations.ts' import { app as plans } from '../../supabase/functions/_backend/private/plans.ts' import { app as publicStats } from '../../supabase/functions/_backend/private/public_stats.ts' import { app as set_org_email } from '../../supabase/functions/_backend/private/set_org_email.ts' @@ -40,8 +39,8 @@ import { app as channel } from '../../supabase/functions/_backend/public/channel import { app as check_cpu_usage } from '../../supabase/functions/_backend/public/check_cpu_usage.ts' import { app as device } from '../../supabase/functions/_backend/public/device/index.ts' import { app as ok } from '../../supabase/functions/_backend/public/ok.ts' -import { app as organization } from '../../supabase/functions/_backend/public/organization/index.ts' import { app as pluginRegions } from '../../supabase/functions/_backend/public/plugin_regions.ts' +import { app as organization } from '../../supabase/functions/_backend/public/organization/index.ts' import { app as replication } from '../../supabase/functions/_backend/public/replication.ts' import { app as statistics } from '../../supabase/functions/_backend/public/statistics/index.ts' import { app as translation } from '../../supabase/functions/_backend/public/translation.ts' @@ -105,7 +104,6 @@ appPrivate.route('/website_stats', publicStats) appPrivate.route('/config', config) appPrivate.route('/config/builder', configBuilder) appPrivate.route('/accept_invitation', accept_invitation) -appPrivate.route('/pending_invitations', pending_invitations) appPrivate.route('/devices', devices_priv) appPrivate.route('/log_as', log_as) appPrivate.route('/invite_new_user_to_org', invite_new_user_to_org) diff --git a/src/modules/auth.ts b/src/modules/auth.ts index aea244007e..024c277178 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -108,25 +108,6 @@ async function updateUser( } } -async function hasPendingInvitations(supabase: SupabaseClient) { - try { - const { data, error } = await supabase.functions.invoke('private/pending_invitations', { - method: 'GET', - }) - - if (error) { - console.error('Failed to load pending organization invitations', error) - return false - } - - return (data?.invitations?.length ?? 0) > 0 - } - catch (error) { - console.error('Failed to load pending organization invitations', error) - return false - } -} - async function maybeProvisionSsoMembership( supabase: SupabaseClient, session: Awaited>['data']['session'] | null, @@ -230,12 +211,12 @@ async function guard( return !organizationStore.organizations.some(org => org.gid === inviteOrgId && org.role.startsWith('invite')) } - async function shouldRedirectToPendingInviteOnboarding(organizationsLoaded: boolean) { + function shouldRedirectToPendingInviteOnboarding(organizationsLoaded: boolean) { if (!organizationsLoaded) return false if (to.path.startsWith('/onboarding/invitation')) return false - return await hasPendingInvitations(supabase) + return organizationStore.organizations.some(org => org.role.startsWith('invite')) } if (hasAuth && sessionUser) { @@ -295,7 +276,7 @@ async function guard( } const organizationsLoaded = await tryLoadOrganizations(() => organizationStore.fetchOrganizations()) - if (await shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { + if (shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { return next({ path: '/onboarding/invitation', query: { @@ -370,7 +351,7 @@ async function guard( } let organizationsLoaded = await tryLoadOrganizations(() => organizationStore.dedupFetchOrganizations()) - if (await shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { + if (shouldRedirectToPendingInviteOnboarding(organizationsLoaded)) { return next({ path: '/onboarding/invitation', query: { diff --git a/src/pages/onboarding/invitation.vue b/src/pages/onboarding/invitation.vue index eed4a57751..02b64973ce 100644 --- a/src/pages/onboarding/invitation.vue +++ b/src/pages/onboarding/invitation.vue @@ -1,4 +1,5 @@