first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import Path from 'node:path'
import { fileURLToPath } from 'node:url'
import UserGetter from '../../../../app/src/Features/User/UserGetter.js'
import UserRegistrationHandler from '../../../../app/src/Features/User/UserRegistrationHandler.js'
import ErrorController from '../../../../app/src/Features/Errors/ErrorController.js'
import { expressify } from '@overleaf/promise-utils'
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
function registerNewUser(req, res, next) {
res.render(Path.resolve(__dirname, '../views/user/register'))
}
async function register(req, res, next) {
const { email } = req.body
if (email == null || email === '') {
return res.sendStatus(422) // Unprocessable Entity
}
const { user, setNewPasswordUrl } =
await UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail(
email
)
res.json({
email: user.email,
setNewPasswordUrl,
})
}
async function activateAccountPage(req, res, next) {
// An 'activation' is actually just a password reset on an account that
// was set with a random password originally.
if (req.query.user_id == null || req.query.token == null) {
return ErrorController.notFound(req, res)
}
if (typeof req.query.user_id !== 'string') {
return ErrorController.forbidden(req, res)
}
const user = await UserGetter.promises.getUser(req.query.user_id, {
email: 1,
loginCount: 1,
})
if (!user) {
return ErrorController.notFound(req, res)
}
if (user.loginCount > 0) {
// Already seen this user, so account must be activated.
// This lets users keep clicking the 'activate' link in their email
// as a way to log in which, if I know our users, they will.
return res.redirect(`/login`)
}
req.session.doLoginAfterPasswordReset = true
res.render(Path.resolve(__dirname, '../views/user/activate'), {
title: 'activate_account',
email: user.email,
token: req.query.token,
})
}
export default {
registerNewUser,
register: expressify(register),
activateAccountPage: expressify(activateAccountPage),
}

View File

@@ -0,0 +1,30 @@
import logger from '@overleaf/logger'
import UserActivateController from './UserActivateController.mjs'
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.js'
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.js'
export default {
apply(webRouter) {
logger.debug({}, 'Init UserActivate router')
webRouter.get(
'/admin/user',
AuthorizationMiddleware.ensureUserIsSiteAdmin,
(req, res) => res.redirect('/admin/register')
)
webRouter.get('/user/activate', UserActivateController.activateAccountPage)
AuthenticationController.addEndpointToLoginWhitelist('/user/activate')
webRouter.get(
'/admin/register',
AuthorizationMiddleware.ensureUserIsSiteAdmin,
UserActivateController.registerNewUser
)
webRouter.post(
'/admin/register',
AuthorizationMiddleware.ensureUserIsSiteAdmin,
UserActivateController.register
)
},
}

View File

@@ -0,0 +1 @@
{ "extends": "../../../../tsconfig.backend.json" }

View File

@@ -0,0 +1,72 @@
extends ../../../../../app/views/layout-website-redesign-bootstrap-5
block content
main.content.content-alt#main-content
.container
div.col-lg-6.col-xl-4.m-auto
.notification-list
.notification.notification-type-success(aria-live="off" role="alert")
.notification-content-and-cta
.notification-icon
span.material-symbols(aria-hidden="true")
| check_circle
.notification-content
p
| #{translate("nearly_activated")}
h1.h3 #{translate("please_set_a_password")}
form(
data-ol-async-form
name="activationForm",
action="/user/password/set",
method="POST",
)
+formMessages()
+customFormMessage('token-expired', 'danger')
| #{translate("activation_token_expired")}
+customFormMessage('invalid-password', 'danger')
| #{translate('invalid_password')}
input(name='_csrf', type='hidden', value=csrfToken)
input(
type="hidden",
name="passwordResetToken",
value=token
)
.form-group
label(for='emailField') #{translate("email")}
input.form-control#emailField(
aria-label="email",
type='email',
name='email',
placeholder="email@example.com",
autocomplete="username"
value=email
required,
disabled
)
.form-group
label(for='passwordField') #{translate("password")}
input.form-control#passwordField(
type='password',
name='password',
placeholder="********",
autocomplete="new-password",
autofocus,
required,
minlength=settings.passwordStrengthOptions.length.min
)
.actions
button.btn.btn-primary(
type='submit',
data-ol-disabled-inflight
aria-label=translate('activate')
)
span(data-ol-inflight="idle")
| #{translate('activate')}
span(hidden data-ol-inflight="pending")
| #{translate('activating')}…

View File

@@ -0,0 +1,13 @@
extends ../../../../../app/views/layout-marketing
block entrypointVar
- entrypoint = 'modules/user-activate/pages/user-activate-page'
block vars
- bootstrap5PageStatus = 'disabled'
block content
.content.content-alt#main-content
.container
#user-activate-register-container

View File

@@ -0,0 +1,87 @@
import PropTypes from 'prop-types'
import { postJSON } from '../../../../../frontend/js/infrastructure/fetch-json'
function RegisterForm({
setRegistrationSuccess,
setEmails,
setRegisterError,
setFailedEmails,
}) {
function handleRegister(event) {
event.preventDefault()
const formData = new FormData(event.target)
const formDataAsEntries = formData.entries()
const formDataAsObject = Object.fromEntries(formDataAsEntries)
const emailString = formDataAsObject.email
setRegistrationSuccess(false)
setRegisterError(false)
setEmails([])
registerGivenUsers(parseEmails(emailString))
}
async function registerGivenUsers(emails) {
const registeredEmails = []
const failingEmails = []
for (const email of emails) {
try {
const result = await registerUser(email)
registeredEmails.push(result)
} catch {
failingEmails.push(email)
}
}
if (registeredEmails.length > 0) setRegistrationSuccess(true)
if (failingEmails.length > 0) {
setRegisterError(true)
setFailedEmails(failingEmails)
}
setEmails(registeredEmails)
}
function registerUser(email) {
const options = { email }
const url = `/admin/register`
return postJSON(url, { body: options })
}
return (
<form onSubmit={handleRegister}>
<div className="row">
<div className="col-md-4 col-xs-8">
<input
className="form-control"
name="email"
type="text"
placeholder="jane@example.com, joe@example.com"
aria-label="emails to register"
aria-describedby="input-details"
/>
<p id="input-details" className="sr-only">
Enter the emails you would like to register and separate them using
commas
</p>
</div>
<div className="col-md-8 col-xs-4">
<button className="btn btn-primary">Register</button>
</div>
</div>
</form>
)
}
function parseEmails(emailsText) {
const regexBySpaceOrComma = /[\s,]+/
let emails = emailsText.split(regexBySpaceOrComma)
emails.map(email => email.trim())
emails = emails.filter(email => email.indexOf('@') !== -1)
return emails
}
RegisterForm.propTypes = {
setRegistrationSuccess: PropTypes.func,
setEmails: PropTypes.func,
setRegisterError: PropTypes.func,
setFailedEmails: PropTypes.func,
}
export default RegisterForm

View File

@@ -0,0 +1,92 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import RegisterForm from './register-form'
function UserActivateRegister() {
const [emails, setEmails] = useState([])
const [failedEmails, setFailedEmails] = useState([])
const [registerError, setRegisterError] = useState(false)
const [registrationSuccess, setRegistrationSuccess] = useState(false)
return (
<div className="row">
<div className="col-md-12">
<div className="card">
<div className="page-header">
<h1> Register New Users</h1>
</div>
<RegisterForm
setRegistrationSuccess={setRegistrationSuccess}
setEmails={setEmails}
setRegisterError={setRegisterError}
setFailedEmails={setFailedEmails}
/>
{registerError ? (
<UserActivateError failedEmails={failedEmails} />
) : null}
{registrationSuccess ? (
<>
<SuccessfulRegistrationMessage />
<hr />
<DisplayEmailsList emails={emails} />
</>
) : null}
</div>
</div>
</div>
)
}
function UserActivateError({ failedEmails }) {
return (
<div className="row-spaced text-danger">
<p>Sorry, an error occured, failed to register these emails.</p>
{failedEmails.map(email => (
<p key={email}>{email}</p>
))}
</div>
)
}
function SuccessfulRegistrationMessage() {
return (
<div className="row-spaced text-success">
<p>We've sent out welcome emails to the registered users.</p>
<p>
You can also manually send them URLs below to allow them to reset their
password and log in for the first time.
</p>
<p>
(Password reset tokens will expire after one week and the user will need
registering again).
</p>
</div>
)
}
function DisplayEmailsList({ emails }) {
return (
<table className="table table-striped ">
<tbody>
<tr>
<th>Email</th>
<th>Set Password Url</th>
</tr>
{emails.map(user => (
<tr key={user.email}>
<td>{user.email}</td>
<td style={{ wordBreak: 'break-all' }}>{user.setNewPasswordUrl}</td>
</tr>
))}
</tbody>
</table>
)
}
DisplayEmailsList.propTypes = {
emails: PropTypes.array,
}
UserActivateError.propTypes = {
failedEmails: PropTypes.array,
}
export default UserActivateRegister

View File

@@ -0,0 +1,9 @@
import '@/marketing'
import ReactDOM from 'react-dom'
import UserActivateRegister from '../components/user-activate-register'
ReactDOM.render(
<UserActivateRegister />,
document.getElementById('user-activate-register-container')
)

View File

@@ -0,0 +1,12 @@
import UserActivateRouter from './app/src/UserActivateRouter.mjs'
/**
* @import { WebModule } from "../../types/web-module"
*/
/** @type {WebModule} */
const UserActivateModule = {
router: UserActivateRouter,
}
export default UserActivateModule

View File

@@ -0,0 +1,62 @@
import { expect } from 'chai'
import { render, screen, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import RegisterForm from '../../../../frontend/js/components/register-form'
describe('RegisterForm', function () {
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('should render the register form', async function () {
const setRegistrationSuccessStub = sinon.stub()
const setEmailsStub = sinon.stub()
const setRegisterErrorStub = sinon.stub()
const setFailedEmailsStub = sinon.stub()
render(
<RegisterForm
setRegistrationSuccess={setRegistrationSuccessStub}
setEmails={setEmailsStub}
setRegisterError={setRegisterErrorStub}
setFailedEmails={setFailedEmailsStub}
/>
)
await screen.findByLabelText('emails to register')
screen.getByRole('button', { name: /register/i })
})
it('should call the fetch request when register button is pressed', async function () {
const email = 'abc@gmail.com'
const setRegistrationSuccessStub = sinon.stub()
const setEmailsStub = sinon.stub()
const setRegisterErrorStub = sinon.stub()
const setFailedEmailsStub = sinon.stub()
const endPointResponse = {
status: 200,
body: {
email,
setNewPasswordUrl: 'SetNewPasswordURL',
},
}
const registerMock = fetchMock.post('/admin/register', endPointResponse)
render(
<RegisterForm
setRegistrationSuccess={setRegistrationSuccessStub}
setEmails={setEmailsStub}
setRegisterError={setRegisterErrorStub}
setFailedEmails={setFailedEmailsStub}
/>
)
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
expect(registerMock.callHistory.called()).to.be.true
})
})

View File

@@ -0,0 +1,140 @@
import { expect } from 'chai'
import { render, screen, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import UserActivateRegister from '../../../../frontend/js/components/user-activate-register'
describe('UserActivateRegister', function () {
beforeEach(function () {
fetchMock.removeRoutes().clearHistory()
})
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('should display the error message', async function () {
const email = 'abc@gmail.com'
render(<UserActivateRegister />)
const endPointResponse = {
status: 500,
}
const registerMock = fetchMock.post('/admin/register', endPointResponse)
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('Sorry, an error occured', { exact: false })
})
it('should display the success message', async function () {
const email = 'abc@gmail.com'
render(<UserActivateRegister />)
const endPointResponse = {
status: 200,
body: {
email,
setNewPasswordUrl: 'SetNewPasswordURL',
},
}
const registerMock = fetchMock.post('/admin/register', endPointResponse)
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
expect(registerMock.callHistory.called()).to.be.true
await screen.findByText(
"We've sent out welcome emails to the registered users."
)
})
it('should display the registered emails', async function () {
const email = 'abc@gmail.com, def@gmail.com'
render(<UserActivateRegister />)
const endPointResponse1 = {
status: 200,
body: {
email: 'abc@gmail.com',
setNewPasswordUrl: 'SetNewPasswordURL',
},
}
const endPointResponse2 = {
status: 200,
body: {
email: 'def@gmail.com',
setNewPasswordUrl: 'SetNewPasswordURL',
},
}
const registerMock = fetchMock.post('/admin/register', (path, req) => {
const body = JSON.parse(req.body)
if (body.email === 'abc@gmail.com') return endPointResponse1
else if (body.email === 'def@gmail.com') return endPointResponse2
})
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('abc@gmail.com')
await screen.findByText('def@gmail.com')
})
it('should display the failed emails', async function () {
const email = 'abc@, def@'
render(<UserActivateRegister />)
const endPointResponse1 = {
status: 500,
}
const endPointResponse2 = {
status: 500,
}
const registerMock = fetchMock.post('/admin/register', (path, req) => {
const body = JSON.parse(req.body)
if (body.email === 'abc@') return endPointResponse1
else if (body.email === 'def@') return endPointResponse2
})
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('abc@')
await screen.findByText('def@')
})
it('should display the registered and failed emails together', async function () {
const email = 'abc@gmail.com, def@'
render(<UserActivateRegister />)
const endPointResponse1 = {
status: 200,
body: {
email: 'abc@gmail.com',
setNewPasswordUrl: 'SetNewPasswordURL',
},
}
const endPointResponse2 = {
status: 500,
}
const registerMock = fetchMock.post('/admin/register', (path, req) => {
const body = JSON.parse(req.body)
if (body.email === 'abc@gmail.com') return endPointResponse1
else if (body.email === 'def@gmail.com') return endPointResponse2
else return 500
})
const registerInput = screen.getByLabelText('emails to register')
const registerButton = screen.getByRole('button', { name: /register/i })
fireEvent.change(registerInput, { target: { value: email } })
fireEvent.click(registerButton)
expect(registerMock.callHistory.called()).to.be.true
await screen.findByText('abc@gmail.com')
await screen.findByText('def@')
})
})

View File

@@ -0,0 +1,134 @@
import Path from 'node:path'
import { fileURLToPath } from 'node:url'
import { strict as esmock } from 'esmock'
import sinon from 'sinon'
const __dirname = Path.dirname(fileURLToPath(import.meta.url))
const MODULE_PATH = '../../../app/src/UserActivateController.mjs'
const VIEW_PATH = Path.join(__dirname, '../../../app/views/user/activate')
describe('UserActivateController', function () {
beforeEach(async function () {
this.user = {
_id: (this.user_id = 'kwjewkl'),
features: {},
email: 'joe@example.com',
}
this.UserGetter = {
promises: {
getUser: sinon.stub(),
},
}
this.UserRegistrationHandler = { promises: {} }
this.ErrorController = { notFound: sinon.stub() }
this.SplitTestHandler = {
promises: {
getAssignment: sinon.stub().resolves({ variant: 'default' }),
},
}
this.UserActivateController = await esmock(MODULE_PATH, {
'../../../../../app/src/Features/User/UserGetter.js': this.UserGetter,
'../../../../../app/src/Features/User/UserRegistrationHandler.js':
this.UserRegistrationHandler,
'../../../../../app/src/Features/Errors/ErrorController.js':
this.ErrorController,
'../../../../../app/src/Features/SplitTests/SplitTestHandler':
this.SplitTestHandler,
})
this.req = {
body: {},
query: {},
session: {
user: this.user,
},
}
this.res = {
json: sinon.stub(),
}
})
describe('activateAccountPage', function () {
beforeEach(function () {
this.UserGetter.promises.getUser = sinon.stub().resolves(this.user)
this.req.query.user_id = this.user_id
this.req.query.token = this.token = 'mock-token-123'
})
it('should 404 without a user_id', async function (done) {
delete this.req.query.user_id
this.ErrorController.notFound = () => done()
this.UserActivateController.activateAccountPage(this.req, this.res)
})
it('should 404 without a token', function (done) {
delete this.req.query.token
this.ErrorController.notFound = () => done()
this.UserActivateController.activateAccountPage(this.req, this.res)
})
it('should 404 without a valid user_id', function (done) {
this.UserGetter.promises.getUser = sinon.stub().resolves(null)
this.ErrorController.notFound = () => done()
this.UserActivateController.activateAccountPage(this.req, this.res)
})
it('should 403 for complex user_id', function (done) {
this.ErrorController.forbidden = () => done()
this.req.query.user_id = { first_name: 'X' }
this.UserActivateController.activateAccountPage(this.req, this.res)
})
it('should redirect activated users to login', function (done) {
this.user.loginCount = 1
this.res.redirect = url => {
sinon.assert.calledWith(this.UserGetter.promises.getUser, this.user_id)
url.should.equal('/login')
return done()
}
this.UserActivateController.activateAccountPage(this.req, this.res)
})
it('render the activation page if the user has not logged in before', function (done) {
this.user.loginCount = 0
this.res.render = (page, opts) => {
page.should.equal(VIEW_PATH)
opts.email.should.equal(this.user.email)
opts.token.should.equal(this.token)
return done()
}
this.UserActivateController.activateAccountPage(this.req, this.res)
})
})
describe('register', function () {
beforeEach(async function () {
this.UserRegistrationHandler.promises.registerNewUserAndSendActivationEmail =
sinon.stub().resolves({
user: this.user,
setNewPasswordUrl: (this.url = 'mock/url'),
})
this.req.body.email = this.user.email = this.email = 'email@example.com'
await this.UserActivateController.register(this.req, this.res)
})
it('should register the user and send them an email', function () {
sinon.assert.calledWith(
this.UserRegistrationHandler.promises
.registerNewUserAndSendActivationEmail,
this.email
)
})
it('should return the user and activation url', function () {
this.res.json
.calledWith({
email: this.email,
setNewPasswordUrl: this.url,
})
.should.equal(true)
})
})
})

View File

@@ -0,0 +1 @@
{ "extends": "../../../../tsconfig.backend.json" }