Merge branch 'main' of https://github.com/classroomio/classroomio into feat/release-v2

This commit is contained in:
Rotimi Best
2025-12-24 04:18:39 +00:00
193 changed files with 10584 additions and 6399 deletions
@@ -30,17 +30,18 @@
isLoading = true;
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/course', {
method: 'POST',
const response = await fetch(`/api/courses/analytics?courseId=${courseId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId })
}
});
if (response.ok) {
courseAnalytics = (await response.json()) as CourseAnalytics;
const { data } = await response.json();
courseAnalytics = data as CourseAnalytics;
} else {
console.error('Failed to fetch course analytics:', response);
snackbar.error('Failed to load analytics data');
@@ -133,4 +134,3 @@
className="h-screen"
/>
{/if}
@@ -1,10 +1,4 @@
<script lang="ts">
interface Props {
courseId: string;
}
let { courseId }: Props = $props();
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Input } from '@cio/ui/base/input';
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { snackbar } from '$features/ui/snackbar/store';
@@ -8,6 +8,9 @@
import { authClient } from '$lib/utils/services/auth/client';
import { globalStore } from '$lib/utils/store/app';
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { setupDOMProtection, updateBodyClass } from '../utils/dom-protection';
const WAIT_SEC = 120; // 2 minutes
const WAIT_TIME = WAIT_SEC * 1000;
@@ -21,6 +24,8 @@
const open = $derived(Boolean(!$profile.isEmailVerified && !!$profile.id && !!$currentOrg.id));
let domProtectionCleanup: ReturnType<typeof setupDOMProtection> | undefined;
const sendVerificationCode = async () => {
if (!$profile.email) {
snackbar.error('verify_email_modal.snackbar.error');
@@ -63,6 +68,32 @@
}, WAIT_TIME);
};
onMount(() => {
if (browser && $profile.id) {
domProtectionCleanup = setupDOMProtection(() => $profile.isEmailVerified ?? false);
}
});
onDestroy(() => {
if (interval) clearInterval(interval);
if (domProtectionCleanup) {
domProtectionCleanup.cleanup();
}
});
$effect(() => {
if (browser) updateBodyClass($profile.isEmailVerified ?? false);
if (browser && !$profile.isEmailVerified && $profile.id) {
console.warn(
'%c🔒 SECURITY WARNING: Email verification required',
'color: red; font-weight: bold; font-size: 16px;',
'\nAll user interactions are disabled until email verification is completed.',
'\nAttempting to bypass this protection is monitored and logged.'
);
}
});
$effect(() => {
if (!open || isSent || loading || autoTriggered) return;
@@ -78,30 +109,30 @@
<Dialog.Title>{$t('verify_email_modal.heading')}</Dialog.Title>
</Dialog.Header>
<div class="flex flex-col items-center space-y-6 text-center">
<img src="/verify-email.svg" alt="email verification" />
<p class="text-xl">{$t('verify_email_modal.heading')}</p>
<p class="w-[70%] text-sm text-gray-700 dark:text-gray-200">
{$t('verify_email_modal.sent_verification')}
{$profile.email}
{$t('verify_email_modal.to_confirm')}
</p>
<img src="/verify-email.svg" alt="email verification" />
<p class="text-xl">{$t('verify_email_modal.heading')}</p>
<p class="w-[70%] text-sm text-gray-700 dark:text-gray-200">
{$t('verify_email_modal.sent_verification')}
{$profile.email}
{$t('verify_email_modal.to_confirm')}
</p>
<div class="flex flex-col items-center">
<Button disabled={loading || isSent} onclick={sendVerificationCode}>
{#if loading}
{$t('verify_email_modal.loading')}
{:else}
{$t('verify_email_modal.resend')}
<div class="flex flex-col items-center">
<Button disabled={loading || isSent} onclick={sendVerificationCode}>
{#if loading}
{$t('verify_email_modal.loading')}
{:else}
{$t('verify_email_modal.resend')}
{/if}
</Button>
{#if isSent}
<p class="text-xs text-gray-700">
{$t('verify_email_modal.resend_in')}
{countDown}
{$t('verify_email_modal.seconds')}
</p>
{/if}
</Button>
{#if isSent}
<p class="text-xs text-gray-700">
{$t('verify_email_modal.resend_in')}
{countDown}
{$t('verify_email_modal.seconds')}
</p>
{/if}
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,254 @@
import type { Readable } from 'svelte/store';
import { browser } from '$app/environment';
export interface DOMProtectionCleanup {
protectionInterval?: ReturnType<typeof setInterval>;
domObserver?: MutationObserver;
cleanup: () => void;
}
/**
* Sets up DOM protection to disable all interactions when email is not verified.
* Returns cleanup functions to properly dispose of intervals and observers.
*
* @param isEmailVerified - A readable store or getter function that returns whether email is verified
* @returns Cleanup object with intervals, observers, and a cleanup function
*/
export function setupDOMProtection(
isEmailVerified: Readable<boolean> | (() => boolean | null)
): DOMProtectionCleanup {
if (!browser) {
return {
cleanup: () => {}
};
}
let protectionInterval: ReturnType<typeof setInterval> | undefined;
let domObserver: MutationObserver | undefined;
// Helper to get current verification status
const getIsVerified = () => {
if (typeof isEmailVerified === 'function') {
return isEmailVerified() ?? false;
}
// For stores, we need to subscribe temporarily - this is a limitation
// In practice, the component should pass a getter function
let value = false;
const unsubscribe = isEmailVerified.subscribe((v) => {
value = v ?? false;
});
unsubscribe();
return value;
};
// Function to disable all interactive elements
function disableInteractions() {
if (getIsVerified()) return;
// Disable all buttons except verification-related ones
const buttons = document.querySelectorAll('button, [role="button"], a[href], input[type="submit"]');
buttons.forEach((button) => {
const el = button as HTMLElement;
// Skip if already protected or is verification-related
if (
el.dataset.verificationProtected === 'true' ||
el.closest('[data-verification-modal]') ||
el.textContent?.toLowerCase().includes('verify') ||
el.textContent?.toLowerCase().includes('resend')
) {
return;
}
el.dataset.verificationProtected = 'true';
el.dataset.originalPointerEvents = el.style.pointerEvents || 'auto';
el.style.pointerEvents = 'none';
el.style.opacity = '0.5';
el.style.cursor = 'not-allowed';
el.setAttribute('title', 'Email verification required');
// Add click prevention
el.addEventListener('click', preventInteraction, true);
el.addEventListener('mousedown', preventInteraction, true);
});
// Disable all form inputs
const inputs = document.querySelectorAll('input, textarea, select');
inputs.forEach((input) => {
const el = input as HTMLInputElement;
// Skip if in verification modal
if (el.closest('[data-verification-modal]')) return;
if (!el.dataset.verificationProtected) {
el.dataset.verificationProtected = 'true';
el.dataset.originalDisabled = el.disabled ? 'true' : 'false';
el.disabled = true;
el.style.opacity = '0.5';
el.addEventListener('focus', preventInteraction, true);
el.addEventListener('input', preventInteraction, true);
}
});
// Disable contenteditable elements
const editables = document.querySelectorAll('[contenteditable="true"]');
editables.forEach((el) => {
const element = el as HTMLElement;
if (!element.dataset.verificationProtected) {
element.dataset.verificationProtected = 'true';
element.contentEditable = 'false';
element.style.opacity = '0.5';
}
});
}
function preventInteraction(event: Event) {
if (!getIsVerified()) {
event.preventDefault();
event.stopImmediatePropagation();
// Show warning
if (event.target) {
const target = event.target as HTMLElement;
target.setAttribute('title', 'Please verify your email to use this feature');
}
// Optional: Show alert
console.warn('🔒 Email verification required to interact with this element');
return false;
}
}
// Function to restore interactions when verified
function enableInteractions() {
const protectedElements = document.querySelectorAll('[data-verification-protected="true"]');
protectedElements.forEach((el) => {
const element = el as HTMLElement;
// Restore styles
if (element.dataset.originalPointerEvents) {
element.style.pointerEvents = element.dataset.originalPointerEvents;
} else {
element.style.pointerEvents = '';
}
element.style.opacity = '';
element.style.cursor = '';
element.removeAttribute('title');
// Restore functionality for inputs
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' || element.tagName === 'SELECT') {
const input = element as HTMLInputElement;
input.disabled = input.dataset.originalDisabled === 'true';
}
// Restore contenteditable
if (element.hasAttribute('contenteditable')) {
element.contentEditable = 'true';
}
// Remove event listeners
element.removeEventListener('click', preventInteraction, true);
element.removeEventListener('mousedown', preventInteraction, true);
element.removeEventListener('focus', preventInteraction, true);
element.removeEventListener('input', preventInteraction, true);
// Clean up data attributes
delete element.dataset.verificationProtected;
delete element.dataset.originalPointerEvents;
delete element.dataset.originalDisabled;
});
}
// Set up mutation observer to protect new elements
if (window.MutationObserver) {
domObserver = new MutationObserver((mutations) => {
let shouldReapply = false;
mutations.forEach((mutation) => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
shouldReapply = true;
}
});
if (shouldReapply && !getIsVerified()) {
setTimeout(disableInteractions, 100);
}
});
domObserver.observe(document.body, {
childList: true,
subtree: true
});
}
// Run protection every second
protectionInterval = setInterval(() => {
if (getIsVerified()) {
enableInteractions();
cleanup();
} else {
disableInteractions();
}
}, 1000);
// Initial protection
if (!getIsVerified()) {
disableInteractions();
}
// Add global CSS to prevent any missed interactions
if (document) {
const style = document.createElement('style');
style.id = 'email-verification-protection';
style.textContent = `
body:not(.email-verified) button:not([data-verification-modal] button),
body:not(.email-verified) a:not([data-verification-modal] a),
body:not(.email-verified) input:not([data-verification-modal] input),
body:not(.email-verified) textarea:not([data-verification-modal] textarea),
body:not(.email-verified) [contenteditable="true"]:not([data-verification-modal] [contenteditable]) {
pointer-events: none !important;
opacity: 0.5 !important;
user-select: none !important;
}
`;
document.head.appendChild(style);
}
function cleanup() {
if (protectionInterval) {
clearInterval(protectionInterval);
protectionInterval = undefined;
}
if (domObserver) {
domObserver.disconnect();
domObserver = undefined;
}
// Clean up protection styles
if (browser) {
const style = document?.getElementById('email-verification-protection');
if (style) style.remove();
}
}
return {
protectionInterval,
domObserver,
cleanup
};
}
/**
* Updates the body class based on email verification status
*/
export function updateBodyClass(isEmailVerified: boolean | null) {
if (!browser) {
return;
}
if (isEmailVerified) {
document.body.classList.add('email-verified');
} else {
document.body.classList.remove('email-verified');
}
}
@@ -78,6 +78,12 @@
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.customDomain = 'Custom domains are only available on paid plans';
return;
}
const sanitizedDomain = sanitizeDomain(customDomain);
const details = parse(sanitizedDomain);
@@ -24,6 +24,12 @@
let isRemoving: number | null = $state(null);
async function onSendInvite() {
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
snackbar.error('upgrade.required');
return;
}
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
if (hasError) {
@@ -48,6 +54,12 @@
}
async function onRemove(id: number) {
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
snackbar.error('upgrade.required');
return;
}
isRemoving = id;
await orgApi.removeTeamMember($currentOrg.id, id);
@@ -1,4 +1,5 @@
<script lang="ts">
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
interface Props {
code?: string;
}
@@ -7,5 +8,5 @@
</script>
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
{@html code}
{@html sanitizeHtml(code)}
</div>
@@ -5,6 +5,7 @@
import { isCourseFree, getStudentInviteLink, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import PaymentModal from './payment-modal.svelte';
import type { Course } from '$lib/utils/types';
@@ -184,7 +185,7 @@
<!-- Gift Container -->
{#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-b border-t border-gray-300 p-10">
<HtmlRender>{@html get(courseData, 'metadata.reward.description', '')}</HtmlRender>
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', ''))}</HtmlRender>
</div>
{/if}
</aside>
@@ -4,6 +4,7 @@
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '$lib/components/Course/function';
@@ -216,7 +217,7 @@
</h3>
<ul class="list font-light">
<HtmlRender>{@html get(courseData, 'metadata.requirements', '')}</HtmlRender>
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HtmlRender>
</ul>
</NavSection>
{/if}
@@ -229,7 +230,7 @@
</h3>
<HtmlRender className="dark:text-white text-sm font-light">
{@html get(courseData, 'metadata.description', '')}
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))}
</HtmlRender>
</NavSection>
{/if}
@@ -239,7 +240,7 @@
<NavSection id="goals">
<h3 class="mb-3 mt-0 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HtmlRender>{@html get(courseData, 'metadata.goals', '')}</HtmlRender>
<HtmlRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HtmlRender>
</ul>
</NavSection>
{/if}
@@ -3,6 +3,8 @@
import * as ImageCropper from '@cio/ui/custom/image-cropper';
import CameraIcon from '@lucide/svelte/icons/camera';
import PencilIcon from '@lucide/svelte/icons/pencil';
import { validateImageUpload } from '$lib/utils/functions/fileValidation';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
@@ -44,6 +46,16 @@
// getCroppedImg outputs PNG format, so use blob.type (which will be 'image/png') and matching filename
const file = new File([blob], 'cropped-image.png', { type: blob.type });
const validation = validateImageUpload(file);
if (!validation.isValid) {
snackbar.error(validation.error || 'Invalid file type');
errorMessage = $t('snackbar.landing_page_settings.error.try_again');
cropperSrc = '';
return;
}
// Update avatar with the cropped file
avatar = file;