Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e513ec5
fix(org): surface invite status and notify existing users
riderx Mar 30, 2026
cda2b02
fix: address org invite review feedback
riderx Mar 30, 2026
ea1b24c
fix: address remaining org invite review feedback
riderx Mar 30, 2026
7591548
fix(backend): serialize org invite resend flow
riderx Mar 31, 2026
2688cf5
fix: delay invite query clearing
riderx Mar 31, 2026
afb9718
fix(cloudflare): register existing org invite endpoint
riderx Mar 31, 2026
858e7db
refactor(private): rename existing org invite endpoint
riderx Apr 1, 2026
2bd8e28
fix(frontend): gate org invite email notifications
riderx Apr 1, 2026
248ad28
fix(email): encode auth template confirmation links
riderx Apr 1, 2026
02ac13f
fix(invites): address remaining org invite review issues
riderx Apr 1, 2026
02436bb
fix(ci): support supabase templates in worktrees
riderx Apr 1, 2026
ef9a70c
fix(invites): notify existing users for invite flows
riderx Apr 1, 2026
ff10ed6
fix(ci): correct supabase template paths
riderx Apr 1, 2026
c09b2cb
fix(ci): link supabase templates in tests workflow
riderx Apr 1, 2026
ea82ee7
fix(invites): keep deep link on dialog cancel
riderx Apr 1, 2026
64e118a
docs: clarify email template workflow
riderx Apr 1, 2026
f9f9a51
fix(email): drop auth template edits from PR
riderx Apr 1, 2026
7e6685d
docs: clarify bento and supabase templates
riderx Apr 1, 2026
b169661
test(cloudflare): increase hook timeout
riderx Apr 1, 2026
a25044a
fix(invite): tighten resend permissions
riderx Apr 1, 2026
774c264
fix(invite): resend pending member emails
riderx Apr 1, 2026
4087ac2
fix(invite): gate resend on pending invites
riderx Apr 2, 2026
ace4d96
fix(invite): await org refresh before clear
riderx Apr 2, 2026
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
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ jobs:
version: latest
- name: Show Supabase CLI version
run: supabase --version
- name: Link Supabase templates
run: ln -sfn supabase/templates templates
- name: Run Supabase Start
run: supabase start -x imgproxy,studio,mailpit,realtime,postgres-meta,supavisor,studio,logflare,vector,realtime
- name: Run Supabase Test DB
Expand Down
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,16 @@ Capgo relies on two layered caches for plugin endpoints (`/updates`, `/stats`, `
- Use the public shape like `npx @capgo/cli@latest ...` for customer-facing command examples.
- Use internal execution equivalents (for example, `bunx @capgo/cli@latest ...`) only in internal tooling context.

### Email Templates

- `supabase/templates/invite_new_user_to_org.html` and `supabase/templates/invite_existing_user_to_org.html` are Bento templates.
- Every other file in `supabase/templates/` is a Supabase auth or notification template.
- Supabase templates use Supabase template syntax.
- Bento templates use Bento template syntax.
- Updating templates in the repository does not upload them anywhere automatically.
- Supabase email templates must be uploaded manually to Supabase.
- Bento email templates must be uploaded manually to Bento.

### Environment Setup

1. Install dependencies: `bun install`
Expand Down
2 changes: 2 additions & 0 deletions cloudflare_workers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { app as deleted_failed_version } from '../../supabase/functions/_backend
import { app as devices_priv } from '../../supabase/functions/_backend/private/devices.ts'
import { app as events } from '../../supabase/functions/_backend/private/events.ts'
import { app as groups } from '../../supabase/functions/_backend/private/groups.ts'
import { app as invite_existing_user_to_org } from '../../supabase/functions/_backend/private/invite_existing_user_to_org.ts'
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'
Expand Down Expand Up @@ -100,6 +101,7 @@ appPrivate.route('/accept_invitation', accept_invitation)
appPrivate.route('/devices', devices_priv)
appPrivate.route('/log_as', log_as)
appPrivate.route('/invite_new_user_to_org', invite_new_user_to_org)
appPrivate.route('/invite_existing_user_to_org', invite_existing_user_to_org)
appPrivate.route('/set_org_email', set_org_email)
appPrivate.route('/validate_password_compliance', validate_password_compliance)
appPrivate.route('/admin_credits', admin_credits)
Expand Down
1 change: 1 addition & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,7 @@
"org-changes-set-email-other-error": "Cannot set the management email, please contact support",
"org-created-successfully": "Org created successfully!",
"org-deleted": "Org deleted successfully",
"org-invite-email-notification-failed": "The invitation was created, but we could not send the email notification.",
"org-invited-user": "Successfully invited user to org",
"org-name": "Organization Name",
"org-name-required": "Organization name required",
Expand Down
8 changes: 8 additions & 0 deletions scripts/supabase-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ function rewriteConfigToml(raw: string, cfg: ReturnType<typeof getSupabaseWorktr
out.push(`inspector_port = ${ports.edgeInspector}`)
else if (section in portBySection && line.match(/^\s*port\s*=\s*\d+\s*$/))
out.push(`port = ${portBySection[section]}`)
else if (line.includes('./supabase/templates/'))
out.push(line.replace('./supabase/templates/', './templates/'))
else
out.push(line)
}
Expand All @@ -125,6 +127,7 @@ function ensureWorktreeSupabaseDir(repoRoot: string): { workdir: string, cfg: Re

// Symlink everything except config.toml so we can safely rewrite ports + project_id per worktree.
const repoSupaDir = resolve(cfg.repoRoot, 'supabase')
const repoTemplatesDir = resolve(repoSupaDir, 'templates')
for (const entry of ['functions', 'migrations', 'schemas', 'tests', 'seed.sql', 'migration_guide.md', '.gitignore']) {
const src = resolve(repoSupaDir, entry)
if (!existsSync(src))
Expand All @@ -133,6 +136,11 @@ function ensureWorktreeSupabaseDir(repoRoot: string): { workdir: string, cfg: Re
ensureSymlink(dst, src)
}

if (existsSync(repoTemplatesDir)) {
ensureSymlink(resolve(workdir, 'templates'), repoTemplatesDir)
ensureSymlink(resolve(supaDir, 'templates'), repoTemplatesDir)
}

const baseConfig = readFileSync(resolve(repoSupaDir, 'config.toml'), 'utf8')
const rewritten = rewriteConfigToml(baseConfig, cfg)
writeFileSync(resolve(supaDir, 'config.toml'), rewritten)
Expand Down
2 changes: 1 addition & 1 deletion src/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,4 +660,4 @@ declare module 'vue' {
readonly watchWithFilter: UnwrapRef<typeof import('@vueuse/core')['watchWithFilter']>
readonly whenever: UnwrapRef<typeof import('@vueuse/core')['whenever']>
}
}
}
65 changes: 61 additions & 4 deletions src/components/dashboard/DropdownOrganization.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { useDialogV2Store } from '~/stores/dialogv2'
import { useMainStore } from '~/stores/main'
import { useOrganizationStore } from '~/stores/organization'

type OrganizationInvitationTarget = Pick<Organization, 'gid' | 'name' | 'role'>

const router = useRouter()
const route = useRoute()
const organizationStore = useOrganizationStore()
const { currentOrganization } = storeToRefs(organizationStore)
const dialogStore = useDialogV2Store()
Expand All @@ -29,6 +32,7 @@ const lastOrganizationLogoRefreshAt = ref(0)
const refreshedBrokenLogoKeys = new Set<string>()
let organizationLogoRefreshInterval: number | null = null
let isOrganizationDropdownMounted = false
const handledInviteOrgId = ref<string | null>(null)
Comment thread
riderx marked this conversation as resolved.

function refreshOnFocus() {
void refreshOrganizationLogosIfNeeded()
Expand All @@ -47,6 +51,8 @@ onMounted(async () => {
if (!isOrganizationDropdownMounted)
return

await openInvitationFromRouteIfNeeded()

lastOrganizationLogoRefreshAt.value = Date.now()

window.addEventListener('focus', refreshOnFocus)
Expand All @@ -66,8 +72,9 @@ onUnmounted(() => {
organizationLogoRefreshInterval = null
})

async function handleOrganizationInvitation(org: Organization) {
async function handleOrganizationInvitation(org: OrganizationInvitationTarget) {
const newName = t('alert-accept-invitation').replace('%ORG%', org.name)
let invitationHandled = false
dialogStore.openDialog({
title: t('alert-confirm-invite'),
description: `${newName}`,
Expand All @@ -86,8 +93,9 @@ async function handleOrganizationInvitation(org: Organization) {
}

if (data === 'OK') {
invitationHandled = true
organizationStore.setCurrentOrganization(org.gid)
organizationStore.fetchOrganizations()
await organizationStore.fetchOrganizations()
toast.success(t('invite-accepted'))
}
else if (data === 'NO_INVITE') {
Expand All @@ -112,12 +120,16 @@ async function handleOrganizationInvitation(org: Organization) {
const { error } = await supabase
.from('org_users')
.delete()
.eq('org_id', org.gid)
.eq('user_id', userId)

if (error)
if (error) {
console.log('Error delete: ', error)
return
}

organizationStore.fetchOrganizations()
invitationHandled = true
await organizationStore.fetchOrganizations()
toast.success(t('alert-denied-invite'))
},
},
Expand All @@ -127,6 +139,34 @@ async function handleOrganizationInvitation(org: Organization) {
},
],
})

await dialogStore.onDialogDismiss()
if (invitationHandled)
await clearInviteOrgQuery()
Comment thread
riderx marked this conversation as resolved.
}

async function clearInviteOrgQuery() {
if (!('invite_org' in route.query))
return

const nextQuery = { ...route.query }
delete nextQuery.invite_org
await router.replace({ query: nextQuery })
handledInviteOrgId.value = null
}

async function openInvitationFromRouteIfNeeded() {
const inviteOrgId = typeof route.query.invite_org === 'string' ? route.query.invite_org : ''
if (!inviteOrgId || inviteOrgId === handledInviteOrgId.value)
return

const inviteOrg = organizationStore.organizations.find(org => org.gid === inviteOrgId)
if (!inviteOrg)
return
Comment thread
riderx marked this conversation as resolved.

handledInviteOrgId.value = inviteOrgId
if (isInvitation(inviteOrg))
await handleOrganizationInvitation(inviteOrg)
}

function closeDropdown() {
Expand Down Expand Up @@ -266,6 +306,23 @@ function onOrgItemKeydown(org: Organization, e: KeyboardEvent) {
closeDropdown()
onOrganizationClick(org)
}

watch(
() => route.query.invite_org,
(inviteOrg) => {
if (typeof inviteOrg !== 'string' || !inviteOrg)
handledInviteOrgId.value = null
void openInvitationFromRouteIfNeeded()
},
{ immediate: true },
)

watch(
() => organizationStore.organizations.map(org => `${org.gid}:${org.role}`),
() => {
void openInvitationFromRouteIfNeeded()
},
)
</script>

<template>
Expand Down
18 changes: 13 additions & 5 deletions src/components/dashboard/InviteTeammateModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useSupabase } from '~/services/supabase'
import { sendEvent } from '~/services/tracking'
import { useDialogV2Store } from '~/stores/dialogv2'
import { useOrganizationStore } from '~/stores/organization'
import { resolveInviteNewUserErrorMessage } from '~/utils/invites'
import { notifyExistingUserInvite, resolveInviteNewUserErrorMessage, shouldNotifyExistingUserInvite } from '~/utils/invites'

interface InviteSuccessPayload {
email: string
Expand Down Expand Up @@ -40,7 +40,8 @@ const captchaKey = ref(import.meta.env.VITE_CAPTCHA_KEY)
const shouldUseCaptcha = computed(() => Boolean(captchaKey.value))
const isInviting = ref(false)
const useRbacInvites = computed(() => organizationStore.currentOrganization?.use_new_rbac === true)
const inviteRole = computed(() => (useRbacInvites.value ? 'org_admin' : 'admin'))
const existingUserInviteRole = computed(() => (useRbacInvites.value ? 'org_admin' : 'invite_admin'))
const newUserInviteRole = computed(() => (useRbacInvites.value ? 'org_admin' : 'admin'))
const emailDialogTitle = computed(() => props.inviteKind === 'technical'
? t('onboarding-invite-option-modal-title')
: t('invite-teammate-modal-title'))
Expand Down Expand Up @@ -215,7 +216,7 @@ async function handleEmailSubmit() {
const result = await supabase.rpc('invite_user_to_org_rbac', {
email,
org_id: orgId,
role_name: inviteRole.value,
role_name: existingUserInviteRole.value,
})
data = result.data
error = result.error
Expand All @@ -224,7 +225,7 @@ async function handleEmailSubmit() {
const result = await supabase.rpc('invite_user_to_org', {
email,
org_id: orgId,
invite_type: inviteRole.value as Database['public']['Enums']['user_min_right'],
invite_type: existingUserInviteRole.value as Database['public']['Enums']['user_min_right'],
})
data = result.data
error = result.error
Expand All @@ -242,6 +243,13 @@ async function handleEmailSubmit() {
}

if (data === 'OK') {
if (shouldNotifyExistingUserInvite(existingUserInviteRole.value, useRbacInvites.value)) {
const notified = await notifyExistingUserInvite(supabase, email, orgId)
if (!notified) {
console.warn('Failed to send invite email notification, but invite was created')
toast.warning(t('org-invite-email-notification-failed'))
}
}
toast.success(t('org-invited-user'))
completeInviteSuccess({
email,
Expand Down Expand Up @@ -322,7 +330,7 @@ async function handleFullDetailsSubmit() {
body: {
email,
org_id: orgId,
invite_type: inviteRole.value,
invite_type: newUserInviteRole.value,
captcha_token: shouldUseCaptcha.value ? inviteCaptchaToken.value : undefined,
first_name: firstName,
last_name: lastName,
Expand Down
34 changes: 27 additions & 7 deletions src/modules/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ async function guard(
const hasAuth = !!claimsData?.claims?.sub && !!sessionUser
const hadAuth = !!main.auth
const needsVerifiedEmail = to.path.startsWith('/settings') || to.path === '/delete_account'
const shouldRedirectToOrgOnboarding = !to.path.startsWith('/onboarding/organization')
const inviteOrgId = typeof to.query.invite_org === 'string' && to.query.invite_org.length > 0
? to.query.invite_org
: null
const isAdminRoute = to.path.startsWith('/admin')

async function tryLoadOrganizations(fetcher: () => Promise<void>) {
Expand All @@ -110,6 +112,14 @@ async function guard(
}
}

function shouldRedirectToOrgOnboarding() {
if (to.path.startsWith('/onboarding/organization'))
return false
if (!inviteOrgId)
return true
return !organizationStore.organizations.some(org => org.gid === inviteOrgId && org.role.startsWith('invite'))
}

if (hasAuth && sessionUser) {
const authConfirmedAt = main.auth?.email_confirmed_at
if (!main.auth || main.auth.id !== sessionUser.id || authConfirmedAt !== sessionUser.email_confirmed_at) {
Expand All @@ -134,7 +144,12 @@ async function guard(
&& mfaData.nextLevel === 'aal2'
&& !isAdminForced
) {
return next(`/login?to=${to.path}`)
return next({
path: '/login',
query: {
to: to.fullPath,
},
})
}

if (hasAuth && sessionUser && !hadAuth) {
Expand All @@ -143,7 +158,7 @@ async function guard(
path: '/resend_email',
query: {
reason: 'email_not_verified',
return_to: to.path,
return_to: to.fullPath,
Comment thread
riderx marked this conversation as resolved.
},
})
}
Expand Down Expand Up @@ -180,7 +195,7 @@ async function guard(
}
}

if (organizationsLoaded && !organizationStore.hasOrganizations && shouldRedirectToOrgOnboarding) {
if (organizationsLoaded && !organizationStore.hasOrganizations && shouldRedirectToOrgOnboarding()) {
if (!isAdminRoute || !main.isAdmin) {
return next({
path: '/onboarding/organization',
Expand Down Expand Up @@ -217,7 +232,12 @@ async function guard(
}
else if (from.path !== 'login' && !hasAuth) {
main.auth = undefined
next(`/login?to=${to.path}`)
next({
path: '/login',
query: {
to: to.fullPath,
},
})
}
else if (hasAuth && main.auth) {
// User is already authenticated, but check if account got disabled
Expand All @@ -228,7 +248,7 @@ async function guard(
path: '/resend_email',
query: {
reason: 'email_not_verified',
return_to: to.path,
return_to: to.fullPath,
},
})
}
Expand All @@ -251,7 +271,7 @@ async function guard(
}

const organizationsLoaded = await tryLoadOrganizations(() => organizationStore.dedupFetchOrganizations())
if (organizationsLoaded && !organizationStore.hasOrganizations && shouldRedirectToOrgOnboarding) {
if (organizationsLoaded && !organizationStore.hasOrganizations && shouldRedirectToOrgOnboarding()) {
return next('/onboarding/organization')
}

Expand Down
Loading
Loading