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); });