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.
This commit is contained in:
Rotimi Best
2026-06-07 20:32:37 +01:00
parent 3561dc0fda
commit 48ce4d4d64
8 changed files with 45 additions and 64 deletions
+25 -39
View File
@@ -10,11 +10,10 @@ import { env } from '@cio/core/config/env';
* *
* Access control: * Access control:
* - Development: open (matches existing dev ergonomics). * - Development: open (matches existing dev ergonomics).
* - Production: requires either * - Production: HTTP Basic Auth with `QUEUE_DASHBOARD_PASSWORD` (browser-native
* - a Better Auth session whose `user.email` is in * prompt, keeps the secret out of the URL/history). Any username is accepted;
* `QUEUE_DASHBOARD_ADMIN_EMAILS` (comma-separated), or * only the password is checked. Leave the var unset to keep the dashboard
* - the `QUEUE_DASHBOARD_TOKEN` bearer escape hatch via header or `?token=`. * unmounted (and the route hidden) outside dev.
* Failures return `404` so the route doesn't reveal its existence.
*/ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mountQueueDashboard(app: Hono<any, any, any>): void { export function mountQueueDashboard(app: Hono<any, any, any>): void {
@@ -24,9 +23,9 @@ export function mountQueueDashboard(app: Hono<any, any, any>): void {
} }
const isProduction = env.NODE_ENV === 'production'; const isProduction = env.NODE_ENV === 'production';
if (isProduction && !canEnforceProdAuth()) { if (isProduction && !env.QUEUE_DASHBOARD_PASSWORD) {
console.info( 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; return;
} }
@@ -46,47 +45,34 @@ export function mountQueueDashboard(app: Hono<any, any, any>): void {
); );
} }
function canEnforceProdAuth(): boolean {
return Boolean(adminEmailSet().size > 0 || env.QUEUE_DASHBOARD_TOKEN);
}
function adminEmailSet(): Set<string> {
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<Response | void> { async function queueDashboardAuth(c: Context, next: Next): Promise<Response | void> {
if (matchesEscapeHatch(c)) { if (matchesBasicAuth(c)) {
return next(); return next();
} }
const user = c.get('user') as { email?: string | null } | null; return c.body(null, 401, {
const email = user?.email?.toLowerCase(); 'WWW-Authenticate': 'Basic realm="ClassroomIO Queues", charset="UTF-8"'
if (email && adminEmailSet().has(email)) { });
return next();
}
// Hide existence rather than 401/403 — fewer hints for crawlers/scanners.
return c.notFound();
} }
function matchesEscapeHatch(c: Context): boolean { function matchesBasicAuth(c: Context): boolean {
const expected = env.QUEUE_DASHBOARD_TOKEN; const expected = env.QUEUE_DASHBOARD_PASSWORD;
if (!expected) return false; if (!expected) return false;
const headerToken = c.req const header = c.req.header('Authorization');
.header('Authorization') if (!header?.startsWith('Basic ')) return false;
?.replace(/^Bearer\s+/i, '')
.trim();
const queryToken = c.req.query('token');
const provided = headerToken || queryToken;
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 { function timingSafeEqual(a: string, b: string): boolean {
@@ -80,6 +80,7 @@
<ViewAsStudentModal <ViewAsStudentModal
bind:open={viewAsStudentOpen} bind:open={viewAsStudentOpen}
courseId={courseApi.course?.id}
courseSlug={courseApi.course?.slug} courseSlug={courseApi.course?.slug}
currentOrgDomain={$currentOrgDomain} currentOrgDomain={$currentOrgDomain}
/> />
@@ -6,11 +6,12 @@
interface Props { interface Props {
open?: boolean; open?: boolean;
courseId?: string | null;
courseSlug?: string | null; courseSlug?: string | null;
currentOrgDomain?: string; 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); let isNavigating = $state(false);
@@ -24,7 +25,7 @@
isNavigating = true; isNavigating = true;
// Opens the student view in a new tab; close the modal once the handoff starts. // 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; isNavigating = false;
if (ok) { if (ok) {
@@ -59,7 +60,7 @@
<Button variant="secondary" onclick={handleCopyLink} disabled={!courseSlug || isNavigating}> <Button variant="secondary" onclick={handleCopyLink} disabled={!courseSlug || isNavigating}>
{$t('course.view_as_student.copy_link')} {$t('course.view_as_student.copy_link')}
</Button> </Button>
<Button variant="default" onclick={handleGoToLms} disabled={!courseSlug} loading={isNavigating}> <Button variant="default" onclick={handleGoToLms} disabled={!courseId} loading={isNavigating}>
{$t('course.view_as_student.go_to_lms')} {$t('course.view_as_student.go_to_lms')}
</Button> </Button>
</Dialog.Footer> </Dialog.Footer>
@@ -30,7 +30,7 @@
import { ZExerciseUpdate } from '@cio/utils/validation/exercise'; import { ZExerciseUpdate } from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { transformQuestionsToApiFormat } from '$features/course/components/exercise/functions'; 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 { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { exerciseApi } from '$features/course/api'; import { exerciseApi } from '$features/course/api';
@@ -400,7 +400,7 @@
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if $isOrgStudent && courseApi.course?.id && exerciseId} {#if $isStudentExperience && courseApi.course?.id && exerciseId}
<ContentNavigationActions courseId={courseApi.course.id} {exerciseId} /> <ContentNavigationActions courseId={courseApi.course.id} {exerciseId} />
{/if} {/if}
@@ -501,7 +501,7 @@
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
<div class="overflow-x-hidden pb-20"> <div class="overflow-x-hidden pb-20">
{#if $isOrgStudent} {#if $isStudentExperience}
{#if isExerciseUnlocked} {#if isExerciseUnlocked}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} /> <ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{:else} {:else}
@@ -15,7 +15,7 @@
import MODES from '$lib/utils/constants/mode'; import MODES from '$lib/utils/constants/mode';
import { profile } from '$lib/utils/store/user'; 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 { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData, UnsavedChanges } from '$features/ui'; import { RefreshPageData, UnsavedChanges } from '$features/ui';
@@ -376,7 +376,7 @@
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if mode === MODES.view && $isOrgStudent} {#if mode === MODES.view && $isStudentExperience}
<ContentNavigationActions {lessonId} {courseId} /> <ContentNavigationActions {lessonId} {courseId} />
{/if} {/if}
@@ -10,7 +10,7 @@ interface OpenCoursePreviewOptions {
} }
interface ViewAsStudentOptions { interface ViewAsStudentOptions {
courseSlug?: string | null; courseId?: string | null;
currentOrgDomain?: string; 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 * 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. * 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') { if (typeof window === 'undefined') {
return false; return false;
} }
if (!courseSlug) { if (!courseId) {
snackbar.info('course.header.preview_missing_slug');
return false; return false;
} }
@@ -78,7 +76,7 @@ export async function viewAsStudent({ courseSlug, currentOrgDomain = '' }: ViewA
const origin = currentOrgDomain?.trim() || window.location.origin; const origin = currentOrgDomain?.trim() || window.location.origin;
const loginLinkUrl = new URL('/api/auth/login-link', origin); const loginLinkUrl = new URL('/api/auth/login-link', origin);
loginLinkUrl.searchParams.set('token', token); 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. // Cross-origin handoff — open in a new tab so the teacher keeps their dashboard.
window.open(loginLinkUrl.toString(), '_blank', 'noopener,noreferrer'); window.open(loginLinkUrl.toString(), '_blank', 'noopener,noreferrer');
@@ -7,7 +7,7 @@
interface Props { interface Props {
allowedRoles?: number[]; allowedRoles?: number[];
onDenied?: any; onDenied?: () => void;
// Since orgAdmin can see all, we need a way to show the content only to students // Since orgAdmin can see all, we need a way to show the content only to students
onlyStudent?: boolean; onlyStudent?: boolean;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
+5 -10
View File
@@ -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 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(), DASHBOARD_ORIGIN: z.string().optional(),
/** /**
* Comma-separated list of Better Auth user emails allowed to access the * Shared password for the /admin/queues HTTP Basic Auth prompt. When set, the
* BullMQ dashboard at /admin/queues in production. Leave unset to keep the * dashboard mounts in production and challenges with `WWW-Authenticate: Basic`,
* dashboard hidden everywhere except dev. * 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(), QUEUE_DASHBOARD_PASSWORD: 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
* <token>` (or `?token=<token>`) bypass the email allowlist.
*/
QUEUE_DASHBOARD_TOKEN: z.string().optional(),
/** Optional Bearer token for Jina Reader (higher rate limits). */ /** Optional Bearer token for Jina Reader (higher rate limits). */
JINA_API_KEY: z.string().optional(), JINA_API_KEY: z.string().optional(),
/** Max completed fetch_documentation_url calls per agent conversation (default 15). */ /** Max completed fetch_documentation_url calls per agent conversation (default 15). */