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,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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user