1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-11-24 08:12:24 +02:00

Server: Added signup pages

This commit is contained in:
Laurent Cozic 2021-05-26 19:55:43 +02:00
parent 0ef7e98479
commit 41ed66d323
15 changed files with 305 additions and 61 deletions

View File

@ -30,7 +30,7 @@ if [ "$RESET_ALL" == "1" ]; then
echo "config sync.9.username $USER_EMAIL" >> "$CMD_FILE"
echo "config sync.9.password 123456" >> "$CMD_FILE"
if [ "$1" == "1" ]; then
if [ "$USER_NUM" == "1" ]; then
curl --data '{"action": "createTestUsers"}' -H 'Content-Type: application/json' http://api-joplincloud.local:22300/api/debug
echo 'mkbook "shared"' >> "$CMD_FILE"

View File

@ -5,6 +5,7 @@ import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/lib/Logger';
import * as MarkdownIt from 'markdown-it';
import config from '../config';
import { NotificationKey } from '../models/NotificationModel';
const logger = Logger.create('notificationHandler');
@ -17,21 +18,12 @@ async function handleChangeAdminPasswordNotification(ctx: AppContext) {
if (defaultAdmin) {
await notificationModel.add(
ctx.owner.id,
'change_admin_password',
NotificationKey.ChangeAdminPassword,
NotificationLevel.Important,
_('The default admin password is insecure and has not been changed! [Change it now](%s)', ctx.models.user().profileUrl())
);
} else {
await notificationModel.markAsRead(ctx.owner.id, 'change_admin_password');
}
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod',
NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
);
await notificationModel.markAsRead(ctx.owner.id, NotificationKey.ChangeAdminPassword);
}
}
@ -43,9 +35,7 @@ async function handleSqliteInProdNotification(ctx: AppContext) {
if (config().database.client === 'sqlite3' && ctx.env === 'prod') {
await notificationModel.add(
ctx.owner.id,
'using_sqlite_in_prod',
NotificationLevel.Important,
'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.'
NotificationKey.UsingSqliteInProd
);
}
}

View File

@ -0,0 +1,12 @@
import { Knex } from 'knex';
import { DbConnection } from '../db';
export async function up(db: DbConnection): Promise<any> {
await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) {
table.integer('account_type').defaultTo(0).notNullable();
});
}
export async function down(_db: DbConnection): Promise<any> {
}

View File

@ -1,5 +1,6 @@
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, expectThrow } from '../utils/testing/testUtils';
import { Notification, NotificationLevel } from '../db';
import { NotificationKey } from './NotificationModel';
describe('NotificationModel', function() {
@ -16,15 +17,15 @@ describe('NotificationModel', function() {
});
test('should require a user to create the notification', async function() {
await expectThrow(async () => models().notification().add('', 'test', NotificationLevel.Normal, 'test'));
await expectThrow(async () => models().notification().add('', NotificationKey.ConfirmEmail, NotificationLevel.Normal, NotificationKey.ConfirmEmail));
});
test('should create a notification', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, 'test');
expect(n.key).toBe('test');
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
const n: Notification = await model.loadByKey(user.id, NotificationKey.ConfirmEmail);
expect(n.key).toBe(NotificationKey.ConfirmEmail);
expect(n.message).toBe('testing');
expect(n.level).toBe(NotificationLevel.Important);
});
@ -32,18 +33,18 @@ describe('NotificationModel', function() {
test('should create only one notification per key', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
expect((await model.all()).length).toBe(1);
});
test('should mark a notification as read', async function() {
const { user } = await createUserAndSession(1, true);
const model = models().notification();
await model.add(user.id, 'test', NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, 'test')).read).toBe(0);
await model.markAsRead(user.id, 'test');
expect((await model.loadByKey(user.id, 'test')).read).toBe(1);
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Important, 'testing');
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(0);
await model.markAsRead(user.id, NotificationKey.ConfirmEmail);
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(1);
});
});

View File

@ -2,6 +2,38 @@ import { Notification, NotificationLevel, Uuid } from '../db';
import { ErrorUnprocessableEntity } from '../utils/errors';
import BaseModel, { ValidateOptions } from './BaseModel';
export enum NotificationKey {
ConfirmEmail = 'confirmEmail',
PasswordSet = 'passwordSet',
EmailConfirmed = 'emailConfirmed',
ChangeAdminPassword = 'change_admin_password',
UsingSqliteInProd = 'using_sqlite_in_prod',
}
interface NotificationType {
level: NotificationLevel;
message: string;
}
const notificationTypes: Record<string, NotificationType> = {
[NotificationKey.ConfirmEmail]: {
level: NotificationLevel.Normal,
message: 'Welcome to Joplin Cloud! An email has been sent to you containing an activation link to complete your registration.',
},
[NotificationKey.EmailConfirmed]: {
level: NotificationLevel.Normal,
message: 'You email has been confirmed',
},
[NotificationKey.PasswordSet]: {
level: NotificationLevel.Normal,
message: 'Welcome to Joplin Cloud! Your password has been set successfully.',
},
[NotificationKey.UsingSqliteInProd]: {
level: NotificationLevel.Important,
message: 'The server is currently using SQLite3 as a database. It is not recommended in production as it is slow and can cause locking issues. Please see the README for information on how to change it.',
},
};
export default class NotificationModel extends BaseModel<Notification> {
protected get tableName(): string {
@ -13,13 +45,32 @@ export default class NotificationModel extends BaseModel<Notification> {
return super.validate(notification, options);
}
public async add(userId: Uuid, key: string, level: NotificationLevel, message: string): Promise<Notification> {
public async add(userId: Uuid, key: NotificationKey, level: NotificationLevel = null, message: string = null): Promise<Notification> {
const n: Notification = await this.loadByKey(userId, key);
if (n) return n;
const type = notificationTypes[key];
if (level === null) {
if (type?.level) {
level = type.level;
} else {
throw new Error('Missing notification level');
}
}
if (message === null) {
if (type?.message) {
message = type.message;
} else {
throw new Error('Missing notification message');
}
}
return this.save({ key, message, level, owner_id: userId });
}
public async markAsRead(userId: Uuid, key: string): Promise<void> {
public async markAsRead(userId: Uuid, key: NotificationKey): Promise<void> {
const n = await this.loadByKey(userId, key);
if (!n) return;
@ -29,7 +80,7 @@ export default class NotificationModel extends BaseModel<Notification> {
.andWhere('owner_id', '=', userId);
}
public loadByKey(userId: Uuid, key: string): Promise<Notification> {
public loadByKey(userId: Uuid, key: NotificationKey): Promise<Notification> {
return this.db(this.tableName)
.select(this.defaultFields)
.where('key', '=', key)

View File

@ -181,15 +181,15 @@ export default class UserModel extends BaseModel<User> {
if (isNew) {
const validationToken = await this.models().token().generate(savedUser.id);
const validationUrl = encodeURI(this.confirmUrl(savedUser.id, validationToken));
const confirmUrl = encodeURI(this.confirmUrl(savedUser.id, validationToken));
await this.models().email().push({
sender_id: EmailSender.NoReply,
recipient_id: savedUser.id,
recipient_email: savedUser.email,
recipient_name: savedUser.full_name || '',
subject: 'Verify your email',
body: `Click this: ${validationUrl}`,
subject: 'Please setup your Joplin account',
body: `Your new Joplin account has been created!\n\nPlease click on the following link to complete the creation of your account:\n\n${confirmUrl}`,
});
}

View File

@ -33,6 +33,8 @@ describe('api_users', function() {
expect(savedUser.email).toBe('toto@example.com');
expect(savedUser.can_share).toBe(0);
expect(savedUser.max_item_size).toBe(1000);
expect(savedUser.email_confirmed).toBe(0);
expect(savedUser.must_set_password).toBe(1);
});
test('should patch a user', async function() {

View File

@ -34,6 +34,7 @@ router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
// set it after clicking on the confirmation link.
user.password = uuidgen();
user.must_set_password = 1;
user.email_confirmed = 0;
const output = await ctx.models.user().save(user);
return ctx.models.user().toApiOutput(output);
});

View File

@ -1,5 +1,6 @@
import { NotificationLevel } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { NotificationKey } from '../../models/NotificationModel';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, models, createUserAndSession } from '../../utils/testing/testUtils';
describe('index_notification', function() {
@ -21,9 +22,9 @@ describe('index_notification', function() {
const model = models().notification();
await model.add(user.id, 'my_notification', NotificationLevel.Normal, 'testing notification');
await model.add(user.id, NotificationKey.ConfirmEmail, NotificationLevel.Normal, 'testing notification');
const notification = await model.loadByKey(user.id, 'my_notification');
const notification = await model.loadByKey(user.id, NotificationKey.ConfirmEmail);
expect(notification.read).toBe(0);
@ -40,7 +41,7 @@ describe('index_notification', function() {
await routeHandler(context);
expect((await model.loadByKey(user.id, 'my_notification')).read).toBe(1);
expect((await model.loadByKey(user.id, NotificationKey.ConfirmEmail)).read).toBe(1);
});
});

View File

@ -0,0 +1,42 @@
import { NotificationKey } from '../../models/NotificationModel';
import { execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils';
describe('index_signup', function() {
beforeAll(async () => {
await beforeAllDb('index_signup');
});
afterAll(async () => {
await afterAllTests();
});
beforeEach(async () => {
await beforeEachDb();
});
test('should create a new account', async function() {
const context = await execRequestC('', 'POST', 'signup', {
full_name: 'Toto',
email: 'toto@example.com',
password: 'testing',
password2: 'testing',
});
// Check that the user has been created
const user = await models().user().loadByEmail('toto@example.com');
expect(user).toBeTruthy();
expect(user.email_confirmed).toBe(0);
// Check that the user is logged in
const session = await models().session().load(context.cookies.get('sessionId'));
expect(session.user_id).toBe(user.id);
// Check that the notification has been created
const notifications = await models().notification().allUnreadByUserId(user.id);
expect(notifications.length).toBe(1);
expect(notifications[0].key).toBe(NotificationKey.ConfirmEmail);
});
});

View File

@ -0,0 +1,57 @@
import { SubPath, redirect } from '../../utils/routeUtils';
import Router from '../../utils/Router';
import { RouteType } from '../../utils/types';
import { AppContext } from '../../utils/types';
import { bodyFields } from '../../utils/requestUtils';
import config from '../../config';
import defaultView from '../../utils/defaultView';
import { View } from '../../services/MustacheService';
import { checkPassword } from './users';
import { NotificationKey } from '../../models/NotificationModel';
function makeView(error: Error = null): View {
const view = defaultView('signup');
view.content.error = error;
view.content.postUrl = `${config().baseUrl}/signup`;
view.navbar = false;
return view;
}
interface FormUser {
full_name: string;
email: string;
password: string;
password2: string;
}
const router: Router = new Router(RouteType.Web);
router.public = true;
router.get('signup', async (_path: SubPath, _ctx: AppContext) => {
return makeView();
});
router.post('signup', async (_path: SubPath, ctx: AppContext) => {
try {
const formUser = await bodyFields<FormUser>(ctx.req);
const password = checkPassword(formUser, true);
const user = await ctx.models.user().save({
email: formUser.email,
full_name: formUser.full_name,
password,
});
const session = await ctx.models.session().createUserSession(user.id);
ctx.cookies.set('sessionId', session.id);
await ctx.models.notification().add(user.id, NotificationKey.ConfirmEmail);
return redirect(ctx, `${config().baseUrl}/home`);
} catch (error) {
return makeView(error);
}
});
export default router;

View File

@ -1,5 +1,6 @@
import { User } from '../../db';
import routeHandler from '../../middleware/routeHandler';
import { NotificationKey } from '../../models/NotificationModel';
import { ErrorForbidden } from '../../utils/errors';
import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
@ -154,13 +155,29 @@ describe('index_users', function() {
});
test('should allow user to set a password for new accounts', async function() {
const { user: user1 } = await createUserAndSession(1);
let user1 = await models().user().save({
email: 'user1@localhost',
must_set_password: 1,
email_confirmed: 0,
password: '123456',
});
const { user: user2 } = await createUserAndSession(2);
const email = (await models().email().all()).find(e => e.recipient_id === user1.id);
const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
const path = matches[1];
const token = matches[3];
// Check that the email at first is not confirmed
// expect(user1.email_confirmed).toBe(0);
// expect(user1.must_set_password).toBe(1);
await execRequest('', 'GET', path, null, { query: { token } });
// As soon as the confirmation page is opened, we know the email is valid
user1 = await models().user().load(user1.id);
expect(user1.email_confirmed).toBe(1);
// Check that the token is valid
expect(await models().token().isValid(user1.id, token)).toBe(true);
@ -201,6 +218,9 @@ describe('index_users', function() {
const loggedInUser = await models().user().login(user1.email, 'newpassword');
expect(loggedInUser.id).toBe(user1.id);
// Check that the email has been verified
expect(user1.email_confirmed).toBe(1);
// Check that the token has been cleared
expect(await models().token().isValid(user1.id, token)).toBe(false);
@ -209,19 +229,37 @@ describe('index_users', function() {
expect(notification.key).toBe('passwordSet');
});
// test('should handle invalid email validation', async function() {
// await createUserAndSession(1);
// const email = (await models().email().all())[0];
// const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
// const path = matches[1];
// const token = matches[3];
test('should allow user to verify their email', async function() {
let user1 = await models().user().save({
email: 'user1@localhost',
must_set_password: 0,
email_confirmed: 0,
password: '123456',
});
// // Valid path but invalid token
// await expectHttpError(async () => execRequest(null, 'GET', path, null, { query: { token: 'invalid' } }), ErrorNotFound.httpCode);
const email = (await models().email().all()).find(e => e.recipient_id === user1.id);
const matches = email.body.match(/\/(users\/.*)(\?token=)(.{32})/);
const path = matches[1];
const token = matches[3];
// // Valid token but invalid path
// await expectHttpError(async () => execRequest(null, 'GET', 'users/abcd1234/confirm', null, { query: { token } }), ErrorNotFound.httpCode);
// });
const context = await execRequestC('', 'GET', path, null, { query: { token } });
user1 = await models().user().load(user1.id);
// Check that the user has been logged in
const sessionId = context.cookies.get('sessionId');
expect(sessionId).toBeFalsy();
// Check that the email has been verified
expect(user1.email_confirmed).toBe(1);
// Check that the token has been cleared
expect(await models().token().isValid(user1.id, token)).toBe(false);
// Check that a notification has been created
const notification = (await models().notification().all())[0];
expect(notification.key).toBe(NotificationKey.EmailConfirmed);
});
test('should apply ACL', async function() {
const { user: admin, session: adminSession } = await createUserAndSession(1, true);

View File

@ -4,14 +4,20 @@ import { RouteType } from '../../utils/types';
import { AppContext, HttpMethod } from '../../utils/types';
import { bodyFields, formParse } from '../../utils/requestUtils';
import { ErrorForbidden, ErrorUnprocessableEntity } from '../../utils/errors';
import { NotificationLevel, User } from '../../db';
import { User } from '../../db';
import config from '../../config';
import { View } from '../../services/MustacheService';
import defaultView from '../../utils/defaultView';
import { AclAction } from '../../models/BaseModel';
import { NotificationKey } from '../../models/NotificationModel';
const prettyBytes = require('pretty-bytes');
function checkPassword(fields: SetPasswordFormData, required: boolean): string {
interface CheckPasswordInput {
password: string;
password2: string;
}
export function checkPassword(fields: CheckPasswordInput, required: boolean): string {
if (fields.password) {
if (fields.password !== fields.password2) throw new ErrorUnprocessableEntity('Passwords do not match');
return fields.password;
@ -113,18 +119,29 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er
const user = await ctx.models.user().load(userId);
const view: View = {
...defaultView('users/confirm'),
content: {
user,
error,
token,
postUrl: ctx.models.user().confirmUrl(userId, token),
},
navbar: false,
};
if (user.must_set_password) {
const view: View = {
...defaultView('users/confirm'),
content: {
user,
error,
token,
postUrl: ctx.models.user().confirmUrl(userId, token),
},
navbar: false,
};
return view;
return view;
} else {
await ctx.models.token().deleteByValue(userId, token);
await ctx.models.notification().add(userId, NotificationKey.EmailConfirmed);
if (ctx.owner) {
return redirect(ctx, `${config().baseUrl}/home`);
} else {
return redirect(ctx, `${config().baseUrl}/login`);
}
}
});
interface SetPasswordFormData {
@ -142,13 +159,13 @@ router.post('users/:id/confirm', async (path: SubPath, ctx: AppContext) => {
const password = checkPassword(fields, true);
await ctx.models.user().save({ id: userId, password });
await ctx.models.user().save({ id: userId, password, must_set_password: 0 });
await ctx.models.token().deleteByValue(userId, fields.token);
const session = await ctx.models.session().createUserSession(userId);
ctx.cookies.set('sessionId', session.id);
await ctx.models.notification().add(userId, 'passwordSet', NotificationLevel.Normal, 'Welcome to Joplin Cloud! Your password has been set successfully.');
await ctx.models.notification().add(userId, NotificationKey.PasswordSet);
return redirect(ctx, `${config().baseUrl}/home`);
} catch (error) {

View File

@ -15,6 +15,7 @@ import indexItems from './index/items';
import indexLogin from './index/login';
import indexLogout from './index/logout';
import indexNotifications from './index/notifications';
import indexSignup from './index/signup';
import indexShares from './index/shares';
import indexUsers from './index/users';
@ -36,6 +37,7 @@ const routes: Routers = {
'login': indexLogin,
'logout': indexLogout,
'notifications': indexNotifications,
'signup': indexSignup,
'shares': indexShares,
'users': indexUsers,

View File

@ -0,0 +1,30 @@
{{> errorBanner}}
<form action="{{{postUrl}}}" method="POST">
<div class="field">
<label class="label">Full name</label>
<div class="control">
<input class="input" type="text" name="full_name" value="{{user.full_name}}"/>
</div>
</div>
<div class="field">
<label class="label">Email</label>
<div class="control">
<input class="input" type="email" name="email" value="{{user.email}}"/>
</div>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input class="input" type="password" name="password" autocomplete="new-password"/>
</div>
</div>
<div class="field">
<label class="label">Repeat password</label>
<div class="control">
<input class="input" type="password" name="password2" autocomplete="new-password"/>
</div>
</div>
<div class="control">
<input type="submit" name="post_button" class="button is-primary" value="Sign up" />
</div>
</form>