From ce0dd376e63447d0549f7e14bd12017824cdc2a1 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sat, 11 Jul 2026 09:17:45 +0100 Subject: [PATCH] fix(dashboard): respect URL tenant org for multi-org students (#727) * fix(dashboard): respect URL tenant org for multi-org students When a student belongs to multiple organizations, setOrgStore was falling back to localStorage or the first /account organization even on tenant sites (subdomain or custom domain). That overwrote the server-resolved org from the URL and sent the wrong cio-org-id on API calls. Pin currentOrg to the URL tenant on org sites, preserve the server org when membership is missing, sync org from /org/[slug] routes, and fix the goToLMS single-org length check. Co-authored-by: Rotimi Best * fix(dashboard): update currentOrg when switching tenant sites Address Greptile P1: shouldSetPublicOrg only ran when the site name matched, so navigating from one tenant subdomain to another left the previous org in currentOrg. React to URL tenant changes in an effect and re-pin via setOrgStore when account data is already loaded. Co-authored-by: Rotimi Best * fix(dashboard): harden tenant sync when switching org sites - Reset globalStore when leaving a tenant URL - Split org-site seeding from account-backed tenant sync - Add syncTenantOrgFromUrl with deduping and auto-enroll on switch - Stop calling setOrgStore directly from the root layout Co-authored-by: Rotimi Best * refactor(dashboard): consolidate org context sync in root layout Address PR review: document why post-setup sync is needed, merge setupApp and syncOrgContext into one effect, resolve /org/[slug] from the root layout via resolveAppOrgParams, and remove duplicate sync from org slug layout. Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- .../src/lib/features/app/init.svelte.ts | 121 ++++++++++++++---- .../features/app/resolve-app-org-params.ts | 36 ++++++ apps/dashboard/src/routes/+layout.svelte | 50 ++++++-- 3 files changed, 173 insertions(+), 34 deletions(-) create mode 100644 apps/dashboard/src/lib/features/app/resolve-app-org-params.ts diff --git a/apps/dashboard/src/lib/features/app/init.svelte.ts b/apps/dashboard/src/lib/features/app/init.svelte.ts index 892feac2..bf4a2e57 100644 --- a/apps/dashboard/src/lib/features/app/init.svelte.ts +++ b/apps/dashboard/src/lib/features/app/init.svelte.ts @@ -3,6 +3,7 @@ import { currentOrg, getOrgPublicUrl, mergeAccountOrgFromServer, orgs } from '$l import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user'; import type { AccountResponse } from './types'; +import { resolveAppOrgParams, type AppOrgParams } from './resolve-app-org-params'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import type { TUser } from '@cio/db/types'; import { authClient } from '$lib/utils/services/auth/client'; @@ -23,12 +24,19 @@ import { setupAnalyticsBasedOnLicense } from '$lib/utils/functions/appSetup'; import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth'; import { ROLE } from '@cio/utils/constants'; -type AppSetupParams = { - isOrgSite: boolean; - orgSiteName: string; - /** Tenant org id from `getOrgSiteInfo`; used to auto-enroll the user if they aren't a member yet. */ - orgId?: string | null; -}; +type AppSetupParams = AppOrgParams; + +function tenantSyncKey(params: AppSetupParams): string | null { + if (!params.orgSiteName) { + return null; + } + + if (params.isOrgSite) { + return `tenant:${params.orgSiteName}:${params.orgId ?? ''}`; + } + + return `admin:${params.orgSiteName}`; +} /* Manages everything related to loading the logged in user and setting up the organization. @@ -36,6 +44,7 @@ type AppSetupParams = { class AppInitApi extends BaseApi { data = $state(null); session = $state(null); + private syncedTenantKey: string | null = null; get loading() { return this.isLoading; @@ -81,7 +90,7 @@ class AppInitApi extends BaseApi { }); } - private async autoEnrollOnTenantSite(orgId: string): Promise { + private async autoEnrollOnTenantSite(orgId: string): Promise { try { const response = await classroomio.organization['auto-enroll-student'].$post( {}, @@ -93,18 +102,53 @@ class AppInitApi extends BaseApi { // visited a tenant site but isn't allowed to enroll. Other failures // shouldn't block the rest of setupApp. console.warn('auto-enroll-student failed', response.status, await response.text().catch(() => '')); - return; + return false; } // The user is a new member, so the session cookie cache (orgRoles) // is stale. Force Better Auth to refetch from DB and rewrite the // session_data cookie — Better Auth manages the cookie name itself. await authClient.getSession({ query: { disableCookieCache: true } }); + return true; } catch (error) { console.warn('auto-enroll-student threw', error); + return false; } } + /** + * Re-pin org context after setupApp when the URL org changes. + * + * setupApp only runs once per session; tenant subdomains and `/org/[slug]` + * routes can change without another /account fetch. This keeps `currentOrg` + * aligned with the URL and attempts auto-enroll on org sites when needed. + */ + async syncOrgContext(params: AppSetupParams): Promise { + if (!this.data?.success || !params.orgSiteName) { + return; + } + + const nextTenantKey = tenantSyncKey(params); + if (nextTenantKey && nextTenantKey === this.syncedTenantKey) { + return; + } + + const isMember = this.data.organizations.some((org) => org.siteName === params.orgSiteName); + if (params.isOrgSite && params.orgId && !isMember) { + const enrolled = await this.autoEnrollOnTenantSite(params.orgId); + if (enrolled) { + const response = await classroomio.account.$get(); + const accountData = (await response.json()) as AccountResponse; + if (accountData?.success) { + this.data = accountData; + } + } + } + + this.setOrgStore(params); + licenseApi.syncFromAccount(this.data.licenseFeatures, get(currentOrg)); + } + /* 1. Update user store 2. Update profile store @@ -133,30 +177,60 @@ class AppInitApi extends BaseApi { return; } - if (!this.data.organizations.length) { - return; - } - orgs.set(this.data.organizations.map((org) => mergeAccountOrgFromServer(org))); - // On a tenant site, pin currentOrg to that tenant — never fall back to - // localStorage / the user's first org, which is what was making a user - // logged in on dblocked.* see the Dblocked dashboard on ciodevs.*. - let nextOrg: (typeof this.data.organizations)[number] | undefined; - if (params?.isOrgSite && params.orgSiteName) { - nextOrg = this.data.organizations.find((org) => org.siteName === params.orgSiteName); - } - + const nextOrg = this.pickCurrentOrg(params); if (!nextOrg) { - const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename'); - nextOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName) ?? this.data.organizations[0]; + return; } currentOrg.set(mergeAccountOrgFromServer(nextOrg)); + if (nextOrg.siteName) { + localStorage.setItem('classroomio_org_sitename', nextOrg.siteName); + } + const theme = get(currentOrg)?.theme; setTheme(theme || 'blue'); + this.syncedTenantKey = tenantSyncKey(params ?? { isOrgSite: false, orgSiteName: '' }); + } + + /** + * Prefer the tenant from the current URL on org sites. Only fall back to + * localStorage / the first account org on the admin host (`app.*`). + */ + private pickCurrentOrg(params?: AppSetupParams) { + const organizations = this.data?.success ? this.data.organizations : []; + const urlResolvedOrg = get(currentOrg); + + if (params?.orgSiteName) { + const membership = organizations.find((org) => org.siteName === params.orgSiteName); + if (membership) { + return membership; + } + + if (params.isOrgSite) { + // Keep the server-resolved tenant org (set from URL in +layout.svelte) instead + // of switching to another membership from /account. + if (urlResolvedOrg.siteName === params.orgSiteName && urlResolvedOrg.id) { + return urlResolvedOrg; + } + + if (params.orgId && urlResolvedOrg.id === params.orgId) { + return urlResolvedOrg; + } + + return undefined; + } + } + + if (!organizations.length) { + return undefined; + } + + const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename'); + return organizations.find((org) => org.siteName === lastOrgSiteName) ?? organizations[0]; } routeUserToNextPage({ isOrgSite }: AppSetupParams) { @@ -216,7 +290,7 @@ class AppInitApi extends BaseApi { const isCloud = PUBLIC_IS_SELFHOSTED !== 'true'; const isStudent = get(isOrgStudent); const selectedOrg = get(currentOrg); - const userHasOneOrg = this.data.organizations === 1; + const userHasOneOrg = this.data.organizations.length === 1; const userIsStudentInAllOrgs = this.data.organizations.every((org) => org.roleId === ROLE.STUDENT); const userIsAdminInAtleastOneOrg = this.data.organizations.some((org) => org.roleId === ROLE.ADMIN); @@ -270,6 +344,7 @@ class AppInitApi extends BaseApi { reset() { super.reset(); this.data = null; + this.syncedTenantKey = null; licenseApi.reset(); user.set(defaultUserState); diff --git a/apps/dashboard/src/lib/features/app/resolve-app-org-params.ts b/apps/dashboard/src/lib/features/app/resolve-app-org-params.ts new file mode 100644 index 00000000..351fa92d --- /dev/null +++ b/apps/dashboard/src/lib/features/app/resolve-app-org-params.ts @@ -0,0 +1,36 @@ +export type AppOrgParams = { + isOrgSite: boolean; + orgSiteName: string; + /** Tenant org id from `getOrgSiteInfo`; used to auto-enroll on org sites. */ + orgId?: string | null; +}; + +type LayoutOrgData = { + isOrgSite: boolean; + orgSiteName: string; + org: { id?: string } | null; +}; + +/** + * Resolve which organization the dashboard should treat as "current" for API calls. + * + * - Org sites: subdomain/custom domain from `getOrgSiteInfo`. + * - App host: `/org/[slug]` when the user is in the admin dashboard. + */ +export function resolveAppOrgParams(layoutData: LayoutOrgData, pathname: string, slugParam?: string): AppOrgParams { + if (layoutData.isOrgSite) { + return { + isOrgSite: true, + orgSiteName: layoutData.orgSiteName, + orgId: layoutData.org?.id ?? null + }; + } + + const adminOrgSlug = pathname.startsWith('/org/') && slugParam && slugParam !== '*' ? slugParam : ''; + + return { + isOrgSite: false, + orgSiteName: adminOrgSlug, + orgId: null + }; +} diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 0b971523..0ae28e38 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -4,9 +4,11 @@ import { Snackbar } from '$features/ui'; import { appInitApi } from '$features/app/init.svelte'; + import { resolveAppOrgParams } from '$features/app/resolve-app-org-params'; import { setupCloudAnalytics } from '$lib/utils/functions/appSetup'; import { globalStore } from '$lib/utils/store/app'; import { currentOrg, mergeAccountOrgFromServer } from '$lib/utils/store/org'; + import { get } from 'svelte/store'; import { user } from '$lib/utils/store/user'; import { setTheme } from '$lib/utils/functions/theme'; import { authClient } from '$lib/utils/services/auth/client'; @@ -42,26 +44,52 @@ currentSession: data.locals.user }); } + }); - if (data.isOrgSite && data.org) { - $globalStore.orgSiteName = data.orgSiteName || ''; - $globalStore.isOrgSite = true; - currentOrg.set(mergeAccountOrgFromServer(data.org)); - setTheme(data.org.theme || 'blue'); + $effect(() => { + if (!data.isOrgSite || !data.org) { + $globalStore.isOrgSite = false; + $globalStore.orgSiteName = ''; + return; } + + $globalStore.orgSiteName = data.orgSiteName || ''; + $globalStore.isOrgSite = true; + + const existingOrg = get(currentOrg); + const shouldSetPublicOrg = + !existingOrg.id || existingOrg.siteName !== data.org.siteName || existingOrg.roleId === 0; + + if (shouldSetPublicOrg) { + currentOrg.set(mergeAccountOrgFromServer(data.org)); + } + + setTheme(data.org.theme || 'blue'); }); const session = authClient.useSession(); const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data); + const appOrgParams = $derived(resolveAppOrgParams(data, page.url.pathname, page.params.slug)); + /* + Auth + org context for the whole dashboard. + + setupApp runs once per session to load /account. After that, org context can + still change when a logged-in user navigates to a different tenant subdomain + or opens another /org/[slug] on the app host — without another setupApp run. + syncOrgContext re-pins currentOrg from the URL + cached account data. + */ $effect(() => { - if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) { - appInitApi.setupApp($session.data as App.Locals, { - isOrgSite: data.isOrgSite, - orgSiteName: data.orgSiteName, - orgId: data.org?.id ?? null - }); + if (!isSessionReady || appInitApi.loading) { + return; } + + if (!appInitApi.isInitializedAndReady) { + appInitApi.setupApp($session.data as App.Locals, appOrgParams); + return; + } + + void appInitApi.syncOrgContext(appOrgParams); });