From fd11bdd4b08b3893db1e16faffba533e20b54cbf Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 11 Mar 2026 17:00:44 +0100 Subject: [PATCH] feat: implement DASHBOARD_ORIGIN for invite and email links, enhancing self-hosted configuration and updating related documentation --- .env.example | 3 ++- apps/api/src/config/dashboard-url.ts | 20 +++++++++++++++++++ apps/api/src/config/env.ts | 4 +++- apps/api/src/middlewares/org-admin.ts | 3 --- apps/api/src/middlewares/org-member.ts | 1 - apps/api/src/middlewares/org-team-member.ts | 2 -- apps/api/src/services/course/invite.ts | 14 +++---------- apps/api/src/services/course/people.ts | 4 ++-- apps/api/src/services/organization/invite.ts | 8 ++------ .../api/src/services/submission/submission.ts | 6 +++--- .../lib/features/course/pages/settings.svelte | 2 +- .../src/lib/utils/services/auth/session.ts | 1 - .../content/docs/quickstart/self-hosting.mdx | 5 ++++- docker/docker-compose.yaml | 1 + 14 files changed, 41 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/config/dashboard-url.ts diff --git a/.env.example b/.env.example index d8e4b10e..aa2f2dbe 100644 --- a/.env.example +++ b/.env.example @@ -37,8 +37,9 @@ PUBLIC_IS_SELFHOSTED=true # Local: http://api:3081 | Production: http://api:3081 (always internal) PRIVATE_SERVER_URL=http://api:3081 -# Dashboard origin for SvelteKit CSRF protection +# Dashboard origin for SvelteKit CSRF protection and invite/email links # Local: http://localhost:3082 | Production: https://app.yourdomain.com +# When set, invite emails and other links point here instead of app.classroomio.com DASHBOARD_ORIGIN=http://localhost:3082 # CSP: external domains allowed by Content-Security-Policy (required when self-hosted) diff --git a/apps/api/src/config/dashboard-url.ts b/apps/api/src/config/dashboard-url.ts new file mode 100644 index 00000000..93a117e8 --- /dev/null +++ b/apps/api/src/config/dashboard-url.ts @@ -0,0 +1,20 @@ +import { env } from './env'; + +/** + * Returns the base URL for the dashboard app, used for invite links, email links, etc. + * When DASHBOARD_ORIGIN is set (e.g. self-hosted), it takes precedence. + * Otherwise: dev → localhost:5173, prod → app.classroomio.com (or subdomain for cloud). + */ +export function getDashboardBaseUrl(orgSiteName?: string): string { + if (env.DASHBOARD_ORIGIN) { + return env.DASHBOARD_ORIGIN.replace(/\/$/, ''); + } + + if (env.NODE_ENV === 'development') { + return 'http://localhost:5173'; + } + + const subdomain = orgSiteName || 'app'; + + return `https://${subdomain}.classroomio.com`; +} diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index f2bbc7ea..9b0d4c4a 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -41,7 +41,9 @@ const envSchema = z.object({ /** When 'true', instance runs in self-hosted mode: single org, auto-enroll as student, no org switching */ PUBLIC_IS_SELFHOSTED: z.string().optional(), /** License key for enterprise features (SSO, token-auth, no-tracking, etc.) */ - LICENSE_KEY: z.string().optional() + LICENSE_KEY: z.string().optional(), + /** Dashboard origin for invite/email links (e.g. https://app.yourdomain.com). When set, all email links point here instead of app.classroomio.com. Required for self-hosted. */ + DASHBOARD_ORIGIN: z.string().optional() }); export const env = envSchema.parse(process.env); diff --git a/apps/api/src/middlewares/org-admin.ts b/apps/api/src/middlewares/org-admin.ts index 8754e017..6197c378 100644 --- a/apps/api/src/middlewares/org-admin.ts +++ b/apps/api/src/middlewares/org-admin.ts @@ -11,7 +11,6 @@ import { isUserOrgAdmin } from '@cio/db/queries/organization'; export const orgAdminMiddleware = async (c: Context, next: Next) => { try { const user = c.get('user'); - console.log('orgAdminMiddleware user', user); if (!user) { return c.json( { @@ -24,7 +23,6 @@ export const orgAdminMiddleware = async (c: Context, next: Next) => { } const orgId = c.req.header('cio-org-id'); - console.log('orgAdminMiddleware orgId', orgId); if (!orgId) { return c.json( @@ -38,7 +36,6 @@ export const orgAdminMiddleware = async (c: Context, next: Next) => { } const isAdmin = await isUserOrgAdmin(orgId, user.id); - console.log('orgAdminMiddleware isAdmin', isAdmin); if (!isAdmin) { return c.json( { diff --git a/apps/api/src/middlewares/org-member.ts b/apps/api/src/middlewares/org-member.ts index 930bc3a6..bb4dc28b 100644 --- a/apps/api/src/middlewares/org-member.ts +++ b/apps/api/src/middlewares/org-member.ts @@ -10,7 +10,6 @@ import { getUserOrgRole } from '@cio/db/queries/organization'; */ export const orgMemberMiddleware = async (c: Context, next: Next) => { try { - console.log('user', c.get('user')); const user = c.get('user'); if (!user) { return c.json( diff --git a/apps/api/src/middlewares/org-team-member.ts b/apps/api/src/middlewares/org-team-member.ts index dc5222ed..6366fde1 100644 --- a/apps/api/src/middlewares/org-team-member.ts +++ b/apps/api/src/middlewares/org-team-member.ts @@ -10,8 +10,6 @@ import { isUserOrgTeamMember } from '@cio/db/queries/organization'; */ export const orgTeamMemberMiddleware = async (c: Context, next: Next) => { try { - console.log('orgTeamMemberMiddleware user', c.get('user')); - const user = c.get('user'); if (!user) { return c.json( diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts index e41365b0..58886d09 100644 --- a/apps/api/src/services/course/invite.ts +++ b/apps/api/src/services/course/invite.ts @@ -19,7 +19,7 @@ import { ROLE } from '@cio/utils/constants'; import type { TCreateCourseInvite } from '@cio/utils/validation/course/invite'; import crypto from 'node:crypto'; import { db } from '@cio/db/drizzle'; -import { env } from '@api/config/env'; +import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getProfileById } from '@cio/db/queries/auth'; import { buildEmailFromName, sendEmail } from '@cio/email'; @@ -150,20 +150,12 @@ function getInviteStatus(invite: { return 'ACTIVE'; } -function getAppBaseUrl(orgSiteName?: string): string { - if (env.NODE_ENV === 'development') { - return 'http://localhost:5173'; - } - const subdomain = orgSiteName && orgSiteName !== 'app' ? orgSiteName : 'app'; - return `https://${subdomain}.classroomio.com`; -} - /** * Builds the course enroll URL for invite links (emails, etc.). - * Uses org subdomain in production. + * Uses DASHBOARD_URL when set (self-hosted), otherwise org subdomain in production. */ export function buildEnrollLink(courseSlug: string, token: string, orgSiteName?: string): string { - const base = getAppBaseUrl(orgSiteName); + const base = getDashboardBaseUrl(orgSiteName); return `${base}/course/${encodeURIComponent(courseSlug)}/enroll?invite_token=${encodeURIComponent(token)}`; } diff --git a/apps/api/src/services/course/people.ts b/apps/api/src/services/course/people.ts index 622380f3..ab848c74 100644 --- a/apps/api/src/services/course/people.ts +++ b/apps/api/src/services/course/people.ts @@ -11,7 +11,7 @@ import { import { ROLE } from '@cio/utils/constants'; import type { TAddCourseMembers } from '@cio/utils/validation/course/people'; import type { TGroupmember } from '@cio/db/types'; -import { env } from '@api/config/env'; +import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { getCourseWithOrgData } from '@cio/db/queries/course'; import { getProfileById } from '@cio/db/queries/auth'; import { buildEmailFromName, sendEmail } from '@cio/email'; @@ -176,7 +176,7 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) { const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member))); // Send welcome emails for teachers (TUTOR role = 2, ADMIN role = 1) - const baseUrl = env.NODE_ENV === 'development' ? 'http://localhost:5173' : 'https://app.classroomio.com'; + const baseUrl = getDashboardBaseUrl(orgSiteName); const inviteLink = `${baseUrl}/org/${orgSiteName}/courses`; // Send welcome emails to teachers diff --git a/apps/api/src/services/organization/invite.ts b/apps/api/src/services/organization/invite.ts index 6b5fdf03..39bc6f6a 100644 --- a/apps/api/src/services/organization/invite.ts +++ b/apps/api/src/services/organization/invite.ts @@ -10,7 +10,7 @@ import { revokeActiveOrganizationInvitesByEmails } from '@cio/db/queries/organization'; import { db } from '@cio/db/drizzle'; -import { env } from '@api/config/env'; +import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { sendEmail } from '@cio/email'; import { ROLE } from '@cio/utils/constants'; import * as schema from '@cio/db/schema'; @@ -42,12 +42,8 @@ function normalizeEmails(emails: string[]): string[] { return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))]; } -function getAppBaseUrl(): string { - return env.NODE_ENV === 'development' ? 'http://localhost:5173' : 'https://app.classroomio.com'; -} - function buildInviteLink(token: string): string { - return `${getAppBaseUrl()}/invite/t/${encodeURIComponent(token)}`; + return `${getDashboardBaseUrl()}/invite/t/${encodeURIComponent(token)}`; } function getRoleLabel(roleId: number): string { diff --git a/apps/api/src/services/submission/submission.ts b/apps/api/src/services/submission/submission.ts index b8cd44f4..a222c568 100644 --- a/apps/api/src/services/submission/submission.ts +++ b/apps/api/src/services/submission/submission.ts @@ -32,7 +32,7 @@ import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from import { QUESTION_TYPE_ID_TO_KEY } from '@cio/question-types'; import { db } from '@cio/db/drizzle'; -import { env } from '@api/config/env'; +import { getDashboardBaseUrl } from '@api/config/dashboard-url'; import { generateDocumentDownloadPresignedUrls } from '@api/utils/s3'; type SubmissionGradingState = 'queued' | 'processing' | 'awaiting_manual' | 'completed' | 'failed'; @@ -775,7 +775,7 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb const orgName = orgResult?.orgName || 'ClassroomIO'; const statusText = LEGACY_BOARD_STATUS_LABELS[newStatusId] || 'Updated'; - const baseUrl = env.NODE_ENV === 'development' ? 'http://localhost:5173' : 'https://app.classroomio.com'; + const baseUrl = getDashboardBaseUrl(); const exerciseLink = `${baseUrl}/courses/${fullSubmission.courseId}/exercises/${fullSubmission.exercise.id}`; // Calculate total and max points @@ -852,7 +852,7 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s const orgName = orgResult?.orgName || 'ClassroomIO'; - const baseUrl = env.NODE_ENV === 'development' ? 'http://localhost:5173' : 'https://app.classroomio.com'; + const baseUrl = getDashboardBaseUrl(); const exerciseLink = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`; const submissionLink = `${baseUrl}/courses/${courseId}/submissions`; diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte index ee945ed9..27e98764 100644 --- a/apps/dashboard/src/lib/features/course/pages/settings.svelte +++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte @@ -158,7 +158,7 @@ logo: logoUrl, isPublished: $settings.isPublished, metadata: metadataPayload, - slug: courseApi.course.slug! + slug: courseApi.course.slug ?? undefined }; const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds); diff --git a/apps/dashboard/src/lib/utils/services/auth/session.ts b/apps/dashboard/src/lib/utils/services/auth/session.ts index 1c17b640..aa1305b4 100644 --- a/apps/dashboard/src/lib/utils/services/auth/session.ts +++ b/apps/dashboard/src/lib/utils/services/auth/session.ts @@ -16,7 +16,6 @@ export const getSessionData = async (cookies: Cookies): Promise @@ -191,6 +191,9 @@ AUTH_BEARER_TOKEN="some-key" PRIVATE_SERVER_KEY="same-key-for-api-and-dashboard" PUBLIC_IS_SELFHOSTED="true" +# Dashboard URL for invite/email links (e.g. https://app.yourdomain.com) +DASHBOARD_ORIGIN="https://app.yourdomain.com" + # Required for auth cookies when API and dashboard use different subdomains — set to your root domain, e.g. yourdomain.com AUTH_COOKIE_DOMAIN="yourdomain.com" diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 487d79cf..9acb668f 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -133,6 +133,7 @@ services: OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL: ${OBJECT_STORAGE_MEDIA_PUBLIC_BASE_URL:-} AUTH_BEARER_TOKEN: ${AUTH_BEARER_TOKEN:-local-dev-api-key} PRIVATE_SERVER_KEY: ${PRIVATE_SERVER_KEY:-local-dev-api-key} + DASHBOARD_ORIGIN: ${DASHBOARD_ORIGIN:-} LICENSE_KEY: ${LICENSE_KEY:-} UNSPLASH_API_KEY: ${UNSPLASH_API_KEY:-} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-local-dev-only-secret-change-this}