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
+2 -2
View File
@@ -16,10 +16,10 @@
<link rel="icon" type="image/png" sizes="32x32" href="/logo-32.png" />
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<script defer src="https://umami.hz.oncws.com/script.js"
<script nonce="%sveltekit.nonce%" defer src="https://umami.hz.oncws.com/script.js"
data-website-id="80a9544a-4dda-4c91-b62f-b6be7a8a3b5c"></script>
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
<script nonce="%sveltekit.nonce%" src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
%sveltekit.head%
</head>
@@ -45,6 +45,12 @@
isSaving = true;
try {
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.description = 'Certificate customization is only available on paid plans';
throw new Error(errors.description);
}
const result = saveCertificateValidation({
description: $course.description || '',
is_certificate_downloadable: $course.is_certificate_downloadable || false,
@@ -1,5 +1,6 @@
<script lang="ts">
import { questionnaire } from '../store/exercise';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
import { InputField } from '@cio/ui/custom/input-field';
@@ -80,7 +81,9 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html $questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
)}
</article>
{/if}
</QuestionContainer>
@@ -25,6 +25,7 @@
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import { lesson } from '../store/lessons';
import { COURSE_TYPE } from '$lib/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -297,7 +298,7 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html $questionnaire.description || 'No desription'}
{@html sanitizeHtml($questionnaire.description || 'No desription')}
</article>
<Button onclick={handleStart} type="button" class="float-right my-5">Start</Button>
@@ -9,6 +9,7 @@
import { diffLines } from 'diff';
import { lesson, lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { supabase } from '$lib/utils/functions/supabase';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
@@ -194,7 +195,7 @@
{#key lessonId}
<HtmlRender id="display" className="m-auto">
<div class="amen">
{@html content}
{@html sanitizeHtml(content)}
</div>
</HtmlRender>
{/key}
@@ -101,6 +101,14 @@
async function uploadDocument() {
if (!selectedFile) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
$lessonDocUpload.error = $t('upgrade.required');
snackbar.error($lessonDocUpload.error);
return;
}
$lessonDocUpload.isUploading = true;
try {
@@ -36,6 +36,17 @@
async function onUpload() {
if (!fileInput) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
formRes = {
type: 'AUTHORIZATION_ERROR',
status: 403,
message: $t('upgrade.required')
};
isLoaded = true;
return;
}
videoUploader.initUpload();
const videoFile = fileInput.files?.[0];
@@ -1,5 +1,6 @@
<script lang="ts">
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lesson, lessonByTranslation } from '$lib/components/Course/components/Lesson/store/lessons';
import { t } from '$lib/utils/functions/translations';
@@ -22,7 +23,7 @@
{#if !isHtmlValueEmpty(content)}
<HtmlRender className="m-auto">
<div>
{@html content}
{@html sanitizeHtml(content)}
</div>
</HtmlRender>
{:else if hasAtLeastOneTranslation}
@@ -11,6 +11,7 @@
import { calDateDiff } from '$lib/utils/functions/date';
import type { Author, Feed } from '$lib/utils/types/feed';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { addNewsfeedCommentValidation } from '$lib/utils/functions/validator';
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
@@ -162,7 +163,7 @@
{#if !isHtmlValueEmpty(feed.content)}
<HtmlRender className="text-sm font-medium w-[80%] mb-4">
<div>
{@html feed.content}
{@html sanitizeHtml(feed.content)}
</div>
</HtmlRender>
{/if}
@@ -124,12 +124,13 @@ export async function setCourse(data: Course, setLesson = true) {
if (!data || !(Object.values(data) && Object.values(data).length)) return;
if (data.group) {
const groupData: GroupStore = {
id: data.group.id,
const copiedGroup = JSON.parse(JSON.stringify(data.group));
const groupData = Object.assign(copiedGroup, {
id: copiedGroup.id,
tutors: [],
students: [],
people: []
};
});
if (Array.isArray(data.group.members)) {
for (const member of data.group.members) {
@@ -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;
@@ -17,7 +17,8 @@ export const ROUTE = {
FORGOT: '/forgot',
RESET: '/reset',
LOGOUT: '/logout',
AUTH_FAILED: '/auth-failed'
AUTH_FAILED: '/auth-failed',
VERIFY_EMAIL_ERROR: '/verify-email-error'
};
export const PUBLIC_ROUTES = [
@@ -30,6 +31,7 @@ export const PUBLIC_ROUTES = [
`^${ROUTE.PAGES}/.*`,
`^${ROUTE.COURSE}/.*`,
'/404',
`^${ROUTE.VERIFY_EMAIL_ERROR}$`,
ROUTE.AUTH_FAILED
];
@@ -50,5 +52,6 @@ export const ROUTES_TO_HIDE_NAV = [
`^${ROUTE.INVITE_STUDENT}/.*`,
`^${ROUTE.FORGOT}$`,
`^${ROUTE.RESET}$`,
`^${ROUTE.ONBOARDING}$`
`^${ROUTE.ONBOARDING}$`,
`^${ROUTE.VERIFY_EMAIL_ERROR}$`
];
@@ -0,0 +1,87 @@
/**
* Server-side file type validation utilities for preventing malicious uploads
* Specifically designed to prevent SVG XSS attacks and other security issues
*/
export const ALLOWED_IMAGE_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp'
] as const;
export const ALLOWED_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] as const;
/**
* Validate file type by MIME type
* @param file - File object to validate
* @returns boolean indicating if file type is allowed
*/
export function validateImageType(file: File): boolean {
return ALLOWED_IMAGE_TYPES.includes(file.type as (typeof ALLOWED_IMAGE_TYPES)[number]);
}
/**
* Validate file extension
* @param filename - Filename to validate
* @returns boolean indicating if extension is allowed
*/
export function validateImageExtension(filename: string): boolean {
const extension = filename.toLowerCase().substring(filename.lastIndexOf('.'));
return ALLOWED_EXTENSIONS.includes(extension as (typeof ALLOWED_EXTENSIONS)[number]);
}
/**
* Comprehensive file validation
* @param file - File object to validate
* @returns object with validation result and error message
*/
export function validateImageUpload(file: File): { isValid: boolean; error?: string } {
// Check file type
if (!validateImageType(file)) {
return {
isValid: false,
error: 'Invalid file type. Only JPEG, PNG, GIF, and WebP files are allowed.'
};
}
// Check file extension (double check for security)
if (!validateImageExtension(file.name)) {
return {
isValid: false,
error: 'Invalid file extension. Only .jpg, .jpeg, .png, .gif, and .webp files are allowed.'
};
}
// Additional security checks
if (file.name.toLowerCase().includes('.svg')) {
return {
isValid: false,
error: 'SVG files are not allowed for security reasons.'
};
}
return { isValid: true };
}
/**
* Sanitize filename to prevent directory traversal and other issues
* @param filename - Original filename
* @returns sanitized filename
*/
export function sanitizeFilename(filename: string): string {
// Remove path traversal attempts
let sanitized = filename.replace(/[/\\]/g, '');
// Remove special characters that could cause issues
sanitized = sanitized.replace(/[<>:"|?*]/g, '');
// Limit length
if (sanitized.length > 255) {
const ext = sanitized.substring(sanitized.lastIndexOf('.'));
const name = sanitized.substring(0, sanitized.lastIndexOf('.'));
sanitized = name.substring(0, 255 - ext.length) + ext;
}
return sanitized;
}
@@ -0,0 +1,56 @@
import type { SupabaseClient } from '@supabase/supabase-js';
export interface UserPermissionResult {
hasAccess: boolean;
isOrgAdmin: boolean;
userMembership: any;
isStudent: boolean;
}
/**
* Check if a user has access to a course and determine their role
*/
export async function checkUserCoursePermissions(
supabase: SupabaseClient,
userId: string,
courseGroupId: string
): Promise<UserPermissionResult> {
// Check if user is a member of the course group
const { data: userMembership } = await supabase
.from('groupmember')
.select('role_id, id, profile_id, email, created_at, assigned_student_id, profile(*)')
.eq('group_id', courseGroupId)
.eq('profile_id', userId)
.single();
// Check if user is org admin
const { data: orgData } = await supabase
.from('group')
.select('organization_id')
.eq('id', courseGroupId)
.single();
let isOrgAdmin = false;
if (orgData?.organization_id) {
const { data: orgMembership } = await supabase
.from('organizationmember')
.select('role_id')
.eq('organization_id', orgData.organization_id)
.eq('profile_id', userId)
.eq('role_id', 1) // Admin role
.single();
isOrgAdmin = !!orgMembership;
}
// Check if user has access (either is course member or org admin)
const hasAccess = !!userMembership || isOrgAdmin;
const isStudent = userMembership?.role_id === 3 && !isOrgAdmin;
return {
hasAccess,
isOrgAdmin,
userMembership,
isStudent
};
}
@@ -10,6 +10,7 @@ import type {
ProfileCourseProgress
} from '$lib/utils/types';
import type { PostgrestError, PostgrestSingleResponse } from '@supabase/supabase-js';
import { getAccessToken, supabase } from '$lib/utils/functions/supabase';
import { GenericUploader } from './presign';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
@@ -18,7 +19,6 @@ import { STATUS } from '$lib/utils/constants/course';
import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isUUID } from '$lib/utils/functions/isUUID';
import { supabase } from '$lib/utils/functions/supabase';
import { uploadImage } from '$lib/utils/services/upload';
export async function fetchCourses(profileId, orgId) {
@@ -155,6 +155,33 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
};
}
export async function fetchCourseFromAPI(courseId: Course['id']) {
try {
const accessToken = await getAccessToken();
const response = await fetch('/api/courses/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId })
});
const result = await response.json();
if (!result.success) {
console.error('fetchCourseFromAPI error:', result.message);
return { data: null, error: result.error || result.message };
}
return { data: result.data, error: null };
} catch (error) {
console.error('fetchCourseFromAPI error:', error);
return { data: null, error };
}
}
export async function fetchExploreCourses(profileId, orgId) {
if (!orgId || !profileId) return;
@@ -345,8 +372,29 @@ export function createExercise(exercise: any) {
return supabase.from('exercise').insert(exercise).select();
}
export function fetchExercisesByMarks(courseId: Course['id']) {
return supabase.rpc('get_exercises').eq('course_id', courseId);
export async function fetchExercisesByMarks(courseId: Course['id']) {
try {
const accessToken = await getAccessToken();
const response = await fetch('/api/courses/exercises', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ courseId })
});
const result = await response.json();
if (!result.success) {
return { data: null, error: result.message };
}
return { data: result.data, error: null };
} catch (error) {
return { data: null, error };
}
}
function isNew(item: any) {
@@ -1,5 +1,5 @@
import type { Course } from '$lib/utils/types';
import { supabase } from '$lib/utils/functions/supabase';
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
import type { Reaction, FeedApi, Feed } from '$lib/utils/types/feed';
export async function fetchNewsFeedReaction(feedId: Feed['id']) {
@@ -7,38 +7,34 @@ export async function fetchNewsFeedReaction(feedId: Feed['id']) {
}
export async function fetchNewsFeeds(courseId?: Course['id']) {
const response = await supabase
.from('course_newsfeed')
.select(
`
id,
created_at,
content,
course_id,
author:groupmember(
profile(
id,
fullname,
avatar_url
)
),
reaction,
is_pinned,
comment:course_newsfeed_comment(
id,
created_at,
author:groupmember( profile(id, fullname, avatar_url) ),
content,
course_newsfeed_id)
`
)
.match({ course_id: courseId })
.order('created_at', { ascending: false })
.returns<FeedApi[]>();
const accessToken = await getAccessToken();
const { data, error } = response;
const response = await fetch(`/api/courses/newsfeed?courseId=${courseId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
return { data, error };
if (!response.ok) {
const error = await response.text();
return {
data: null,
error: { message: error }
};
}
const { success, data, message } = await response.json();
if (!success) {
return {
data: null,
error: { message }
};
}
return { data, error: null };
}
export async function createNewFeed(post: {
@@ -1,38 +1,39 @@
import { supabase } from '$lib/utils/functions/supabase';
import { supabase, getAccessToken } from '$lib/utils/functions/supabase';
import type { ExerciseSubmissions } from '$lib/utils/types';
export function fetchSubmissionStatus() {
return supabase.from('submissionstatus').select(`*`);
}
export function fetchSubmissions(course_id: string) {
return supabase
.from('submission')
.select(
`
id,
created_at,
answers:question_answer(*),
exercise:exercise_id(
id, title, due_by,
lesson:lesson_id(id, title),
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
),
status_id,
feedback,
course:course_id(*),
groupmember:submitted_by(
profile(*)
)
`
)
.match({
course_id
});
export async function fetchSubmissions(course_id: string) {
const accessToken = await getAccessToken();
const response = await fetch(`/api/courses/submissions?courseId=${course_id}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
if (!response.ok) {
const error = await response.text();
return {
data: null,
error: { message: error }
};
}
const { success, data, message } = await response.json();
if (!success) {
return {
data: null,
error: { message }
};
}
return { data, error: null };
}
export async function fetchSubmission({
@@ -44,40 +45,38 @@ export async function fetchSubmission({
courseId?: string;
submittedBy?: string;
}) {
const query: {
exercise_id: string;
course_id?: string;
submitted_by?: string;
} = {
exercise_id: exerciseId
};
const accessToken = await getAccessToken();
if (courseId) {
query.course_id = courseId;
}
if (submittedBy) {
query.submitted_by = submittedBy;
const params = new URLSearchParams({ exerciseId });
if (courseId) params.append('courseId', courseId);
if (submittedBy) params.append('submittedBy', submittedBy);
const response = await fetch(`/api/courses/submission?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
}
});
if (!response.ok) {
const error = await response.text();
return {
data: null,
error: { message: error }
};
}
return supabase
.from('submission')
.select(
`
id,
answers:question_answer(*),
status_id,
feedback,
submitted_by:groupmember!inner(
profile!inner(
id,
fullname,
avatar_url
)
)
`
)
.match(query)
.returns<ExerciseSubmissions[]>();
const { success, data, message } = await response.json();
if (!success) {
return {
data: null,
error: { message }
};
}
return { data, error: null };
}
export async function updateSubmission(
@@ -99,21 +99,6 @@
"email": "Ugyldig e -mail -adresse",
"username": "Brugernavn kan ikke være tomt",
"fullname": "Navnet skal være mindst 5 tegn"
},
"generic": {
"invalid_type": "Ugyldig type{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
"too_big": "Værdien er for stor{maksimum, vælg, udefineret{} andet{. Maksimum {maksimum}}}",
"too_small": "Værdien er for lille{minimum, vælg, udefineret{} andet{. Minimum {minimum}}}",
"invalid_format": "Ugyldigt format{validering, vælg, udefineret{} andet{ for {validation}}}",
"not_multiple_of": "Værdien skal være et multiplum af {multipleOf}",
"unrecognized_keys": "Indeholder ikke-genkendte nøgler{nøgler, vælg, udefineret{} andet{: {nøgler}}}",
"invalid_union": "Ugyldig værdi for angivne muligheder{unionErrors, select, undefined{} other{. {unionErrors} valgmulighed(er) mislykkedes}}",
"invalid_key": "Ugyldig nøgle{nøgle, vælg, udefineret{} anden{: {nøgle}}}",
"invalid_element": "Ugyldigt element{indeks, vælg, udefineret{} andet{ ved indeks {indeks}}}",
"invalid_value": "Ugyldig værdi{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
"custom": "{besked}",
"required_field": "Dette felt er påkrævet",
"invalid_field": "Ugyldig værdi"
}
},
"generic": {
@@ -327,7 +312,8 @@
"team_members": "Teammedlemmer",
"back": "Tilbage til indstillinger",
"download_lessons": "Opgrader for at downloade lektioner",
"download_course": "Opgrader for at downloade kursus"
"download_course": "Opgrader for at downloade kursus",
"required": "Opgradering påkrævet"
},
"analytics": {
"courses": "Kurser",
@@ -869,8 +855,7 @@
"heading": "Beskrivelse",
"describe": "Beskriv øvelsen til din studerende",
"no": "Ingen beskrivelse"
},
"add_question": "Tilføj et spørgsmål"
}
},
"new_exercise_modal": {
"heading": "Ny øvelse",
@@ -1085,6 +1070,7 @@
"students": "studerende",
"published": "Offentliggjort",
"unpublished": "Upubliceret",
"continue_course": "Fortsæt kursus",
"learn_more": "Lær mere",
"list_view": {
"title": "Titel",
@@ -1140,6 +1126,7 @@
"no_courses": "Ingen kurser i gang endnu",
"start_course": "Når du starter et kursus, afspejler dine fremskridt her",
"lms_dashboard_hero": "I dag er en anden dag for at bringe dig tættere på dine læringsmål. Giv ikke op, jo mere du lærer, jo bedre bliver du.",
"dont": "Giv ikke op",
"lessons_completed": "Lektioner afsluttet",
"no_courses_started": "Ingen kurser startede",
"revenue": "Indtægter",
@@ -1595,8 +1582,46 @@
"seconds": "sekunder",
"snackbar_error": "Fejl under afsendelse af kode"
},
"new_course_modal": {
"live_class_label": "Live klasse",
"self_paced_label": "Selv tempo"
"email_verification": {
"security_footer": "Din kontosikkerhed er vores topprioritet. Alle verifikationsforsøg logges til sikkerhedsovervågning.",
"actions": {
"request_new": "Anmod om ny bekræftelses-e-mail",
"contact_support": "Kontakt support",
"back_to_dashboard": "Tilbage til Dashboard"
},
"security_notice": {
"title": "Sikkerhedsforbedring",
"message": "Vi har opgraderet vores e-mailbekræftelsessystem for bedre sikkerhed. Gamle bekræftelseslinks er ikke længere gyldige for at beskytte din konto."
},
"errors": {
"unknown": {
"title": "Bekræftelse mislykkedes",
"message": "Der opstod en uventet fejl under e-mailbekræftelse. Prøv venligst igen, eller kontakt vores supportteam for at få hjælp."
},
"database": {
"title": "Systemfejl",
"message": "Vi oplever tekniske problemer. Prøv venligst igen senere, eller kontakt vores supportteam, hvis problemet fortsætter."
},
"legacy_blocked": {
"title": "Ikke-understøttet bekræftelsesmetode",
"message": "Denne bekræftelsesmetode understøttes ikke længere af sikkerhedsmæssige årsager. Anmod om en ny bekræftelses-e-mail ved hjælp af vores sikre bekræftelsessystem."
},
"email_mismatch": {
"title": "E-mail-adresse ændret",
"message": "Din e-mailadresse er blevet ændret, siden dette bekræftelseslink blev genereret. Anmod om et nyt bekræftelseslink til din nuværende e-mailadresse."
},
"token_expired": {
"title": "Bekræftelseslink er udløbet",
"message": "Dette bekræftelseslink er udløbet af sikkerhedsmæssige årsager. Anmod om en ny bekræftelses-e-mail for at fortsætte."
},
"token_used": {
"title": "Link er allerede brugt",
"message": "Dette bekræftelseslink er allerede blevet brugt. Hvis du har brug for at bekræfte din e-mail igen, bedes du anmode om et nyt bekræftelseslink."
},
"invalid_token": {
"title": "Ugyldigt bekræftelseslink",
"message": "Dette bekræftelseslink er ugyldigt eller forkert udformet. Tjek venligst din e-mail for det korrekte link, eller anmod om en ny bekræftelses-e-mail."
}
}
}
}
}
@@ -197,7 +197,8 @@
"back": "Zurück zu den Einstellungen",
"download_lessons": "Führen Sie ein Upgrade durch, um Lektionen herunterzuladen",
"download_course": "Upgrade zum Download-Kurs",
"domain": "Aktualisieren Sie Ihren Plan, um benutzerdefinierte Domänen hinzuzufügen"
"domain": "Aktualisieren Sie Ihren Plan, um benutzerdefinierte Domänen hinzuzufügen",
"required": "Upgrade erforderlich"
},
"audience": {
"title": "Publikum",
@@ -679,8 +680,7 @@
},
"no_answer": "Es wurde keine Antwort gegeben",
"check": "Überprüfen",
"address": "Geben Sie hier Ihre Adresse ein",
"add_question": "Fügen Sie eine Frage hinzu"
"address": "Geben Sie hier Ihre Adresse ein"
},
"new_exercise_modal": {
"heading": "Neue Übung",
@@ -916,6 +916,7 @@
"students": "Studenten",
"published": "Veröffentlicht",
"unpublished": "Unveröffentlicht",
"continue_course": "Kurs fortsetzen",
"list_view": {
"title": "Titel",
"description": "Beschreibung",
@@ -975,6 +976,7 @@
"no_courses": "Es sind noch keine Kurse im Gange",
"start_course": "Sobald Sie einen Kurs beginnen, werden Ihre Fortschritte hier angezeigt",
"lms_dashboard_hero": "Heute ist ein weiterer Tag, um Sie Ihren Lernzielen näher zu bringen. Geben Sie nicht auf, je mehr Sie lernen, desto besser werden Sie.",
"dont": "Gib nicht auf",
"lessons_completed": "Lektionen abgeschlossen",
"no_courses_started": "Es wurden keine Kurse gestartet",
"revenue": "Einnahmen",
@@ -1553,21 +1555,6 @@
"email": "Ungültige E-Mail-Adresse",
"username": "Benutzername darf nicht leer sein",
"fullname": "Der Name sollte mindestens 5 Zeichen lang sein"
},
"generic": {
"invalid_type": "Ungültiger Typ {erwartet, ausgewählt, undefiniert{} andere{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
"too_big": "Der Wert ist zu groß{maximum, select, undefiniert{} other{. Maximum {maximum}}}",
"too_small": "Der Wert ist zu klein{minimum, select, undefiniert{} other{. Minimum {Minimum}}}",
"invalid_format": "Ungültiges Format{validation, select, undefiniert{} other{ für {validation}}}",
"not_multiple_of": "Der Wert muss ein Vielfaches von {multipleOf} sein",
"unrecognized_keys": "Enthält nicht erkannte Schlüssel{keys, select, undefiniert{} other{: {keys}}}",
"invalid_union": "Ungültiger Wert für die bereitgestellten Optionen {unionErrors, select, undefined{} other{. {unionErrors}-Option(en) fehlgeschlagen}}",
"invalid_key": "Ungültiger Schlüssel{key, select, undefiniert{} other{: {key}}}",
"invalid_element": "Ungültiges Element{index, select, undefiniert{} other{ am Index {index}}}",
"invalid_value": "Invalid value{expected, select, undefined{} other{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
"custom": "{Nachricht}",
"required_field": "Dieses Feld ist erforderlich",
"invalid_field": "Ungültiger Wert"
}
},
"explore": {
@@ -1626,8 +1613,46 @@
"message": "Sie haben nicht gerettete Änderungen. Bist du sicher, dass du gehen willst?"
}
},
"new_course_modal": {
"live_class_label": "Live-Klasse",
"self_paced_label": "Eigenständig"
"email_verification": {
"security_footer": "Die Sicherheit Ihres Kontos hat für uns oberste Priorität. Alle Verifizierungsversuche werden zur Sicherheitsüberwachung protokolliert.",
"actions": {
"request_new": "Fordern Sie eine neue Bestätigungs-E-Mail an",
"contact_support": "Kontaktieren Sie den Support",
"back_to_dashboard": "Zurück zum Dashboard"
},
"security_notice": {
"title": "Sicherheitsverbesserung",
"message": "Wir haben unser E-Mail-Verifizierungssystem für mehr Sicherheit aktualisiert. Alte Bestätigungslinks sind zum Schutz Ihres Kontos nicht mehr gültig."
},
"errors": {
"unknown": {
"title": "Überprüfung fehlgeschlagen",
"message": "Bei der E-Mail-Verifizierung ist ein unerwarteter Fehler aufgetreten. Bitte versuchen Sie es erneut oder wenden Sie sich an unser Support-Team, um Hilfe zu erhalten."
},
"database": {
"title": "Systemfehler",
"message": "Wir haben technische Schwierigkeiten. Bitte versuchen Sie es später noch einmal oder kontaktieren Sie unser Support-Team, wenn das Problem weiterhin besteht."
},
"legacy_blocked": {
"title": "Nicht unterstützte Verifizierungsmethode",
"message": "Diese Verifizierungsmethode wird aus Sicherheitsgründen nicht mehr unterstützt. Bitte fordern Sie über unser sicheres Verifizierungssystem eine neue Verifizierungs-E-Mail an."
},
"email_mismatch": {
"title": "E-Mail-Adresse geändert",
"message": "Ihre E-Mail-Adresse wurde seit der Erstellung dieses Bestätigungslinks geändert. Bitte fordern Sie einen neuen Bestätigungslink für Ihre aktuelle E-Mail-Adresse an."
},
"token_expired": {
"title": "Bestätigungslink abgelaufen",
"message": "Dieser Bestätigungslink ist aus Sicherheitsgründen abgelaufen. Bitte fordern Sie eine neue Bestätigungs-E-Mail an, um fortzufahren."
},
"token_used": {
"title": "Link bereits verwendet",
"message": "Dieser Bestätigungslink wurde bereits verwendet. Wenn Sie Ihre E-Mail-Adresse erneut bestätigen müssen, fordern Sie bitte einen neuen Bestätigungslink an."
},
"invalid_token": {
"title": "Ungültiger Bestätigungslink",
"message": "Dieser Bestätigungslink ist ungültig oder fehlerhaft. Bitte überprüfen Sie Ihre E-Mails auf den richtigen Link oder fordern Sie eine neue Bestätigungs-E-Mail an."
}
}
}
}
}
@@ -10,6 +10,49 @@
"snackbar_error": "error while sending code"
},
"email_verification": {
"errors": {
"invalid_token": {
"title": "Invalid Verification Link",
"message": "This verification link is invalid or malformed. Please check your email for the correct link or request a new verification email."
},
"token_used": {
"title": "Link Already Used",
"message": "This verification link has already been used. If you need to verify your email again, please request a new verification link."
},
"token_expired": {
"title": "Verification Link Expired",
"message": "This verification link has expired for security reasons. Please request a new verification email to continue."
},
"email_mismatch": {
"title": "Email Address Changed",
"message": "Your email address has been changed since this verification link was generated. Please request a new verification link for your current email address."
},
"legacy_blocked": {
"title": "Unsupported Verification Method",
"message": "This verification method is no longer supported for security reasons. Please request a new verification email using our secure verification system."
},
"database": {
"title": "System Error",
"message": "We're experiencing technical difficulties. Please try again later or contact our support team if the issue persists."
},
"unknown": {
"title": "Verification Failed",
"message": "An unexpected error occurred during email verification. Please try again or contact our support team for assistance."
}
},
"security_notice": {
"title": "Security Enhancement",
"message": "We've upgraded our email verification system for better security. Old verification links are no longer valid to protect your account."
},
"actions": {
"request_new": "Request New Verification Email",
"contact_support": "Contact Support",
"back_to_dashboard": "Back to Dashboard"
},
"security_footer": "Your account security is our top priority. All verification attempts are logged for security monitoring."
},
"delete_modal": {
"label": "Delete",
"content": "Are you sure?",
@@ -1343,7 +1386,8 @@
"team_members": "Team members",
"back": "Back to Settings",
"download_lessons": "Upgrade to download lessons",
"download_course": "Upgrade to download course"
"download_course": "Upgrade to download course",
"required": "Upgrade Required"
},
"lms_navigation": {
@@ -197,7 +197,8 @@
"back": "Volver a configuración",
"download_lessons": "Actualice para descargar lecciones",
"download_course": "Actualizar para descargar el curso",
"domain": "Actualice su plan para agregar dominios personalizados"
"domain": "Actualice su plan para agregar dominios personalizados",
"required": "Actualización requerida"
},
"audience": {
"title": "Audiencia",
@@ -679,8 +680,7 @@
},
"no_answer": "No se proporcionó respuesta",
"check": "Controlar",
"address": "Escribe tu dirección aquí",
"add_question": "Añadir una pregunta"
"address": "Escribe tu dirección aquí"
},
"new_exercise_modal": {
"heading": "Nuevo ejercicio",
@@ -916,6 +916,7 @@
"students": "estudiantes",
"published": "Publicado",
"unpublished": "Inédito",
"continue_course": "Continuar curso",
"list_view": {
"title": "Título",
"description": "Descripción",
@@ -975,6 +976,7 @@
"no_courses": "Aún no hay cursos en curso",
"start_course": "Una vez que comiences un curso, tu progreso se reflejará aquí.",
"lms_dashboard_hero": "Hoy es otro día para acercarte a tus objetivos de aprendizaje. No te rindas, cuanto más aprendas, mejor serás.",
"dont": "no te rindas",
"lessons_completed": "Lecciones completadas",
"no_courses_started": "No se iniciaron cursos",
"revenue": "Ganancia",
@@ -1553,21 +1555,6 @@
"email": "Dirección de correo electrónico no válida",
"username": "El nombre de usuario no puede estar vacío",
"fullname": "El nombre debe tener un mínimo de 5 caracteres."
},
"generic": {
"invalid_type": "Invalid type{expected, select, undefined{} other{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
"too_big": "El valor es demasiado grande{máximo, selección, undefinido{} otro{. Máximo {máximo}}}",
"too_small": "El valor es demasiado pequeño{mínimo, seleccionado, undefinido{} otro{. Mínimo {mínimo}}}",
"invalid_format": "Formato no válido{validación, selección, indefinido{} otro{ para {validación}}}",
"not_multiple_of": "El valor debe ser múltiplo de {multipleOf}",
"unrecognized_keys": "Contiene claves no reconocidas{keys, select, undefined{} other{: {keys}}}",
"invalid_union": "Valor no válido para las opciones proporcionadas{unionErrors, select, undefined{} other{. {unionErrors} opción(es) fallida(s)}}",
"invalid_key": "Clave no válida{clave, selección, undefinido{} otro{: {clave}}}",
"invalid_element": "Elemento no válido{índice, selección, undefinido{} otro{ en el índice {índice}}}",
"invalid_value": "Valor no válido{esperado, seleccionado, indefinido{} otro{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
"custom": "{mensaje}",
"required_field": "Este campo es obligatorio",
"invalid_field": "Invalid value"
}
},
"explore": {
@@ -1626,8 +1613,46 @@
"message": "Tienes cambios no salvos. ¿Estás seguro de que quieres irte?"
}
},
"new_course_modal": {
"live_class_label": "Clase en vivo",
"self_paced_label": "a tu propio ritmo"
"email_verification": {
"security_footer": "La seguridad de su cuenta es nuestra principal prioridad. Todos los intentos de verificación se registran para el control de seguridad.",
"actions": {
"request_new": "Solicitar nuevo correo electrónico de verificación",
"contact_support": "Contactar con soporte",
"back_to_dashboard": "Volver al panel"
},
"security_notice": {
"title": "Mejora de la seguridad",
"message": "Hemos actualizado nuestro sistema de verificación de correo electrónico para mayor seguridad. Los enlaces de verificación antiguos ya no son válidos para proteger su cuenta."
},
"errors": {
"unknown": {
"title": "Verificación fallida",
"message": "Se produjo un error inesperado durante la verificación del correo electrónico. Inténtelo de nuevo o comuníquese con nuestro equipo de soporte para obtener ayuda."
},
"database": {
"title": "Error del sistema",
"message": "Estamos experimentando dificultades técnicas. Inténtelo de nuevo más tarde o comuníquese con nuestro equipo de soporte si el problema persiste."
},
"legacy_blocked": {
"title": "Método de verificación no admitido",
"message": "Este método de verificación ya no es compatible por razones de seguridad. Solicite un nuevo correo electrónico de verificación utilizando nuestro sistema de verificación seguro."
},
"email_mismatch": {
"title": "Dirección de correo electrónico cambiada",
"message": "Su dirección de correo electrónico ha cambiado desde que se generó este enlace de verificación. Solicite un nuevo enlace de verificación para su dirección de correo electrónico actual."
},
"token_expired": {
"title": "Enlace de verificación caducado",
"message": "Este enlace de verificación ha caducado por motivos de seguridad. Solicite un nuevo correo electrónico de verificación para continuar."
},
"token_used": {
"title": "Enlace ya utilizado",
"message": "Este enlace de verificación ya se ha utilizado. Si necesita verificar su correo electrónico nuevamente, solicite un nuevo enlace de verificación."
},
"invalid_token": {
"title": "Enlace de verificación no válido",
"message": "Este enlace de verificación no es válido o tiene un formato incorrecto. Revise su correo electrónico para ver el enlace correcto o solicite un nuevo correo electrónico de verificación."
}
}
}
}
}
@@ -197,7 +197,8 @@
"back": "Retour aux paramètres",
"download_lessons": "Mettre à niveau pour télécharger les leçons",
"download_course": "Mettre à niveau pour télécharger le cours",
"domain": "Mettez à niveau votre forfait pour ajouter des domaines personnalisés"
"domain": "Mettez à niveau votre forfait pour ajouter des domaines personnalisés",
"required": "Mise à niveau requise"
},
"audience": {
"title": "Public",
@@ -679,8 +680,7 @@
},
"no_answer": "Aucune réponse n'a été fournie",
"check": "Vérifier",
"address": "Écrivez votre adresse ici",
"add_question": "Ajouter une question"
"address": "Écrivez votre adresse ici"
},
"new_exercise_modal": {
"heading": "Nouvel exercice",
@@ -916,6 +916,7 @@
"students": "étudiants",
"published": "Publié",
"unpublished": "Inédit",
"continue_course": "Continuer le cours",
"list_view": {
"title": "Titre",
"description": "Description",
@@ -975,6 +976,7 @@
"no_courses": "Aucun cours en cours pour le moment",
"start_course": "Une fois que vous aurez commencé un cours, vos progrès seront reflétés ici",
"lms_dashboard_hero": "Aujourd'hui est un autre jour pour vous rapprocher de vos objectifs d'apprentissage. N'abandonnez pas, plus vous apprenez, mieux vous vous améliorez.",
"dont": "N'abandonne pas",
"lessons_completed": "Leçons terminées",
"no_courses_started": "Aucun cours n'a commencé",
"revenue": "Revenu",
@@ -1553,21 +1555,6 @@
"email": "Adresse e-mail invalide",
"username": "Le nom d'utilisateur ne peut pas être vide",
"fullname": "Le nom doit comporter au minimum 5 caractères"
},
"generic": {
"invalid_type": "Type non valide{attendu, sélectionné, non défini{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
"too_big": "La valeur est trop grande{maximum, select, undefined{} autre{. Maximum {maximum}}}",
"too_small": "La valeur est trop petite{minimum, select, undefined{} autre{. Minimum {minimum}}}",
"invalid_format": "Format invalide{validation, select, non défini{} autre{ pour {validation}}}",
"not_multiple_of": "La valeur doit être un multiple de {multipleOf}",
"unrecognized_keys": "Contient des clés non reconnues{keys, select, undefined{} other{: {keys}}}",
"invalid_union": "Valeur non valide pour les options fournies {unionErrors, select, undefined{} other{. {unionErrors} option(s) a échoué}}",
"invalid_key": "Clé invalide{key, select, undefined{} other{: {key}}}",
"invalid_element": "Élément invalide{index, select, undefined{} autre{ à l'index {index}}}",
"invalid_value": "Valeur non valide{attendue, sélectionnée, non définie{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
"custom": "{message}",
"required_field": "Ce champ est obligatoire",
"invalid_field": "Valeur invalide"
}
},
"explore": {
@@ -1626,8 +1613,46 @@
"message": "Vous avez des changements non sauvés. Êtes-vous sûr de vouloir partir?"
}
},
"new_course_modal": {
"live_class_label": "Cours en direct",
"self_paced_label": "À votre rythme"
"email_verification": {
"security_footer": "La sécurité de votre compte est notre priorité absolue. Toutes les tentatives de vérification sont enregistrées pour la surveillance de la sécurité.",
"actions": {
"request_new": "Demander un nouvel e-mail de vérification",
"contact_support": "Contacter l'assistance",
"back_to_dashboard": "Retour au tableau de bord"
},
"security_notice": {
"title": "Amélioration de la sécurité",
"message": "Nous avons mis à niveau notre système de vérification des e-mails pour une meilleure sécurité. Les anciens liens de vérification ne sont plus valides pour protéger votre compte."
},
"errors": {
"unknown": {
"title": "Échec de la vérification",
"message": "Une erreur inattendue s'est produite lors de la vérification du courrier électronique. Veuillez réessayer ou contacter notre équipe d'assistance pour obtenir de l'aide."
},
"database": {
"title": "Erreur système",
"message": "Nous rencontrons des difficultés techniques. Veuillez réessayer plus tard ou contacter notre équipe d'assistance si le problème persiste."
},
"legacy_blocked": {
"title": "Méthode de vérification non prise en charge",
"message": "Cette méthode de vérification n'est plus prise en charge pour des raisons de sécurité. Veuillez demander un nouvel e-mail de vérification en utilisant notre système de vérification sécurisé."
},
"email_mismatch": {
"title": "Adresse e-mail modifiée",
"message": "Votre adresse e-mail a été modifiée depuis la génération de ce lien de vérification. Veuillez demander un nouveau lien de vérification pour votre adresse e-mail actuelle."
},
"token_expired": {
"title": "Lien de vérification expiré",
"message": "Ce lien de vérification a expiré pour des raisons de sécurité. Veuillez demander un nouvel e-mail de vérification pour continuer."
},
"token_used": {
"title": "Lien déjà utilisé",
"message": "Ce lien de vérification a déjà été utilisé. Si vous devez vérifier à nouveau votre adresse e-mail, veuillez demander un nouveau lien de vérification."
},
"invalid_token": {
"title": "Lien de vérification invalide",
"message": "Ce lien de vérification est invalide ou mal formé. Veuillez vérifier votre courrier électronique pour trouver le lien correct ou demander un nouvel e-mail de vérification."
}
}
}
}
}
@@ -197,7 +197,8 @@
"back": "सेटिंग्स पर वापस जाएँ",
"download_lessons": "पाठ डाउनलोड करने के लिए अपग्रेड करें",
"download_course": "पाठ्यक्रम डाउनलोड करने के लिए अपग्रेड करें",
"domain": "कस्टम डोमेन जोड़ने के लिए अपनी योजना को अपग्रेड करें"
"domain": "कस्टम डोमेन जोड़ने के लिए अपनी योजना को अपग्रेड करें",
"required": "अपग्रेड आवश्यक"
},
"audience": {
"title": "श्रोता",
@@ -679,8 +680,7 @@
},
"no_answer": "कोई उत्तर नहीं दिया गया",
"check": "जाँच करना",
"address": "अपना पता यहां लिखें",
"add_question": "एक प्रश्न जोड़ें"
"address": "अपना पता यहां लिखें"
},
"new_exercise_modal": {
"heading": "नया व्यायाम",
@@ -916,6 +916,7 @@
"students": "छात्र",
"published": "प्रकाशित",
"unpublished": "अप्रकाशित",
"continue_course": "पाठ्यक्रम जारी रखें",
"list_view": {
"title": "शीर्षक",
"description": "विवरण",
@@ -975,6 +976,7 @@
"no_courses": "अभी तक कोई पाठ्यक्रम प्रगति पर नहीं है",
"start_course": "एक बार जब आप कोई कोर्स शुरू कर देंगे, तो आपकी प्रगति यहां दिखाई देगी",
"lms_dashboard_hero": "आज का दिन आपको आपके सीखने के लक्ष्यों के करीब लाने का एक और दिन है। हार मत मानो, जितना अधिक सीखोगे उतना बेहतर हो जाओगे।",
"dont": "हिम्मत मत हारो",
"lessons_completed": "पाठ पूरा हुआ",
"no_courses_started": "कोई पाठ्यक्रम शुरू नहीं हुआ",
"revenue": "आय",
@@ -1553,21 +1555,6 @@
"email": "अमान्य ईमेल पता",
"username": "उपयोक्तानाम खाली नहीं हो सकता",
"fullname": "नाम कम से कम 5 अक्षर का होना चाहिए"
},
"generic": {
"invalid_type": "अमान्य प्रकार{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
"too_big": "मान बहुत बड़ा है {अधिकतम, चयन करें, अपरिभाषित {} अन्य {। अधिकतम {अधिकतम}}}",
"too_small": "मान बहुत छोटा है {न्यूनतम, चयन करें, अपरिभाषित {} अन्य {। न्यूनतम {न्यूनतम}}}",
"invalid_format": "अमान्य प्रारूप{सत्यापन, चयन, अपरिभाषित{} अन्य{ {सत्यापन}}} के लिए",
"not_multiple_of": "मान {multipleOf} का गुणज होना चाहिए",
"unrecognized_keys": "अज्ञात कुंजियाँ शामिल हैं {कुंजियाँ, चयन करें, अपरिभाषित{} अन्य{: {कुंजियाँ}}}",
"invalid_union": "प्रदत्त विकल्पों के लिए अमान्य मान {संघत्रुटि, चयन, अपरिभाषित{} अन्य{। {unionErrors} विकल्प विफल रहे}}",
"invalid_key": "अमान्य कुंजी{कुंजी, चयन, अपरिभाषित{} अन्य{: {कुंजी}}}",
"invalid_element": "अमान्य तत्व {सूचकांक, चयन, अपरिभाषित {} अन्य {सूचकांक {सूचकांक}}} पर",
"invalid_value": "अमान्य मान{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
"custom": "{संदेश}",
"required_field": "यह फ़ील्ड आवश्यक है",
"invalid_field": "अमान्य मान"
}
},
"explore": {
@@ -1626,8 +1613,46 @@
"message": "आपके पास अनसुना परिवर्तन हैं। क्या आपकी वाकई छोड़ने की इच्छा है?"
}
},
"new_course_modal": {
"live_class_label": "लाइव क्लास",
"self_paced_label": "स्वयं गति"
"email_verification": {
"security_footer": "आपके खाते की सुरक्षा हमारी सर्वोच्च प्राथमिकता है। सुरक्षा निगरानी के लिए सभी सत्यापन प्रयास लॉग किए जाते हैं।",
"actions": {
"request_new": "नए सत्यापन ईमेल का अनुरोध करें",
"contact_support": "सहायता से संपर्क करें",
"back_to_dashboard": "डैशबोर्ड पर वापस जाएँ"
},
"security_notice": {
"title": "सुरक्षा संवर्धन",
"message": "हमने बेहतर सुरक्षा के लिए अपनी ईमेल सत्यापन प्रणाली को उन्नत किया है। पुराने सत्यापन लिंक अब आपके खाते की सुरक्षा के लिए मान्य नहीं हैं।"
},
"errors": {
"unknown": {
"title": "सत्यापन विफल",
"message": "ईमेल सत्यापन के दौरान एक अप्रत्याशित त्रुटि उत्पन्न हुई. कृपया पुनः प्रयास करें या सहायता के लिए हमारी सहायता टीम से संपर्क करें।"
},
"database": {
"title": "सिस्टम त्रुटि",
"message": "हम तकनीकी कठिनाइयों का सामना कर रहे हैं. कृपया बाद में पुनः प्रयास करें या यदि समस्या बनी रहती है तो हमारी सहायता टीम से संपर्क करें।"
},
"legacy_blocked": {
"title": "असमर्थित सत्यापन विधि",
"message": "सुरक्षा कारणों से यह सत्यापन विधि अब समर्थित नहीं है। कृपया हमारी सुरक्षित सत्यापन प्रणाली का उपयोग करके एक नए सत्यापन ईमेल का अनुरोध करें।"
},
"email_mismatch": {
"title": "ईमेल पता बदल गया",
"message": "इस सत्यापन लिंक के जनरेट होने के बाद से आपका ईमेल पता बदल दिया गया है। कृपया अपने वर्तमान ईमेल पते के लिए एक नए सत्यापन लिंक का अनुरोध करें।"
},
"token_expired": {
"title": "सत्यापन लिंक समाप्त हो गया",
"message": "सुरक्षा कारणों से यह सत्यापन लिंक समाप्त हो गया है। जारी रखने के लिए कृपया एक नए सत्यापन ईमेल का अनुरोध करें।"
},
"token_used": {
"title": "लिंक पहले से ही प्रयुक्त है",
"message": "इस सत्यापन लिंक का उपयोग पहले ही किया जा चुका है. यदि आपको अपना ईमेल दोबारा सत्यापित करने की आवश्यकता है, तो कृपया एक नए सत्यापन लिंक का अनुरोध करें।"
},
"invalid_token": {
"title": "अमान्य सत्यापन लिंक",
"message": "यह सत्यापन लिंक अमान्य या विकृत है. कृपया सही लिंक के लिए अपना ईमेल जांचें या नए सत्यापन ईमेल का अनुरोध करें।"
}
}
}
}
}
@@ -94,21 +94,6 @@
"email": "Nieprawidłowy adres e -mail",
"username": "Nazwa użytkownika nie może być pusta",
"fullname": "Nazwa powinna mieć minimum 5 znaków"
},
"generic": {
"invalid_type": "Nieprawidłowy typ{oczekiwany, wybierz, niezdefiniowany{} inny{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
"too_big": "Wartość jest za duża{maksymalna, wybierz, niezdefiniowana{} inna{. Maksymalnie {maksimum}}}",
"too_small": "Wartość jest za mała{minimum, wybierz, niezdefiniowane{} inne{. Minimum {minimum}}}",
"invalid_format": "Nieprawidłowy format{validacja, wybierz, niezdefiniowany{} other{ for {validation}}}",
"not_multiple_of": "Wartość musi być wielokrotnością {multipleOf}",
"unrecognized_keys": "Zawiera nierozpoznane klucze{klucze, wybierz, niezdefiniowane{} inne{: {klucze}}}",
"invalid_union": "Nieprawidłowa wartość podanych opcji{unionErrors, wybierz, niezdefiniowane{} inne{. Niepowodzenie opcji: {unionErrors}}}",
"invalid_key": "Nieprawidłowy klucz{klucz, wybierz, niezdefiniowany{} inny{: {klucz}}}",
"invalid_element": "Nieprawidłowy element{index, wybierz, niezdefiniowany{} other{ w indeksie {index}}}",
"invalid_value": "Nieprawidłowa wartość{oczekiwana, wybierz, niezdefiniowana{} inna{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
"custom": "{wiadomość}",
"required_field": "To pole jest wymagane",
"invalid_field": "Nieprawidłowa wartość"
}
},
"generic": {
@@ -322,7 +307,8 @@
"team_members": "Członkowie zespołu",
"back": "Powrót do ustawień",
"download_lessons": "Upgrade to pobieranie lekcji",
"download_course": "Upgrade to Pobierz"
"download_course": "Upgrade to Pobierz",
"required": "Wymagana aktualizacja"
},
"analytics": {
"courses": "Kursy",
@@ -862,8 +848,7 @@
"describe": "Opisz ćwiczenie swoim uczniowi",
"no": "Brak opisu"
},
"address": "Wpisz tutaj swój adres",
"add_question": "Dodaj pytanie"
"address": "Wpisz tutaj swój adres"
},
"new_exercise_modal": {
"heading": "Nowe ćwiczenie",
@@ -1081,6 +1066,7 @@
"students": "studenci",
"published": "Opublikowany",
"unpublished": "Niepublikowane",
"continue_course": "Kontynuuj kurs",
"learn_more": "Dowiedz się więcej",
"list_view": {
"title": "Tytuł",
@@ -1136,6 +1122,7 @@
"no_courses": "Nie ma jeszcze żadnych kursów",
"start_course": "Po rozpoczęciu kursu twoje postępy będą się tutaj odzwierciedlać",
"lms_dashboard_hero": "Dzisiaj jest kolejny dzień, aby zbliżyć cię do celów uczenia się. Nie poddawaj się, im więcej uczysz się, tym lepiej.",
"dont": "Nie poddawaj się",
"lessons_completed": "Ukończone lekcje",
"no_courses_started": "Żadne kursy nie rozpoczęły się",
"revenue": "Przychód",
@@ -1596,8 +1583,46 @@
"message": "Masz niezbawione zmiany. Czy na pewno chcesz odejść?"
}
},
"new_course_modal": {
"live_class_label": "Zajęcia na żywo",
"self_paced_label": "Samodzielne tempo"
"email_verification": {
"security_footer": "Bezpieczeństwo Twojego konta jest naszym najwyższym priorytetem. Wszystkie próby weryfikacji są rejestrowane w celu monitorowania bezpieczeństwa.",
"actions": {
"request_new": "Poproś o nowy e-mail weryfikacyjny",
"contact_support": "Skontaktuj się z pomocą techniczną",
"back_to_dashboard": "Powrót do Panelu"
},
"security_notice": {
"title": "Zwiększenie bezpieczeństwa",
"message": "Dla większego bezpieczeństwa ulepszyliśmy nasz system weryfikacji e-mailem. Stare linki weryfikacyjne nie są już ważne i chronią Twoje konto."
},
"errors": {
"unknown": {
"title": "Weryfikacja nie powiodła się",
"message": "Wystąpił nieoczekiwany błąd podczas weryfikacji e-mailowej. Spróbuj ponownie lub skontaktuj się z naszym zespołem wsparcia w celu uzyskania pomocy."
},
"database": {
"title": "Błąd systemu",
"message": "Mamy problemy techniczne. Spróbuj ponownie później lub skontaktuj się z naszym zespołem wsparcia, jeśli problem będzie się powtarzał."
},
"legacy_blocked": {
"title": "Nieobsługiwana metoda weryfikacji",
"message": "Ta metoda weryfikacji nie jest już obsługiwana ze względów bezpieczeństwa. Poproś o nowy e-mail weryfikacyjny, korzystając z naszego bezpiecznego systemu weryfikacji."
},
"email_mismatch": {
"title": "Adres e-mail zmieniony",
"message": "Twój adres e-mail został zmieniony od czasu wygenerowania tego linku weryfikacyjnego. Poproś o nowy link weryfikacyjny dla bieżącego adresu e-mail."
},
"token_expired": {
"title": "Link weryfikacyjny wygasł",
"message": "Ten link weryfikacyjny wygasł ze względów bezpieczeństwa. Aby kontynuować, poproś o nowy e-mail weryfikacyjny."
},
"token_used": {
"title": "Link już używany",
"message": "Ten link weryfikacyjny został już wykorzystany. Jeśli chcesz ponownie zweryfikować swój adres e-mail, poproś o nowy link weryfikacyjny."
},
"invalid_token": {
"title": "Nieprawidłowy link weryfikacyjny",
"message": "Ten link weryfikacyjny jest nieprawidłowy lub źle sformułowany. Sprawdź, czy w skrzynce e-mail znajduje się właściwy link, lub poproś o nowy e-mail weryfikacyjny."
}
}
}
}
}
@@ -197,7 +197,8 @@
"back": "Voltar para configurações",
"download_lessons": "Atualize para baixar lições",
"download_course": "Atualize para baixar o curso",
"domain": "Atualize seu plano para adicionar domínios personalizados"
"domain": "Atualize seu plano para adicionar domínios personalizados",
"required": "Atualização necessária"
},
"audience": {
"title": "Público",
@@ -679,8 +680,7 @@
},
"no_answer": "Nenhuma resposta foi fornecida",
"check": "Verificar",
"address": "Escreva seu endereço aqui",
"add_question": "Adicione uma pergunta"
"address": "Escreva seu endereço aqui"
},
"new_exercise_modal": {
"heading": "Novo exercício",
@@ -916,6 +916,7 @@
"students": "estudantes",
"published": "Publicados",
"unpublished": "Não publicado",
"continue_course": "Continuar curso",
"list_view": {
"title": "Título",
"description": "Descrição",
@@ -975,6 +976,7 @@
"no_courses": "Nenhum curso em andamento ainda",
"start_course": "Depois de iniciar um curso, seu progresso será refletido aqui",
"lms_dashboard_hero": "Hoje é mais um dia para aproximar você de seus objetivos de aprendizagem. Não desista, quanto mais você aprende melhor você fica.",
"dont": "Não desista",
"lessons_completed": "Lições concluídas",
"no_courses_started": "Nenhum curso iniciado",
"revenue": "Receita",
@@ -1553,21 +1555,6 @@
"email": "Endereço de email invalido",
"username": "O nome de usuário não pode ficar vazio",
"fullname": "O nome deve ter no mínimo 5 caracteres"
},
"generic": {
"invalid_type": "Tipo inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
"too_big": "O valor é muito grande{máximo, seleção, indefinido{} outro{. Máximo {máximo}}}",
"too_small": "O valor é muito pequeno{mínimo, selecione, indefinido{} outro{. Mínimo {mínimo}}}",
"invalid_format": "Formato inválido{validação, seleção, indefinido{} outro{ para {validação}}}",
"not_multiple_of": "O valor precisa ser um múltiplo de {multipleOf}",
"unrecognized_keys": "Contém chaves não reconhecidas{keys, select, undefined{} other{: {keys}}}",
"invalid_union": "Valor inválido para opções fornecidas{unionErrors, select, indefinido{} other{. Falha na(s) opção(ões) {unionErrors}}}",
"invalid_key": "Chave inválida{chave, seleção, indefinida{} outra{: {chave}}}",
"invalid_element": "Elemento inválido{index, select, indefinido{} other{ no índice {index}}}",
"invalid_value": "Valor inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
"custom": "{mensagem}",
"required_field": "Este campo é obrigatório",
"invalid_field": "Valor inválido"
}
},
"explore": {
@@ -1626,8 +1613,46 @@
"message": "Você tem mudanças não salvas. Tem certeza de que deseja sair?"
}
},
"new_course_modal": {
"live_class_label": "Aula ao vivo",
"self_paced_label": "Individualizado"
"email_verification": {
"security_footer": "A segurança da sua conta é a nossa principal prioridade. Todas as tentativas de verificação são registradas para monitoramento de segurança.",
"actions": {
"request_new": "Solicitar novo e-mail de verificação",
"contact_support": "Contate o suporte",
"back_to_dashboard": "Voltar ao painel"
},
"security_notice": {
"title": "Aprimoramento de segurança",
"message": "Atualizamos nosso sistema de verificação de e-mail para melhor segurança. Links de verificação antigos não são mais válidos para proteger sua conta."
},
"errors": {
"unknown": {
"title": "Falha na verificação",
"message": "Ocorreu um erro inesperado durante a verificação do email. Tente novamente ou entre em contato com nossa equipe de suporte para obter assistência."
},
"database": {
"title": "Erro de sistema",
"message": "Estamos enfrentando dificuldades técnicas. Tente novamente mais tarde ou entre em contato com nossa equipe de suporte se o problema persistir."
},
"legacy_blocked": {
"title": "Método de verificação não suportado",
"message": "Este método de verificação não é mais suportado por motivos de segurança. Solicite um novo e-mail de verificação usando nosso sistema de verificação seguro."
},
"email_mismatch": {
"title": "Endereço de e-mail alterado",
"message": "Seu endereço de e-mail foi alterado desde que este link de verificação foi gerado. Solicite um novo link de verificação para seu endereço de e-mail atual."
},
"token_expired": {
"title": "Link de verificação expirou",
"message": "Este link de verificação expirou por motivos de segurança. Solicite um novo e-mail de verificação para continuar."
},
"token_used": {
"title": "Link já usado",
"message": "Este link de verificação já foi usado. Se você precisar verificar seu e-mail novamente, solicite um novo link de verificação."
},
"invalid_token": {
"title": "Link de verificação inválido",
"message": "Este link de verificação é inválido ou está incorreto. Verifique seu e-mail para obter o link correto ou solicite um novo e-mail de verificação."
}
}
}
}
}
@@ -197,7 +197,8 @@
"back": "Вернуться к настройкам",
"download_lessons": "Обновите, чтобы скачивать уроки",
"download_course": "Обновите, чтобы загрузить курс",
"domain": "Обновите свой план, чтобы добавить персональные домены"
"domain": "Обновите свой план, чтобы добавить персональные домены",
"required": "Требуется обновление"
},
"audience": {
"title": "Аудитория",
@@ -679,8 +680,7 @@
},
"no_answer": "Ответ не был предоставлен",
"check": "Проверять",
"address": "Напишите здесь свой адрес",
"add_question": "Добавить вопрос"
"address": "Напишите здесь свой адрес"
},
"new_exercise_modal": {
"heading": "Новое упражнение",
@@ -916,6 +916,7 @@
"students": "студенты",
"published": "Опубликовано",
"unpublished": "Неопубликовано",
"continue_course": "Продолжить курс",
"list_view": {
"title": "Заголовок",
"description": "Описание",
@@ -975,6 +976,7 @@
"no_courses": "Курсов пока нет",
"start_course": "Здесь будет отражаться ваш прогресс после начала курса.",
"lms_dashboard_hero": "Сегодня еще один день, который приблизит вас к вашим целям обучения. Не сдавайтесь, чем больше вы узнаете, тем лучше у вас получится.",
"dont": "Не сдавайся",
"lessons_completed": "Уроки завершены",
"no_courses_started": "Курсы не начались",
"revenue": "Доход",
@@ -1553,21 +1555,6 @@
"email": "Неверный адрес электронной почты",
"username": "Имя пользователя не может быть пустым",
"fullname": "Имя должно состоять минимум из 5 символов."
},
"generic": {
"invalid_type": "Неверный тип {ожидаемый, выбранный, неопределенный{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
"too_big": "Значение слишком велико{максимум, выберите, неопределенное{} другое{. Максимум {максимум}}}",
"too_small": "Значение слишком мало{минимальное, выберите, неопределенное{} другое{. Минимум {минимум}}}",
"invalid_format": "Неверный формат{проверка, выбор, неопределенное{} другое{ для {проверка}}}",
"not_multiple_of": "Значение должно быть кратно {multipleOf}.",
"unrecognized_keys": "Содержит нераспознанные ключи{keys, select, undefine{}other{: {keys}}}",
"invalid_union": "Недопустимое значение для предоставленных параметров{unionErrors, select, undefine{}other{. Вариант(ы) {unionErrors} не выполнен}}",
"invalid_key": "Неверный ключ{key, select, undefine{}other{: {key}}}",
"invalid_element": "Неверный элемент{index, select, undefined{}other{ по индексу {index}}}",
"invalid_value": "Недопустимое значение{ожидаемое, выбранное, неопределенное{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
"custom": "{сообщение}",
"required_field": "Это поле обязательно для заполнения",
"invalid_field": "Недопустимое значение"
}
},
"explore": {
@@ -1626,8 +1613,46 @@
"message": "У вас есть неспасенные изменения. Вы уверены, что хотите уйти?"
}
},
"new_course_modal": {
"live_class_label": "Живой урок",
"self_paced_label": "Самостоятельный темп"
"email_verification": {
"security_footer": "Безопасность вашего аккаунта является нашим главным приоритетом. Все попытки проверки протоколируются для мониторинга безопасности.",
"actions": {
"request_new": "Запросить новое письмо для подтверждения",
"contact_support": "Обратиться в службу поддержки",
"back_to_dashboard": "Вернуться к панели управления"
},
"security_notice": {
"title": "Повышение безопасности",
"message": "Мы обновили нашу систему проверки электронной почты для большей безопасности. Старые ссылки для проверки больше не действительны для защиты вашей учетной записи."
},
"errors": {
"unknown": {
"title": "Проверка не удалась",
"message": "Во время проверки электронной почты произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз или обратитесь в нашу службу поддержки за помощью."
},
"database": {
"title": "Системная ошибка",
"message": "У нас возникли технические трудности. Повторите попытку позже или свяжитесь с нашей службой поддержки, если проблема не исчезнет."
},
"legacy_blocked": {
"title": "Неподдерживаемый метод проверки",
"message": "Этот метод проверки больше не поддерживается по соображениям безопасности. Пожалуйста, запросите новое электронное письмо с подтверждением, используя нашу безопасную систему проверки."
},
"email_mismatch": {
"title": "Адрес электронной почты изменен",
"message": "Ваш адрес электронной почты был изменен с момента создания этой ссылки для подтверждения. Пожалуйста, запросите новую ссылку для подтверждения для вашего текущего адреса электронной почты."
},
"token_expired": {
"title": "Срок действия ссылки для подтверждения истек",
"message": "Срок действия этой ссылки для проверки истек по соображениям безопасности. Чтобы продолжить, запросите новое письмо с подтверждением."
},
"token_used": {
"title": "Ссылка уже использована",
"message": "Эта ссылка для проверки уже использовалась. Если вам необходимо еще раз подтвердить свой адрес электронной почты, запросите новую ссылку для подтверждения."
},
"invalid_token": {
"title": "Неверная ссылка для подтверждения",
"message": "Эта ссылка для проверки недействительна или имеет неправильный формат. Пожалуйста, проверьте свою электронную почту на наличие правильной ссылки или запросите новое письмо для подтверждения."
}
}
}
}
}
@@ -197,7 +197,8 @@
"back": "Quay lại Cài đặt",
"download_lessons": "Nâng cấp để tải bài học",
"download_course": "Nâng cấp để tải xuống khóa học",
"domain": "Nâng cấp gói của bạn để thêm miền tùy chỉnh"
"domain": "Nâng cấp gói của bạn để thêm miền tùy chỉnh",
"required": "Yêu cầu nâng cấp"
},
"audience": {
"title": "Khán giả",
@@ -679,8 +680,7 @@
},
"no_answer": "Không có câu trả lời nào được đưa ra",
"check": "Kiểm tra",
"address": "Viết địa chỉ của bạn ở đây",
"add_question": "Thêm câu hỏi"
"address": "Viết địa chỉ của bạn ở đây"
},
"new_exercise_modal": {
"heading": "Bài tập mới",
@@ -916,6 +916,7 @@
"students": "sinh viên",
"published": "Được phát hành",
"unpublished": "Chưa xuất bản",
"continue_course": "Tiếp tục khóa học",
"list_view": {
"title": "Tiêu đề",
"description": "Sự miêu tả",
@@ -975,6 +976,7 @@
"no_courses": "Chưa có khóa học nào đang diễn ra",
"start_course": "Khi bạn bắt đầu một khóa học, sự tiến bộ của bạn sẽ phản ánh ở đây",
"lms_dashboard_hero": "Hôm nay là một ngày khác để đưa bạn đến gần hơn với mục tiêu học tập của mình. Đừng bỏ cuộc, bạn càng học nhiều thì bạn càng tiến bộ hơn.",
"dont": "Đừng bỏ cuộc",
"lessons_completed": "Bài học đã hoàn thành",
"no_courses_started": "Không có khóa học nào bắt đầu",
"revenue": "Doanh thu",
@@ -1553,21 +1555,6 @@
"email": "địa chỉ email không hợp lệ",
"username": "Tên ngươi dung không được để trông",
"fullname": "Tên phải có tối thiểu 5 ký tự"
},
"generic": {
"invalid_type": "Loại không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
"too_big": "Giá trị quá lớn{tối đa, chọn, không xác định{} khác{. Tối đa {tối đa}}}",
"too_small": "Giá trị quá nhỏ{tối thiểu, chọn, không xác định{} khác{. Tối thiểu {tối thiểu}}}",
"invalid_format": "Định dạng không hợp lệ{xác thực, chọn, không xác định{} khác{ cho {xác thực}}}",
"not_multiple_of": "Giá trị phải là bội số của {multipleOf}",
"unrecognized_keys": "Chứa các khóa không được nhận dạng{keys, select, undefind{} other{: {keys}}}",
"invalid_union": "Giá trị không hợp lệ cho các tùy chọn được cung cấp{unionErrors, select, unknown{} other{. {unionErrors} tùy chọn không thành công}}",
"invalid_key": "Khóa không hợp lệ{khóa, chọn, không xác định{} khác{: {key}}}",
"invalid_element": "Phần tử không hợp lệ{chỉ mục, chọn, không xác định{} khác{ tại chỉ mục {index}}}",
"invalid_value": "Giá trị không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
"custom": "{tin nhắn}",
"required_field": "Trường này là bắt buộc",
"invalid_field": "Giá trị không hợp lệ"
}
},
"explore": {
@@ -1626,8 +1613,46 @@
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc bạn muốn rời đi?"
}
},
"new_course_modal": {
"live_class_label": "Lớp học trực tiếp",
"self_paced_label": "tự nhịp độ"
"email_verification": {
"security_footer": "Bảo mật tài khoản của bạn là ưu tiên hàng đầu của chúng tôi. Tất cả các nỗ lực xác minh đều được ghi lại để giám sát bảo mật.",
"actions": {
"request_new": "Yêu cầu email xác minh mới",
"contact_support": "Liên hệ hỗ trợ",
"back_to_dashboard": "Quay lại Trang tổng quan"
},
"security_notice": {
"title": "Tăng cường bảo mật",
"message": "Chúng tôi đã nâng cấp hệ thống xác minh email của mình để bảo mật tốt hơn. Các liên kết xác minh cũ không còn hiệu lực để bảo vệ tài khoản của bạn."
},
"errors": {
"unknown": {
"title": "Xác minh không thành công",
"message": "Đã xảy ra lỗi không mong muốn trong quá trình xác minh email. Vui lòng thử lại hoặc liên hệ với nhóm hỗ trợ của chúng tôi để được hỗ trợ."
},
"database": {
"title": "Lỗi hệ thống",
"message": "Chúng tôi đang gặp khó khăn về mặt kỹ thuật. Vui lòng thử lại sau hoặc liên hệ với nhóm hỗ trợ của chúng tôi nếu sự cố vẫn tiếp diễn."
},
"legacy_blocked": {
"title": "Phương thức xác minh không được hỗ trợ",
"message": "Phương thức xác minh này không còn được hỗ trợ vì lý do bảo mật. Vui lòng yêu cầu email xác minh mới bằng hệ thống xác minh an toàn của chúng tôi."
},
"email_mismatch": {
"title": "Địa chỉ Email đã thay đổi",
"message": "Địa chỉ email của bạn đã được thay đổi kể từ khi liên kết xác minh này được tạo. Vui lòng yêu cầu liên kết xác minh mới cho địa chỉ email hiện tại của bạn."
},
"token_expired": {
"title": "Liên kết xác minh đã hết hạn",
"message": "Liên kết xác minh này đã hết hạn vì lý do bảo mật. Vui lòng yêu cầu email xác minh mới để tiếp tục."
},
"token_used": {
"title": "Liên kết đã được sử dụng",
"message": "Liên kết xác minh này đã được sử dụng. Nếu bạn cần xác minh lại email của mình, vui lòng yêu cầu liên kết xác minh mới."
},
"invalid_token": {
"title": "Liên kết xác minh không hợp lệ",
"message": "Liên kết xác minh này không hợp lệ hoặc không đúng định dạng. Vui lòng kiểm tra email của bạn để tìm liên kết chính xác hoặc yêu cầu email xác minh mới."
}
}
}
}
}
@@ -0,0 +1,66 @@
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export async function POST({ request }) {
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return json(
{
success: false,
error: 'Unauthorized'
},
{ status: 401 }
);
}
const supabase = getServerSupabase();
try {
// Clean up expired tokens
const { data: cleanupResult, error } = await supabase.rpc(
'cleanup_expired_verification_tokens'
);
if (error) {
console.error('Token cleanup failed:', error);
return json(
{
success: false,
error: error.message
},
{ status: 500 }
);
}
console.log('Token cleanup completed:', {
deletedTokens: cleanupResult,
timestamp: new Date().toISOString()
});
return json({
success: true,
deletedTokens: cleanupResult,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Unexpected error during cleanup:', error);
return json(
{
success: false,
error: 'Unexpected error'
},
{ status: 500 }
);
}
}
// Health check endpoint
export async function GET() {
return json({
service: 'email-verification-cleanup',
status: 'active',
timestamp: new Date().toISOString()
});
}
@@ -0,0 +1,85 @@
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export async function GET({ url, request }) {
// Simple auth check - in production this should be more robust
const authHeader = request.headers.get('authorization');
const adminSecret = process.env.ADMIN_SECRET;
if (!adminSecret || authHeader !== `Bearer ${adminSecret}`) {
return json(
{
success: false,
error: 'Unauthorized'
},
{ status: 401 }
);
}
const supabase = getServerSupabase();
try {
// Get verification token statistics
const { data: tokenStats, error: tokenError } = await supabase
.from('email_verification_tokens')
.select('id, created_at, expires_at, used_at, created_by_ip, used_by_ip, email')
.order('created_at', { ascending: false })
.limit(100);
if (tokenError) {
console.error('Failed to fetch token stats:', tokenError);
return json(
{
success: false,
error: 'Database error'
},
{ status: 500 }
);
}
// Calculate stats
const now = new Date();
const last24Hours = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const recent = tokenStats.filter((token) => new Date(token.created_at) > last24Hours);
const used = tokenStats.filter((token) => token.used_at);
const expired = tokenStats.filter(
(token) => new Date(token.expires_at) < now && !token.used_at
);
// Count unique IPs for anomaly detection
const uniqueCreationIPs = new Set(tokenStats.map((t) => t.created_by_ip)).size;
const uniqueUsageIPs = new Set(tokenStats.filter((t) => t.used_by_ip).map((t) => t.used_by_ip))
.size;
return json({
success: true,
stats: {
total_tokens: tokenStats.length,
recent_24h: recent.length,
used_tokens: used.length,
expired_unused: expired.length,
unique_creation_ips: uniqueCreationIPs,
unique_usage_ips: uniqueUsageIPs,
success_rate:
tokenStats.length > 0 ? ((used.length / tokenStats.length) * 100).toFixed(1) : 0
},
recent_activity: recent.slice(0, 10).map((token) => ({
created_at: token.created_at,
email: token.email.replace(/(.{2}).*@/, '$1***@'), // Mask email for privacy
used: !!token.used_at,
expired: new Date(token.expires_at) < now,
ip: token.created_by_ip
}))
});
} catch (error) {
console.error('Security monitoring error:', error);
return json(
{
success: false,
error: 'Unexpected error'
},
{ status: 500 }
);
}
}
@@ -1,37 +1,56 @@
import type { CourseAnalytics, StudentOverview } from '$lib/utils/types/analytics';
import type { RequestHandler } from './$types';
import type { UserExercisesStats } from '$lib/utils/types/analytics';
import { calcPercentageWithRounding } from '$lib/utils/functions/number.js';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { json } from '@sveltejs/kit';
const supabase = getServerSupabase();
const CACHE_DURATION = 60 * 5; // 5 minutes
export async function POST({ setHeaders, request }) {
const { courseId } = await request.json();
if (!courseId) {
return json({ success: false, message: 'Request is missing required fields' }, { status: 400 });
export const GET: RequestHandler = async ({ setHeaders, request }) => {
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
setHeaders({
'cache-control': `max-age=${CACHE_DURATION}`,
'content-type': 'application/json'
});
const url = new URL(request.url);
const courseId = url.searchParams.get('courseId');
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const analytics = await getCourseAnalytics(courseId);
return json(analytics, { status: 200 });
const supabase = getServerSupabase();
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
setHeaders({
'cache-control': `max-age=${CACHE_DURATION}`,
'content-type': 'application/json'
});
const analytics = await getCourseAnalytics(supabase, courseId);
return json({ success: true, data: analytics });
} catch (error) {
console.error('Course analytics error:', error);
return json({ error: 'Something went wrong' }, { status: 500 });
return json({ success: false, message: 'Internal server error' }, { status: 500 });
}
}
};
async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
async function getCourseAnalytics(supabase: any, courseId: string): Promise<CourseAnalytics> {
const analytics: CourseAnalytics = {
totalTutors: 0,
totalStudents: 0,
@@ -89,7 +108,9 @@ async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
// Get student analytics
const studentAnalytics = await Promise.all(
members.filter((member) => member.role_id === 3).map((member) => getStudentOverview(courseId, member))
members
.filter((member) => member.role_id === 3)
.map((member) => getStudentOverview(supabase, courseId, member))
);
analytics.students = studentAnalytics.filter((student): student is StudentOverview => student !== null);
@@ -116,7 +137,7 @@ async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
return analytics;
}
async function getLastLogin(userId: string): Promise<string | undefined> {
async function getLastLogin(supabase: any, userId: string): Promise<string | undefined> {
try {
const { data, error } = await supabase
.from('analytics_login_events')
@@ -134,7 +155,11 @@ async function getLastLogin(userId: string): Promise<string | undefined> {
}
}
async function getStudentOverview(courseId: string, member: any): Promise<StudentOverview | null> {
async function getStudentOverview(
supabase: any,
courseId: string,
member: any
): Promise<StudentOverview | null> {
try {
// Use the same service function that the working analytics/user API uses
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
@@ -154,14 +179,14 @@ async function getStudentOverview(courseId: string, member: any): Promise<Studen
}
// Fetch exercise stats using the same method as the people page
const userExercisesStats = await fetchUserExercisesStats(courseId, member.profile_id);
const userExercisesStats = await fetchUserExercisesStats(supabase, courseId, member.profile_id);
// Calculate exercise completion using the same logic as people page
const completedExercises = userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
const totalExercises = userExercisesStats?.length || 0;
// Get last login using the same approach as analytics/user API
const lastLoginDate = await getLastLogin(member.profile_id);
const lastLoginDate = await getLastLogin(supabase, member.profile_id);
// Format last seen
let lastSeen = 'Never';
@@ -211,7 +236,11 @@ async function getStudentOverview(courseId: string, member: any): Promise<Studen
}
}
async function fetchUserExercisesStats(courseId: string, userId: string): Promise<UserExercisesStats[] | undefined> {
async function fetchUserExercisesStats(
supabase: any,
courseId: string,
userId: string
): Promise<UserExercisesStats[] | undefined> {
try {
const { data: courseData, error: queryError } = await supabase
.from('course')
@@ -0,0 +1,123 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { STATUS } from '$lib/utils/constants/course';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
const COURSE_SELECT_QUERY = `
id, title, type, description, overview, logo, is_published, version, group_id,
slug, cost, currency, metadata, is_certificate_downloadable, certificate_theme,
lesson_section(id, title, order, created_at),
lessons:lesson(
id, title, public, lesson_at, is_unlocked, order, created_at, section_id,
note, videos, slide_url, call_url, totalExercises:exercise(count),
totalComments:lesson_comment(count), profile:teacher_id(id, avatar_url, fullname),
lesson_completion(id, profile_id, is_complete)
),
attendance:group_attendance(*),
polls:apps_poll(status)
`;
const GROUP_MEMBERS_SELECT = `
id,
members:groupmember(
id, role_id, profile_id, email, created_at, assigned_student_id, profile(*)
)
`;
async function getCourse(supabase: any, courseId: string) {
const { data: course, error } = await supabase
.from('course')
.select(COURSE_SELECT_QUERY)
.eq('id', courseId)
.eq('status', STATUS[STATUS.ACTIVE])
.single();
if (error || !course) {
throw new Error('Course not found');
}
return course;
}
async function getGroupMembers(
supabase: any,
groupId: string,
isStudent: boolean,
userMembership: any
) {
if (isStudent) {
return {
id: groupId,
members: userMembership ? [userMembership] : []
};
}
const { data: group, error } = await supabase
.from('group')
.select(GROUP_MEMBERS_SELECT)
.eq('id', groupId)
.single();
if (error) {
throw new Error('Error fetching group members');
}
return {
id: group.id,
members: group.members
};
}
export const POST: RequestHandler = async ({ request }) => {
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
const { courseId } = await request.json();
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
const course = await getCourse(supabase, courseId);
const { hasAccess, isStudent, userMembership } = await checkUserCoursePermissions(
supabase,
userId,
course.group_id
);
if (!hasAccess) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course.',
data: null
},
{ status: 403 }
);
}
const group = await getGroupMembers(supabase, course.group_id, isStudent, userMembership);
return json({
success: true,
data: { ...course, group }
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
const status = message === 'Course not found' ? 404 : 500;
return json(
{
success: false,
message,
data: null
},
{ status }
);
}
};
@@ -0,0 +1,66 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const POST: RequestHandler = async ({ request }) => {
const userId = request.headers.get('user_id');
const { courseId } = await request.json();
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
if (!userId) {
return json({ success: false, message: 'User ID is required' }, { status: 401 });
}
try {
const supabase = getServerSupabase();
// Get course group_id for permission check
const { data: course } = await supabase
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 });
}
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
if (!hasAccess) {
return json(
{
success: false,
message: 'Access denied'
},
{ status: 403 }
);
}
const { data: exercises, error } = await supabase
.rpc('get_exercises')
.eq('course_id', courseId);
if (error) {
throw new Error('Error fetching exercises');
}
return json({
success: true,
data: exercises
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -0,0 +1,66 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const POST: RequestHandler = async ({ request }) => {
const userId = request.headers.get('user_id');
const { courseId } = await request.json();
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
if (!userId) {
return json({ success: false, message: 'User ID is required' }, { status: 401 });
}
try {
const supabase = getServerSupabase();
// Get course group_id for permission check
const { data: course } = await supabase
.from('course')
.select('group_id')
.eq('id', courseId)
.single();
if (!course) {
return json({ success: false, message: 'Course not found' }, { status: 404 });
}
const { hasAccess } = await checkUserCoursePermissions(supabase, userId, course.group_id);
if (!hasAccess) {
return json(
{
success: false,
message: 'Access denied'
},
{ status: 403 }
);
}
const { data: marks, error } = await supabase.rpc('get_marks').eq('course_id', courseId);
if (error) {
console.log('error', error);
throw new Error('Error fetching marks');
}
return json({
success: true,
data: marks
});
} catch (error) {
console.log('error', error);
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -0,0 +1,80 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const GET: RequestHandler = async ({ request, url }) => {
const courseId = url.searchParams.get('courseId');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
// Fetch newsfeeds
const { data, error } = await supabase
.from('course_newsfeed')
.select(
`
id,
created_at,
content,
course_id,
author:groupmember(
profile(
id,
fullname,
avatar_url
)
),
reaction,
is_pinned,
comment:course_newsfeed_comment(
id,
created_at,
author:groupmember( profile(id, fullname, avatar_url) ),
content,
course_newsfeed_id)
`
)
.match({ course_id: courseId })
.order('created_at', { ascending: false });
if (error) {
throw new Error('Error fetching newsfeeds');
}
return json({
success: true,
data: data || []
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -0,0 +1,92 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const GET: RequestHandler = async ({ request, url }) => {
const exerciseId = url.searchParams.get('exerciseId');
const courseId = url.searchParams.get('courseId');
const submittedBy = url.searchParams.get('submittedBy');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!exerciseId) {
return json({ success: false, message: 'Exercise ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
// If courseId is provided, check permissions
if (courseId) {
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
}
// Build query object
const query: {
exercise_id: string;
course_id?: string;
submitted_by?: string;
} = {
exercise_id: exerciseId
};
if (courseId) {
query.course_id = courseId;
}
if (submittedBy) {
query.submitted_by = submittedBy;
}
// Fetch submission
const { data, error } = await supabase
.from('submission')
.select(
`
id,
answers:question_answer(*),
status_id,
feedback,
submitted_by:groupmember!inner(
profile!inner(
id,
fullname,
avatar_url
)
)
`
)
.match(query);
if (error) {
throw new Error('Error fetching submission');
}
return json({
success: true,
data: data || []
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -0,0 +1,80 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { checkUserCoursePermissions } from '$lib/utils/functions/permissions';
export const GET: RequestHandler = async ({ request, url }) => {
const courseId = url.searchParams.get('courseId');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!courseId) {
return json({ success: false, message: 'Course ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
const hasPermission = await checkUserCoursePermissions(supabase, userId, courseId);
if (!hasPermission) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this course or organization.'
},
{ status: 403 }
);
}
// Fetch submissions
const { data, error } = await supabase
.from('submission')
.select(
`
id,
created_at,
answers:question_answer(*),
exercise:exercise_id(
id, title, due_by,
lesson:lesson_id(id, title),
questions:question(
*,
options:option(*),
question_type:question_type_id(id, label)
)
),
status_id,
feedback,
course:course_id(*),
groupmember:submitted_by(
profile(*)
)
`
)
.match({
course_id: courseId
});
if (error) {
throw new Error('Error fetching submissions');
}
return json({
success: true,
data: data || []
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -0,0 +1,86 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export const GET: RequestHandler = async ({ request, url }) => {
const userId = request.headers.get('user_id');
const orgId = url.searchParams.get('orgId');
if (!orgId) {
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
}
if (!userId) {
return json({ success: false, message: 'User ID is required' }, { status: 401 });
}
try {
const supabase = getServerSupabase();
// Check if user has access to this organization
const { data: orgMember } = await supabase
.from('organizationmember')
.select('role_id')
.eq('organization_id', orgId)
.eq('profile_id', userId)
.in('role_id', [1, 2]) // Admin or Member roles
.single();
if (!orgMember) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this organization.'
},
{ status: 403 }
);
}
// Get all students who are participants in any course belonging to an org
const { data, error } = await supabase
.from('profile')
.select(
`
id,
fullname,
email,
avatar_url,
created_at,
groupmember!inner(
role_id,
group_id:group!inner(
organization_id
)
)
`
)
.eq('groupmember.group.organization_id', orgId)
.eq('groupmember.role_id', 3); // STUDENT role
if (error) {
throw new Error('Error fetching organization audience');
}
const audience = (data || []).map((profile) => ({
id: profile.id,
name: profile.fullname,
email: profile.email,
avatar_url: profile.avatar_url,
date_joined: new Date(profile.created_at).toDateString()
}));
return json({
success: true,
audience: audience
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -0,0 +1,88 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
export const GET: RequestHandler = async ({ request, url }) => {
const orgId = url.searchParams.get('orgId');
const userId = request.headers.get('user_id');
if (!userId) {
return json({ success: false, message: 'Unauthorized' }, { status: 401 });
}
if (!orgId) {
return json({ success: false, message: 'Organization ID is required' }, { status: 400 });
}
try {
const supabase = getServerSupabase();
// Check if user has access to this organization
const { data: orgMember } = await supabase
.from('organizationmember')
.select('role_id')
.eq('organization_id', orgId)
.eq('profile_id', userId)
.in('role_id', [1]) // Admin roles only
.single();
if (!orgMember) {
return json(
{
success: false,
message: 'Access denied. User is not a member of this organization.'
},
{ status: 403 }
);
}
// Fetch organization team
const { data, error } = await supabase
.from('organizationmember')
.select(
`
id,
email,
verified,
role_id,
profile(
id,
fullname,
email
)
`
)
.eq('organization_id', orgId)
.neq('role_id', ROLE.STUDENT)
.order('id', { ascending: false });
if (error) {
throw new Error('Error fetching organization team');
}
const team = (data || []).map((teamMember) => ({
id: teamMember.id,
email: teamMember?.profile?.email || teamMember.email,
verified: teamMember.verified,
profileId: teamMember?.profile?.id,
fullname: teamMember?.profile?.fullname || '',
role: ROLE_LABEL[teamMember?.role_id] || '',
isAdmin: teamMember?.role_id === ROLE.ADMIN
}));
return json({
success: true,
team: team
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Internal server error';
return json(
{
success: false,
message
},
{ status: 500 }
);
}
};
@@ -168,7 +168,7 @@
<Page.Body>
{#snippet child()}
<MarksPage courseId={data.courseId} />
<MarksPage />
{/snippet}
</Page.Body>
</RoleBasedSecurity>
@@ -1,7 +1,5 @@
<script lang="ts">
import { PeoplePage } from '$features/course/pages';
let { data } = $props();
</script>
<PeoplePage courseId={data.courseId} />
<PeoplePage />
@@ -0,0 +1,26 @@
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request }) => {
try {
const report = await request.json();
// Log the CSP violation for monitoring
// console.warn('CSP Violation Report:', {
// timestamp: new Date().toISOString(),
// documentURI: report['document-uri'],
// violatedDirective: report['violated-directive'],
// blockedURI: report['blocked-uri'],
// effectiveDirective: report['effective-directive'],
// originalPolicy: report['original-policy'],
// sourceFile: report['source-file'],
// lineNumber: report['line-number'],
// columnNumber: report['column-number'],
// userAgent: request.headers.get('user-agent')
// });
return new Response('OK', { status: 200 });
} catch (error) {
console.error('Error processing CSP report:', error);
return new Response('Bad Request', { status: 400 });
}
};
@@ -0,0 +1,147 @@
<script>
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
let errorType = '';
let errorMessage = '';
let errorTitle = '';
let showRetry = false;
onMount(() => {
errorType = $page.url.searchParams.get('error') || 'unknown';
switch (errorType) {
case 'invalid_token':
errorTitle = $t('email_verification.errors.invalid_token.title');
errorMessage = $t('email_verification.errors.invalid_token.message');
showRetry = true;
break;
case 'token_already_used':
errorTitle = $t('email_verification.errors.token_used.title');
errorMessage = $t('email_verification.errors.token_used.message');
showRetry = true;
break;
case 'token_expired':
errorTitle = $t('email_verification.errors.token_expired.title');
errorMessage = $t('email_verification.errors.token_expired.message');
showRetry = true;
break;
case 'email_mismatch':
errorTitle = $t('email_verification.errors.email_mismatch.title');
errorMessage = $t('email_verification.errors.email_mismatch.message');
showRetry = true;
break;
case 'legacy_method_blocked':
errorTitle = $t('email_verification.errors.legacy_blocked.title');
errorMessage = $t('email_verification.errors.legacy_blocked.message');
showRetry = true;
break;
case 'database_error':
errorTitle = $t('email_verification.errors.database.title');
errorMessage = $t('email_verification.errors.database.message');
showRetry = false;
break;
default:
errorTitle = $t('email_verification.errors.unknown.title');
errorMessage = $t('email_verification.errors.unknown.message');
showRetry = true;
}
});
function handleRetry() {
goto('/dashboard');
}
function handleSupport() {
window.open(
'mailto:help@classroomio.com?subject=Email Verification Issue&body=Error Type: ' + errorType,
'_blank'
);
}
</script>
<svelte:head>
<title>Email Verification Error - ClassroomIO</title>
<meta name="description" content="There was an issue verifying your email address." />
</svelte:head>
<div
class="flex min-h-screen w-screen items-center justify-center bg-gray-50 px-4 py-12 sm:px-6 lg:px-8 dark:bg-gray-900"
>
<div class="w-full max-w-md space-y-8">
<div class="text-center">
<!-- Error Icon -->
<svg
class="mx-auto h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h2 class="mt-6 text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
{errorTitle}
</h2>
<p class="mt-4 text-sm text-gray-600 dark:text-gray-400">
{errorMessage}
</p>
{#if errorType === 'legacy_method_blocked'}
<div
class="mt-4 rounded-md border border-yellow-200 bg-yellow-50 p-4 dark:border-yellow-800 dark:bg-yellow-900/20"
>
<div class="flex">
<svg
class="h-5 w-5 flex-shrink-0 text-yellow-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-200">
{$t('email_verification.security_notice.title')}
</h3>
<p class="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
{$t('email_verification.security_notice.message')}
</p>
</div>
</div>
</div>
{/if}
</div>
<div class="mt-8 flex w-full flex-col justify-center gap-4 md:flex-row">
<PrimaryButton
onClick={handleSupport}
variant={VARIANTS.OUTLINED}
label={$t('email_verification.actions.contact_support')}
/>
<PrimaryButton
onClick={() => goto('/')}
variant={VARIANTS.OUTLINED}
label={$t('email_verification.actions.back_to_dashboard')}
/>
</div>
<!-- Security Information -->
<div class="mt-8 text-center text-xs text-gray-500 dark:text-gray-400">
<p>{$t('email_verification.security_footer')}</p>
</div>
</div>
</div>