feat: implement DASHBOARD_ORIGIN for invite and email links, enhancing self-hosted configuration and updating related documentation
This commit is contained in:
+2
-1
@@ -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)
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
logo: logoUrl,
|
||||
isPublished: $settings.isPublished,
|
||||
metadata: metadataPayload,
|
||||
slug: courseApi.course.slug!
|
||||
slug: courseApi.course.slug ?? undefined
|
||||
};
|
||||
|
||||
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
||||
|
||||
@@ -16,7 +16,6 @@ export const getSessionData = async (cookies: Cookies): Promise<App.Locals | nul
|
||||
|
||||
const locals = await getThroughAuthClient(cioCookies);
|
||||
|
||||
console.log('locals', locals);
|
||||
if (!locals) return null;
|
||||
|
||||
// This will always be true because if we don't have classroomio cookies, we won't be able to this line of code.
|
||||
|
||||
@@ -72,7 +72,7 @@ Docker self-hosting uses a single root `.env` file. Every `docker compose` comma
|
||||
| `PRIVATE_SERVER_KEY` | Dashboard-to-API auth key (same in both) | Auto-generated |
|
||||
| `PUBLIC_IS_SELFHOSTED` | Self-hosted mode flag | `true` |
|
||||
| `PRIVATE_SERVER_URL` | Internal API URL for SSR (Docker network) | `http://api:3081` |
|
||||
| `DASHBOARD_ORIGIN` | Dashboard URL for CSRF protection | `http://localhost:3082` |
|
||||
| `DASHBOARD_ORIGIN` | Dashboard origin for invite links, email links, etc. | `http://localhost:3082` |
|
||||
| `ALLOWED_EXTERNAL_DOMAINS` | CSP allowed domains (comma-separated) | — |
|
||||
|
||||
<Callout type="warn" title="CSP Domains">
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user