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
@@ -50,7 +50,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 { autoJoinOrg } from '@api/services/organization/auto-join';
|
||||
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
@@ -125,13 +125,13 @@ export const organizationRouter = new Hono()
|
||||
* 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 /organization/auto-join
|
||||
* Idempotently joins the authenticated user to 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) => {
|
||||
.post('/auto-join', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
@@ -140,10 +140,10 @@ export const organizationRouter = new Hono()
|
||||
return c.json({ success: false, error: 'Organization ID is required', code: 'ORG_ID_REQUIRED' }, 400);
|
||||
}
|
||||
|
||||
const result = await autoEnrollStudent(user.id, orgId);
|
||||
const result = await autoJoinOrg(user.id, orgId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to auto-enroll student');
|
||||
return handleError(c, error, 'Failed to auto-join organization');
|
||||
}
|
||||
})
|
||||
.get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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';
|
||||
import { assertStudentCapacityOrThrow } from './student-limit';
|
||||
|
||||
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 `<org>.myclassroomio.com` 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<AutoEnrollResult> {
|
||||
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 assertStudentCapacityOrThrow(orgId, 1);
|
||||
|
||||
await createOrganizationMember({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: profile.email,
|
||||
roleId: ROLE.STUDENT,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return { alreadyMember: false };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createOrganizationMember,
|
||||
getOrganizationById,
|
||||
getOrganizationMemberIdByOrgAndProfile
|
||||
} from '@cio/db/queries/organization';
|
||||
import {
|
||||
getActivePendingOrgInviteForEmail,
|
||||
selectOrganizationMemberByOrgAndNormalizedEmail,
|
||||
updateOrganizationMemberById
|
||||
} from '@cio/db/queries/organization/invite';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { assertStudentCapacityOrThrow } from './student-limit';
|
||||
|
||||
interface OrgSignupSettings {
|
||||
signup?: {
|
||||
inviteOnly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AutoJoinResult {
|
||||
alreadyMember: boolean;
|
||||
/** True when an email-only roster row was linked to the signing-in profile (session refresh needed). */
|
||||
linkedExistingMember: boolean;
|
||||
/** True when the user has an active org invite — caller should surface accept UI instead of joining. */
|
||||
pendingInvite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links a pre-existing org roster row (audience import, team invite, tutor add)
|
||||
* that was created with email but no profile_id yet. Preserves the existing role
|
||||
* (TUTOR, ADMIN, etc.) — does not downgrade to STUDENT.
|
||||
*/
|
||||
async function linkExistingMemberByEmail(
|
||||
userId: string,
|
||||
orgId: string,
|
||||
normalizedEmail: string
|
||||
): Promise<AutoJoinResult | null> {
|
||||
const existingMemberByEmail = await selectOrganizationMemberByOrgAndNormalizedEmail(db, orgId, normalizedEmail);
|
||||
|
||||
if (!existingMemberByEmail) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingMemberByEmail.profileId && existingMemberByEmail.profileId !== userId) {
|
||||
throw new AppError('Email is already linked to another profile', ErrorCodes.CONFLICT, 409);
|
||||
}
|
||||
|
||||
if (!existingMemberByEmail.profileId) {
|
||||
await updateOrganizationMemberById(db, existingMemberByEmail.id, {
|
||||
profileId: userId,
|
||||
email: normalizedEmail,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return { alreadyMember: true, linkedExistingMember: true };
|
||||
}
|
||||
|
||||
return { alreadyMember: true, linkedExistingMember: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-join the authenticated user to 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 `<org>.myclassroomio.com` or a verified BYOD domain).
|
||||
*
|
||||
* New members are created as STUDENT when no roster row or invite applies.
|
||||
* Idempotent: existing members get an `alreadyMember: true` no-op so invited
|
||||
* admins/tutors don't get downgraded.
|
||||
*
|
||||
* Pre-existing roster rows matched by email (e.g. tutors added with
|
||||
* `profile_id` null) are linked before signup policy checks — invite-only and
|
||||
* disableSignup only apply to net-new student signups.
|
||||
*/
|
||||
export async function autoJoinOrg(userId: string, orgId: string): Promise<AutoJoinResult> {
|
||||
const existingMemberId = await getOrganizationMemberIdByOrgAndProfile(orgId, userId);
|
||||
if (existingMemberId) {
|
||||
return { alreadyMember: true, linkedExistingMember: false };
|
||||
}
|
||||
|
||||
const profile = await getProfileById(userId);
|
||||
if (!profile?.email) {
|
||||
throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
const normalizedEmail = profile.email.toLowerCase().trim();
|
||||
|
||||
const pendingInvite = await getActivePendingOrgInviteForEmail(orgId, normalizedEmail);
|
||||
if (pendingInvite) {
|
||||
return { alreadyMember: false, linkedExistingMember: false, pendingInvite: true };
|
||||
}
|
||||
|
||||
const linked = await linkExistingMemberByEmail(userId, orgId, normalizedEmail);
|
||||
if (linked) {
|
||||
return linked;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
await assertStudentCapacityOrThrow(orgId, 1);
|
||||
|
||||
await createOrganizationMember({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: normalizedEmail,
|
||||
roleId: ROLE.STUDENT,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return { alreadyMember: false, linkedExistingMember: false };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { z, ZodError } from 'zod';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { ZCourseType } from '@cio/utils/validation/course';
|
||||
import { AppError, ErrorCodes, formatZodErrorMessage, handlePublicApiError } from '@api/utils/errors';
|
||||
import { AppError, ErrorCodes, formatZodErrorMessage, handleError, handlePublicApiError } from '@api/utils/errors';
|
||||
|
||||
describe('formatZodErrorMessage', () => {
|
||||
it('formats issue paths and messages', () => {
|
||||
@@ -65,3 +65,32 @@ describe('handlePublicApiError', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleError', () => {
|
||||
it('maps postgres unique violations to 409 CONFLICT', async () => {
|
||||
const app = new Hono().get('/test', async (c) => {
|
||||
try {
|
||||
throw {
|
||||
name: 'DrizzleQueryError',
|
||||
message: 'Failed query',
|
||||
cause: {
|
||||
code: '23505',
|
||||
constraint_name: 'organizationmember_org_email_unique'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to create member');
|
||||
}
|
||||
});
|
||||
|
||||
const response = await app.request('/test');
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: 'Resource already exists',
|
||||
code: ErrorCodes.CONFLICT
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Context } from 'hono';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { AppError, ErrorCodes, type ErrorCode } from '@cio/utils/errors';
|
||||
import { AppError, ErrorCodes, type ErrorCode, isUniqueConstraintViolation } from '@cio/utils/errors';
|
||||
|
||||
// AppError moved to `@cio/utils/errors` so it can be thrown from
|
||||
// non-api workspace packages (notably `@cio/core`). Re-exported
|
||||
@@ -81,6 +81,17 @@ export const handleError = (
|
||||
);
|
||||
}
|
||||
|
||||
if (isUniqueConstraintViolation(error)) {
|
||||
return c.json<ErrorResponse>(
|
||||
{
|
||||
success: false,
|
||||
error: 'Resource already exists',
|
||||
code: ErrorCodes.CONFLICT
|
||||
},
|
||||
409
|
||||
);
|
||||
}
|
||||
|
||||
return c.json<ErrorResponse>(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -67,6 +67,21 @@ export const isOrgAdmin = derived(currentOrg, ($currentOrg) => {
|
||||
|
||||
return $currentOrg.roleId === ROLE.ADMIN;
|
||||
});
|
||||
|
||||
/** True when the user can manage an org (admin dashboard), not a student-only member. */
|
||||
export function isOrgManagerRole(roleId: number): boolean {
|
||||
return roleId === ROLE.ADMIN || roleId === ROLE.TUTOR;
|
||||
}
|
||||
|
||||
/** Orgs where the user is ADMIN or TUTOR — eligible for the admin org switcher. */
|
||||
export const managedOrgs = derived(orgs, ($orgs) => $orgs.filter((org) => isOrgManagerRole(org.roleId)));
|
||||
|
||||
export const isOrgTeamMember = derived(currentOrg, ($currentOrg) => {
|
||||
if ($currentOrg.roleId === 0) return null;
|
||||
|
||||
return isOrgManagerRole($currentOrg.roleId);
|
||||
});
|
||||
|
||||
const getActivePlan = (org: AccountOrg) => {
|
||||
return org.plans.find((p) => p.isActive);
|
||||
};
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { DashboardPage } from '$features/lms/pages';
|
||||
import PendingInviteModal from '$features/lms/components/pending-invite-modal.svelte';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import type { PendingOrgInvite } from '$features/lms/utils/types';
|
||||
|
||||
let pendingInvite = $state<PendingOrgInvite | null>(null);
|
||||
let showInviteModal = $state(false);
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
const todayLabel = new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'long',
|
||||
@@ -41,26 +33,6 @@
|
||||
);
|
||||
|
||||
let progressPercentage = $derived(totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0);
|
||||
|
||||
onMount(async () => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
|
||||
try {
|
||||
const response = await classroomio.invite.organization.pending.$get();
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success && result.data) {
|
||||
pendingInvite = result.data;
|
||||
showInviteModal = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check pending org invite', error);
|
||||
}
|
||||
});
|
||||
|
||||
function handleInviteAccepted() {
|
||||
window.location.href = '/lms';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -91,7 +63,3 @@
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
{#if pendingInvite}
|
||||
<PendingInviteModal bind:open={showInviteModal} invite={pendingInvite} onAccepted={handleInviteAccepted} />
|
||||
{/if}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { appInitApi } from '$features/app/init.svelte';
|
||||
import { AppHeader } from '$features/ui';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
@@ -27,9 +27,9 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Decide only once the account/role has loaded — until then the student flag
|
||||
// defaults to false and a student is indistinguishable from an admin.
|
||||
if (appInitApi.isInitializedAndReady && $isStudentExperience) {
|
||||
// Students must not use the admin org dashboard on app.* — send them to LMS.
|
||||
// isStudentExperience is false on the app host in cloud mode even for students.
|
||||
if (appInitApi.isInitializedAndReady && $isOrgStudent) {
|
||||
goto(resolve('/lms', {}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { Snackbar } from '$features/ui';
|
||||
import { appInitApi } from '$features/app/init.svelte';
|
||||
import PendingInviteModal from '$features/lms/components/pending-invite-modal.svelte';
|
||||
import { resolveAppOrgParams } from '$features/app/resolve-app-org-params';
|
||||
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
@@ -109,6 +110,14 @@
|
||||
|
||||
<Snackbar />
|
||||
|
||||
{#if appInitApi.pendingOrgInvite}
|
||||
<PendingInviteModal
|
||||
bind:open={appInitApi.showPendingInviteModal}
|
||||
invite={appInitApi.pendingOrgInvite}
|
||||
onAccepted={(redirectTo) => appInitApi.handlePendingInviteAccepted(redirectTo)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ErrorCodes, type ErrorCode } from '../constants';
|
||||
|
||||
export { ErrorCodes, type ErrorCode };
|
||||
export { getPostgresError, isUniqueConstraintViolation, type PostgresErrorDetails } from './postgres-error';
|
||||
|
||||
/**
|
||||
* Standard HTTP status code value. Kept as a structural `number` so this
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type PostgresErrorDetails = {
|
||||
code: string;
|
||||
constraint?: string;
|
||||
detail?: string;
|
||||
table?: string;
|
||||
};
|
||||
|
||||
function readPostgresField(error: object, key: string): string | undefined {
|
||||
if (!(key in error)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = (error as Record<string, unknown>)[key];
|
||||
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks an error's `cause` chain and returns the first Postgres error payload
|
||||
* (postgres.js / Drizzle wrap the driver error as `cause`).
|
||||
*/
|
||||
export function getPostgresError(error: unknown): PostgresErrorDetails | null {
|
||||
let current: unknown = error;
|
||||
|
||||
while (current && typeof current === 'object') {
|
||||
const code = readPostgresField(current, 'code');
|
||||
|
||||
if (code && /^[0-9A-Z]{5}$/.test(code)) {
|
||||
return {
|
||||
code,
|
||||
constraint: readPostgresField(current, 'constraint_name'),
|
||||
detail: readPostgresField(current, 'detail'),
|
||||
table: readPostgresField(current, 'table_name')
|
||||
};
|
||||
}
|
||||
|
||||
current = 'cause' in current ? (current as { cause: unknown }).cause : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isUniqueConstraintViolation(error: unknown): boolean {
|
||||
return getPostgresError(error)?.code === '23505';
|
||||
}
|
||||
Reference in New Issue
Block a user