From 0b5247bed3c88d5b45596830459599fcd458cc3a Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 12 Jul 2026 09:16:39 +0100 Subject: [PATCH] fix(org): route admin and tutor invites to app domain (#731) Team member invite emails and link-invite copy URLs were built with the org tenant host (custom domain or siteName.myclassroomio.com). Admin and tutor invites should always point at the admin dashboard origin (app.classroomio.com in cloud). Student org invites are unchanged. --- apps/api/src/services/organization/invite.ts | 11 ++++------- .../src/lib/features/settings/pages/teams.svelte | 4 ++-- apps/dashboard/src/lib/utils/store/org.ts | 14 +++++++++++++- packages/core/src/config/dashboard-url.ts | 8 ++++++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index b6f548ed..e91d8dc4 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -28,7 +28,7 @@ import { ROLE } from '@cio/utils/constants'; import type { TNewOrganizationInviteAudit } from '@db/types'; import crypto from 'node:crypto'; import { db, type DbOrTxClient } from '@cio/db/drizzle'; -import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; +import { getAppBaseUrl } from '@cio/core/config/dashboard-url'; import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org'; import { getProfileById, markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { enqueueTransactionalEmail } from '@api/services/jobs'; @@ -61,11 +61,8 @@ function normalizeEmails(emails: string[]): string[] { return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))]; } -function buildInviteLink( - token: string, - org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null } -): string { - return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`; +function buildTeamInviteLink(token: string): string { + return `${getAppBaseUrl()}/invite/${encodeURIComponent(token)}`; } function getRoleLabel(roleId: number): string { @@ -251,7 +248,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId: } }); - const inviteLink = buildInviteLink(token, organization); + const inviteLink = buildTeamInviteLink(token); try { await enqueueTransactionalEmail('inviteTeacher', { diff --git a/apps/dashboard/src/lib/features/settings/pages/teams.svelte b/apps/dashboard/src/lib/features/settings/pages/teams.svelte index f7b65c6a..486ed212 100644 --- a/apps/dashboard/src/lib/features/settings/pages/teams.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/teams.svelte @@ -4,7 +4,7 @@ import CopyIcon from '@lucide/svelte/icons/copy'; import { profile } from '$lib/utils/store/user'; - import { isFreePlan, currentOrg } from '$lib/utils/store/org'; + import { getAppOrigin, isFreePlan, currentOrg } from '$lib/utils/store/org'; import { orgApi } from '$features/org/api/org.svelte'; import { t } from '$lib/utils/functions/translations'; import { snackbar } from '$features/ui/snackbar/store'; @@ -25,7 +25,7 @@ let isRemoving: number | null = $state(null); function buildLinkInviteUrl(token: string): string { - return `${window.location.origin}/invite/link/${encodeURIComponent(token)}`; + return `${getAppOrigin()}/invite/link/${encodeURIComponent(token)}`; } async function onCopyInviteLink() { diff --git a/apps/dashboard/src/lib/utils/store/org.ts b/apps/dashboard/src/lib/utils/store/org.ts index 4427eae7..71eb85b7 100644 --- a/apps/dashboard/src/lib/utils/store/org.ts +++ b/apps/dashboard/src/lib/utils/store/org.ts @@ -6,7 +6,7 @@ import type { AccountOrg } from '$features/app/types'; import type { OrgTeamMember } from '../types/org'; import { canUsePublicApi, isOrgOnFreePlan, PLAN } from '@cio/utils/plans'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; -import { ROLE, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; +import { BRAND_ROOT_DOMAIN, ROLE, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { STEPS } from '../constants/quiz'; import type { Writable } from 'svelte/store'; @@ -79,6 +79,18 @@ export const currentOrgPath = derived(currentOrg, ($currentOrg) => type OrgPublicOrigin = Pick; +/** + * Admin dashboard origin (`app.classroomio.com` in cloud). Use for team invite links + * and other admin-only URLs — not student-facing tenant pages. + */ +export function getAppOrigin(): string { + if (PUBLIC_IS_SELFHOSTED === 'true' || dev) { + return browser ? window.location.origin : ''; + } + + return `https://app.${BRAND_ROOT_DOMAIN}`; +} + /** * Public origin for an org's tenant site (student LMS, public course pages, login-link handoff). * Self-hosted uses the current deployment URL. On cloud, verified custom domains win over the diff --git a/packages/core/src/config/dashboard-url.ts b/packages/core/src/config/dashboard-url.ts index 25fbf16d..ef973902 100644 --- a/packages/core/src/config/dashboard-url.ts +++ b/packages/core/src/config/dashboard-url.ts @@ -46,3 +46,11 @@ export function getDashboardBaseUrl(org?: DashboardOrg): string { return `https://app.${BRAND_ROOT_DOMAIN}`; } + +/** + * Returns the admin dashboard origin (`app.classroomio.com` in cloud). + * Use for team-member invite links (admin/tutor) — not student-facing URLs. + */ +export function getAppBaseUrl(): string { + return getDashboardBaseUrl(); +}