diff --git a/config.yaml b/config.yaml index f8d65877..284584cb 100644 --- a/config.yaml +++ b/config.yaml @@ -33,6 +33,9 @@ courses: accounts: enabled: false + teachers: false + parents: false + restricted: false tutor: enabled: true diff --git a/docs/accounts.md b/docs/accounts.md index 3f24f28e..ef98e3fa 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -8,6 +8,11 @@ database. You can enable and configure this functionality using the `accounts` k accounts: emabled: true + # Whether teacher, parent and restricted accounts (for students aged <13) are supported. + teachers: boolean; + parents: boolean; + restricted: boolean; + # Minimum age allowed for users minAge: 13 diff --git a/docs/example/config.yaml b/docs/example/config.yaml index 8cf02459..99651785 100644 --- a/docs/example/config.yaml +++ b/docs/example/config.yaml @@ -45,6 +45,9 @@ social: accounts: enabled: true + teachers: true + parents: true + restricted: true minAge: 13 privacyPolicy: https://mathigon.org/policies termsOfUse: https://mathigon.org/policies#terms diff --git a/frontend/accounts.scss b/frontend/accounts.scss index 8fbc7429..3a443be9 100755 --- a/frontend/accounts.scss +++ b/frontend/accounts.scss @@ -10,6 +10,7 @@ h1 { text-align: center; margin-bottom: 1.5em; } h1 x-icon { margin: 0 8px -14px; } +h1 .subtitle { display: block; font-size: 60%; } .form-min-height { min-height: 360px; } .m-red { color: $red; } @@ -47,3 +48,32 @@ x-password { &.on { opacity: 1; } } } + +.signup-tabs { + display: flex; + justify-content: center; + margin-bottom: 50px; + button { + border: 2px solid $blue; + text-align: center; + padding: 6px 0; + font-weight: 600; + width: 100px; + color: $blue; + transition: color .2s, background .2s; + &:first-child { border-radius: 6px 0 0 6px; border-right: none; } + &:last-child { border-radius: 0 6px 6px 0; border-left: none; } + &:hover { background: rgba($blue, 20%); } + &.active { background: $blue; color: white; cursor: default; } + } +} + +.signup-box { + max-width: 580px; + min-height: 400px; + margin: 0 auto; + .btn-row { display: flex; flex-wrap: wrap; justify-content: center; margin: 8px 0 0; } + .btn { display: block; min-width: 120px; margin: 8px; } + .alternative { text-align: center; font-size: 16px; font-weight: 600; } + .alternative { a, .a { display: inline-block; margin: 12px 16px 0; } } +} diff --git a/frontend/accounts.ts b/frontend/accounts.ts index 3999e606..049f41fe 100755 --- a/frontend/accounts.ts +++ b/frontend/accounts.ts @@ -4,10 +4,14 @@ // ============================================================================= -import {$, CustomElementView, FormView, InputView, observe, register, Router} from '@mathigon/boost'; +import {cache} from '@mathigon/core'; +import {$, Browser, CustomElementView, FormView, observe, register, Router} from '@mathigon/boost'; import './main'; +const validate = cache((query: string) => fetch(`/validate?${query}`).then(r => r.text())); + + // ----------------------------------------------------------------------------- // Password Component @@ -91,16 +95,63 @@ Router.setup({ } }); +Router.paths('/login', '/forgot', '/reset', '/reset/:token', '/profile'); + + +// ----------------------------------------------------------------------------- +// Signup Form + Router.view('/signup', { enter($el) { - const $bday = $el.$('input[name="birthday"]') as InputView; - $bday.change((date) => { - const age = (Date.now() - (+new Date(date))) / (1000 * 60 * 60 * 24 * 365); - if (age < 0 || age > 100) return $bday.setValidity('Please enter a valid date of birth.'); - if (age < 13) return $bday.setValidity('You have to be at least 13 years old to create an account.'); - $bday.setValidity(''); + const hash = Browser.getHash(); + const year = 1000 * 60 * 60 * 24 * 365; + + const model = observe({ + step: 1, + next: () => (model.step = 2), + back: () => (model.step = 1), + changeType: () => (model.step = 1), + + type: ['student', 'teacher', 'parent'].includes(hash) ? hash : 'student', + birthday: Browser.isIOS ? '2000-01-01' : '', // Prefill on iOS to fix styling + classCode: '', + username: '', + email: '', + isRestricted: false, + + birthdayError: false, + classCodeError: false, + emailError: '', + usernameError: '' + }); + + // Birthday validation + model.watch(({birthday}) => { + const age = (Date.now() - (+new Date(birthday))) / year; + model.birthdayError = age < 1 || age > 110; + model.isRestricted = (age < 13); }); + + // Class code validation + model.watch(async ({classCode}) => { + if (!classCode) return model.classCodeError = false; + let c = classCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8); + if (c.length > 4) c = c.slice(0, 4) + '-' + c.slice(4, 8); + model.classCode = c; + model.classCodeError = c.length !== 9 || (await validate(`classcode=${c}`)) === 'invalid'; + }); + + // Username validation + model.watch(async ({username}) => { + const u = model.username = username.toLowerCase().replace(/[^a-z0-9]/g, ''); + model.usernameError = u.length < 4 ? 'short' : await validate(`username=${u}`); + }); + + // Email validation + model.watch(async ({email}) => { + model.emailError = await validate(`email=${email}`); + }); + + $el.bindModel(model); } }); - -Router.paths('/login', '/forgot', '/reset', '/reset/:token', '/profile'); diff --git a/frontend/dashboard.scss b/frontend/dashboard.scss index 528f3b29..cdc38b34 100755 --- a/frontend/dashboard.scss +++ b/frontend/dashboard.scss @@ -19,7 +19,6 @@ h1 .avatar { } h1 .subtitle { display: block; font-size: 60%; } - .dashboard-body { width: 640px; max-width: calc(100% - 48px); } .overflow-wrap { margin: -32px -24px; padding: 32px 24px; min-height: 240px; @include overflow-scroll; } h2:first-child { margin-top: 0; } @@ -90,3 +89,157 @@ form + p.caption { margin-top: 16px; } margin: 0 6px 20px; x-icon { margin: 8px; } } + + +// ----------------------------------------------------------------------------- +// Teacher Dashboards + +.class-panel { + width: 240px; + height: 150px; + border-radius: 8px; + padding: 16px 18px; + transition: transform .2s; + background: mix($blue, white, 20%); + h3 { font-size: 24px; margin-bottom: 4px; } + p { font-size: 16px; opacity: 0.7; margin: 0; } + &:hover, &:focus { transform: scale(1.05); } +} + +.new-class { + background: $grey-background; + text-align: center; + font-weight: 600; + font-size: 24px; + color: rgba(black, 30%); + x-icon { display: block; margin: 0 auto; fill: rgba(black, 20%); } +} + +a.back { + display: block; + font-size: 22px; + margin: 80px 0 -72px -8px; + width: fit-content; + padding: 0 16px 0 8px; + border-radius: 20px; + color: $medium-grey; + transition: background .2s, color .2s; + &:hover, &:focus { background: rgba($medium-grey, 30%); } + x-icon { display: inline-block; margin: 0 4px -4px 0; } +} + +.edit-class { + padding: 8px; + margin: 0 0 0 10px; + cursor: pointer; + color: $medium-grey; + transition: color .2s; + x-icon { margin: 0; display: block; } + &:hover, &:focus { color: $grey; } +} + +.class-banner { + float: right; + margin-top: 40px; + text-align: center; + background: mix($blue, white, 20%); + border-radius: 6px; + line-height: 1.3; + padding: 8px 12px; + strong { display: block; font-size: 24px; } +} + +.class-row { + display: flex; + align-items: center; + padding-bottom: 8px; + margin: 0 20px 8px; + border-bottom: 1px solid #aaa; + @include ellipsis; + + img { border-radius: 100%; margin-right: 10px; } + h3 { margin: 0; font-size: 16px; font-weight: 400; } + + button { opacity: 0; cursor: pointer; transition: opacity .2s; padding: 6px 0 6px 6px; } + &:hover button { opacity: 0.3; } + button:hover, button:focus { opacity: 0.6; } + button x-icon { display: block; } +} + + +// ----------------------------------------------------------------------------- +// Roster + +#roster { + $highlight: mix(white, $light-grey); + $highlight-dark: mix($dark-mode, white, 75%); + + font-size: 16px; + display: grid; + grid-template-columns: 1fr 1fr 1fr 110px; + min-width: 640px; + + .cell { + border-bottom: 1px solid mix($medium-grey, white); + transition: color .2s, background .2s, border .2s; + line-height: 32px; + padding: 4px 6px; + @include ellipsis; + &.c1 { border-right: 2px solid $medium-grey; } + &.c2 { padding-left: 12px; } + &.c3 { padding-right: 12px; border-right: 2px solid $medium-grey; } + &.c4 { text-align: center; } + &.title { font-weight: 600; border-bottom: 2px solid $medium-grey; } + &.title.c2 { padding-right: 20px; } + &.interactive { cursor: pointer; } + &.interactive:hover, &.interactive:focus { @include theme(background, $highlight, $highlight-dark); } + } + + .avatar, .course-img { + display: block; + float: left; + margin-right: 10px; + width: 32px; + height: 32px; + background: $grey; + } + .avatar { border-radius: 100%; } + .course-img { border-radius: 3px; background-size: cover; background-position: center; } + + .popup-body { + position: absolute; + z-index: 2; + @include theme(background, white, mix($dark-mode, white, 90%)); + max-height: 240px; + left: -3px; + right: -3px; + top: -9px; + border-radius: 8px; + padding: 5px; + box-shadow: 2px 6px 28px rgba(black, 40%); + transition: max-height .3s; + @include overflow-scroll; + } + + .popup-row { + padding: 5px 7px; + cursor: pointer; + border-radius: 4px; + margin: 3px; + line-height: 32px; + font-weight: 600; + @include ellipsis; + &.active, &:not(.locked):hover { @include theme(background, $highlight, $highlight-dark); } + &.locked { cursor: default; color: $text-light; padding: 2px 7px; } + } + + .popup.section { top: -6px; left: -9px; } + .popup.section .popup-row { padding: 2px 7px 6px; } + + .progress { height: 20px; @include theme(background, rgba(black, 8%), rgba(white, 8%)); border-radius: 10px; margin-top: 6px; overflow: hidden; } + .progress .bar { background: currentColor; height: 100%; border-radius: 0 10px 10px 0; transition: width .2s; } + .popup-row .progress { height: 3px; margin-top: -3px; } + + .arrow { position: absolute; left: -18px; top: 5px; fill: none; + stroke: $medium-grey; stroke-width: 2px; stroke-linecap: round; } +} diff --git a/frontend/dashboard.ts b/frontend/dashboard.ts index 4e64d77f..76de8435 100755 --- a/frontend/dashboard.ts +++ b/frontend/dashboard.ts @@ -4,4 +4,77 @@ // ============================================================================= +import {cache} from '@mathigon/core'; +import {$, $$, Browser, observe, Popup} from '@mathigon/boost'; import './main'; + + +const checkClassCode = cache((c: string) => fetch(`/validate?classcode=${c}`) + .then(r => r.text()).then(t => (t === 'invalid') || undefined)); + +interface Course { + id: string; + title: string; + color: string; + icon: string; + progress: number; + sections: {id: string, title: string, locked: boolean, progress: number}[]; +} + +interface Student { + id: string; + name: string; + avatar: string; + minutes: number; + recent: string[]; + progress: Record>; +} + +// ----------------------------------------------------------------------------- + +Browser.ready(() => { + for (const $a of $$('.alert')) $a.$('.close')!.on('click', () => $a.remove()); + + const $addCode = $('#add-class-code form'); + if ($addCode) { + const model = observe({classCode: '', invalid: false as boolean|undefined}); + model.watch(async ({classCode}) => { + let c = classCode.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8); + if (c.length > 4) c = c.slice(0, 4) + '-' + c.slice(4, 8); + model.classCode = c; + model.invalid = !!c; + if (c.length === 9) model.invalid = await checkClassCode(c); + }); + $addCode.bindModel(model); + } + + // --------------------------------------------------------------------------- + + const $roster = $('#roster'); + if ($roster) { + const students = JSON.parse($('#student-data')!.text) as Student[]; + const courses = JSON.parse($('#course-data')!.text) as Course[]; + const $popups = $roster.$$('x-popup') as Popup[]; + + const studentMap = new Map(); + for (const s of students) studentMap.set(s.id, s); + + const model = observe({ + course: courses[0], + section: courses[0].sections[0], + student: undefined as Student|undefined, + setCourse: (courseId: string) => { + model.course = courses.find(c => c.id === courseId)!; + model.section = model.course.sections.find(s => !s.locked)!; + for (const $p of $popups) $p.close(); + }, + setSection: (s: any) => { + model.section = s; + for (const $p of $popups) $p.close(); + }, + getProgress: (student: string, section = 'total') => studentMap.get(student)?.progress[model.course.id]?.[section] || 0, + remove: (student: string) => (model.student = studentMap.get(student)) + }); + $roster.parents('.dashboard-body')[0].bindModel(model); + } +}); diff --git a/server/accounts.ts b/server/accounts.ts index a28f2dbb..22c912cf 100755 --- a/server/accounts.ts +++ b/server/accounts.ts @@ -8,11 +8,12 @@ import express from 'express'; const crypto = require('crypto'); import {MathigonStudioApp} from './app'; -import {User, UserDocument} from './models/user'; +import {Classroom} from './models/classroom'; +import {User, USER_TYPES, UserDocument} from './models/user'; import {Progress} from './models/progress'; -import {CONFIG, loadData, pastDate} from './utilities/utilities'; -import {sendChangeEmailConfirmation, sendEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendWelcomeEmail} from './utilities/emails'; -import {checkBirthday, normalizeEmail, sanitizeString} from './utilities/validate'; +import {age, CONFIG, loadData, pastDate} from './utilities/utilities'; +import {sendChangeEmailConfirmation, sendEmail, sendGuardianConsentEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendWelcomeEmail} from './utilities/emails'; +import {checkBirthday, isClassCode, normalizeEmail, normalizeUsername, sanitizeString} from './utilities/validate'; import {oAuthCallback, oAuthLogin} from './utilities/oauth'; @@ -29,33 +30,87 @@ async function signup(req: express.Request) { const email = normalizeEmail(req.body.email); if (!email) return {error: 'invalidEmail'}; - const birthday = checkBirthday(req.body.birthday); - if (!birthday) return {error: 'invalidBirthday'}; + const isStudent = req.body.type === 'student'; + const classCode = isStudent ? req.body.code : undefined; + const type = USER_TYPES.includes(req.body.type) ? req.body.type : 'student'; + + const birthday = isStudent ? checkBirthday(req.body.birthday) : undefined; + if (isStudent && !birthday) return {error: 'invalidBirthday'}; const password = req.body.password; if (password.length < 4) return {error: 'passwordLength'}; - if (!req.body.policies) return {error: 'acceptPolicies'}; + const isRestricted = isStudent ? !classCode && age(birthday!) < 13 : false; + const country = COUNTRY_CODES.includes(req.body.country) ? req.body.country : req.country; + + const username = isRestricted ? normalizeUsername(req.body.username) : undefined; + if (isRestricted && !username) return {error: 'invalidUsername'}; - const existingUser = await User.lookup(email); + const existingUser = await User.lookup(isRestricted ? username! : email); if (existingUser) return {error: 'accountExists', redirect: '/login'}; - const user = new User({type: 'student', email, birthday, password, acceptedPolicies: true}); - user.firstName = sanitizeString(req.body.first); - user.lastName = sanitizeString(req.body.last); - user.country = COUNTRY_CODES.includes(req.body.country) ? req.body.country : req.country; - user.emailVerificationToken = crypto.randomBytes(16).toString('hex'); + const user = new User({type, country, birthday, password, acceptedPolicies: true}); + const token = crypto.randomBytes(16).toString('hex'); + + if (isRestricted) { + user.isRestricted = true; + user.username = username; + user.guardianEmail = email; + user.guardianConsentToken = token; + sendGuardianConsentEmail(user); // async + + } else { + if (!req.body.policies && !classCode) return {error: 'acceptPolicies'}; + + user.firstName = sanitizeString(req.body.first); + user.lastName = sanitizeString(req.body.last); + if (!user.firstName || !user.lastName) return {error: 'invalidName'}; - if (!user.firstName || !user.lastName) return {error: 'invalidName'}; + user.emailVerificationToken = token; + user.email = email; + user.school = type === 'teacher' ? sanitizeString(req.body.school) : undefined; + + if (type !== 'student') await Classroom.make('Default Class', user).save(); + sendWelcomeEmail(user); // async + } await user.save(); + if (type === 'student' && classCode) { + const classroom = await Classroom.lookup(classCode); + if (!classroom) return {error: 'invalidClassCode'}; + await classroom.addStudent(user); + } + // Copy course data from temporary user to new user account. if (req.tmpUser) await Progress.updateMany({userId: req.tmpUser}, {userId: user.id}).exec(); - sendWelcomeEmail(user); // async + return {user, success: isRestricted ? 'guardianWelcome' : 'welcome', params: [CONFIG.siteName]}; +} - return {user, success: 'welcome'}; +async function validateInput(req: express.Request) { + if (req.query.email) { + const email = normalizeEmail(req.query.email.toString()); + if (!email) return 'invalid'; + const existing = await User.findOne({email}); + return existing ? 'duplicate' : ''; + } + + if (req.query.username) { + const username = normalizeUsername(req.query.username.toString()); + if (!username) return 'invalid'; + const existing = await User.findOne({username}); + return existing ? 'duplicate' : ''; + } + + if (req.query.classcode) { + const code = req.query.classcode.toString(); + if (!isClassCode(code)) return 'invalid'; + const classroom = await Classroom.lookup(code); + return classroom ? '' : 'invalid'; + } + + return ''; } @@ -66,7 +121,7 @@ async function login(req: express.Request) { if (!req.body.email || !req.body.password) return {error: 'missingParameters'}; const user = await User.lookup(req.body.email); - if (!user || !user.checkPassword(req.body.password.trim())) return {error: 'invalidLogin'}; + if (!user || !user.checkPassword(req.body.password)) return {error: 'invalidLogin'}; return {user}; } @@ -86,6 +141,11 @@ async function confirmEmail(req: express.Request) { async function resendVerificationEmail(req: express.Request) { if (!req.user) return {error: 'unauthenticated', errorCode: 401}; + if (req.user.isRestricted && req.user.guardianConsentToken) { + await sendGuardianConsentEmail(req.user); + return {success: 'guardianConsentEmailSent'}; + } + if (req.user.email && req.user.emailVerificationToken) { await sendWelcomeEmail(req.user); return {success: 'verificationEmailSent'}; @@ -94,6 +154,23 @@ async function resendVerificationEmail(req: express.Request) { return {error: 'unknown'}; } +async function giveGuardianConsent(req: express.Request) { + if (!req.params.token) return {error: 'missingParameters'}; + + const child = await User.findOne({guardianConsentToken: req.params.token}); + if (!child) return {error: 'consentError'}; + + // Add the child to this parent's default class. + const parent = await User.findOne({email: child.guardianEmail}); + if (child.guardianConsentToken && parent?.type === 'parent') { + await Classroom.updateOne({admin: parent.id}, {$push: {students: child.id}}); + } + + child.guardianConsentToken = undefined; + await child.save(); + return {child, parent}; +} + // ---------------------------------------------------------------------------- // User Profile @@ -106,19 +183,21 @@ async function acceptPolicies(req: express.Request) { async function updateProfile(req: express.Request) { if (!req.user) return {error: 'unauthenticated', errorCode: 401, redirect: '/login'}; + if (req.user.isRestricted) return {error: 'cantUpdateRestricted'}; req.user.firstName = sanitizeString(req.body.first); req.user.lastName = sanitizeString(req.body.last); if (!req.user.firstName || !req.user.lastName) return {error: 'invalidName'}; if (req.body.country && COUNTRY_CODES.includes(req.body.country)) req.user.country = req.body.country; + if (req.user.type === 'teacher') req.user.school = sanitizeString(req.body.school); if (req.body.email && req.body.email !== req.user.email) { if (!req.user.password) return {error: 'cantChangeEmail'}; const email = normalizeEmail(req.body.email); if (!email) return {error: 'invalidEmail'}; if (await User.lookup(email)) return {error: 'accountExists'}; - req.user.previousEmails.push(req.user.email); + req.user.previousEmails.push(req.user.email!); req.user.email = email; req.user.emailVerificationToken = crypto.randomBytes(16).toString('hex'); sendChangeEmailConfirmation(req.user); // async @@ -131,6 +210,7 @@ async function updateProfile(req: express.Request) { async function updatePassword(req: express.Request) { if (!req.user) return {error: 'unauthenticated', errorCode: 401, redirect: '/login'}; if (req.user.emailVerificationToken) return {error: 'passwordUnverifiedEmail'}; + if (req.user.isRestricted && req.user.guardianConsentToken) return {error: 'passwordNoGuardianConsent'}; if (!req.user.checkPassword(req.body.oldpassword)) return {error: 'wrongPassword'}; @@ -142,6 +222,41 @@ async function updatePassword(req: express.Request) { return {success: 'passwordChanged'}; } +async function switchAccountType(req: express.Request) { + if (!req.user) return {error: 'unauthenticated', errorCode: 401, redirect: '/login'}; + if (!req.user.canUpgrade) return {error: 'unknown'}; + + const type = req.query.type; + if (req.user.type === type) return {error: 'unknown'}; + if (req.user.emailVerificationToken) return {error: 'upgradeVerifyError'}; + + // TODO Upgrade restricted student accounts to full student accounts. + if (req.user.isRestricted) return {error: 'unknown'}; + + // Change a teacher/parent account to a student account, and delete classes + if (type === 'student') { + const classrooms = await Classroom.find({admin: req.user.id}).exec(); + if (classrooms.some(c => c.students.length)) return {error: 'downgradeError'}; + await Classroom.deleteMany({admin: req.user.id}).exec(); + await Classroom.updateMany({teachers: req.user.id}, {$pull: {teachers: req.user.id}}).exec(); + req.user.type = type; + req.user.guardianEmail = req.user.guardianConsentToken = undefined; + await req.user.save(); + return {success: 'downgradeAccount'}; + } + + // Upgrade student accounts to teacher or parent accounts. + if (type === 'teacher' || type === 'parent') { + await Classroom.updateMany({students: req.user.id}, {$pull: {students: req.user.id}}).exec(); + req.user.type = type; + await req.user.save(); + await Classroom.make('Default Class', req.user).save(); + return {success: 'upgradeAccount', params: [type]}; + } + + return {error: 'unknown'}; +} + async function deleteAccount(req: express.Request, toDelete = true) { if (!req.user) return {error: 'unauthenticated', errorCode: 401, redirect: '/login'}; @@ -158,6 +273,7 @@ async function requestPasswordResetEmail(req: express.Request) { const user = await User.lookup(req.body.email); if (!user) return {error: 'accountNotFound'}; + if (user.isRestricted && user.guardianConsentToken) return {error: 'passwordNoGuardianConsent'}; if (user.emailVerificationToken) return {error: 'passwordUnverifiedEmail'}; const buffer = await crypto.randomBytes(16); @@ -166,7 +282,7 @@ async function requestPasswordResetEmail(req: express.Request) { await user.save(); await sendPasswordResetEmail(user, user.passwordResetToken!); - return {success: 'emailSent', params: [user.email]}; + return {success: 'emailSent', params: [user.email || user.guardianEmail!]}; } async function checkResetToken(req: express.Request) { @@ -226,19 +342,26 @@ async function exportData(user: UserDocument) { // CRON Jobs to automatically delete users async function cleanupUsers() { + const restricted = await User.find({isRestricted: true, guardianConsent: {$exists: false}, createdAt: {$lt: pastDate(8)}}).exec(); const requested = await User.find({deletionRequested: {$lt: +pastDate(7)}}).exec(); - const outdated = await User.find({lastOnline: {$lt: pastDate(4 * 365)}}).exec(); + const outdated = await User.find({lastOnline: {$lt: pastDate(3 * 365)}}).exec(); + let classrooms = 0; - for (const user of [...outdated, ...requested]) { + for (const user of [...restricted, ...outdated, ...requested]) { + classrooms += (await Classroom.deleteMany({admin: user.id}).exec()).deletedCount; + await Classroom.updateMany({$pull: {students: user.id, teachers: user.id}}).exec(); await User.deleteOne({_id: user._id}); + // TODO Send a warning email before deleting accounts. } - const total = requested.length + outdated.length; + const total = restricted.length + requested.length + outdated.length + classrooms; if (!total || !CONFIG.accounts.cronNotificationsEmail) return; let text = `Mathigon cron job results from ${new Date().toISOString()}:\n`; - text += ` * Deleted ${requested} users who requested account deletion 7 days ago.`; - text += ` * Deleted ${outdated} users who have not used Mathigon within 5 years.`; + if (restricted.length) text += ` * Deleted ${restricted.length} restricted users that weren't approved by a guardian within 7 days.`; + if (requested.length) text += ` * Deleted ${requested.length} users who requested account deletion 7 days ago.`; + if (outdated.length) text += ` * Deleted ${outdated.length} users who have not used Mathigon within 3 years.`; + if (classrooms) text += ` * Deleted ${classrooms} classrooms managed by deleted users.`; await sendEmail({ subject: `Mathigon Cron Results: ${total} users deleted`, @@ -251,9 +374,9 @@ async function cleanupUsers() { // ----------------------------------------------------------------------------- // Server Endpoints -type ResponseData = {params?: string[], errorCode?: number, error?: string, success?: string, redirect?: string}; +export type ResponseData = {params?: string[], errorCode?: number, error?: string, success?: string, redirect?: string}; -function redirect(req: express.Request, res: express.Response, data: ResponseData, url: string, errorUrl?: string) { +export function redirect(req: express.Request, res: express.Response, data: ResponseData, url: string, errorUrl?: string) { const params = data.params || []; if (data.error) req.flash('errors', req.__(MESSAGES[data.error], ...params)); if (data.success) req.flash('success', req.__(MESSAGES[data.success], ...params)); @@ -293,11 +416,19 @@ export default function setupAuthEndpoints(app: MathigonStudioApp) { redirect(req, res, response, '/dashboard', '/signup'); }); + app.get('/validate', async (req, res) => res.send(await validateInput(req))); + app.get('/confirm/:id/:token', async (req, res) => { const response = await confirmEmail(req); redirect(req, res, response, '/dashboard', '/login'); }); + app.get('/consent/:token', async (req, res) => { + const response = await giveGuardianConsent(req); + if (response.error) return redirect(req, res, response, '/signup'); + res.render('accounts/consent', response); + }); + app.get('/forgot', (req, res) => { if (req.user) return res.redirect('/dashboard'); res.render('accounts/forgot'); @@ -334,12 +465,17 @@ export default function setupAuthEndpoints(app: MathigonStudioApp) { redirect(req, res, response, '/profile'); }); - app.get('/profile/delete', async (req, res) => { + app.get('/profile/upgrade', async (req, res) => { + const response = await switchAccountType(req); + redirect(req, res, response, '/dashboard', '/profile'); + }); + + app.post('/profile/delete', async (req, res) => { const response = await deleteAccount(req, true); redirect(req, res, response, '/profile', '/profile'); }); - app.get('/profile/undelete', async (req, res) => { + app.post('/profile/undelete', async (req, res) => { const response = await deleteAccount(req, false); redirect(req, res, response, '/profile', '/profile'); }); diff --git a/server/app.ts b/server/app.ts index eb45d4a2..93dbbe91 100755 --- a/server/app.ts +++ b/server/app.ts @@ -18,9 +18,10 @@ import {safeToJSON} from '@mathigon/core'; import {search, SEARCH_DOCS} from './search'; import {CourseRequestOptions, ServerOptions} from './interfaces'; import setupAuthEndpoints from './accounts'; +import setupDashboardEndpoints from './dashboards'; import {getMongoStore} from './utilities/mongodb'; import {OAUTHPROVIDERS} from './utilities/oauth'; -import {cacheBust, CONFIG, CONTENT_DIR, COURSES, ENV, findNextSection, getCourse, href, include, IS_PROD, lighten, ONE_YEAR, OUT_DIR, PROJECT_DIR, promisify, removeCacheBust} from './utilities/utilities'; +import {cacheBust, CONFIG, CONTENT_DIR, ENV, findNextSection, getCourse, href, include, IS_PROD, lighten, ONE_YEAR, OUT_DIR, PROJECT_DIR, promisify, removeCacheBust} from './utilities/utilities'; import {AVAILABLE_LOCALES, getCountry, getLocale, isInEU, Locale, LOCALES, translate} from './utilities/i18n'; import {User, UserDocument} from './models/user'; import {CourseAnalytics, LoginAnalytics} from './models/analytics'; @@ -43,6 +44,7 @@ declare global { declare module 'express-session' { interface SessionData { auth?: {user?: string}; + pending?: {birthday?: number, type?: 'student'|'teacher'|'parent', classCode?: string}; } } @@ -124,13 +126,6 @@ export class MathigonStudioApp { store: CONFIG.accounts.enabled ? getMongoStore() : undefined })); - this.app.use(lusca({ - csrf: {blocklist: options?.csrfBlocklist}, // Cross Site Request Forgery - hsts: {maxAge: 31536000}, // Strict-Transport-Security - nosniff: true, // X-Content-Type-Options - xssProtection: true // X-XSS-Protection - })); - this.app.use((req, res, next) => { req.url = removeCacheBust(req.url); req.country = getCountry(req); @@ -149,6 +144,13 @@ export class MathigonStudioApp { oAuthProviders: OAUTHPROVIDERS }); + this.app.use(lusca({ + csrf: {blocklist: options?.csrfBlocklist}, // Cross Site Request Forgery + hsts: {maxAge: 31536000}, // Strict-Transport-Security + nosniff: true, // X-Content-Type-Options + xssProtection: true // X-XSS-Protection + })); + next(); }); @@ -265,19 +267,7 @@ export class MathigonStudioApp { }); setupAuthEndpoints(this); - - this.get('/dashboard', async (req, res) => { - if (!req.user) return res.redirect('/login'); - - const progress = await Progress.getUserData(req.user.id); - const stats = await CourseAnalytics.getLastWeekStats(req.user.id); - const recent = (await Progress.getRecentCourses(req.user.id)).slice(0, 6); - - const items = Math.min(4, 6 - recent.length); - const recommended = COURSES.filter(x => !progress.has(x)).slice(0, items); - - res.render('dashboard', {progress, recent, recommended, stats}); - }); + setupDashboardEndpoints(this); return this; } diff --git a/server/dashboards.ts b/server/dashboards.ts new file mode 100755 index 00000000..a34efc37 --- /dev/null +++ b/server/dashboards.ts @@ -0,0 +1,143 @@ +// ============================================================================= +// Dashboard Server +// (c) Mathigon +// ============================================================================= + + +import express from 'express'; +import {redirect} from './accounts'; +import {MathigonStudioApp} from './app'; +import {CourseAnalytics} from './models/analytics'; +import {Classroom} from './models/classroom'; +import {Progress} from './models/progress'; +import {User} from './models/user'; +import {CONFIG, COURSES} from './utilities/utilities'; + + +// ----------------------------------------------------------------------------- +// Dashboard Views + +async function getStudentDashboard(req: express.Request, res: express.Response) { + const progress = await Progress.getUserData(req.user!.id); + const stats = await CourseAnalytics.getLastWeekStats(req.user!.id); + const recent = (await Progress.getRecentCourses(req.user!.id)).slice(0, 6); + const teachers = await Classroom.getTeachers(req.user!); + + const items = Math.min(4, 6 - recent.length); + const recommended = COURSES.filter(x => !progress.has(x)).slice(0, items); + + res.render('dashboards/student', {progress, recent, recommended, stats, teachers}); +} + +async function getClassList(req: express.Request, res: express.Response) { + const classrooms = await Classroom.find({teachers: req.user!.id}).exec(); + res.render('dashboards/teacher', {classrooms}); +} + +async function getClassDashboard(req: express.Request, res: express.Response, next: express.NextFunction) { + if (!req.user) return res.redirect('/login'); + const classroom = await Classroom.lookup(req.params.code); + if (!classroom || !classroom.isTeacher(req.user.id)) return next(); + + const {studentData, courseData} = await classroom.getDashboardData(); + res.render('dashboards/teacher-class', {studentData, courseData, classroom}); +} + +async function getParentDashboard(req: express.Request, res: express.Response) { + const classroom = await Classroom.findOne({teachers: req.user!.id}); + const students = classroom ? await classroom.getStudents() : []; + + const studentData = await Promise.all(students.map(async (student) => { + const progress = await Progress.getUserData(student.id); + const courses = (await Progress.getRecentCourses(req.user!.id)).slice(0, 6); + return {data: student, courses, progress}; + })); + + res.render('dashboard/parent', {classroom, studentData}); +} + + +// ----------------------------------------------------------------------------- +// POST Requests + +async function postJoinClass(req: express.Request, res: express.Response) { + if (!req.user) return res.redirect('/login'); + + const classroom = await Classroom.lookup(req.body.code); + if (!classroom) return redirect(req, res, {error: 'invalidClassCode'}, '/dashboard'); + + const response = await classroom.addStudent(req.user); + return redirect(req, res, response, '/dashboard'); +} + +async function postRemoveStudent(req: express.Request, res: express.Response, next: express.NextFunction) { + if (!req.user) return res.redirect('/login'); + + const classroom = await Classroom.lookup(req.params.code); + if (!classroom || !classroom.isTeacher(req.user.id)) return next(); + + if (!classroom.students.includes(req.params.student)) { + return redirect(req, res, {error: 'removeClassCodeError'}, `/dashboard/${req.params.code}`); + } + + classroom.students = classroom.students.filter(s => s !== req.params.student); + await classroom.save(); + const student = await User.findOne({_id: req.params.student}).exec(); + const name = student?.fullName || 'student'; + redirect(req, res, {success: 'removeClassCode', params: [name]}, `/dashboard/${req.params.code}`); +} + +async function postNewClass(req: express.Request, res: express.Response) { + if (!req.user) return res.redirect('/login'); + if (req.user.type !== 'teacher') return res.redirect('/dashboard'); + + const count = await Classroom.count({admin: req.user.id}); + if (count >= 20) return redirect(req, res, {error: 'tooManyClasses'}, '/dashboard'); + + const classroom = Classroom.make(req.body.title || '', req.user); + await classroom.save(); + return res.redirect(`/dashboard/${classroom.code}`); +} + +async function postEditClass(req: express.Request, res: express.Response, next: express.NextFunction) { + if (!req.user) return res.redirect('/login'); + + const classroom = await Classroom.lookup(req.params.code); + if (!classroom || !classroom.isTeacher(req.user.id)) return next(); + + if (req.body.title) classroom.title = req.body.title.trim(); + await classroom.save(); + return res.redirect(`/dashboard/${classroom.code}`); +} + +async function postDeleteClass(req: express.Request, res: express.Response, next: express.NextFunction) { + if (!req.user) return res.redirect('/login'); + + const classroom = await Classroom.lookup(req.params.code); + if (!classroom || classroom.admin !== req.user.id) return next(); + + await classroom.delete(); + return redirect(req, res, {success: 'classDeleted', params: [classroom.title]}, '/dashboard'); +} + + +// ----------------------------------------------------------------------------- +// Exports + +function getDashboard(req: express.Request, res: express.Response) { + if (!req.user) return res.redirect('/login'); + if (CONFIG.accounts.teachers && req.user.type === 'teacher') return getClassList(req, res); + if (CONFIG.accounts.parents && req.user.type === 'parent') return getParentDashboard(req, res); + return getStudentDashboard(req, res); +} + +export default function setupDashboardEndpoints(app: MathigonStudioApp) { + app.get('/dashboard', getDashboard); + app.get('/dashboard/:code', getClassDashboard); + + app.post('/dashboard/add', postJoinClass); + app.post('/dashboard/new', postNewClass); + app.post('/dashboard/:code', postEditClass); + app.post('/dashboard/:code/remove/:student', postRemoveStudent); + app.post('/dashboard/:code/delete', postDeleteClass); +} diff --git a/server/data/messages.yaml b/server/data/messages.yaml index 8942ca9b..e7ee65da 100644 --- a/server/data/messages.yaml +++ b/server/data/messages.yaml @@ -6,7 +6,7 @@ blankPassword: Your password cannot be blank. passwordLength: Your password must be at least four characters long. usernameExists: There already exists an account with this username. Please sign in! acceptPolicies: Please accept out privacy policy and terms of service. -accountExists: There already is a account with this email address. Please sign in! +accountExists: There already is a account with this email address or username. Please sign in! invalidName: Please provide a valid first and last name. cantChangeEmail: You have to set a password for your account, before changing your email address. @@ -39,3 +39,28 @@ socialLoginLink: Your $0 account has been linked to your existing account with t emailVerificationToken: You have to verify your email address before you can change your password. markedForDeleted: Your account is now marked for deletion and will be removed in 7 days. Until then, you can undo this action using the link below. unmarkedForDeletion: Your account is no longer marked for deletion. + +# Classes and Guardian Consent +invalidClassCode: The class code you entered is not valid. +passwordNoGuardianConsent: Your parent or guardian has to approve your account before you can change your password. +guardianConsentEmailSent: We’ve sent another verification email to your parent or guardian! +guardianWelcome: Welcome to $0! We have sent a notification email to your parent or guardian. They have seven days to approve your account using the link in the email. +consentError: todo +upgradeVerifyError: Please verify your email address before changing your account type. +downgradeError: You cannot change your account type while you have students linked to your classes. Please remove those first and then try again! +upgradeAccount: Your account has been changed to a $0 account! Get started by sharing your unique class code with any students you want to link to your dashboard. +downgradeAccount: Your account has been changed to a student account! You can now join classes with a class code provided by your teacher. +removeClassCodeError: An error occured while trying to unlink this student account. +removeClassCode: Successfully unlinked $0 from your account! +joinClassError: An error occured while trying to add this class code. +joinClass: You have successfully linked $0 to your account! +alreadyJoinedClassError: This class code is already linked to your account! +classDeleted: Your class “$0” has been successfully deleted! +cantDeleteClass: Unfortunately, you don’t have permission to delete this class. +tooManyClasses: Unfortunately, you have reached the limit for classes per account. + +# Google Classroom Integration +classroomError: An error occurred while trying to import your data from Google Classroom. Please try again! +classroomImport: Successfully imported $0 new students from your Google Classroom roster. +classroomNoCourses: Your Google Classroom account does not contain any courses we can import. +classroomNoStudents: Your Google Classroom course did not contain any new students to import. diff --git a/server/interfaces.ts b/server/interfaces.ts index 8d88ba91..45b7a16a 100644 --- a/server/interfaces.ts +++ b/server/interfaces.ts @@ -129,6 +129,9 @@ export interface Config { accounts: { enabled: boolean; + teachers: boolean; + parents: boolean; + restricted: boolean; minAge?: number; privacyPolicy?: string; termsOfUse?: string; diff --git a/server/models/classroom.ts b/server/models/classroom.ts new file mode 100755 index 00000000..bf9fd46a --- /dev/null +++ b/server/models/classroom.ts @@ -0,0 +1,135 @@ +// ============================================================================= +// Classroom Model +// (c) Mathigon +// ============================================================================= + + +import crypto from 'crypto'; +import {Document, model, Model, Schema} from 'mongoose'; +import {total, unique} from '@mathigon/core'; + +import {ResponseData} from '../accounts'; +import {Course} from '../interfaces'; +import {sendClassCodeAddedEmail} from '../utilities/emails'; +import {getCourse} from '../utilities/utilities'; +import {isClassCode} from '../utilities/validate'; +import {CourseAnalytics} from './analytics'; +import {Progress} from './progress'; +import {User, UserDocument} from './user'; + +const random = (length: number) => crypto.randomBytes(length).toString('base64').toUpperCase().replace(/[+/\-_0O]/g, 'A').slice(0, length); + + +export interface ClassroomDocument extends Document { + title: string; + code: string; + admin: string; + teachers: string[]; + students: string[]; + + // Methods + getStudents: () => Promise; + addStudent: (user: UserDocument) => Promise; + isTeacher: (userId: string) => boolean; + getDashboardData: () => Promise<{studentData: unknown, courseData: unknown}>; +} + +interface ClassroomModel extends Model { + // Static Methods + getStudents: (user: UserDocument) => Promise; + getTeachers: (user: UserDocument) => Promise; + lookup: (code: string) => Promise; + make: (title: string, admin: UserDocument) => ClassroomDocument; +} + +const ClassroomSchema = new Schema({ + title: {type: String, required: true, maxLength: 32, trim: true}, + code: {type: String, index: true, unique: true, required: true}, + admin: {type: String, index: true, required: true}, + teachers: {type: [String], required: true, index: true, default: []}, + students: {type: [String], required: true, index: true, default: []} +}, {timestamps: true}); + +ClassroomSchema.methods.getStudents = async function() { + const students = await User.find({_id: this.students}).exec(); + return students.sort((a, b) => a.sortingName.localeCompare(b.sortingName)); +}; + +ClassroomSchema.methods.addStudent = async function(student: UserDocument) { + if (student.type !== 'student') return {error: 'joinClassError'}; + if (this.students.includes(student.id)) return {error: 'alreadyJoinedClassError'}; + const teacher = (await User.findOne({_id: this.admin}))!; + + // If a restricted student account has not been verified, we do that now. + if (student.isRestricted && !student.guardianEmail) { + student.guardianEmail = teacher.email; + student.guardianConsentToken = undefined; + } + + this.students.push(student.id); + await this.save(); + sendClassCodeAddedEmail(student, teacher); // async + + return {success: 'joinClass', params: [teacher.fullName]}; +}; + +ClassroomSchema.methods.isTeacher = function(userId: string) { + return this.teachers.includes(userId); +}; + +// The interfaces for the files returned here are in frontend/dashboard.ts. +ClassroomSchema.methods.getDashboardData = async function(this: ClassroomDocument) { + const students = await this.getStudents(); + const count = students.length; + + const stats = await Promise.all(students.map(s => CourseAnalytics.getLastWeekStats(s.id))); + const progress = await Promise.all(students.map(s => Progress.getUserDataMap(s.id))); + const courses = unique(progress.flatMap(p => Object.keys(p))).sort().map(c => getCourse(c)).filter(c => c) as Course[]; + + const studentData = students.map((s, i) => ({ + id: s.id, + name: s.fullName, + avatar: s.avatar(64), + minutes: stats[i].minutes, + recent: Object.entries(progress[i]).sort((p, q) => q[1].total - p[1].total).map(p => p[0]), + progress: progress[i] + })); + + const courseData = courses.map(c => ({ + id: c.id, title: c.title, color: c.color, icon: c.icon || c.hero, + progress: total(progress.map(p => p[c.id]?.total || 0)) / count, + sections: c.sections.map(s => ({ + id: s.id, title: s.title, locked: s.locked, + progress: s.locked ? 0 : total(progress.map(p => p[c.id]?.[s.id] || 0)) / count + })) + })); + courseData.filter(c => c.progress).sort((a, b) => b.progress - a.progress); + + return {studentData, courseData}; +}; + +ClassroomSchema.statics.getTeachers = async function(user: UserDocument) { + if (user.type !== 'student') return []; + const classes = await Classroom.find({students: user.id}).exec(); + const teachers = unique(classes.flatMap(c => c.teachers)); + return User.find({_id: teachers}); +}; + +ClassroomSchema.statics.getStudents = async function(user: UserDocument) { + if (user.type === 'student') return []; + const classes = await Classroom.find({teachers: user.id}).exec(); + const students = unique(classes.flatMap(c => c.students)); + return User.find({_id: students}); +}; + +ClassroomSchema.statics.lookup = function(code: string) { + if (!isClassCode(code)) return; + return Classroom.findOne({code}).exec(); +}; + +ClassroomSchema.statics.make = function(title: string, admin: UserDocument) { + const code = random(4) + '-' + random(4); + return new Classroom({title: title.trim() || 'Untitled Class', code, admin: admin.id, teachers: [admin.id]}); +}; + +export const Classroom = model('Classroom', ClassroomSchema); diff --git a/server/models/progress.ts b/server/models/progress.ts index 27143e73..fac88bf2 100755 --- a/server/models/progress.ts +++ b/server/models/progress.ts @@ -54,6 +54,7 @@ interface ProgressModel extends Model { lookup: (req: express.Request, courseId: string, createNew?: boolean) => Promise; delete: (req: express.Request, courseId: string) => Promise; getUserData: (userId: string) => Promise; + getUserDataMap: (userId: string) => Promise>>; getRecentCourses: (userId: string) => Promise; } @@ -202,6 +203,20 @@ ProgressSchema.statics.getUserData = async function(userId: string) { return data; }; +// Same as above, but in JSON format to send to client. +ProgressSchema.statics.getUserDataMap = async function(userId: string) { + const progress = await this.getUserData(userId); + const data: Record> = {}; + for (const p of progress.values()) { + if (!p.progress) continue; + data[p.courseId] = {total: p.progress}; + for (const [id, s] of p.sections.entries()) { + if (s.progress) data[p.courseId][id] = s.progress; + } + } + return data; +}; + /** Returns all course IDs which a student has attempted, in order of recency. */ ProgressSchema.statics.getRecentCourses = async function(userId: string) { const courses = Array.from((await Progress.getUserData(userId)).values()); diff --git a/server/models/user.ts b/server/models/user.ts index 18457687..d2e86e68 100755 --- a/server/models/user.ts +++ b/server/models/user.ts @@ -9,8 +9,8 @@ import bcrypt from 'bcryptjs'; import * as date from 'date-fns'; import {Document, Model, model, Schema, Types} from 'mongoose'; -import {age, hash} from '../utilities/utilities'; -import {normalizeEmail} from '../utilities/validate'; +import {age, CONFIG, hash, ONE_DAY} from '../utilities/utilities'; +import {normalizeEmail, normalizeUsername} from '../utilities/validate'; export const USER_TYPES = ['student', 'teacher', 'parent'] as const; export type UserType = typeof USER_TYPES[number]; @@ -21,13 +21,15 @@ const INDEX = {index: true, unique: true, sparse: true}; // Interfaces export interface UserBase { - email: string; + username?: string; + email?: string; previousEmails: string[]; firstName: string; lastName: string; type: UserType; country: string; + school?: string; birthday?: Date; picture?: string; lastOnline?: Date; @@ -39,6 +41,10 @@ export interface UserBase { oAuthTokens: string[]; deletionRequested?: number; acceptedPolicies?: boolean; + + isRestricted?: boolean; + guardianEmail?: string; + guardianConsentToken?: string; } export interface UserDocument extends UserBase, Document { @@ -53,8 +59,9 @@ export interface UserDocument extends UserBase, Document { shortName: string; sortingName: string; birthdayString: string; - consentDaysRemaining: number; + consentDaysRemaining: string; age: number; + canUpgrade: boolean; // Methods checkPassword: (candidate: string) => boolean; @@ -70,14 +77,24 @@ interface UserModel extends Model { // ----------------------------------------------------------------------------- // Schema +function unrestricted(this: UserDocument) { + return !this.isRestricted; +} + +function restricted(this: UserDocument) { + return !!this.isRestricted; +} + const UserSchema = new Schema({ - email: {type: String, required: true, lowercase: true, ...INDEX, maxLength: 64}, + username: {type: String, required: restricted, lowercase: true, ...INDEX, maxLength: 32}, + email: {type: String, required: unrestricted, lowercase: true, ...INDEX, maxLength: 64}, previousEmails: {type: [String], default: []}, firstName: {type: String, default: '', maxLength: 32}, lastName: {type: String, default: '', maxLength: 32}, type: {type: String, enum: USER_TYPES, default: 'student'}, country: {type: String, default: 'US'}, + school: {type: String, maxLength: 32}, birthday: Date, picture: String, lastOnline: Date, @@ -86,36 +103,57 @@ const UserSchema = new Schema({ passwordResetToken: {type: String, ...INDEX}, passwordResetExpires: Number, emailVerificationToken: String, - oAuthTokens: {type: [String], default: [], ...INDEX}, + oAuthTokens: {type: [String], default: [], index: true}, deletionRequested: Number, - acceptedPolicies: Boolean + acceptedPolicies: Boolean, + + isRestricted: {type: Boolean, default: false}, + guardianEmail: {type: String, lowercase: true, required: restricted}, + guardianConsentToken: {type: String, ...INDEX} }, {timestamps: true}); UserSchema.virtual('fullName').get(function(this: UserDocument) { - return (`${this.firstName} ${this.lastName}`).trim(); + return (`${this.firstName || ''} ${this.lastName || ''}`).trim() || this.username; }); UserSchema.virtual('shortName').get(function(this: UserDocument) { - return this.firstName || this.lastName; + return this.firstName || this.lastName || this.username; }); UserSchema.virtual('sortingName').get(function(this: UserDocument) { - return this.lastName || this.firstName; + return this.lastName || this.firstName || this.username || ''; }); UserSchema.virtual('birthdayString').get(function(this: UserDocument) { return this.birthday ? date.format(new Date(this.birthday), 'dd MMMM yyyy') : ''; }); +UserSchema.virtual('consentDaysRemaining').get(function(this: UserDocument) { + if (!this.isRestricted || !this.guardianConsentToken) return undefined; + const accountAge = Math.floor((Date.now() - (+this.createdAt)) / ONE_DAY); + const daysLeft = Math.max(1, 7 - accountAge); + return `${daysLeft} day${daysLeft > 1 ? 's' : ''}`; +}); + UserSchema.virtual('age').get(function(this: UserDocument) { return this.birthday ? age(this.birthday) : NaN; }); +UserSchema.virtual('canUpgrade').get(function(this: UserDocument) { + if (this.isRestricted) return this.age >= 13; + return this.type !== 'student' || !this.birthday || this.age >= 16; +}); + UserSchema.pre('save', async function(next) { if (this.password && this.isModified('password')) { const salt = bcrypt.genSaltSync(10); this.password = bcrypt.hashSync(this.password, salt); } + + if (this.isRestricted && !CONFIG.accounts.restricted) throw new Error('Restricted accounts are not supported.'); + if (this.type === 'teacher' && !CONFIG.accounts.teachers) throw new Error('Teacher accounts are not supported.'); + if (this.type === 'parent' && !CONFIG.accounts.parents) throw new Error('Parent accounts are not supported.'); + next(); }); @@ -141,9 +179,11 @@ UserSchema.methods.getJSON = function(this: any, ...keys: string[]) { return data; }; -UserSchema.statics.lookup = async function(email: string) { - const cleanEmail = normalizeEmail(email); - return cleanEmail ? User.findOne({email}) : undefined; +UserSchema.statics.lookup = async function(emailOrUsername: string) { + const cleanEmail = normalizeEmail(emailOrUsername); + if (cleanEmail) return User.findOne({email: cleanEmail}); + const cleanUsername = normalizeUsername(emailOrUsername); + return cleanUsername ? User.findOne({username: cleanUsername}) : undefined; }; // ----------------------------------------------------------------------------- diff --git a/server/templates/_header.pug b/server/templates/_header.pug index 622d2304..b4b863b2 100755 --- a/server/templates/_header.pug +++ b/server/templates/_header.pug @@ -89,8 +89,8 @@ if !user && config.accounts.enabled input(type="hidden" name="_csrf" value=_csrf) label.form-field - input(type="email" name="email" placeholder=__('Email') required autocomplete="email") - span.placeholder #{__('Email')} + input(name="email" placeholder=__('Email or Username') required autocomplete="email") + span.placeholder #{__('Email or Username')} label.form-field input#password.form-field(type="password" name="password" placeholder=__('Password') minlength="4" required autocomplete="password") diff --git a/server/templates/_mixins.pug b/server/templates/_mixins.pug index faed2274..586fb511 100755 --- a/server/templates/_mixins.pug +++ b/server/templates/_mixins.pug @@ -1,7 +1,7 @@ -mixin course(id) +mixin course(id, myProgress) //- progressData is a global variable that contains all student course progress - var course = getCourse(id, locale.id) - - var p = progress ? progress[id] : undefined + - var p = myProgress || (progress ? progress[id] : undefined) .course(lang=(course.locale !== locale.id ? course.locale : undefined)) .course-img(style=`background-color: ${course.color}; background-image: url(${course.icon || course.hero});`) h3 @@ -41,3 +41,17 @@ mixin modal(id) button.close: x-icon(name="close") .modal-body block + +mixin socialButtons() + if config.social.twitter + a.btn.twitter(href=`https://twitter.com/${config.social.twitter.handle}` title="Twitter" target="_blank" rel="noopener") + x-icon(name="twitter" size=32) + if config.social.facebook + a.btn.facebook(href=`https://www.facebook.com/${config.social.facebook.page}/` title="Facebook" target="_blank" rel="noopener") + x-icon(name="facebook" size=32) + if config.social.instagram + a.btn.instagram(href=`https://www.instagram.com/${config.social.instagram.handle}/` title="Instagram" target="_blank" rel="noopener") + x-icon(name="instagram" size=32) + if config.social.youtube + a.btn.youtube(href=`https://www.youtube.com/c/${config.social.youtube.channel}` title="YouTube" target="_blank" rel="noopener") + x-icon(name="youtube" size=32) diff --git a/server/templates/accounts/consent.pug b/server/templates/accounts/consent.pug new file mode 100755 index 00000000..cd472a03 --- /dev/null +++ b/server/templates/accounts/consent.pug @@ -0,0 +1,19 @@ +extends ../_layout + +block vars + - var title = __('Child Account Approval') + - var styles = ['/accounts.css'] + - var scripts = ['/accounts.js'] + +block main + .container.narrow + h1 #[x-icon(name="lock" size=54)] #{__('Child Account Approval')} + +flash(messages) + + h2.text-center= __('Thanks for approving your child’s $0 account!', config.siteName) + if parent + p.text-center You can track their progress on your #[a(href="/dashboard") #{parent.type} dashboard]. + else + p.text-center!= __('You can create a new parent account to track your child’s progress. If you already have a $0 account, you can convert it to a parent account on your profile page, and then ask your child to link your unique class code on their student dashboard.', config.siteName) + + include ../_footer diff --git a/server/templates/accounts/forgot.pug b/server/templates/accounts/forgot.pug index 88e31cc4..ded96528 100755 --- a/server/templates/accounts/forgot.pug +++ b/server/templates/accounts/forgot.pug @@ -16,8 +16,8 @@ block main p= __('Enter your email address or username below, and we will send you instructions how to reset your password.') label.form-field - input(type="email" name="email" placeholder=__("Email") required autocomplete="email" autofocus=req.body.email) - span.placeholder= __('Email') + input(type="text" name="email" placeholder=__("Email or Username") required autocomplete="email" autofocus=req.body.email) + span.placeholder= __('Email or Username') button.btn.btn-red.btn-large(type='submit')= __('Reset Password') diff --git a/server/templates/accounts/login.pug b/server/templates/accounts/login.pug index 09e527a6..16630179 100755 --- a/server/templates/accounts/login.pug +++ b/server/templates/accounts/login.pug @@ -22,8 +22,8 @@ block main input(type="hidden" name="_csrf" value=_csrf) label.form-field - input(type="email" name="email" placeholder=__("Email") required autofocus=true autocomplete="email" value=req.body.email) - span.placeholder= __('Email') + input(name="email" placeholder=__("Email or Username") required autofocus=true autocomplete="email" value=req.body.email) + span.placeholder= __('Email or Username') label.form-field input.form-field(type="password" name="password" placeholder=__("Password") required minlength="4" autocomplete="password") diff --git a/server/templates/accounts/profile.pug b/server/templates/accounts/profile.pug index 113d7209..afe9adce 100755 --- a/server/templates/accounts/profile.pug +++ b/server/templates/accounts/profile.pug @@ -10,36 +10,65 @@ block main h1 img.avatar(src=user.avatar(120) width=60 height=60) | #{__('Account Settings')} + if user.type === 'student' + .subtitle= user.isRestricted ? __('Restricted Student') : __('Student') + else if user.type === 'teacher' + .subtitle= __('Teacher') + else if user.type === 'parent' + .subtitle= __('Parent') +flash(messages) h2.text-center= __('Personal Details') form.form-large(action="/profile/details" method="POST") input(type="hidden" name="_csrf" value=_csrf) - .form-row - label.form-field - input(type='text' name='first' required autocomplete="fname" placeholder=__("First Name") value=user.firstName) - span.placeholder= __('First Name') + + if user.isRestricted + p.text-center= __('You have a restricted account for students aged 12 years or younger.') label.form-field - input(type='text' name='last' required autocomplete="lname" placeholder=__("Last Name") value=user.lastName) - span.placeholder= __('Last Name') - label.form-field - input(type='email' name='email' required disabled=(!user.password) autocomplete="email" placeholder=__("Email") value=user.email) - span.placeholder= __('Email') - if user.emailVerificationToken - p.form-hint - strong.m-red= __('Email not verified!') - | #{" – "} - a(href="/profile/resend")= __('Resend verification email') - if user.birthday + input(name="username" disabled value=user.username placeholder=__("Username")) + span.placeholder= __('Username') label.form-field - input(type='text' name='birthday' disabled value=user.birthdayString placeholder=__("Date of Birth")) + input(name="birthday" disabled value=user.birthdayString() placeholder=__("Date of Birth")) span.placeholder= __('Date of Birth') - label.form-field - select(name='country') - for c in countries - option(value=c.id selected=(c.id === user.country))= c.name - button.btn.btn-red(type='submit')= __('Update Profile') + label.form-field + input(type="email" name="guardianEmail" disabled value=user.guardianEmail) + span.placeholder= __('Guardian Email') + if !user.guardianConsent + p.form-hint #[strong.m-red #{__('Your parent or guardian has not yet approved your account.')}] #{__('They need to approve your account within $0, using the link in the email we sent them, or we need to delete your account.', user.consentDaysRemaining())} #[a(href="/profile/resend") #{__('Resend verification email')}] + + else + .form-row + label.form-field + input(name="first" required autocomplete="fname" placeholder=__("First Name") value=user.firstName) + span.placeholder= __('First Name') + label.form-field + input(name="last" required autocomplete="lname" placeholder=__("Last Name") value=user.lastName) + span.placeholder= __('Last Name') + label.form-field + input(type="email" name="email" required disabled=(!user.password) autocomplete="email" placeholder=__("Email") value=user.email) + span.placeholder= __('Email') + if user.emailVerificationToken + p.form-hint + strong.m-red= __('Email not verified!') + | #{" – "} + a(href="/profile/resend")= __('Resend verification email') + if user.birthday + label.form-field + input(name="birthday" disabled value=user.birthdayString placeholder=__("Date of Birth")) + span.placeholder= __('Date of Birth') + if user.type === 'teacher' + label.form-field + input(name="school" value=user.school placeholder=__("School Name")) + span.placeholder= __('School Name') + label.form-field + select(name="country") + for c in countries + option(value=c.id selected=(c.id === user.country))= c.name + span.placeholder= __('Country') + button.btn.btn-red(type='submit')= __('Update Profile') + + //- ------------------------------------------------------------------------ if user.password && !user.emailVerificationToken hr(style="margin: 2em 0") @@ -52,23 +81,51 @@ block main x-password button.btn.btn-red(type='submit')= __('Change Password') + //- ------------------------------------------------------------------------ + + if user.canUpgrade + hr(style="margin: 2em 0") + h2.text-center#upgrade= __('Change Account Type') + .btn-row.text-center + if user.type === 'student' + a.btn.btn-blue(data-modal="upgrade-teacher")= __('Teacher Account') + a.btn.btn-green(data-modal="upgrade-parent")= __('Parent Account') + +modal('upgrade-teacher') + h2 #[x-icon(name="user" size=32)] #{__('Change to Teacher Account')} + p= __('You currently have a student account. Changing to a teacher account will allow you to manage student accounts and track their progress.') + .btn-row: a.btn.btn-red(href="/profile/upgrade?type=teacher")= __('Continue') + +modal('upgrade-parent') + h2 #[x-icon(name="user" size=32)] #{__('Change to Parent Account')} + p= __('You currently have a student account. Changing to a parent account will allow you to manage your children’s accounts and track their progress.') + .btn-row: a.btn.btn-red(href="/profile/upgrade?type=parent")= __('Continue') + else + a.btn.btn-yellow(data-modal="upgrade-student")= __('Student Account') + +modal('upgrade-student') + h2 #[x-icon(name="user" size=32)] #{__('Change to Student Account')} + p= __('Are you sure that you want to change to a student account? You existing classes will be deleted and will no longer be able to manage students.') + .btn-row: a.btn.btn-red(href="/profile/upgrade?type=student")= __('Continue') + + //- ------------------------------------------------------------------------ + hr(style="margin: 2em 0") h2.text-center= __('Privacy Settings') - p!= __('Please review our Privacy Policy and our Terms of Service. Make sure you understand what personal data we collect, and how we use that data to personalise and improve our content.', config.accounts.privacyPolicy, config.accounts.termsOfUse) + if !user.isRestricted + p!= __('Please review our Privacy Policy and our Terms of Service. Make sure you understand what personal data we collect, and how we use that data to personalise and improve our content.', config.accounts.privacyPolicy, config.accounts.termsOfUse) if user.deletionRequested p.m-red.b Your account is currently marked for deletion and will be removed in #{Math.floor(7 - (Date.now() - user.deletionRequested) / (1000 * 60 * 60 * 24))} days. .btn-row a.btn.btn-blue(href="/profile/data.json" download)= __('Download all my data') if user.deletionRequested - a.btn.btn-green(href="/profile/undelete")= __('Don’t delete my account') + form(action="/profile/undelete" method="POST") + input(type="hidden" name="_csrf" value=_csrf) + button.btn.btn-green(type="submit")= __('Don’t delete my account') else button.btn.btn-red(data-modal="delete")= __('Delete my account') - if !user.deletionRequested - x-modal#delete - button.close: x-icon(name="close") - .modal-body + +modal('delete') h2 #[x-icon(name="delete" size=32)] #{__('Delete my account')} p= __('Are you sure that you want to delete your $0 account and all associated data?', config.siteName) - .btn-row: a.btn.btn-red(href="/profile/delete")= __('Delete') + form.btn-row(action="/profile/delete" method="POST") + input(type="hidden" name="_csrf" value=_csrf) + button.btn.btn-red(type="submit")= __('Delete') include ../_footer diff --git a/server/templates/accounts/signup.pug b/server/templates/accounts/signup.pug index 2721fb1c..2f916eb1 100755 --- a/server/templates/accounts/signup.pug +++ b/server/templates/accounts/signup.pug @@ -11,44 +11,90 @@ block main h1 #[x-icon(name="user" size=54)] #{__('Create New Account')} +flash(messages) - if oAuthProviders.length - .btn-row.text-center - for p in oAuthProviders - a.btn.btn-large(href=`/auth/${p.id}` class=p.id) #[x-icon(name=p.id size=28)] #{p.name} - hr + if config.accounts.teachers || config.accounts.parents + x-select.signup-tabs(:bind="type" @change="changeType()") + button(value="student")= __('Student') + if config.accounts.teachers + button(value="teacher")= __('Teacher') + if config.accounts.parents + button(value="parent")= __('Parent') - form(method="POST").form-large.form-min-height - input(type="hidden" name="_csrf" value=_csrf) + mixin socialButtons() + .btn-row.text-center&attributes(attributes) + for p in oAuthProviders + a.btn.btn-large(href=`/auth/${p.id}?` class=p.id) #[x-icon(name=p.id size=28)] #{p.name} + // TODO ?type='+type+'&birthday='+birthday+'&classCode='+classCode" + button.btn.btn-red.btn-large(type="button" @click="next()") #[x-icon(name="email" size=28)] #{__('Email')} - .form-row + .signup-box + form(:if="type === 'student' && step === 1") label.form-field - input(type='text' name='first' required autocomplete="fname" placeholder=__("First Name")) - span.placeholder= __('First Name') + input(type="date" name="birthday" :bind="birthday" placeholder=__('Date of Birth') autocomplete="bday" required :class="birthdayError ? 'invalid' : ''") + span.placeholder= __('Date of Birth') + p.form-error(:if="birthday && birthdayError")= __('This can’t be right…') label.form-field - input(type='text' name='last' required autocomplete="lname" placeholder=__("Last Name")) - span.placeholder= __('Last Name') - - label.form-field - input(name="email" type="email" required placeholder=__("Email Address") autocomplete="email") - span.placeholder= __('Email Address') - - label.form-field.password - x-password - - label.form-field - select(name='country' required) - for c in countries - option(value=c.id, selected=(c.id === country))= c.name - span.placeholder= __('Country') + input(:bind="classCode" placeholder="Class code (optional)" :class="classCodeError ? 'invalid' : ''") + span.placeholder= __('Class code (optional)') + p.form-error(:if="classCode && classCodeError")= __('This class code is not valid.') + p.form-hint= __('You might get a class code from a teacher or parent. It consists of eight numbers or letters.') + div(:if="birthday && !birthdayError && (!classCode || !classCodeError)") + +socialButtons()(:if="classCode || !isRestricted") + .btn-row(:if="!classCode && isRestricted") + button.btn.btn-red.btn-large(type="button" @click="next()")= __('Continue') - label.form-field - input(name="birthday" type="date" :bind="birthday" placeholder=__('Date of Birth') autocomplete="bday" required) - span.placeholder= __('Date of Birth') + form(:if="type !== 'student' && step === 1") + +socialButtons() - label.form-checkbox!= __('I’ve read and accept the Privacy Policy and our Terms of Service.', config.accounts.privacyPolicy, config.accounts.termsOfUse) - input(type="checkbox" name="policies" required) - .control + form(:if="type === 'student' && isRestricted && !classCode && step === 2" method="POST") + input(type="hidden" name="_csrf" value=_csrf) + input(type="hidden" name="type" value="student") + input(type="hidden" name="birthday" :value="birthday") + label.form-field + input(name="username" :bind="username" pattern="[a-zA-Z1-9]{4,}" placeholder=__('Username') required :class="usernameError ? 'invalid' : ''") + span.placeholder= __('username') + p.form-error(:if="usernameError === 'duplicate'")= __('There already exists an account with this username.') + p.form-hint= __('Your username can contain letters and numbers. For security, don’t use your real name.') + label.form-field + input(type="email" name="email" placeholder=__('Guardian Email') required autocomplete="email") + span.placeholder= __('Guardian Email') + p.form-hint= __('We need to notify your parent or guardian about your account.') + label.form-field.password + x-password + .btn-row: button.btn.btn-red.btn-large(type="submit")= __('Create account') + p.alternative: button.a(type="button" @click="back()")= __('Go back') - .btn-row: button.btn.btn-red.btn-large(type='submit')= __('Create Account') + form(:if="(type !== 'student' || classCode || !isRestricted) && step === 2" method="POST") + input(type="hidden" name="_csrf" value=_csrf) + input(type="hidden" name="type" :value="type") + input(type="hidden" name="code" :value="classCode" :if="type === 'student' && classCode") + input(type="hidden" name="birthday" :value="birthday" :if="type === 'student'") + .form-row + label.form-field + input(name="first" required autocomplete="fname" placeholder=__("First name")) + span.placeholder= __('First name') + label.form-field + input(name="last" required autocomplete="lname" placeholder=__("Last name")) + span.placeholder= __('Last name') + label.form-field + input(name="email" type="email" required autocomplete="email" placeholder=__("Email Address")) + span.placeholder= __('Email Address') + .form-error(:if="emailError === 'duplicate'")!= __('There already exists an account with this email address. Please sign in instead.') + .form-error(:if="emailError === 'invalid'")= __('This email address is not valid.') + label.form-field.password + x-password + label.form-field(:if="type === 'teacher'") + input(name="school" required placeholder=__("School name")) + span.placeholder= __('School name') + label.form-field + select(name="country" required) + for c in countries + option(value=c.id selected=(c.id === country))= c.name + span.placeholder= __('Country') + div + label.form-checkbox!= __('I’ve read and accept the Privacy Policy and our Terms of Service.', config.accounts.privacyPolicy, config.accounts.termsOfUse) + input(type="checkbox" name="policies" required) + .control + .btn-row: button.btn.btn-red.btn-large(type="submit")= __('Create Account') + p.alternative: button.a(type="button" @click="back()")= __('Go back') include ../_footer diff --git a/server/templates/dashboard.pug b/server/templates/dashboard.pug deleted file mode 100755 index 6cc71718..00000000 --- a/server/templates/dashboard.pug +++ /dev/null @@ -1,62 +0,0 @@ -extends _layout - -block vars - - var title = __('Student Dashboard') - - var styles = ['/dashboard.css'] - - var scripts = ['/dashboard.js'] - -block main - .container - h1 - img.avatar(src=user.avatar(200) width=100 height=100) - | #{user.fullName} - span.subtitle= __('Student Dashboard') - - .row.padded - .dashboard-body.grow - block dashboard-body - +flash(messages) - if recent.length - h2= __('Recent progress') - for course in recent - +course(course) - if recommended.length - h2= __('Recommended for you') - for course in recommended - +course(course) - - .dashboard-sidebar: .sidebar-wrap - .sidebar-panel - block dashboard-sidebar - h2= __('Weekly Stats') - .stats-row - .stats - svg(width=110 height=96) - path(d="M23.89,86.11a44,44,0,1,1,62.22,0") - path.m-red(d="M23.89,86.11a44,44,0,1,1,62.22,0" stroke-dasharray=`${208 * stats.points / 100} 1000`) - h3.m-red= stats.points - .small= __('Points') - .stats - svg(width=110 height=96) - path(d="M23.89,86.11a44,44,0,1,1,62.22,0") - path.m-blue(d="M23.89,86.11a44,44,0,1,1,62.22,0" stroke-dasharray=`${208 * stats.minutes / 60} 1000`) - h3.m-blue= stats.minutes - .small= __('Minutes') - - .sidebar-panel - h2= __('Connect with $0', config.siteName) - .connect.row - if config.social.twitter - a.btn.twitter(href=`https://twitter.com/${config.social.twitter.handle}` title="Twitter" target="_blank" rel="noopener") - x-icon(name="twitter" size=32) - if config.social.facebook - a.btn.facebook(href=`https://www.facebook.com/${config.social.facebook.page}/` title="Facebook" target="_blank" rel="noopener") - x-icon(name="facebook" size=32) - if config.social.instagram - a.btn.instagram(href=`https://www.instagram.com/${config.social.instagram.handle}/` title="Instagram" target="_blank" rel="noopener") - x-icon(name="instagram" size=32) - if config.social.youtube - a.btn.youtube(href=`https://www.youtube.com/c/${config.social.youtube.channel}` title="YouTube" target="_blank" rel="noopener") - x-icon(name="youtube" size=32) - - include _footer diff --git a/server/templates/dashboards/parent.pug b/server/templates/dashboards/parent.pug new file mode 100755 index 00000000..d4aa9eac --- /dev/null +++ b/server/templates/dashboards/parent.pug @@ -0,0 +1,55 @@ +extends ../_layout + +block vars + - var title = __('Parent Dashboard') + - var styles = ['/dashboard.css'] + - var scripts = ['/dashboard.js'] + +block main + .container + h1 + img.avatar(src=user.avatar(200) width=100 height=100) + | #{user.fullName} + span.subtitle= __('Parent Dashboard') + + .row.padded + .dashboard-body.grow + block dashboard-body + +flash(messages) + if studentData.length + for student in studentData + h2= student.data.fullName + if student.courses.length + for course in student.courses + +course(course, student.progress[course]) + else + .no-courses + x-icon(name="construction" size=40) + | #{__('$0 has not started any courses.', student.data.shortName)} + else + .empty-dashboard + x-icon(name="construction" size=80) + p= __('No students are linked to your account yet.') + + .dashboard-sidebar: .sidebar-wrap + .sidebar-panel + h2 #{__('Class code')}: #[strong= classroom.code] + .sidebar-panel + h2= __('Linked Students') + for student in studentData + .class-row + img(src=student.data.avatar(80) alt="Avatar" width=40 height=40) + .grow + h3= student.data.fullName + if student.data.isRestricted + .small Restricted + button(data-modal=`remove-${student.data.id}`): x-icon(name="delete") + +modal(`remove-${student.data.id}`)(style="width: 440px") + h2 #[x-icon(name="delete" size=32)] #{__('Remove Student')} + form.form-large(action=`/dashboard/remove/${student.data.id}` method="POST") + p!= __('Are you sure you want to remove $0 from your parent dashboard? To revert this action, they will have to add your class code again on their student dashboard.', `${student.data.fullName}`) + input(type="hidden" name="_csrf" value=_csrf) + p.btn-row: button.btn.btn-red(type="submit")= __('Remove') + p(class=(studentData.length ? 'small' : ''))!= __('Your children should use your unique code $0 when signing up for $1. If they already have an account, they can click “Add class code” in the sidebar of their student dashboard.', `${classroom.code}`, config.siteName) + + include ../_footer diff --git a/server/templates/dashboards/student.pug b/server/templates/dashboards/student.pug new file mode 100755 index 00000000..6bc2bfcb --- /dev/null +++ b/server/templates/dashboards/student.pug @@ -0,0 +1,69 @@ +extends ../_layout + +block vars + - var title = __('Student Dashboard') + - var styles = ['/dashboard.css'] + - var scripts = ['/dashboard.js'] + +block main + .container + h1 + img.avatar(src=user.avatar(200) width=100 height=100) + | #{user.fullName} + span.subtitle= __('Student Dashboard') + + .row.padded + .dashboard-body.grow + block dashboard-body + +flash(messages) + if recent.length + h2= __('Recent progress') + for course in recent + +course(course) + if recommended.length + h2= __('Recommended for you') + for course in recommended + +course(course) + + .dashboard-sidebar: .sidebar-wrap + .sidebar-panel + block dashboard-sidebar + h2= __('Weekly Stats') + .stats-row + .stats + svg(width=110 height=96) + path(d="M23.89,86.11a44,44,0,1,1,62.22,0") + path.m-red(d="M23.89,86.11a44,44,0,1,1,62.22,0" stroke-dasharray=`${208 * stats.points / 100} 1000`) + h3.m-red= stats.points + .small= __('Points') + .stats + svg(width=110 height=96) + path(d="M23.89,86.11a44,44,0,1,1,62.22,0") + path.m-blue(d="M23.89,86.11a44,44,0,1,1,62.22,0" stroke-dasharray=`${208 * stats.minutes / 60} 1000`) + h3.m-blue= stats.minutes + .small= __('Minutes') + + if config.accounts.teachers || config.accounts.parents + .sidebar-panel + h2= __('Teachers and Parents') + for t in teachers + .class-row + img(src=t.avatar(80) alt="Avatar" width=40 height=40) + div + h3= t.fullName + .small= t.type === 'parent' ? __('Parent') : t.school || __('Teacher') + p(class=(teachers.length ? 'small' : '')) #{__('Parent or teacher accounts have access to all your progress data.')} #[a(data-modal="add-class-code") #{__('Add new class code')}] + + if config.accounts.teachers || config.accounts.parents + +modal('add-class-code')(style="width: 440px") + h2 #[x-icon(name="teacher" size=32)] #{__('Add class code')} + form.form-large(action="/dashboard/add" method="POST") + p= __('Adding a class code will give your teacher or parent access to your progress data on $0.', config.siteName) + input(type="hidden" name="_csrf" value=_csrf) + label.form-field + input(name="code" type="text" placeholder=__("Class code") required :bind="classCode" :class="invalid ? 'dirty invalid' : ''") + span(class="placeholder")= __('Class code') + p.form-error(:if="invalid")= __('This class code is not valid.') + p.btn-row: button.btn.btn-red(type="submit" :disabled="!classCode || invalid")= __('Join Class') + + include ../_footer diff --git a/server/templates/dashboards/teacher-class.pug b/server/templates/dashboards/teacher-class.pug new file mode 100755 index 00000000..f1a64b18 --- /dev/null +++ b/server/templates/dashboards/teacher-class.pug @@ -0,0 +1,104 @@ +extends ../_layout + +block vars + - var title = __('Teacher Dashboard') + - var styles = ['/dashboard.css'] + - var scripts = ['/dashboard.js'] + +block main + .container + a.back(href="/dashboard") #[x-icon(name="back")] #{__('All Classes')} + .class-banner #{__('Class code')} #[strong= classroom.code] + h1= classroom.title + button.edit-class(data-modal="edit-class"): x-icon(name="settings" size=26) + + .row.padded + .dashboard-body.grow + +flash(messages) + + if studentData.length && courseData.length + .overflow-wrap: #roster + //- Title Row + .cell.title.c1= __('Student Name') + x-popup.r + .cell.title.c2.interactive.popup-target + .course-img(style="background-color:${course.color};background-image:url(${course.icon})") + | ${course.title} + .popup-body + for course in courseData + .popup-row(@click=`setCourse(${course.id})` :class=`course.id === ${course.id} ? 'active' : ''` tabindex=0) + .course-img(style=`background-color:${course.color};background-image:url(${course.icon})`) + | #{ course.title } + .progress: .bar(style=`color:${course.color};width:${course.progress}%`) + x-popup.r + svg.arrow(width=15 height=32): path(d="M3 3L12 16L3 29") + .cell.title.c3.interactive.popup-target ${section.title} + .popup-body + div(:for="s in course.sections" tabindex=0) + .popup-row.locked(:if="s.locked") ${s.title} + .popup-row(:if="!s.locked" @click="setSection(s)" :class="section.id === s.id ? 'active' : ''") + | ${s.title} + .progress: .bar(style="color:${course.color};width:${s.progress}%") + .cell.title.c4= __('Weekly Time') + + //- Body Rows + for student in studentData + .cell.c1.interactive(tabindex="0" data-modal=`student-${student.id}`) + img.avatar(src=student.avatar alt="" width=32 height=32) + | #{student.name} + .cell.c2: .progress: .bar(style=("color:${course.color};width:${getProgress('" + student.id + "')}%")) + .cell.c3: .progress: .bar(style=("color:${course.color};width:${getProgress('" + student.id + "',section.id)}%")) + .cell.c4 #{student.minutes} min + + script#student-data(type="application/json")!= JSON.stringify(studentData) + script#course-data(type="application/json")!= JSON.stringify(courseData) + + for student in studentData + +modal(`student-${student.id}`) + h2= student.name + // TODO Student dashboard + button.btn.btn-red(data-modal="remove-student" @click=`remove('${student.id}')`) #[x-icon(name="delete")] #{__('Remove student')} + + +modal('remove-student')(style="width: 440px") + h2 #[x-icon(name="delete" size=32)] #{__('Remove Student')} + form.form-large(action=("/dashboard/" + classroom.code + "/remove/${student.id}") method="POST") + p!= __('Are you sure you want to remove $0 from your teacher dashboard? To revert this action, they will have to add your class code again on their student dashboard.', '${student.name}') + p!= __('$0 will continue to be able to use their Mathigon account independently. Please ensure that they have any required parent or guardian consent to do so.', '${student.name}') + input(type="hidden" name="_csrf" value=_csrf) + p.btn-row: button.btn.btn-red(type='submit')= __('Remove') + + else if studentData.length + .empty-dashboard + x-icon(name="construction" size=80) + p= __('Your students have not yet started any courses.') + else + .empty-dashboard + x-icon(name="construction" size=80) + p= __('No students have joined this class yet.') + + .dashboard-sidebar: .sidebar-wrap + .sidebar-panel + h2= __('Add Students') + p!= __('New students can join your class by using the code $0 when signing up for $1. If they already have an account, they can click “Add class code” in the sidebar of their student dashboard.', `${classroom.code}`, config.siteName) + p= __('It is your responsibility to obtain any necessary guardian consent before asking students to sign up.') + + +modal('edit-class') + h2 #[x-icon(name="settings" size=32)] #{__('Class settings')} + form(method="POST") + input(type="hidden" name="_csrf" value=_csrf) + label.form-field + input(name="title" placeholder="Class name" value=classroom.title required maxlength=30) + span.placeholder= __('Class name') + p.btn-row + button.btn.btn-red(type="button" data-modal="delete-class")= __('Delete class') + button.btn.btn-blue(type="submit")= __('Save changes') + + +modal('delete-class') + h2 #[x-icon(name="delete" size=32)] #{__('Delete class')} + form(action=`/dashboard/${classroom.code}/delete` method="POST") + p= __('Are you sure you want to delete this class? It is not possible to restore a class once deleted. Deleting a class won’t delete the accounts of students within that class.') + input(type="hidden" name="_csrf" value=_csrf) + p.btn-row + button.btn.btn-red(type="submit")= __('Delete class') + + include ../_footer diff --git a/server/templates/dashboards/teacher.pug b/server/templates/dashboards/teacher.pug new file mode 100755 index 00000000..63c5c950 --- /dev/null +++ b/server/templates/dashboards/teacher.pug @@ -0,0 +1,36 @@ +extends ../_layout + +block vars + - var title = __('Teacher Dashboard') + - var styles = ['/dashboard.css'] + - var scripts = ['/dashboard.js'] + +block main + .container + h1 + img.avatar(src=user.avatar(200) width=100 height=100) + | #{user.fullName} + span.subtitle= __('Teacher Dashboard') + + +flash(messages) + + .row.padded-thin(style="justify-content:left") + for classroom in classrooms + a.class-panel(href=`/dashboard/${classroom.code}`) + h3= classroom.title + p Class code: #{classroom.code} + - var size = classroom.students.length + p= !size ? __('No students') : size === 1 ? __('One student') : __('$0 students', size) + button.class-panel.new-class(data-modal="new-class") #[x-icon(name="plus" size=80)] #{__('Create new class')} + + include ../_footer + + +modal('new-class') + h2 #[x-icon(name="plus" size=32)] #{__('Create a new class')} + form.form-large(action="/dashboard/new" method="POST") + input(type="hidden" name="_csrf" value=_csrf) + p= __('Every class has a unique code, which allows you to link student accounts and track their progress. Students can be part of multiple different classes.') + label.form-field + input(name="title" type="text" placeholder="Class name" required maxlength=30) + span(class="placeholder")= __('Class name') + p.btn-row: button.btn.btn-red(type="submit")= __('Create') diff --git a/server/templates/emails/classroom-welcome-simple.pug b/server/templates/emails/classroom-welcome-simple.pug new file mode 100644 index 00000000..18b2227b --- /dev/null +++ b/server/templates/emails/classroom-welcome-simple.pug @@ -0,0 +1,5 @@ +| Hi #{user.shortName},#{'\n'} + +| Welcome to #{config.siteName}! Your teacher #{teacher.shortName} has just created a new account for you. You can sign in using Google, by clicking the link below. We will save your work, personalise our content, and share your progress with your teachers.#{'\n'} + +| https://#{config.domain}/login diff --git a/server/templates/emails/classroom-welcome.pug b/server/templates/emails/classroom-welcome.pug new file mode 100644 index 00000000..e49cd92d --- /dev/null +++ b/server/templates/emails/classroom-welcome.pug @@ -0,0 +1,9 @@ +extends _template + +block preheader + | Welcome to #{config.siteName}! + +block body + h1(style="font-size: 24px;margin:0 0 20px 0") Hi #{user.shortName}, + p Welcome to #{config.siteName}! Your teacher #[em= teacher.shortName] has just created a new account for you. You can sign in using Google, by clicking the link below. We will save your work, personalise our content, and share your progress with your teachers. + +button(`https://${config.domain}/login`, "Sign in") diff --git a/server/templates/emails/consent-simple.pug b/server/templates/emails/consent-simple.pug new file mode 100644 index 00000000..26ccd807 --- /dev/null +++ b/server/templates/emails/consent-simple.pug @@ -0,0 +1,16 @@ +| Hi,#{'\n'} + +| This email is to let you know that your child just created a #{config.siteName} account with username #{user.username}, and provided your email address as their parent or guardian.#{'\n'} + +| In order to comply with child privacy laws, we require your consent before your child can use #{config.siteName}. Please have a look at our Privacy Policy (#{config.accounts.privacyPolicy}) and Terms of Use (#{config.accounts.termsOfUse}).#{'\n'} + +| Then, grant your approval by clicking the link below. If you do not approve, your child’s account will be deactivated in 7 days: +| https://#{config.domain}/consent/#{user.consentToken}#{'\n'} + +| Your child will have a #[em restricted account], which means we will collect no personal information other than their country and date of birth. By approving your child’s account, you agree to our Terms of Use and Privacy Policy.#{'\n'} + +| If you believe you received this email in error, you can safely ignore it and we will remove your email address from our system.#{'\n'} + +| Please contact us if you have any questions, and thanks for allowing your child to learn with us.#{'\n'} + +| The #{config.siteName} Team diff --git a/server/templates/emails/consent.pug b/server/templates/emails/consent.pug new file mode 100644 index 00000000..6f40e3b9 --- /dev/null +++ b/server/templates/emails/consent.pug @@ -0,0 +1,23 @@ +extends _template + +block preheader + | Approve your child’s #{config.siteName} account + +block body + h1(style="font-size: 24px;margin:0 0 20px 0") Hi, + + p This email is to let you know that your child just created a #{config.siteName} account with username #[strong= user.username], and provided your email address as their parent or guardian. + + p In order to comply with child privacy laws, we require your consent before your child can use #{config.siteName}. Please have a look at our #[a(href="#{config.accounts.privacyPolicy}") Privacy Policy] and #[a(href="#{config.accounts.termsOfUse}") Terms of Use], and then grant your approval by clicking the link below. If you do not approve, your child’s account will be deactivated in 7 days. + + +button(`https://#{config.domain}/consent/${user.consentToken}`, `Allow my child to use ${config.siteName}`) + + p Alternatively, copy this URL into your browser: https://#{config.domain}/consent/#{user.consentToken} + + p Your child will have a #[em restricted account], which means we will collect no personal information other than their country and date of birth. By approving your child’s account, you agree to our Terms of Use and Privacy Policy. + + p If you believe you received this email in error, you can safely ignore it and we will remove your email address from our system. + + p Please contact us if you have any questions, and thanks for allowing your child to learn with us. + + p: em The #{config.siteName} Team diff --git a/server/templates/emails/join-class-simple.pug b/server/templates/emails/join-class-simple.pug new file mode 100644 index 00000000..714f71fc --- /dev/null +++ b/server/templates/emails/join-class-simple.pug @@ -0,0 +1,5 @@ +| Dear #{user.shortName},#{'\n'} + +| We wanted to let you know that #{student.fullName} has just joined your Mathigon class using your unique class code. You can manage your students’ accounts on your #{user.type} dashboard: https://#{config.domain}/dashboard#{'\n'} + +| Thanks for using #{config.siteName}!#{'\n'} diff --git a/server/templates/emails/join-class.pug b/server/templates/emails/join-class.pug new file mode 100644 index 00000000..38ce39fe --- /dev/null +++ b/server/templates/emails/join-class.pug @@ -0,0 +1,11 @@ +extends _template + +block preheader + | #{student.name} has joined your #{config.siteName} class + +block body + h1(style="font-size: 24px;margin:0 0 20px 0") Dear #{user.shortName}, + + p We wanted to let you know that #[strong= student.fullName] has just joined your #{config.siteName} class using your unique class code. You can manage your students’ accounts on your #[a(href="https://#{config.domain}/dashboard") #{user.type} dashboard]. + + p Thanks for using #{config.siteName}! diff --git a/server/utilities/emails.ts b/server/utilities/emails.ts index aeb0a355..67f594b4 100755 --- a/server/utilities/emails.ts +++ b/server/utilities/emails.ts @@ -7,7 +7,6 @@ import path from 'path'; import {compileFile} from 'pug'; import Sendgrid from '@sendgrid/mail'; - import {UserDocument} from '../models/user'; import {CONFIG} from './utilities'; @@ -25,7 +24,10 @@ type MailData = Omit & {from?: string, t export async function sendEmail(options: MailData) { if (!options.from) options.from = `${CONFIG.siteName} `; - if (options.user && !options.to) options.to = `${options.user.fullName} <${options.user.email}>`; + if (options.user && !options.to) { + const email = options.user.email || options.user.guardianEmail; + options.to = `${options.user.fullName} <${email}>`; + } try { return await Sendgrid.send(options as Sendgrid.MailDataRequired); @@ -39,7 +41,7 @@ export async function sendEmail(options: MailData) { const WELCOME = loadEmailTemplate('welcome'); export function sendWelcomeEmail(user: UserDocument) { return sendEmail({ - subject: 'Welcome to Mathigon!', + subject: `Welcome to ${CONFIG.siteName}!`, html: WELCOME[0]({user, config: CONFIG}), text: WELCOME[1]({user, config: CONFIG}), user @@ -49,7 +51,7 @@ export function sendWelcomeEmail(user: UserDocument) { const RESET = loadEmailTemplate('reset'); export function sendPasswordResetEmail(user: UserDocument, token: string) { return sendEmail({ - subject: 'Mathigon Password Reset', + subject: `${CONFIG.siteName} Password Reset`, html: RESET[0]({user, token, config: CONFIG}), text: RESET[1]({user, token, config: CONFIG}), user @@ -59,7 +61,7 @@ export function sendPasswordResetEmail(user: UserDocument, token: string) { const PASSWORD = loadEmailTemplate('password'); export function sendPasswordChangedEmail(user: UserDocument) { return sendEmail({ - subject: 'Mathigon Password Change Notification', + subject: `${CONFIG.siteName} Password Change Notification`, html: PASSWORD[0]({user, config: CONFIG}), text: PASSWORD[1]({user, config: CONFIG}), user @@ -69,9 +71,39 @@ export function sendPasswordChangedEmail(user: UserDocument) { const CHANGE_EMAIL = loadEmailTemplate('change-email'); export function sendChangeEmailConfirmation(user: UserDocument) { return sendEmail({ - subject: 'Confirm your new email address for Mathigon', + subject: `Confirm your new email address for ${CONFIG.siteName}`, html: CHANGE_EMAIL[0]({user, config: CONFIG}), text: CHANGE_EMAIL[1]({user, config: CONFIG}), user }); } + +const CONSENT = loadEmailTemplate('consent'); +export function sendGuardianConsentEmail(user: UserDocument) { + return sendEmail({ + subject: `Approve your child’s ${CONFIG.siteName} account`, + text: CONSENT[0]({user, config: CONFIG}), + html: CONSENT[1]({user, config: CONFIG}), + to: user.guardianEmail + }); +} + +const JOIN_CLASS = loadEmailTemplate('join-class'); +export function sendClassCodeAddedEmail(student: UserDocument, teacher: UserDocument) { + return sendEmail({ + subject: `A new student joined your ${CONFIG.siteName} class`, + text: JOIN_CLASS[0]({student, user: teacher, config: CONFIG}), + html: JOIN_CLASS[1]({student, user: teacher, config: CONFIG}), + user: teacher! + }); +} + +const CLASSROOM_WELCOME = loadEmailTemplate('classroom-welcome'); +export function sendStudentAddedToClassEmail(user: UserDocument, teacher: UserDocument) { + return sendEmail({ + subject: `Welcome to ${CONFIG.siteName}`, + text: CLASSROOM_WELCOME[0]({user, teacher, config: CONFIG}), + html: CLASSROOM_WELCOME[1]({user, teacher, config: CONFIG}), + user + }); +} diff --git a/server/utilities/oauth.ts b/server/utilities/oauth.ts index 115cec3c..5cdee8ac 100644 --- a/server/utilities/oauth.ts +++ b/server/utilities/oauth.ts @@ -7,12 +7,13 @@ import express from 'express'; import {URLSearchParams} from 'url'; import fetch from 'node-fetch'; +import {Classroom} from '../models/classroom'; import {Progress} from '../models/progress'; import {User} from '../models/user'; import {sendWelcomeEmail} from './emails'; import {CONFIG, loadData, q} from './utilities'; -import {normalizeEmail} from './validate'; +import {checkBirthday, isClassCode, normalizeEmail, sanitizeType} from './validate'; // ----------------------------------------------------------------------------- @@ -180,6 +181,15 @@ export async function oAuthLogin(req: express.Request) { if (!CONFIG.accounts.oAuth?.[req.params.provider]) return; const provider = req.params.provider as Provider; + // Save pending user information to session storage. + const birthday = checkBirthday(q(req, 'birthday')); + const classCode = q(req, 'classCode'); + req.session.pending = { + type: sanitizeType(q(req, 'type')), + birthday: birthday ? +birthday : undefined, + classCode: isClassCode(classCode) ? classCode : undefined + }; + const redirect = login(req, provider); return redirect ? {redirect} : {error: 'socialLoginError', params: [PROVIDERS[provider].title]}; } @@ -190,5 +200,19 @@ export async function oAuthCallback(req: express.Request) { const profile = await getProfile(req, provider); const user = profile ? await findOrCreateUser(req, provider, profile) : undefined; + + if (user?.isRestricted) user.isRestricted = user.guardianConsentToken = undefined; + + // Apply information that users provided before clicking the OAuth button. + const pending = req.session.pending; + if (user && pending) { + if (pending.type && user.type === 'student') user.type = pending.type; + if (!user.birthday && pending.birthday) user.birthday = new Date(pending.birthday); + const classroom = pending.classCode ? await Classroom.lookup(pending.classCode) : undefined; + if (classroom) await classroom.addStudent(user); + req.session.pending = undefined; + if (user.type !== 'student') await Classroom.make('Default Class', user).save(); + } + return user ? {user} : {error: 'socialLoginError', params: [PROVIDERS[provider].title]}; } diff --git a/server/utilities/utilities.ts b/server/utilities/utilities.ts index 0418fb37..7e2d0842 100644 --- a/server/utilities/utilities.ts +++ b/server/utilities/utilities.ts @@ -22,7 +22,8 @@ export const OUT_DIR = PROJECT_DIR + '/public'; export const ENV = process.env.NODE_ENV || 'development'; export const IS_PROD = ENV === 'production'; -export const ONE_YEAR = 1000 * 60 * 60 * 24 * 365; +export const ONE_DAY = 1000 * 60 * 60 * 24; +export const ONE_YEAR = ONE_DAY * 365; // ----------------------------------------------------------------------------- diff --git a/server/utilities/validate.ts b/server/utilities/validate.ts index 3f36faf0..a169d91e 100644 --- a/server/utilities/validate.ts +++ b/server/utilities/validate.ts @@ -6,6 +6,7 @@ import {isBetween} from '@mathigon/fermat'; import validator from 'validator'; +import {USER_TYPES} from '../models/user'; import {ONE_YEAR} from './utilities'; @@ -14,6 +15,11 @@ export function sanitizeString(str: string, maxLength = 40) { .replace(/\s+/, ' ').slice(0, maxLength); } +export function sanitizeType(str?: string|null): 'student'|'teacher'|'parent' { + if (!str) return 'student'; + return USER_TYPES.includes(str as any) ? (str as 'student'|'teacher'|'parent') : 'student'; +} + export function checkBirthday(birthdayString?: string|null) { const date = birthdayString ? validator.toDate(birthdayString) : undefined; if (!date) return; @@ -26,3 +32,13 @@ export function normalizeEmail(str?: string|null) { if (!str || !validator.isEmail(str)) return; return validator.normalizeEmail(str) || undefined; } + +export function normalizeUsername(str?: string|null) { + if (!str) return; + str = str.toLowerCase().trim(); + return /^[a-z0-9_]{4,}$/.test(str) ? str : undefined; +} + +export function isClassCode(str?: string|null) { + return !!str && /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(str); +}