From 48ce4d4d6498c3dd6e9c0fedbf0916fbaea59fa2 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Sun, 7 Jun 2026 20:32:37 +0100 Subject: [PATCH] chore: update queue dashboard authentication and improve course component handling - Replaced email/token-based authentication with HTTP Basic Auth for the queue dashboard in production. - Updated course components to use courseId instead of courseSlug for better consistency. - Adjusted role-based security checks to utilize isStudentExperience for conditional rendering. - Enhanced view-as-student functionality to redirect correctly based on courseId. --- apps/api/src/routes/admin/queues.ts | 64 ++++++++----------- .../course/components/course-header.svelte | 1 + .../components/view-as-student-modal.svelte | 7 +- .../lib/features/course/pages/exercise.svelte | 6 +- .../lib/features/course/pages/lesson.svelte | 4 +- .../features/course/utils/course-preview.ts | 10 ++- .../features/ui/role-based-security.svelte | 2 +- packages/core/src/config/env.ts | 15 ++--- 8 files changed, 45 insertions(+), 64 deletions(-) diff --git a/apps/api/src/routes/admin/queues.ts b/apps/api/src/routes/admin/queues.ts index 0935bd35..f50afcbc 100644 --- a/apps/api/src/routes/admin/queues.ts +++ b/apps/api/src/routes/admin/queues.ts @@ -10,11 +10,10 @@ import { env } from '@cio/core/config/env'; * * Access control: * - Development: open (matches existing dev ergonomics). - * - Production: requires either - * - a Better Auth session whose `user.email` is in - * `QUEUE_DASHBOARD_ADMIN_EMAILS` (comma-separated), or - * - the `QUEUE_DASHBOARD_TOKEN` bearer escape hatch via header or `?token=`. - * Failures return `404` so the route doesn't reveal its existence. + * - Production: HTTP Basic Auth with `QUEUE_DASHBOARD_PASSWORD` (browser-native + * prompt, keeps the secret out of the URL/history). Any username is accepted; + * only the password is checked. Leave the var unset to keep the dashboard + * unmounted (and the route hidden) outside dev. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export function mountQueueDashboard(app: Hono): void { @@ -24,9 +23,9 @@ export function mountQueueDashboard(app: Hono): void { } const isProduction = env.NODE_ENV === 'production'; - if (isProduction && !canEnforceProdAuth()) { + if (isProduction && !env.QUEUE_DASHBOARD_PASSWORD) { console.info( - 'Queue dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_ADMIN_EMAILS or QUEUE_DASHBOARD_TOKEN to enable it.' + 'Queue dashboard (/admin/queues) not mounted in production. Set QUEUE_DASHBOARD_PASSWORD to enable it.' ); return; } @@ -46,47 +45,34 @@ export function mountQueueDashboard(app: Hono): void { ); } -function canEnforceProdAuth(): boolean { - return Boolean(adminEmailSet().size > 0 || env.QUEUE_DASHBOARD_TOKEN); -} - -function adminEmailSet(): Set { - const raw = env.QUEUE_DASHBOARD_ADMIN_EMAILS ?? ''; - return new Set( - raw - .split(',') - .map((email) => email.trim().toLowerCase()) - .filter(Boolean) - ); -} - async function queueDashboardAuth(c: Context, next: Next): Promise { - if (matchesEscapeHatch(c)) { + if (matchesBasicAuth(c)) { return next(); } - const user = c.get('user') as { email?: string | null } | null; - const email = user?.email?.toLowerCase(); - if (email && adminEmailSet().has(email)) { - return next(); - } - - // Hide existence rather than 401/403 — fewer hints for crawlers/scanners. - return c.notFound(); + return c.body(null, 401, { + 'WWW-Authenticate': 'Basic realm="ClassroomIO Queues", charset="UTF-8"' + }); } -function matchesEscapeHatch(c: Context): boolean { - const expected = env.QUEUE_DASHBOARD_TOKEN; +function matchesBasicAuth(c: Context): boolean { + const expected = env.QUEUE_DASHBOARD_PASSWORD; if (!expected) return false; - const headerToken = c.req - .header('Authorization') - ?.replace(/^Bearer\s+/i, '') - .trim(); - const queryToken = c.req.query('token'); - const provided = headerToken || queryToken; + const header = c.req.header('Authorization'); + if (!header?.startsWith('Basic ')) return false; - return Boolean(provided && timingSafeEqual(provided, expected)); + let decoded: string; + try { + decoded = Buffer.from(header.slice('Basic '.length).trim(), 'base64').toString('utf-8'); + } catch { + return false; + } + + // Credentials are `username:password`; the username is ignored. + const password = decoded.slice(decoded.indexOf(':') + 1); + + return timingSafeEqual(password, expected); } function timingSafeEqual(a: string, b: string): boolean { diff --git a/apps/dashboard/src/lib/features/course/components/course-header.svelte b/apps/dashboard/src/lib/features/course/components/course-header.svelte index 60fe1623..09561423 100644 --- a/apps/dashboard/src/lib/features/course/components/course-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-header.svelte @@ -80,6 +80,7 @@ diff --git a/apps/dashboard/src/lib/features/course/components/view-as-student-modal.svelte b/apps/dashboard/src/lib/features/course/components/view-as-student-modal.svelte index 1d69613d..d509665a 100644 --- a/apps/dashboard/src/lib/features/course/components/view-as-student-modal.svelte +++ b/apps/dashboard/src/lib/features/course/components/view-as-student-modal.svelte @@ -6,11 +6,12 @@ interface Props { open?: boolean; + courseId?: string | null; courseSlug?: string | null; currentOrgDomain?: string; } - let { open = $bindable(false), courseSlug = null, currentOrgDomain = '' }: Props = $props(); + let { open = $bindable(false), courseId = null, courseSlug = null, currentOrgDomain = '' }: Props = $props(); let isNavigating = $state(false); @@ -24,7 +25,7 @@ isNavigating = true; // Opens the student view in a new tab; close the modal once the handoff starts. - const ok = await viewAsStudent({ courseSlug, currentOrgDomain }); + const ok = await viewAsStudent({ courseId, currentOrgDomain }); isNavigating = false; if (ok) { @@ -59,7 +60,7 @@ - diff --git a/apps/dashboard/src/lib/features/course/pages/exercise.svelte b/apps/dashboard/src/lib/features/course/pages/exercise.svelte index 4e2cba99..1ff876c6 100644 --- a/apps/dashboard/src/lib/features/course/pages/exercise.svelte +++ b/apps/dashboard/src/lib/features/course/pages/exercise.svelte @@ -30,7 +30,7 @@ import { ZExerciseUpdate } from '@cio/utils/validation/exercise'; import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { transformQuestionsToApiFormat } from '$features/course/components/exercise/functions'; - import { isOrgStudent } from '$lib/utils/store/app'; + import { isOrgStudent, isStudentExperience } from '$lib/utils/store/app'; import { t } from '$lib/utils/functions/translations'; import { snackbar } from '$features/ui/snackbar/store'; import { exerciseApi } from '$features/course/api'; @@ -400,7 +400,7 @@
- {#if $isOrgStudent && courseApi.course?.id && exerciseId} + {#if $isStudentExperience && courseApi.course?.id && exerciseId} {/if} @@ -501,7 +501,7 @@ {#snippet child()}
- {#if $isOrgStudent} + {#if $isStudentExperience} {#if isExerciseUnlocked} {:else} diff --git a/apps/dashboard/src/lib/features/course/pages/lesson.svelte b/apps/dashboard/src/lib/features/course/pages/lesson.svelte index d9deee85..ad93e944 100644 --- a/apps/dashboard/src/lib/features/course/pages/lesson.svelte +++ b/apps/dashboard/src/lib/features/course/pages/lesson.svelte @@ -15,7 +15,7 @@ import MODES from '$lib/utils/constants/mode'; import { profile } from '$lib/utils/store/user'; - import { isOrgStudent } from '$lib/utils/store/app'; + import { isOrgStudent, isStudentExperience } from '$lib/utils/store/app'; import { currentOrg } from '$lib/utils/store/org'; import { snackbar } from '$features/ui/snackbar/store'; import { RefreshPageData, UnsavedChanges } from '$features/ui'; @@ -376,7 +376,7 @@
- {#if mode === MODES.view && $isOrgStudent} + {#if mode === MODES.view && $isStudentExperience} {/if} diff --git a/apps/dashboard/src/lib/features/course/utils/course-preview.ts b/apps/dashboard/src/lib/features/course/utils/course-preview.ts index 2bbb7e2a..a32a6f9e 100644 --- a/apps/dashboard/src/lib/features/course/utils/course-preview.ts +++ b/apps/dashboard/src/lib/features/course/utils/course-preview.ts @@ -10,7 +10,7 @@ interface OpenCoursePreviewOptions { } interface ViewAsStudentOptions { - courseSlug?: string | null; + courseId?: string | null; currentOrgDomain?: string; } @@ -57,14 +57,12 @@ export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' * dashboard's own `hooks.server.ts` proxies `/api/auth/*` to the API. Either way the * cookie lands on `currentOrgDomain` and the relative `redirect` resolves there. */ -export async function viewAsStudent({ courseSlug, currentOrgDomain = '' }: ViewAsStudentOptions) { +export async function viewAsStudent({ courseId, currentOrgDomain = '' }: ViewAsStudentOptions) { if (typeof window === 'undefined') { return false; } - if (!courseSlug) { - snackbar.info('course.header.preview_missing_slug'); - + if (!courseId) { return false; } @@ -78,7 +76,7 @@ export async function viewAsStudent({ courseSlug, currentOrgDomain = '' }: ViewA const origin = currentOrgDomain?.trim() || window.location.origin; const loginLinkUrl = new URL('/api/auth/login-link', origin); loginLinkUrl.searchParams.set('token', token); - loginLinkUrl.searchParams.set('redirect', `/course/${courseSlug}`); + loginLinkUrl.searchParams.set('redirect', `/courses/${courseId}/lessons?next=true`); // Cross-origin handoff — open in a new tab so the teacher keeps their dashboard. window.open(loginLinkUrl.toString(), '_blank', 'noopener,noreferrer'); diff --git a/apps/dashboard/src/lib/features/ui/role-based-security.svelte b/apps/dashboard/src/lib/features/ui/role-based-security.svelte index 6e21e3b4..a4315fe3 100644 --- a/apps/dashboard/src/lib/features/ui/role-based-security.svelte +++ b/apps/dashboard/src/lib/features/ui/role-based-security.svelte @@ -7,7 +7,7 @@ interface Props { allowedRoles?: number[]; - onDenied?: any; + onDenied?: () => void; // Since orgAdmin can see all, we need a way to show the content only to students onlyStudent?: boolean; children?: import('svelte').Snippet; diff --git a/packages/core/src/config/env.ts b/packages/core/src/config/env.ts index a59ac68f..b370dd57 100644 --- a/packages/core/src/config/env.ts +++ b/packages/core/src/config/env.ts @@ -52,17 +52,12 @@ const envSchema = z.object({ /** 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(), /** - * Comma-separated list of Better Auth user emails allowed to access the - * BullMQ dashboard at /admin/queues in production. Leave unset to keep the - * dashboard hidden everywhere except dev. + * Shared password for the /admin/queues HTTP Basic Auth prompt. When set, the + * dashboard mounts in production and challenges with `WWW-Authenticate: Basic`, + * accepting any username with this password — keeps the secret out of the + * URL/history. Leave unset to keep the dashboard hidden outside dev. */ - QUEUE_DASHBOARD_ADMIN_EMAILS: z.string().optional(), - /** - * Bearer-token escape hatch for /admin/queues — useful for curl scripts or - * when SSO is unavailable. When set, requests with `Authorization: Bearer - * ` (or `?token=`) bypass the email allowlist. - */ - QUEUE_DASHBOARD_TOKEN: z.string().optional(), + QUEUE_DASHBOARD_PASSWORD: z.string().optional(), /** Optional Bearer token for Jina Reader (higher rate limits). */ JINA_API_KEY: z.string().optional(), /** Max completed fetch_documentation_url calls per agent conversation (default 15). */