From 41ed66d3235eae2f2800d09a00078515c6c17109 Mon Sep 17 00:00:00 2001 From: Laurent Cozic Date: Wed, 26 May 2021 19:55:43 +0200 Subject: [PATCH] Server: Added signup pages --- packages/app-desktop/runForSharing.sh | 2 +- .../src/middleware/notificationHandler.ts | 18 ++---- .../migrations/20210526180359_account_type.ts | 12 ++++ .../src/models/NotificationModel.test.ts | 21 ++++--- .../server/src/models/NotificationModel.ts | 57 ++++++++++++++++- packages/server/src/models/UserModel.ts | 6 +- packages/server/src/routes/api/users.test.ts | 2 + packages/server/src/routes/api/users.ts | 1 + .../src/routes/index/notifications.test.ts | 7 ++- .../server/src/routes/index/signup.test.ts | 42 +++++++++++++ packages/server/src/routes/index/signup.ts | 57 +++++++++++++++++ .../server/src/routes/index/users.test.ts | 62 +++++++++++++++---- packages/server/src/routes/index/users.ts | 47 +++++++++----- packages/server/src/routes/routes.ts | 2 + .../server/src/views/index/signup.mustache | 30 +++++++++ 15 files changed, 305 insertions(+), 61 deletions(-) create mode 100644 packages/server/src/migrations/20210526180359_account_type.ts create mode 100644 packages/server/src/routes/index/signup.test.ts create mode 100644 packages/server/src/routes/index/signup.ts create mode 100644 packages/server/src/views/index/signup.mustache diff --git a/packages/app-desktop/runForSharing.sh b/packages/app-desktop/runForSharing.sh index a9cb9cd3ba..ec10c74e5c 100755 --- a/packages/app-desktop/runForSharing.sh +++ b/packages/app-desktop/runForSharing.sh @@ -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" diff --git a/packages/server/src/middleware/notificationHandler.ts b/packages/server/src/middleware/notificationHandler.ts index 3732b98ab0..747618a944 100644 --- a/packages/server/src/middleware/notificationHandler.ts +++ b/packages/server/src/middleware/notificationHandler.ts @@ -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 ); } } diff --git a/packages/server/src/migrations/20210526180359_account_type.ts b/packages/server/src/migrations/20210526180359_account_type.ts new file mode 100644 index 0000000000..ed3a6b4cbc --- /dev/null +++ b/packages/server/src/migrations/20210526180359_account_type.ts @@ -0,0 +1,12 @@ +import { Knex } from 'knex'; +import { DbConnection } from '../db'; + +export async function up(db: DbConnection): Promise { + await db.schema.alterTable('users', function(table: Knex.CreateTableBuilder) { + table.integer('account_type').defaultTo(0).notNullable(); + }); +} + +export async function down(_db: DbConnection): Promise { + +} diff --git a/packages/server/src/models/NotificationModel.test.ts b/packages/server/src/models/NotificationModel.test.ts index 7fb88d97cb..2d1b43a5c9 100644 --- a/packages/server/src/models/NotificationModel.test.ts +++ b/packages/server/src/models/NotificationModel.test.ts @@ -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); }); }); diff --git a/packages/server/src/models/NotificationModel.ts b/packages/server/src/models/NotificationModel.ts index 4351733c93..73ee6aa333 100644 --- a/packages/server/src/models/NotificationModel.ts +++ b/packages/server/src/models/NotificationModel.ts @@ -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 = { + [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 { protected get tableName(): string { @@ -13,13 +45,32 @@ export default class NotificationModel extends BaseModel { return super.validate(notification, options); } - public async add(userId: Uuid, key: string, level: NotificationLevel, message: string): Promise { + public async add(userId: Uuid, key: NotificationKey, level: NotificationLevel = null, message: string = null): Promise { 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 { + public async markAsRead(userId: Uuid, key: NotificationKey): Promise { const n = await this.loadByKey(userId, key); if (!n) return; @@ -29,7 +80,7 @@ export default class NotificationModel extends BaseModel { .andWhere('owner_id', '=', userId); } - public loadByKey(userId: Uuid, key: string): Promise { + public loadByKey(userId: Uuid, key: NotificationKey): Promise { return this.db(this.tableName) .select(this.defaultFields) .where('key', '=', key) diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index 7842aa32cd..d0f34a4a68 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -181,15 +181,15 @@ export default class UserModel extends BaseModel { 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}`, }); } diff --git a/packages/server/src/routes/api/users.test.ts b/packages/server/src/routes/api/users.test.ts index 989f6c4e39..748b815b0e 100644 --- a/packages/server/src/routes/api/users.test.ts +++ b/packages/server/src/routes/api/users.test.ts @@ -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() { diff --git a/packages/server/src/routes/api/users.ts b/packages/server/src/routes/api/users.ts index b74b5d7f62..54971c0e97 100644 --- a/packages/server/src/routes/api/users.ts +++ b/packages/server/src/routes/api/users.ts @@ -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); }); diff --git a/packages/server/src/routes/index/notifications.test.ts b/packages/server/src/routes/index/notifications.test.ts index 8d07474989..31d74d604a 100644 --- a/packages/server/src/routes/index/notifications.test.ts +++ b/packages/server/src/routes/index/notifications.test.ts @@ -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); }); }); diff --git a/packages/server/src/routes/index/signup.test.ts b/packages/server/src/routes/index/signup.test.ts new file mode 100644 index 0000000000..e8cad650a2 --- /dev/null +++ b/packages/server/src/routes/index/signup.test.ts @@ -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); + }); + +}); diff --git a/packages/server/src/routes/index/signup.ts b/packages/server/src/routes/index/signup.ts new file mode 100644 index 0000000000..ce60e01b5f --- /dev/null +++ b/packages/server/src/routes/index/signup.ts @@ -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(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; diff --git a/packages/server/src/routes/index/users.test.ts b/packages/server/src/routes/index/users.test.ts index a00e62ac56..3c71640b78 100644 --- a/packages/server/src/routes/index/users.test.ts +++ b/packages/server/src/routes/index/users.test.ts @@ -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); diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts index 97b9d18c08..bbef84a070 100644 --- a/packages/server/src/routes/index/users.ts +++ b/packages/server/src/routes/index/users.ts @@ -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) { diff --git a/packages/server/src/routes/routes.ts b/packages/server/src/routes/routes.ts index 80bfcb6025..3863de394a 100644 --- a/packages/server/src/routes/routes.ts +++ b/packages/server/src/routes/routes.ts @@ -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, diff --git a/packages/server/src/views/index/signup.mustache b/packages/server/src/views/index/signup.mustache new file mode 100644 index 0000000000..867b443b78 --- /dev/null +++ b/packages/server/src/views/index/signup.mustache @@ -0,0 +1,30 @@ +{{> errorBanner}} +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+