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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-12 22:46:44 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 7eaff0040c
commit 8bd07a524c
15 changed files with 471 additions and 171 deletions
@@ -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 };
}
+30 -1
View File
@@ -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
});
});
});
+12 -1
View File
@@ -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;
+15
View File
@@ -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', {}));
}
});
+9
View File
@@ -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
View File
@@ -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';
}