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:
@@ -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<any, any, any>): void {
|
||||
@@ -24,9 +23,9 @@ export function mountQueueDashboard(app: Hono<any, any, any>): 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<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> {
|
||||
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 {
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
|
||||
<ViewAsStudentModal
|
||||
bind:open={viewAsStudentOpen}
|
||||
courseId={courseApi.course?.id}
|
||||
courseSlug={courseApi.course?.slug}
|
||||
currentOrgDomain={$currentOrgDomain}
|
||||
/>
|
||||
|
||||
@@ -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 @@
|
||||
<Button variant="secondary" onclick={handleCopyLink} disabled={!courseSlug || isNavigating}>
|
||||
{$t('course.view_as_student.copy_link')}
|
||||
</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')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
|
||||
@@ -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 @@
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<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} />
|
||||
{/if}
|
||||
|
||||
@@ -501,7 +501,7 @@
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="overflow-x-hidden pb-20">
|
||||
{#if $isOrgStudent}
|
||||
{#if $isStudentExperience}
|
||||
{#if isExerciseUnlocked}
|
||||
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
|
||||
{:else}
|
||||
|
||||
@@ -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 @@
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if mode === MODES.view && $isOrgStudent}
|
||||
{#if mode === MODES.view && $isStudentExperience}
|
||||
<ContentNavigationActions {lessonId} {courseId} />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user