diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 789088b1..c3b0d59b 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -48,6 +48,7 @@ import { Hono } from '@api/utils/hono'; import { ROLE } from '@cio/utils/constants'; import { TOrganization } from '@db/types'; import { assetsRouter } from '@api/routes/organization/assets'; +import { autoEnrollStudent } from '@api/services/organization/auto-enroll'; import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor'; import { authMiddleware } from '@api/middlewares/auth'; import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key'; @@ -115,6 +116,28 @@ export const organizationRouter = new Hono() * Gets organization team members (non-students) * Requires authentication */ + /** + * POST /organization/auto-enroll-student + * Idempotently enrolls the authenticated user as a STUDENT in the org + * identified by the `cio-org-id` header. Called by the dashboard's + * `setupApp` after a user authenticates on a tenant site they aren't a + * member of yet. Respects `disableSignup` and `inviteOnly` policies. + */ + .post('/auto-enroll-student', authMiddleware, async (c) => { + try { + const user = c.get('user')!; + const orgId = c.req.header('cio-org-id'); + + if (!orgId) { + return c.json({ success: false, error: 'Organization ID is required', code: 'ORG_ID_REQUIRED' }, 400); + } + + const result = await autoEnrollStudent(user.id, orgId); + return c.json({ success: true, data: result }, 200); + } catch (error) { + return handleError(c, error, 'Failed to auto-enroll student'); + } + }) .get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => { try { const orgId = c.req.header('cio-org-id')!; diff --git a/apps/api/src/services/organization/auto-enroll.ts b/apps/api/src/services/organization/auto-enroll.ts new file mode 100644 index 00000000..fb35ddc5 --- /dev/null +++ b/apps/api/src/services/organization/auto-enroll.ts @@ -0,0 +1,68 @@ +import { AppError, ErrorCodes } from '@api/utils/errors'; +import { + createOrganizationMember, + getOrganizationById, + getOrganizationMemberIdByOrgAndProfile +} from '@cio/db/queries/organization'; + +import { ROLE } from '@cio/utils/constants'; +import { getProfileById } from '@cio/db/queries/auth'; + +interface OrgSignupSettings { + signup?: { + inviteOnly?: boolean; + }; +} + +export interface AutoEnrollResult { + alreadyMember: boolean; +} + +/** + * Auto-enroll the authenticated user as a STUDENT in the given org. Used by + * the dashboard when a newly-signed-in user lands on a tenant site they + * aren't yet a member of (free-tier `.classroomio.school` or a verified + * BYOD domain). + * + * Idempotent: existing members get an `alreadyMember: true` no-op so invited + * admins/tutors don't get downgraded. + * + * Respects the same org policies as `signupGuard` does for email/password + * signups: `disableSignup` and `settings.signup.inviteOnly` both block the + * insert. + */ +export async function autoEnrollStudent(userId: string, orgId: string): Promise { + const existingMemberId = await getOrganizationMemberIdByOrgAndProfile(orgId, userId); + if (existingMemberId) { + return { alreadyMember: true }; + } + + const organization = await getOrganizationById(orgId); + if (!organization) { + throw new AppError('Organization not found', ErrorCodes.NOT_FOUND, 404); + } + + if (organization.disableSignup) { + throw new AppError('Signup is disabled for this organization', ErrorCodes.FORBIDDEN, 403); + } + + const settings = organization.settings as OrgSignupSettings | null; + if (settings?.signup?.inviteOnly) { + throw new AppError('This organization requires an invitation to join', ErrorCodes.FORBIDDEN, 403); + } + + const profile = await getProfileById(userId); + if (!profile?.email) { + throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404); + } + + await createOrganizationMember({ + organizationId: orgId, + profileId: userId, + email: profile.email, + roleId: ROLE.STUDENT, + verified: true + }); + + return { alreadyMember: false }; +} diff --git a/apps/dashboard/src/lib/features/app/init.svelte.ts b/apps/dashboard/src/lib/features/app/init.svelte.ts index 58f9f4d0..e7844cb8 100644 --- a/apps/dashboard/src/lib/features/app/init.svelte.ts +++ b/apps/dashboard/src/lib/features/app/init.svelte.ts @@ -5,6 +5,7 @@ import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils import type { AccountResponse } from './types'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; import type { TUser } from '@cio/db/types'; +import { authClient } from '$lib/utils/services/auth/client'; import { get } from 'svelte/store'; import { goto } from '$app/navigation'; import { handleLocaleChange } from '$lib/utils/functions/translations'; @@ -23,6 +24,8 @@ import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAu 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; }; /* @@ -49,14 +52,22 @@ class AppInitApi extends BaseApi { this.session = locals; + // Auto-enroll on tenant sites for first-time signups. Idempotent on the + // API side (no-ops for existing members so invited admins/tutors keep + // their roles). Runs BEFORE the account fetch so the returned org list + // already reflects the new membership. + if (params.isOrgSite && params.orgId) { + await this.autoEnrollOnTenantSite(params.orgId); + } + await this.execute({ requestFn: () => classroomio.account.$get(), logContext: 'fetching account', - onSuccess: (accountData) => { - this.data = accountData; - licenseApi.setFeatures(accountData.licenseFeatures); + onSuccess: (data) => { + this.data = data; + licenseApi.setFeatures(data.licenseFeatures); setupAnalyticsBasedOnLicense(); - this.setupStores(); + this.setupStores(params); this.setUserAnalytics(); this.routeUserToNextPage(params); }, @@ -66,12 +77,36 @@ class AppInitApi extends BaseApi { }); } + private async autoEnrollOnTenantSite(orgId: string): Promise { + try { + const response = await classroomio.organization['auto-enroll-student'].$post( + {}, + { headers: { 'cio-org-id': orgId } } + ); + + if (!response.ok) { + // 403 is expected for invite-only / disabled-signup orgs — the user + // 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; + } + + // 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 } }); + } catch (error) { + console.warn('auto-enroll-student threw', error); + } + } + /* 1. Update user store 2. Update profile store 3. Update organizations store */ - setupStores() { + setupStores(params?: AppSetupParams) { if (!this.data?.success || !this.session) { return; } @@ -86,10 +121,10 @@ class AppInitApi extends BaseApi { profile.set(this.data.profile); handleLocaleChange(this.data.profile.locale ?? 'en'); - this.setOrgStore(); + this.setOrgStore(params); } - setOrgStore() { + setOrgStore(params?: AppSetupParams) { if (!this.data?.success || !this.data) { return; } @@ -100,10 +135,20 @@ class AppInitApi extends BaseApi { orgs.set(this.data.organizations.map((org) => mergeAccountOrgFromServer(org))); - const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename'); - const lastOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName); + // 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); + } - currentOrg.set(mergeAccountOrgFromServer(lastOrg || this.data.organizations[0])); + if (!nextOrg) { + const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename'); + nextOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName) ?? this.data.organizations[0]; + } + + currentOrg.set(mergeAccountOrgFromServer(nextOrg)); const theme = get(currentOrg)?.theme; diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/constants.ts b/apps/dashboard/src/lib/features/course/components/sidebar/constants.ts index d02408a3..c8645097 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/constants.ts +++ b/apps/dashboard/src/lib/features/course/components/sidebar/constants.ts @@ -14,7 +14,7 @@ export const NAV_IDS = { AI_ASSISTANT: 'AI_ASSISTANT' }; -export const COURSE_SIDEBAR_DEFAULT_WIDTH = 256; +export const COURSE_SIDEBAR_DEFAULT_WIDTH = 320; export const COURSE_SIDEBAR_MIN_WIDTH = 220; export const COURSE_SIDEBAR_MAX_WIDTH = 420; export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width'; diff --git a/apps/dashboard/src/lib/features/settings/pages/domains.svelte b/apps/dashboard/src/lib/features/settings/pages/domains.svelte index 03729c07..54babd85 100644 --- a/apps/dashboard/src/lib/features/settings/pages/domains.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/domains.svelte @@ -1,10 +1,10 @@