fix(org): auto-enroll idempotency, signup enrollment, and pending invite gate (#729)
* fix(org): auto-enroll idempotency, pending invites, and tenant enrollment - Map Postgres unique violations to 409 CONFLICT instead of 500 - Link email-only roster rows before signup policy gates (preserve role) - Return pendingInvite when an active org invite exists; surface modal globally - Enroll at signup via create-profile hook when cio-org-id header is present - Gate client auto-enroll on session orgRoles and pending invite check - Throw 409 when org email row is linked to a different profile (Greptile) - Enforce free-plan student cap via assertStudentCapacityOrThrow on new enrolls - Hide student-only orgs from admin org switcher; redirect students off /org/[slug] - Route tutor/admin pending-invite accepts on tenant sites to app host Co-authored-by: Rotimi Best <[email protected]> * perf(dashboard): parallelize account and pending invite fetch on tenant bootstrap Start GET /account and GET /invite/organization/pending together when a logged-in user visits a tenant site without session membership. Auto-enroll still runs sequentially after both complete when no invite is found, then refetches /account if enrollment succeeds. Co-authored-by: Rotimi Best <[email protected]> * fix: address PR review — restore AppModeWatcher and drop signup ctx enrollment Better Auth database hooks do not receive frontend cio-org-id headers in ctx, so enrollFromSignupOrgContext could never work. Revert to client-side auto-enroll fallback and restore main's AppModeWatcher in the root layout. Co-authored-by: Rotimi Best <[email protected]> * refactor(org): rename auto-enroll-student endpoint to auto-join POST /organization/auto-join replaces /auto-enroll-student to clarify this joins org membership, not course enrollment. Renamed service to autoJoinOrg. Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
7eaff0040c
commit
8bd07a524c
@@ -1,9 +1,17 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
import { currentOrg, getOrgPublicUrl, mergeAccountOrgFromServer, orgs } from '$lib/utils/store/org';
|
||||
import {
|
||||
currentOrg,
|
||||
getAppOrigin,
|
||||
getOrgPublicUrl,
|
||||
isOrgManagerRole,
|
||||
mergeAccountOrgFromServer,
|
||||
orgs
|
||||
} from '$lib/utils/store/org';
|
||||
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 type { AccountResponse, AccountSuccess } from './types';
|
||||
import { type AppOrgParams } from './resolve-app-org-params';
|
||||
import type { PendingOrgInvite } from '$features/lms/utils/types';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import type { TUser } from '@cio/db/types';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
@@ -12,7 +20,7 @@ import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import { identifyPosthogUser } from '$lib/utils/services/posthog';
|
||||
import { identifyUserJotUser } from '$lib/utils/services/userjot';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { isOrgStudent, globalStore } from '$lib/utils/store/app';
|
||||
import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { licenseApi } from '$features/license/api/license.svelte';
|
||||
import { logout } from '$lib/utils/functions/logout';
|
||||
@@ -44,6 +52,8 @@ function tenantSyncKey(params: AppSetupParams): string | null {
|
||||
class AppInitApi extends BaseApi {
|
||||
data = $state<AccountResponse>(null);
|
||||
session = $state<App.Locals | null>(null);
|
||||
pendingOrgInvite = $state<PendingOrgInvite | null>(null);
|
||||
showPendingInviteModal = $state(false);
|
||||
private syncedTenantKey: string | null = null;
|
||||
|
||||
get loading() {
|
||||
@@ -51,57 +61,178 @@ class AppInitApi extends BaseApi {
|
||||
}
|
||||
|
||||
async setupApp(locals: App.Locals, params: AppSetupParams): Promise<boolean | undefined> {
|
||||
if (!locals.user) {
|
||||
if (!params.isOrgSite) {
|
||||
goto(resolve('/login', {}));
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('No user found in locals');
|
||||
if (this.isLoading || this.isInitializedAndReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.session = locals;
|
||||
this.isLoading = true;
|
||||
|
||||
// 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);
|
||||
}
|
||||
try {
|
||||
if (!locals.user) {
|
||||
if (!params.isOrgSite) {
|
||||
goto(resolve('/login', {}));
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.account.$get>({
|
||||
requestFn: () => classroomio.account.$get(),
|
||||
logContext: 'fetching account',
|
||||
onSuccess: (data) => {
|
||||
this.data = data;
|
||||
this.setupStores(params);
|
||||
licenseApi.syncFromAccount(data.licenseFeatures, get(currentOrg));
|
||||
setupAnalyticsBasedOnLicense(
|
||||
data.profile?.id ? { id: data.profile.id, email: data.profile.email, name: data.profile.fullname } : undefined
|
||||
);
|
||||
this.setUserAnalytics();
|
||||
this.routeUserToNextPage(params);
|
||||
},
|
||||
onError: () => {
|
||||
logout();
|
||||
console.log('No user found in locals');
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
this.session = locals;
|
||||
|
||||
const needsTenantMembership = params.isOrgSite && params.orgId && !this.isMemberOfOrg(locals, params.orgId);
|
||||
|
||||
if (needsTenantMembership && params.orgId) {
|
||||
const orgId = params.orgId;
|
||||
const [pendingInvite, accountData] = await Promise.all([
|
||||
this.fetchPendingOrgInvite(orgId),
|
||||
this.fetchAccountData()
|
||||
]);
|
||||
|
||||
if (!accountData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pendingInvite) {
|
||||
this.showPendingOrgInvite(pendingInvite);
|
||||
this.applyAccountData(accountData, params);
|
||||
return;
|
||||
}
|
||||
|
||||
const joined = await this.autoJoinOnTenantSite(orgId);
|
||||
if (joined) {
|
||||
const refreshedAccount = await this.fetchAccountData();
|
||||
if (refreshedAccount) {
|
||||
this.applyAccountData(refreshedAccount, params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.applyAccountData(accountData, params);
|
||||
return;
|
||||
}
|
||||
|
||||
const accountData = await this.fetchAccountData();
|
||||
if (accountData) {
|
||||
this.applyAccountData(accountData, params);
|
||||
}
|
||||
} finally {
|
||||
if (!this.isInitializedAndReady) {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async autoEnrollOnTenantSite(orgId: string): Promise<boolean> {
|
||||
/**
|
||||
* Whether the current session already reflects membership in `orgId`.
|
||||
* Better Auth's `customSession` attaches an `orgRoles` map ({ orgId: roleId })
|
||||
* that RBAC middlewares also use, so a present key means the user is a member.
|
||||
*/
|
||||
private isMemberOfOrg(locals: App.Locals, orgId: string): boolean {
|
||||
const orgRoles = (locals as { orgRoles?: Record<string, number> }).orgRoles;
|
||||
|
||||
return !!orgRoles && orgId in orgRoles;
|
||||
}
|
||||
|
||||
private applyAccountData(accountData: AccountSuccess, params: AppSetupParams) {
|
||||
this.resetErrors();
|
||||
this.success = true;
|
||||
this.data = accountData;
|
||||
this.setupStores(params);
|
||||
licenseApi.syncFromAccount(accountData.licenseFeatures, get(currentOrg));
|
||||
setupAnalyticsBasedOnLicense(
|
||||
accountData.profile?.id
|
||||
? { id: accountData.profile.id, email: accountData.profile.email, name: accountData.profile.fullname }
|
||||
: undefined
|
||||
);
|
||||
this.setUserAnalytics();
|
||||
this.routeUserToNextPage(params);
|
||||
}
|
||||
|
||||
private async fetchAccountData(): Promise<AccountSuccess | undefined> {
|
||||
try {
|
||||
const response = await classroomio.organization['auto-enroll-student'].$post(
|
||||
const response = await classroomio.account.$get();
|
||||
const result = (await response.json()) as AccountResponse;
|
||||
|
||||
if (!result?.success) {
|
||||
this.error = JSON.stringify(result);
|
||||
logout();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error fetching account:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Error fetching account';
|
||||
logout();
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private showPendingOrgInvite(pendingInvite: PendingOrgInvite) {
|
||||
this.pendingOrgInvite = pendingInvite;
|
||||
this.showPendingInviteModal = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* On tenant sites, link an existing roster row or auto-join the org as STUDENT —
|
||||
* unless the user has a pending org invite, in which case we surface accept UI.
|
||||
* Returns true when a new membership was created or an email-only row was linked.
|
||||
*/
|
||||
private async ensureTenantMembership(
|
||||
orgId: string,
|
||||
options?: { knownPendingInvite?: PendingOrgInvite | null }
|
||||
): Promise<boolean> {
|
||||
const pendingInvite =
|
||||
options && 'knownPendingInvite' in options
|
||||
? (options.knownPendingInvite ?? null)
|
||||
: await this.fetchPendingOrgInvite(orgId);
|
||||
|
||||
if (pendingInvite) {
|
||||
this.showPendingOrgInvite(pendingInvite);
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.autoJoinOnTenantSite(orgId);
|
||||
}
|
||||
|
||||
private async fetchPendingOrgInvite(orgId: string): Promise<PendingOrgInvite | null> {
|
||||
try {
|
||||
const response = await classroomio.invite.organization.pending.$get({}, { headers: { 'cio-org-id': orgId } });
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('pending org invite check failed', error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async autoJoinOnTenantSite(orgId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await classroomio.organization['auto-join'].$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
|
||||
// visited a tenant site but isn't allowed to join. Other failures
|
||||
// shouldn't block the rest of setupApp.
|
||||
console.warn('auto-enroll-student failed', response.status, await response.text().catch(() => ''));
|
||||
console.warn('auto-join failed', response.status, await response.text().catch(() => ''));
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data?.pendingInvite) {
|
||||
const pendingInvite = await this.fetchPendingOrgInvite(orgId);
|
||||
if (pendingInvite) {
|
||||
this.showPendingOrgInvite(pendingInvite);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -111,7 +242,7 @@ class AppInitApi extends BaseApi {
|
||||
await authClient.getSession({ query: { disableCookieCache: true } });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('auto-enroll-student threw', error);
|
||||
console.warn('auto-join threw', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -121,7 +252,7 @@ class AppInitApi extends BaseApi {
|
||||
*
|
||||
* 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.
|
||||
* aligned with the URL and attempts auto-join on org sites when needed.
|
||||
*/
|
||||
async syncOrgContext(params: AppSetupParams): Promise<void> {
|
||||
if (!this.data?.success || !params.orgSiteName) {
|
||||
@@ -135,11 +266,11 @@ class AppInitApi extends BaseApi {
|
||||
|
||||
const isMember = this.data.organizations.some((org) => org.siteName === params.orgSiteName);
|
||||
if (params.isOrgSite && params.orgId && !isMember) {
|
||||
const enrolled = await this.autoEnrollOnTenantSite(params.orgId);
|
||||
const pendingInvite = await this.fetchPendingOrgInvite(params.orgId);
|
||||
const enrolled = await this.ensureTenantMembership(params.orgId, { knownPendingInvite: pendingInvite });
|
||||
if (enrolled) {
|
||||
const response = await classroomio.account.$get();
|
||||
const accountData = (await response.json()) as AccountResponse;
|
||||
if (accountData?.success) {
|
||||
const accountData = await this.fetchAccountData();
|
||||
if (accountData) {
|
||||
this.data = accountData;
|
||||
}
|
||||
}
|
||||
@@ -229,8 +360,14 @@ class AppInitApi extends BaseApi {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const managedOrganizations = organizations.filter((org) => isOrgManagerRole(org.roleId));
|
||||
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
|
||||
return organizations.find((org) => org.siteName === lastOrgSiteName) ?? organizations[0];
|
||||
|
||||
return (
|
||||
managedOrganizations.find((org) => org.siteName === lastOrgSiteName) ??
|
||||
managedOrganizations[0] ??
|
||||
organizations[0]
|
||||
);
|
||||
}
|
||||
|
||||
routeUserToNextPage({ isOrgSite }: AppSetupParams) {
|
||||
@@ -341,10 +478,39 @@ class AppInitApi extends BaseApi {
|
||||
});
|
||||
}
|
||||
|
||||
async handlePendingInviteAccepted(redirectTo?: string): Promise<void> {
|
||||
this.showPendingInviteModal = false;
|
||||
this.pendingOrgInvite = null;
|
||||
|
||||
await authClient.getSession({ query: { disableCookieCache: true } });
|
||||
|
||||
const response = await classroomio.account.$get();
|
||||
const accountData = (await response.json()) as AccountResponse;
|
||||
if (accountData?.success) {
|
||||
this.data = accountData;
|
||||
this.setupStores();
|
||||
}
|
||||
|
||||
const isOnOrgSite = get(globalStore).isOrgSite;
|
||||
if (redirectTo?.startsWith('/org/') && isOnOrgSite) {
|
||||
window.location.href = `${getAppOrigin()}${redirectTo}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirectTo) {
|
||||
window.location.href = redirectTo;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
this.data = null;
|
||||
this.syncedTenantKey = null;
|
||||
this.pendingOrgInvite = null;
|
||||
this.showPendingInviteModal = false;
|
||||
licenseApi.reset();
|
||||
|
||||
user.set(defaultUserState);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type AppOrgParams = {
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
/** Tenant org id from `getOrgSiteInfo`; used to auto-enroll on org sites. */
|
||||
/** Tenant org id from `getOrgSiteInfo`; used to auto-join on org sites. */
|
||||
orgId?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
invite: PendingOrgInvite;
|
||||
onAccepted?: () => void;
|
||||
onAccepted?: (redirectTo?: string) => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), invite, onAccepted }: Props = $props();
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
snackbar.success('invite.organization.messages.joined');
|
||||
open = false;
|
||||
onAccepted?.();
|
||||
onAccepted?.(result.data.redirectTo);
|
||||
} catch (error) {
|
||||
console.error('Failed to accept organization invite by id', error);
|
||||
snackbar.error('invite.organization.messages.join_failed');
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { currentOrg, currentOrgPath, mergeAccountOrgFromServer, orgs } from '$lib/utils/store/org';
|
||||
import { currentOrg, currentOrgPath, managedOrgs, mergeAccountOrgFromServer } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import ComingSoon from '$features/ui/coming-soon.svelte';
|
||||
@@ -33,8 +33,9 @@
|
||||
|
||||
const accountRootId = $derived(rootIdFor($currentOrg));
|
||||
|
||||
const accountWorkspaces = $derived($orgs.filter((org) => rootIdFor(org) === accountRootId));
|
||||
const otherOrgs = $derived($orgs.filter((org) => rootIdFor(org) !== accountRootId));
|
||||
const switchableOrgs = $derived($managedOrgs);
|
||||
const accountWorkspaces = $derived(switchableOrgs.filter((org) => rootIdFor(org) === accountRootId));
|
||||
const otherOrgs = $derived(switchableOrgs.filter((org) => rootIdFor(org) !== accountRootId));
|
||||
|
||||
function onClick(org: AccountOrg) {
|
||||
if (org.id === $currentOrg.id) return;
|
||||
|
||||
Reference in New Issue
Block a user