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 { ROLE } from '@cio/utils/constants';
|
||||||
import { TOrganization } from '@db/types';
|
import { TOrganization } from '@db/types';
|
||||||
import { assetsRouter } from '@api/routes/organization/assets';
|
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 { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
||||||
import { authMiddleware } from '@api/middlewares/auth';
|
import { authMiddleware } from '@api/middlewares/auth';
|
||||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||||
@@ -125,13 +125,13 @@ export const organizationRouter = new Hono()
|
|||||||
* Requires authentication
|
* Requires authentication
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* POST /organization/auto-enroll-student
|
* POST /organization/auto-join
|
||||||
* Idempotently enrolls the authenticated user as a STUDENT in the org
|
* Idempotently joins the authenticated user to the org identified by the
|
||||||
* identified by the `cio-org-id` header. Called by the dashboard's
|
* `cio-org-id` header. Called by the dashboard's `setupApp` after a user
|
||||||
* `setupApp` after a user authenticates on a tenant site they aren't a
|
* authenticates on a tenant site they aren't a member of yet. Respects
|
||||||
* member of yet. Respects `disableSignup` and `inviteOnly` policies.
|
* `disableSignup` and `inviteOnly` policies.
|
||||||
*/
|
*/
|
||||||
.post('/auto-enroll-student', authMiddleware, async (c) => {
|
.post('/auto-join', authMiddleware, async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = c.get('user')!;
|
const user = c.get('user')!;
|
||||||
const orgId = c.req.header('cio-org-id');
|
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);
|
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);
|
return c.json({ success: true, data: result }, 200);
|
||||||
} catch (error) {
|
} 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) => {
|
.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 { Hono } from 'hono';
|
||||||
|
|
||||||
import { ZCourseType } from '@cio/utils/validation/course';
|
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', () => {
|
describe('formatZodErrorMessage', () => {
|
||||||
it('formats issue paths and messages', () => {
|
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 type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
import { ZodError } from 'zod';
|
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
|
// AppError moved to `@cio/utils/errors` so it can be thrown from
|
||||||
// non-api workspace packages (notably `@cio/core`). Re-exported
|
// 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>(
|
return c.json<ErrorResponse>(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
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 { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user';
|
||||||
|
|
||||||
import type { AccountResponse } from './types';
|
import type { AccountResponse, AccountSuccess } from './types';
|
||||||
import { resolveAppOrgParams, type AppOrgParams } from './resolve-app-org-params';
|
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 { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||||
import type { TUser } from '@cio/db/types';
|
import type { TUser } from '@cio/db/types';
|
||||||
import { authClient } from '$lib/utils/services/auth/client';
|
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 { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||||
import { identifyPosthogUser } from '$lib/utils/services/posthog';
|
import { identifyPosthogUser } from '$lib/utils/services/posthog';
|
||||||
import { identifyUserJotUser } from '$lib/utils/services/userjot';
|
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 { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||||
import { licenseApi } from '$features/license/api/license.svelte';
|
import { licenseApi } from '$features/license/api/license.svelte';
|
||||||
import { logout } from '$lib/utils/functions/logout';
|
import { logout } from '$lib/utils/functions/logout';
|
||||||
@@ -44,6 +52,8 @@ function tenantSyncKey(params: AppSetupParams): string | null {
|
|||||||
class AppInitApi extends BaseApi {
|
class AppInitApi extends BaseApi {
|
||||||
data = $state<AccountResponse>(null);
|
data = $state<AccountResponse>(null);
|
||||||
session = $state<App.Locals | null>(null);
|
session = $state<App.Locals | null>(null);
|
||||||
|
pendingOrgInvite = $state<PendingOrgInvite | null>(null);
|
||||||
|
showPendingInviteModal = $state(false);
|
||||||
private syncedTenantKey: string | null = null;
|
private syncedTenantKey: string | null = null;
|
||||||
|
|
||||||
get loading() {
|
get loading() {
|
||||||
@@ -51,57 +61,178 @@ class AppInitApi extends BaseApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setupApp(locals: App.Locals, params: AppSetupParams): Promise<boolean | undefined> {
|
async setupApp(locals: App.Locals, params: AppSetupParams): Promise<boolean | undefined> {
|
||||||
if (!locals.user) {
|
if (this.isLoading || this.isInitializedAndReady) {
|
||||||
if (!params.isOrgSite) {
|
|
||||||
goto(resolve('/login', {}));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('No user found in locals');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.session = locals;
|
this.isLoading = true;
|
||||||
|
|
||||||
// Auto-enroll on tenant sites for first-time signups. Idempotent on the
|
try {
|
||||||
// API side (no-ops for existing members so invited admins/tutors keep
|
if (!locals.user) {
|
||||||
// their roles). Runs BEFORE the account fetch so the returned org list
|
if (!params.isOrgSite) {
|
||||||
// already reflects the new membership.
|
goto(resolve('/login', {}));
|
||||||
if (params.isOrgSite && params.orgId) {
|
return false;
|
||||||
await this.autoEnrollOnTenantSite(params.orgId);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await this.execute<typeof classroomio.account.$get>({
|
console.log('No user found in locals');
|
||||||
requestFn: () => classroomio.account.$get(),
|
return;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
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 {
|
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 } }
|
{ headers: { 'cio-org-id': orgId } }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// 403 is expected for invite-only / disabled-signup orgs — the user
|
// 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.
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +242,7 @@ class AppInitApi extends BaseApi {
|
|||||||
await authClient.getSession({ query: { disableCookieCache: true } });
|
await authClient.getSession({ query: { disableCookieCache: true } });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('auto-enroll-student threw', error);
|
console.warn('auto-join threw', error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,7 +252,7 @@ class AppInitApi extends BaseApi {
|
|||||||
*
|
*
|
||||||
* setupApp only runs once per session; tenant subdomains and `/org/[slug]`
|
* setupApp only runs once per session; tenant subdomains and `/org/[slug]`
|
||||||
* routes can change without another /account fetch. This keeps `currentOrg`
|
* 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> {
|
async syncOrgContext(params: AppSetupParams): Promise<void> {
|
||||||
if (!this.data?.success || !params.orgSiteName) {
|
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);
|
const isMember = this.data.organizations.some((org) => org.siteName === params.orgSiteName);
|
||||||
if (params.isOrgSite && params.orgId && !isMember) {
|
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) {
|
if (enrolled) {
|
||||||
const response = await classroomio.account.$get();
|
const accountData = await this.fetchAccountData();
|
||||||
const accountData = (await response.json()) as AccountResponse;
|
if (accountData) {
|
||||||
if (accountData?.success) {
|
|
||||||
this.data = accountData;
|
this.data = accountData;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,8 +360,14 @@ class AppInitApi extends BaseApi {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const managedOrganizations = organizations.filter((org) => isOrgManagerRole(org.roleId));
|
||||||
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
|
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) {
|
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() {
|
reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this.syncedTenantKey = null;
|
this.syncedTenantKey = null;
|
||||||
|
this.pendingOrgInvite = null;
|
||||||
|
this.showPendingInviteModal = false;
|
||||||
licenseApi.reset();
|
licenseApi.reset();
|
||||||
|
|
||||||
user.set(defaultUserState);
|
user.set(defaultUserState);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type AppOrgParams = {
|
export type AppOrgParams = {
|
||||||
isOrgSite: boolean;
|
isOrgSite: boolean;
|
||||||
orgSiteName: string;
|
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;
|
orgId?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
invite: PendingOrgInvite;
|
invite: PendingOrgInvite;
|
||||||
onAccepted?: () => void;
|
onAccepted?: (redirectTo?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open = $bindable(false), invite, onAccepted }: Props = $props();
|
let { open = $bindable(false), invite, onAccepted }: Props = $props();
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
snackbar.success('invite.organization.messages.joined');
|
snackbar.success('invite.organization.messages.joined');
|
||||||
open = false;
|
open = false;
|
||||||
onAccepted?.();
|
onAccepted?.(result.data.redirectTo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to accept organization invite by id', error);
|
console.error('Failed to accept organization invite by id', error);
|
||||||
snackbar.error('invite.organization.messages.join_failed');
|
snackbar.error('invite.organization.messages.join_failed');
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import type { AccountOrg } from '$features/app/types';
|
import type { AccountOrg } from '$features/app/types';
|
||||||
|
|
||||||
import { setTheme } from '$lib/utils/functions/theme';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
import ComingSoon from '$features/ui/coming-soon.svelte';
|
import ComingSoon from '$features/ui/coming-soon.svelte';
|
||||||
@@ -33,8 +33,9 @@
|
|||||||
|
|
||||||
const accountRootId = $derived(rootIdFor($currentOrg));
|
const accountRootId = $derived(rootIdFor($currentOrg));
|
||||||
|
|
||||||
const accountWorkspaces = $derived($orgs.filter((org) => rootIdFor(org) === accountRootId));
|
const switchableOrgs = $derived($managedOrgs);
|
||||||
const otherOrgs = $derived($orgs.filter((org) => rootIdFor(org) !== accountRootId));
|
const accountWorkspaces = $derived(switchableOrgs.filter((org) => rootIdFor(org) === accountRootId));
|
||||||
|
const otherOrgs = $derived(switchableOrgs.filter((org) => rootIdFor(org) !== accountRootId));
|
||||||
|
|
||||||
function onClick(org: AccountOrg) {
|
function onClick(org: AccountOrg) {
|
||||||
if (org.id === $currentOrg.id) return;
|
if (org.id === $currentOrg.id) return;
|
||||||
|
|||||||
@@ -67,6 +67,21 @@ export const isOrgAdmin = derived(currentOrg, ($currentOrg) => {
|
|||||||
|
|
||||||
return $currentOrg.roleId === ROLE.ADMIN;
|
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) => {
|
const getActivePlan = (org: AccountOrg) => {
|
||||||
return org.plans.find((p) => p.isActive);
|
return org.plans.find((p) => p.isActive);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { DashboardPage } from '$features/lms/pages';
|
import { DashboardPage } from '$features/lms/pages';
|
||||||
import PendingInviteModal from '$features/lms/components/pending-invite-modal.svelte';
|
|
||||||
import { getGreeting } from '$lib/utils/functions/date';
|
import { getGreeting } from '$lib/utils/functions/date';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { profile } from '$lib/utils/store/user';
|
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 { coursesApi } from '$features/course/api';
|
||||||
import type { PendingOrgInvite } from '$features/lms/utils/types';
|
import * as Page from '@cio/ui/base/page';
|
||||||
|
|
||||||
let pendingInvite = $state<PendingOrgInvite | null>(null);
|
|
||||||
let showInviteModal = $state(false);
|
|
||||||
|
|
||||||
const todayLabel = new Intl.DateTimeFormat('en-US', {
|
const todayLabel = new Intl.DateTimeFormat('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
@@ -41,26 +33,6 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
let progressPercentage = $derived(totalLessons > 0 ? Math.round((totalCompleted / totalLessons) * 100) : 0);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -91,7 +63,3 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</Page.Body>
|
</Page.Body>
|
||||||
</Page.Root>
|
</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 * as Sidebar from '@cio/ui/base/sidebar';
|
||||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
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 { appInitApi } from '$features/app/init.svelte';
|
||||||
import { AppHeader } from '$features/ui';
|
import { AppHeader } from '$features/ui';
|
||||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Decide only once the account/role has loaded — until then the student flag
|
// Students must not use the admin org dashboard on app.* — send them to LMS.
|
||||||
// defaults to false and a student is indistinguishable from an admin.
|
// isStudentExperience is false on the app host in cloud mode even for students.
|
||||||
if (appInitApi.isInitializedAndReady && $isStudentExperience) {
|
if (appInitApi.isInitializedAndReady && $isOrgStudent) {
|
||||||
goto(resolve('/lms', {}));
|
goto(resolve('/lms', {}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { Snackbar } from '$features/ui';
|
import { Snackbar } from '$features/ui';
|
||||||
import { appInitApi } from '$features/app/init.svelte';
|
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 { resolveAppOrgParams } from '$features/app/resolve-app-org-params';
|
||||||
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
|
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
|
||||||
import { globalStore } from '$lib/utils/store/app';
|
import { globalStore } from '$lib/utils/store/app';
|
||||||
@@ -109,6 +110,14 @@
|
|||||||
|
|
||||||
<Snackbar />
|
<Snackbar />
|
||||||
|
|
||||||
|
{#if appInitApi.pendingOrgInvite}
|
||||||
|
<PendingInviteModal
|
||||||
|
bind:open={appInitApi.showPendingInviteModal}
|
||||||
|
invite={appInitApi.pendingOrgInvite}
|
||||||
|
onAccepted={(redirectTo) => appInitApi.handlePendingInviteAccepted(redirectTo)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ErrorCodes, type ErrorCode } from '../constants';
|
import { ErrorCodes, type ErrorCode } from '../constants';
|
||||||
|
|
||||||
export { ErrorCodes, type ErrorCode };
|
export { ErrorCodes, type ErrorCode };
|
||||||
|
export { getPostgresError, isUniqueConstraintViolation, type PostgresErrorDetails } from './postgres-error';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Standard HTTP status code value. Kept as a structural `number` so this
|
* 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