Add account management (email addresses management, password management)
Closes #8 Closes #9
This commit is contained in:
parent
784f2c976c
commit
49168b5391
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
app: {
|
app: {
|
||||||
name: 'Example App',
|
name: 'Example App',
|
||||||
contact_email: 'contact@example.net'
|
contact_email: 'contact@example.net',
|
||||||
|
display_email_warning: true,
|
||||||
},
|
},
|
||||||
log: {
|
log: {
|
||||||
level: "DEBUG",
|
level: "DEBUG",
|
||||||
|
11
src/Mails.ts
11
src/Mails.ts
@ -3,7 +3,9 @@ import {MailTemplate} from "./mail/Mail";
|
|||||||
|
|
||||||
export const MAGIC_LINK_MAIL = new MailTemplate(
|
export const MAGIC_LINK_MAIL = new MailTemplate(
|
||||||
'magic_link',
|
'magic_link',
|
||||||
data => data.type === 'register' ? 'Registration' : 'Login magic link',
|
data => data.type === 'register' ?
|
||||||
|
'Registration' :
|
||||||
|
'Login magic link',
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
|
export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
|
||||||
@ -13,5 +15,10 @@ export const ACCOUNT_REVIEW_NOTICE_MAIL_TEMPLATE: MailTemplate = new MailTemplat
|
|||||||
|
|
||||||
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
|
export const PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
|
||||||
'pending_account_review',
|
'pending_account_review',
|
||||||
() => 'A new account is pending review on ' + config.get<string>('app.name'),
|
() => `A new account is pending review on ${config.get<string>('app.name')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ADD_EMAIL_MAIL_TEMPLATE: MailTemplate = new MailTemplate(
|
||||||
|
'add_email',
|
||||||
|
(data) => `Add ${data.email} address to your ${config.get<string>('app.name')} account.`,
|
||||||
);
|
);
|
||||||
|
@ -28,6 +28,9 @@ import MailController from "./mail/MailController";
|
|||||||
import WebSocketServerComponent from "./components/WebSocketServerComponent";
|
import WebSocketServerComponent from "./components/WebSocketServerComponent";
|
||||||
import Controller from "./Controller";
|
import Controller from "./Controller";
|
||||||
import packageJson = require('../package.json');
|
import packageJson = require('../package.json');
|
||||||
|
import AccountController from "./auth/AccountController";
|
||||||
|
import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagicLinksSessionNotUniqueMigration";
|
||||||
|
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration";
|
||||||
|
|
||||||
export const MIGRATIONS = [
|
export const MIGRATIONS = [
|
||||||
CreateMigrationsTable,
|
CreateMigrationsTable,
|
||||||
@ -35,6 +38,8 @@ export const MIGRATIONS = [
|
|||||||
AddPasswordToUsersMigration,
|
AddPasswordToUsersMigration,
|
||||||
CreateMagicLinksTableMigration,
|
CreateMagicLinksTableMigration,
|
||||||
AddNameToUsersMigration,
|
AddNameToUsersMigration,
|
||||||
|
MakeMagicLinksSessionNotUniqueMigration,
|
||||||
|
AddUsedToMagicLinksMigration,
|
||||||
];
|
];
|
||||||
|
|
||||||
export default class TestApp extends Application {
|
export default class TestApp extends Application {
|
||||||
@ -97,6 +102,7 @@ export default class TestApp extends Application {
|
|||||||
protected registerControllers(): void {
|
protected registerControllers(): void {
|
||||||
this.use(new MailController());
|
this.use(new MailController());
|
||||||
this.use(new AuthController());
|
this.use(new AuthController());
|
||||||
|
this.use(new AccountController());
|
||||||
|
|
||||||
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));
|
this.use(new MagicLinkController(this.as<MagicLinkWebSocketListener<this>>(MagicLinkWebSocketListener)));
|
||||||
|
|
||||||
|
147
src/auth/AccountController.ts
Normal file
147
src/auth/AccountController.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import Controller from "../Controller";
|
||||||
|
import {RequireAuthMiddleware} from "./AuthComponent";
|
||||||
|
import {Request, Response} from "express";
|
||||||
|
import {BadRequestError, ForbiddenHttpError, NotFoundHttpError} from "../HttpError";
|
||||||
|
import config from "config";
|
||||||
|
import Validator, {EMAIL_REGEX, InvalidFormatValidationError} from "../db/Validator";
|
||||||
|
import UserPasswordComponent from "./password/UserPasswordComponent";
|
||||||
|
import User from "./models/User";
|
||||||
|
import ModelFactory from "../db/ModelFactory";
|
||||||
|
import UserEmail from "./models/UserEmail";
|
||||||
|
import MagicLinkController from "./magic_link/MagicLinkController";
|
||||||
|
import {MailTemplate} from "../mail/Mail";
|
||||||
|
import {ADD_EMAIL_MAIL_TEMPLATE} from "../Mails";
|
||||||
|
import {log} from "../Logger";
|
||||||
|
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType";
|
||||||
|
|
||||||
|
export default class AccountController extends Controller {
|
||||||
|
private readonly addEmailMailTemplate: MailTemplate;
|
||||||
|
|
||||||
|
public constructor(addEmailMailTemplate: MailTemplate = ADD_EMAIL_MAIL_TEMPLATE) {
|
||||||
|
super();
|
||||||
|
this.addEmailMailTemplate = addEmailMailTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRoutesPrefix(): string {
|
||||||
|
return '/account';
|
||||||
|
}
|
||||||
|
|
||||||
|
public routes(): void {
|
||||||
|
this.get('/', this.getAccount, 'account', RequireAuthMiddleware);
|
||||||
|
|
||||||
|
if (ModelFactory.get(User).hasComponent(UserPasswordComponent)) {
|
||||||
|
this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.post('/add-email', this.addEmail, 'add-email', RequireAuthMiddleware);
|
||||||
|
this.post('/set-main-email', this.postSetMainEmail, 'set-main-email', RequireAuthMiddleware);
|
||||||
|
this.post('/remove-email', this.postRemoveEmail, 'remove-email', RequireAuthMiddleware);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async getAccount(req: Request, res: Response): Promise<void> {
|
||||||
|
const user = req.as(RequireAuthMiddleware).getUser();
|
||||||
|
|
||||||
|
res.render('auth/account', {
|
||||||
|
main_email: await user.mainEmail.get(),
|
||||||
|
emails: await user.emails.get(),
|
||||||
|
display_email_warning: config.get('app.display_email_warning'),
|
||||||
|
has_password: user.asOptional(UserPasswordComponent)?.hasPassword(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postChangePassword(req: Request, res: Response): Promise<void> {
|
||||||
|
const validationMap = {
|
||||||
|
'new_password': new Validator().defined(),
|
||||||
|
'new_password_confirmation': new Validator().sameAs('new_password', req.body.new_password),
|
||||||
|
};
|
||||||
|
await Validator.validate(validationMap, req.body);
|
||||||
|
const user = req.as(RequireAuthMiddleware).getUser();
|
||||||
|
const passwordComponent = user.as(UserPasswordComponent);
|
||||||
|
if (passwordComponent.hasPassword() && !await passwordComponent.verifyPassword(req.body.current_password)) {
|
||||||
|
req.flash('error', 'Invalid current password.');
|
||||||
|
res.redirectBack(Controller.route('account'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await passwordComponent.setPassword(req.body.new_password, 'new_password');
|
||||||
|
await user.save();
|
||||||
|
log.debug('user ' + user.id + ' changed their password and was saved.');
|
||||||
|
|
||||||
|
req.flash('success', 'Password changed successfully.');
|
||||||
|
res.redirectBack(Controller.route('account'));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
protected async addEmail(req: Request, res: Response): Promise<void> {
|
||||||
|
await Validator.validate({
|
||||||
|
email: new Validator().defined().regexp(EMAIL_REGEX),
|
||||||
|
}, req.body);
|
||||||
|
|
||||||
|
const email = req.body.email;
|
||||||
|
|
||||||
|
// Existing email
|
||||||
|
if (await UserEmail.select().where('email', email).first()) {
|
||||||
|
const error = new InvalidFormatValidationError('You already have this email.');
|
||||||
|
error.thingName = 'email';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await MagicLinkController.sendMagicLink(
|
||||||
|
this.getApp(),
|
||||||
|
req.getSession().id,
|
||||||
|
AuthMagicLinkActionType.ADD_EMAIL,
|
||||||
|
Controller.route('account'),
|
||||||
|
email,
|
||||||
|
this.addEmailMailTemplate,
|
||||||
|
{
|
||||||
|
email: email,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
res.redirect(Controller.route('magic_link_lobby', undefined, {
|
||||||
|
redirect_uri: Controller.route('account'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postSetMainEmail(req: Request, res: Response): Promise<void> {
|
||||||
|
if (!req.body.id)
|
||||||
|
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
|
||||||
|
|
||||||
|
const user = req.as(RequireAuthMiddleware).getUser();
|
||||||
|
|
||||||
|
const userEmail = await UserEmail.getById(req.body.id);
|
||||||
|
if (!userEmail)
|
||||||
|
throw new NotFoundHttpError('email', req.url);
|
||||||
|
if (userEmail.user_id !== user.id)
|
||||||
|
throw new ForbiddenHttpError('email', req.url);
|
||||||
|
if (userEmail.id === user.main_email_id)
|
||||||
|
throw new BadRequestError('This address is already your main address',
|
||||||
|
'Try refreshing the account page.', req.url);
|
||||||
|
|
||||||
|
user.main_email_id = userEmail.id;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
req.flash('success', 'This email was successfully set as your main address.');
|
||||||
|
res.redirectBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async postRemoveEmail(req: Request, res: Response): Promise<void> {
|
||||||
|
if (!req.body.id)
|
||||||
|
throw new BadRequestError('Missing id field', 'Check form parameters.', req.url);
|
||||||
|
|
||||||
|
const user = req.as(RequireAuthMiddleware).getUser();
|
||||||
|
|
||||||
|
const userEmail = await UserEmail.getById(req.body.id);
|
||||||
|
if (!userEmail)
|
||||||
|
throw new NotFoundHttpError('email', req.url);
|
||||||
|
if (userEmail.user_id !== user.id)
|
||||||
|
throw new ForbiddenHttpError('email', req.url);
|
||||||
|
if (userEmail.id === user.main_email_id)
|
||||||
|
throw new BadRequestError('Cannot remove main email address', 'Try refreshing the account page.', req.url);
|
||||||
|
|
||||||
|
await userEmail.delete();
|
||||||
|
|
||||||
|
req.flash('success', 'This email was successfully removed from your account.');
|
||||||
|
res.redirectBack();
|
||||||
|
}
|
||||||
|
}
|
12
src/auth/magic_link/AddUsedToMagicLinksMigration.ts
Normal file
12
src/auth/magic_link/AddUsedToMagicLinksMigration.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import Migration from "../../db/Migration";
|
||||||
|
|
||||||
|
export default class AddUsedToMagicLinksMigration extends Migration {
|
||||||
|
public async install(): Promise<void> {
|
||||||
|
await this.query(`ALTER TABLE magic_links
|
||||||
|
ADD COLUMN used BOOLEAN NOT NULL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rollback(): Promise<void> {
|
||||||
|
await this.query('ALTER TABLE magic_links DROP COLUMN IF EXISTS used');
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
export default {
|
export default {
|
||||||
LOGIN: 'login',
|
LOGIN: 'login',
|
||||||
REGISTER: 'register',
|
REGISTER: 'register',
|
||||||
|
ADD_EMAIL: 'add_email',
|
||||||
};
|
};
|
||||||
|
@ -17,6 +17,7 @@ import AuthMagicLinkActionType from "./AuthMagicLinkActionType";
|
|||||||
import {QueryVariable} from "../../db/MysqlConnectionManager";
|
import {QueryVariable} from "../../db/MysqlConnectionManager";
|
||||||
import UserNameComponent from "../models/UserNameComponent";
|
import UserNameComponent from "../models/UserNameComponent";
|
||||||
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
|
import MagicLinkUserNameComponent from "../models/MagicLinkUserNameComponent";
|
||||||
|
import {log} from "../../Logger";
|
||||||
|
|
||||||
export default class MagicLinkController<A extends Application> extends Controller {
|
export default class MagicLinkController<A extends Application> extends Controller {
|
||||||
public static async sendMagicLink(
|
public static async sendMagicLink(
|
||||||
@ -29,8 +30,10 @@ export default class MagicLinkController<A extends Application> extends Controll
|
|||||||
data: ParsedUrlQueryInput,
|
data: ParsedUrlQueryInput,
|
||||||
magicLinkData: Record<string, QueryVariable> = {},
|
magicLinkData: Record<string, QueryVariable> = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
Throttler.throttle('magic_link', 2, MagicLink.validityPeriod(), sessionId, 0, 0);
|
Throttler.throttle('magic_link', process.env.NODE_ENV === 'test' ? 10 : 2, MagicLink.validityPeriod(),
|
||||||
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(), email, 0, 0);
|
sessionId, 0, 0);
|
||||||
|
Throttler.throttle('magic_link', 1, MagicLink.validityPeriod(),
|
||||||
|
email, 0, 0);
|
||||||
|
|
||||||
const link = MagicLink.create(Object.assign(magicLinkData, {
|
const link = MagicLink.create(Object.assign(magicLinkData, {
|
||||||
session_id: sessionId,
|
session_id: sessionId,
|
||||||
@ -118,6 +121,7 @@ export default class MagicLinkController<A extends Application> extends Controll
|
|||||||
const link = await MagicLink.select()
|
const link = await MagicLink.select()
|
||||||
.where('session_id', req.getSession().id)
|
.where('session_id', req.getSession().id)
|
||||||
.sortBy('authorized')
|
.sortBy('authorized')
|
||||||
|
.where('used', 0)
|
||||||
.first();
|
.first();
|
||||||
if (!link) {
|
if (!link) {
|
||||||
throw new NotFoundHttpError('magic link', req.url);
|
throw new NotFoundHttpError('magic link', req.url);
|
||||||
@ -130,6 +134,8 @@ export default class MagicLinkController<A extends Application> extends Controll
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (await link.isAuthorized()) {
|
if (await link.isAuthorized()) {
|
||||||
|
link.use();
|
||||||
|
await link.save();
|
||||||
await this.performAction(link, req, res);
|
await this.performAction(link, req, res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -190,6 +196,45 @@ export default class MagicLinkController<A extends Application> extends Controll
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case AuthMagicLinkActionType.ADD_EMAIL: {
|
||||||
|
const session = req.getSessionOptional();
|
||||||
|
if (!session || magicLink.session_id !== session.id) throw new BadOwnerMagicLink();
|
||||||
|
|
||||||
|
await magicLink.delete();
|
||||||
|
|
||||||
|
const authGuard = this.getApp().as(AuthComponent).getAuthGuard();
|
||||||
|
const proofs = await authGuard.getProofsForSession(session);
|
||||||
|
const user = await proofs[0]?.getResource();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const email = await magicLink.getOrFail('email');
|
||||||
|
if (await UserEmail.select().with('user').where('email', email).first()) {
|
||||||
|
req.flash('error', 'An account already exists with this email address.' +
|
||||||
|
' Please first remove it there before adding it here.');
|
||||||
|
res.redirect(Controller.route('account'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEmail = UserEmail.create({
|
||||||
|
user_id: user.id,
|
||||||
|
email: email,
|
||||||
|
main: false,
|
||||||
|
});
|
||||||
|
await userEmail.save();
|
||||||
|
|
||||||
|
if (!user.main_email_id) {
|
||||||
|
user.main_email_id = userEmail.id;
|
||||||
|
await user.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
req.flash('success', `Email address ${userEmail.email} successfully added.`);
|
||||||
|
res.redirect(Controller.route('account'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.warn('Unknown magic link action type ' + magicLink.action_type);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,12 @@
|
|||||||
|
import Migration from "../../db/Migration";
|
||||||
|
|
||||||
|
export default class MakeMagicLinksSessionNotUniqueMigration extends Migration {
|
||||||
|
public async install(): Promise<void> {
|
||||||
|
await this.query(`ALTER TABLE magic_links
|
||||||
|
DROP INDEX IF EXISTS session_id`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rollback(): Promise<void> {
|
||||||
|
await this.query('ALTER TABLE magic_links ADD CONSTRAINT UNIQUE (session_id)');
|
||||||
|
}
|
||||||
|
}
|
@ -20,14 +20,16 @@ export default class MagicLink extends Model implements AuthProof<User> {
|
|||||||
public readonly original_url?: string = undefined;
|
public readonly original_url?: string = undefined;
|
||||||
private generated_at?: Date = undefined;
|
private generated_at?: Date = undefined;
|
||||||
private authorized: boolean = false;
|
private authorized: boolean = false;
|
||||||
|
private used: boolean = false;
|
||||||
|
|
||||||
protected init(): void {
|
protected init(): void {
|
||||||
this.setValidation('session_id').defined().length(32).unique(this);
|
this.setValidation('session_id').defined().length(32);
|
||||||
this.setValidation('email').defined().regexp(EMAIL_REGEX);
|
this.setValidation('email').defined().regexp(EMAIL_REGEX);
|
||||||
this.setValidation('token').defined().length(96);
|
this.setValidation('token').defined().length(96);
|
||||||
this.setValidation('action_type').defined().maxLength(64);
|
this.setValidation('action_type').defined().maxLength(64);
|
||||||
this.setValidation('original_url').defined().maxLength(1745);
|
this.setValidation('original_url').defined().maxLength(1745);
|
||||||
this.setValidation('authorized').defined();
|
this.setValidation('authorized').defined();
|
||||||
|
this.setValidation('used').defined();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getResource(): Promise<User | null> {
|
public async getResource(): Promise<User | null> {
|
||||||
@ -55,6 +57,14 @@ export default class MagicLink extends Model implements AuthProof<User> {
|
|||||||
this.authorized = true;
|
this.authorized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public isUsed(): boolean {
|
||||||
|
return this.used;
|
||||||
|
}
|
||||||
|
|
||||||
|
public use(): void {
|
||||||
|
this.used = true;
|
||||||
|
}
|
||||||
|
|
||||||
public async generateToken(email: string): Promise<string> {
|
public async generateToken(email: string): Promise<string> {
|
||||||
const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64
|
const rawToken = crypto.randomBytes(48).toString('base64'); // Raw token length = 64
|
||||||
this.email = email;
|
this.email = email;
|
||||||
|
@ -5,6 +5,7 @@ import config from "config";
|
|||||||
import {ManyModelRelation} from "../../db/ModelRelation";
|
import {ManyModelRelation} from "../../db/ModelRelation";
|
||||||
import UserEmail from "./UserEmail";
|
import UserEmail from "./UserEmail";
|
||||||
import UserApprovedComponent from "./UserApprovedComponent";
|
import UserApprovedComponent from "./UserApprovedComponent";
|
||||||
|
import UserNameComponent from "./UserNameComponent";
|
||||||
|
|
||||||
export default class User extends Model {
|
export default class User extends Model {
|
||||||
public static isApprovalMode(): boolean {
|
public static isApprovalMode(): boolean {
|
||||||
@ -37,4 +38,16 @@ export default class User extends Model {
|
|||||||
public isApproved(): boolean {
|
public isApproved(): boolean {
|
||||||
return !User.isApprovalMode() || this.as(UserApprovedComponent).approved;
|
return !User.isApprovalMode() || this.as(UserApprovedComponent).approved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected getPersonalInfoFields(): { name: string, value: string }[] {
|
||||||
|
const fields: { name: string, value: string }[] = [];
|
||||||
|
const nameComponent = this.asOptional(UserNameComponent);
|
||||||
|
if (nameComponent && nameComponent.name) {
|
||||||
|
fields.push({
|
||||||
|
name: 'Name',
|
||||||
|
value: nameComponent.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,10 @@ export default class UserPasswordComponent extends ModelComponent<User> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> {
|
public async setPassword(rawPassword: string, fieldName: string = 'password'): Promise<void> {
|
||||||
await new Validator<string>().defined().minLength(12).maxLength(512)
|
await new Validator<string>()
|
||||||
|
.defined()
|
||||||
|
.minLength(UserPasswordComponent.PASSWORD_MIN_LENGTH)
|
||||||
|
.maxLength(512)
|
||||||
.execute(fieldName, rawPassword, true);
|
.execute(fieldName, rawPassword, true);
|
||||||
this.password = await argon2.hash(rawPassword, {
|
this.password = await argon2.hash(rawPassword, {
|
||||||
timeCost: 10,
|
timeCost: 10,
|
||||||
@ -25,8 +28,12 @@ export default class UserPasswordComponent extends ModelComponent<User> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async verifyPassword(passwordGuess: string): Promise<boolean> {
|
public async verifyPassword(passwordGuess: string): Promise<boolean> {
|
||||||
if (!this.password) return false;
|
if (!this.password || !passwordGuess) return false;
|
||||||
|
|
||||||
return await argon2.verify(this.password, passwordGuess);
|
return await argon2.verify(this.password, passwordGuess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasPassword(): boolean {
|
||||||
|
return typeof this.password === 'string';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import UserPasswordComponent from "../src/auth/password/UserPasswordComponent";
|
|||||||
import {popEmail} from "./_mail_server";
|
import {popEmail} from "./_mail_server";
|
||||||
import AuthComponent from "../src/auth/AuthComponent";
|
import AuthComponent from "../src/auth/AuthComponent";
|
||||||
import {followMagicLinkFromMail, testLogout} from "./_authentication_common";
|
import {followMagicLinkFromMail, testLogout} from "./_authentication_common";
|
||||||
|
import UserEmail from "../src/auth/models/UserEmail";
|
||||||
|
|
||||||
let app: TestApp;
|
let app: TestApp;
|
||||||
useApp(async (addr, port) => {
|
useApp(async (addr, port) => {
|
||||||
@ -645,3 +646,378 @@ describe('Authenticate with email and password (password)', () => {
|
|||||||
await testLogout(agent, cookies, csrf);
|
await testLogout(agent, cookies, csrf);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('Change password', () => {
|
||||||
|
let cookies: string[], csrf: string;
|
||||||
|
test('Prepare user', async () => {
|
||||||
|
const res = await agent.get('/csrf').expect(200);
|
||||||
|
cookies = res.get('Set-Cookie');
|
||||||
|
csrf = res.text;
|
||||||
|
|
||||||
|
await agent.post('/auth/register')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
auth_method: 'magic_link',
|
||||||
|
identifier: 'aang@example.org',
|
||||||
|
name: 'aang',
|
||||||
|
})
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', '/magic/lobby?redirect_uri=%2Fcsrf');
|
||||||
|
|
||||||
|
await followMagicLinkFromMail(agent, cookies);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set password to blank from blank', async () => {
|
||||||
|
await agent.post('/account/change-password')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
'current_password': '',
|
||||||
|
'new_password': '',
|
||||||
|
'new_password_confirmation': '',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const user = await User.select()
|
||||||
|
.where('name', 'aang')
|
||||||
|
.first();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.as(UserPasswordComponent).hasPassword()).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set password to something from blank', async () => {
|
||||||
|
await agent.post('/account/change-password')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
'current_password': '',
|
||||||
|
'new_password': 'a_very_strong_password',
|
||||||
|
'new_password_confirmation': 'a_very_strong_password',
|
||||||
|
})
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
|
||||||
|
|
||||||
|
const user = await User.select()
|
||||||
|
.where('name', 'aang')
|
||||||
|
.first();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set password to blank from something', async () => {
|
||||||
|
await agent.post('/account/change-password')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
'current_password': 'a_very_strong_password',
|
||||||
|
'new_password': '',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const user = await User.select()
|
||||||
|
.where('name', 'aang')
|
||||||
|
.first();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set password to something from something', async () => {
|
||||||
|
await agent.post('/account/change-password')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
'current_password': 'a_very_strong_password',
|
||||||
|
'new_password': 'a_very_strong_password_but_different',
|
||||||
|
'new_password_confirmation': 'a_very_strong_password_but_different',
|
||||||
|
})
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
|
||||||
|
|
||||||
|
const user = await User.select()
|
||||||
|
.where('name', 'aang')
|
||||||
|
.first();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set password to unconfirmed', async () => {
|
||||||
|
await agent.post('/account/change-password')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
'current_password': 'a_very_strong_password_but_different',
|
||||||
|
'new_password': 'a_very_strong_password',
|
||||||
|
'new_password_confirmation': '',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await agent.post('/account/change-password')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
'current_password': 'a_very_strong_password_but_different',
|
||||||
|
'new_password': 'a_very_strong_password',
|
||||||
|
'new_password_confirmation': 'a_very_strong_password_but_different2',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const user = await User.select()
|
||||||
|
.where('name', 'aang')
|
||||||
|
.first();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('bad_password')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password')).toBeFalsy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different2')).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set password to too short password', async () => {
|
||||||
|
await agent.post('/account/change-password')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
'current_password': 'a_very_strong_password_but_different',
|
||||||
|
'new_password': '123',
|
||||||
|
'new_password_confirmation': '123',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
const user = await User.select()
|
||||||
|
.where('name', 'aang')
|
||||||
|
.first();
|
||||||
|
expect(user).toBeDefined();
|
||||||
|
expect(user?.as(UserPasswordComponent).hasPassword()).toBeTruthy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('a_very_strong_password_but_different')).toBeTruthy();
|
||||||
|
expect(await user?.as(UserPasswordComponent).verifyPassword('123')).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('Manage email addresses', () => {
|
||||||
|
let cookies: string[], csrf: string;
|
||||||
|
test('Prepare user', async () => {
|
||||||
|
const res = await agent.get('/csrf').expect(200);
|
||||||
|
cookies = res.get('Set-Cookie');
|
||||||
|
csrf = res.text;
|
||||||
|
|
||||||
|
await agent.post('/auth/register')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
auth_method: 'magic_link',
|
||||||
|
identifier: 'katara@example.org',
|
||||||
|
name: 'katara',
|
||||||
|
})
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', '/magic/lobby?redirect_uri=%2Fcsrf');
|
||||||
|
|
||||||
|
await followMagicLinkFromMail(agent, cookies);
|
||||||
|
|
||||||
|
// Add fake, not owned secondary email
|
||||||
|
const user = User.create({
|
||||||
|
name: 'not_katara',
|
||||||
|
});
|
||||||
|
await user.save();
|
||||||
|
await UserEmail.create({
|
||||||
|
email: 'not_katara@example.org',
|
||||||
|
user_id: user.id,
|
||||||
|
}).save();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Add invalid email addresses', async () => {
|
||||||
|
await agent.post('/account/add-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
await agent.post('/account/add-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
email: '',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
await agent.post('/account/add-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
email: 'katara@example.org',
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(await UserEmail.select().where('email', 'katara@example.org').count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Add valid email', async () => {
|
||||||
|
const expectedUserId = (await User.select('id').where('name', 'katara').first())?.id;
|
||||||
|
|
||||||
|
for (const email of [
|
||||||
|
'katara2@example.org',
|
||||||
|
'katara3@example.org',
|
||||||
|
'katara4@example.org',
|
||||||
|
]) {
|
||||||
|
await agent.post('/account/add-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
email: email,
|
||||||
|
})
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', '/magic/lobby?redirect_uri=%2Faccount%2F');
|
||||||
|
|
||||||
|
await followMagicLinkFromMail(agent, cookies, '/account/');
|
||||||
|
|
||||||
|
const userEmail = await UserEmail.select().where('email', email).first();
|
||||||
|
expect(userEmail).not.toBeNull();
|
||||||
|
expect(userEmail?.user_id).toBe(expectedUserId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function testMainSecondaryState(main: string, secondary: string) {
|
||||||
|
const user = await User.select('main_email_id').where('name', 'katara').first();
|
||||||
|
|
||||||
|
const mainEmail = await UserEmail.select().where('email', main).first();
|
||||||
|
expect(mainEmail).not.toBeNull();
|
||||||
|
expect(user?.main_email_id).toBe(mainEmail?.id);
|
||||||
|
|
||||||
|
const secondaryEmail = await UserEmail.select().where('email', secondary).first();
|
||||||
|
expect(secondaryEmail).not.toBeNull();
|
||||||
|
expect(user?.main_email_id).not.toBe(secondaryEmail?.id);
|
||||||
|
|
||||||
|
return secondaryEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Set main email address as main email address', async () => {
|
||||||
|
await testMainSecondaryState('katara@example.org', 'katara3@example.org');
|
||||||
|
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/set-main-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: (await UserEmail.select().where('email', 'katara@example.org').first())?.id,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
await testMainSecondaryState('katara@example.org', 'katara3@example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set secondary email address as main email address', async () => {
|
||||||
|
const beforeSecondaryEmail = await testMainSecondaryState('katara@example.org', 'katara3@example.org');
|
||||||
|
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/set-main-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: beforeSecondaryEmail?.id,
|
||||||
|
})
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
|
||||||
|
|
||||||
|
await testMainSecondaryState('katara3@example.org', 'katara@example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set non-owned address as main email address', async () => {
|
||||||
|
const beforeSecondaryEmail = await testMainSecondaryState('katara3@example.org', 'not_katara@example.org');
|
||||||
|
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/set-main-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: beforeSecondaryEmail?.id,
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
await testMainSecondaryState('katara3@example.org', 'not_katara@example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Set non-existing address as main email address', async () => {
|
||||||
|
await testMainSecondaryState('katara3@example.org', 'katara4@example.org');
|
||||||
|
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/set-main-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: 999,
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
|
||||||
|
await testMainSecondaryState('katara3@example.org', 'katara4@example.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Remove secondary email address', async () => {
|
||||||
|
expect(await UserEmail.select().where('email', 'katara2@example.org').count()).toBe(1);
|
||||||
|
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/remove-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: (await UserEmail.select().where('email', 'katara2@example.org').first())?.id,
|
||||||
|
})
|
||||||
|
.expect(302)
|
||||||
|
.expect('Location', '/csrf'); // TODO: because of buggy RedirectBackComponent, change to /account once fixed.
|
||||||
|
|
||||||
|
expect(await UserEmail.select().where('email', 'katara2@example.org').count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Remove non-owned email address', async () => {
|
||||||
|
expect(await UserEmail.select().where('email', 'not_katara@example.org').count()).toBe(1);
|
||||||
|
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/remove-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: (await UserEmail.select().where('email', 'not_katara@example.org').first())?.id,
|
||||||
|
})
|
||||||
|
.expect(403);
|
||||||
|
|
||||||
|
expect(await UserEmail.select().where('email', 'not_katara@example.org').count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Remove non-existing email address', async () => {
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/remove-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: 999,
|
||||||
|
})
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Remove main email address', async () => {
|
||||||
|
expect(await UserEmail.select().where('email', 'katara3@example.org').count()).toBe(1);
|
||||||
|
|
||||||
|
// Set secondary as main
|
||||||
|
await agent.post('/account/remove-email')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.send({
|
||||||
|
csrf: csrf,
|
||||||
|
id: (await UserEmail.select().where('email', 'katara3@example.org').first())?.id,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(await UserEmail.select().where('email', 'katara3@example.org').count()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -4,6 +4,7 @@ import supertest from "supertest";
|
|||||||
export async function followMagicLinkFromMail(
|
export async function followMagicLinkFromMail(
|
||||||
agent: supertest.SuperTest<supertest.Test>,
|
agent: supertest.SuperTest<supertest.Test>,
|
||||||
cookies: string[],
|
cookies: string[],
|
||||||
|
expectedRedirectUrl: string = '/',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const mail: Record<string, unknown> | null = await popEmail();
|
const mail: Record<string, unknown> | null = await popEmail();
|
||||||
expect(mail).not.toBeNull();
|
expect(mail).not.toBeNull();
|
||||||
@ -16,7 +17,7 @@ export async function followMagicLinkFromMail(
|
|||||||
await agent.get('/magic/lobby')
|
await agent.get('/magic/lobby')
|
||||||
.set('Cookie', cookies)
|
.set('Cookie', cookies)
|
||||||
.expect(302)
|
.expect(302)
|
||||||
.expect('Location', '/');
|
.expect('Location', expectedRedirectUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function testLogout(
|
export async function testLogout(
|
||||||
|
@ -5,14 +5,14 @@ export const MAIL_SERVER = new MailDev({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export async function setupMailServer(): Promise<void> {
|
export async function setupMailServer(): Promise<void> {
|
||||||
await new Promise((resolve, reject) => MAIL_SERVER.listen((err?: Error) => {
|
await new Promise<void>((resolve, reject) => MAIL_SERVER.listen((err?: Error) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function teardownMailServer(): Promise<void> {
|
export async function teardownMailServer(): Promise<void> {
|
||||||
await new Promise((resolve, reject) => MAIL_SERVER.close((err?: Error) => {
|
await new Promise<void>((resolve, reject) => MAIL_SERVER.close((err?: Error) => {
|
||||||
if (err) reject(err);
|
if (err) reject(err);
|
||||||
else resolve();
|
else resolve();
|
||||||
}));
|
}));
|
||||||
|
@ -21,8 +21,11 @@
|
|||||||
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
|
<li><a href="{{ route('backend') }}"><i data-feather="settings"></i> <span class="tip">Backend</span></a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# <li><a href="{{ route('account') }}"><i data-feather="user"></i>#}
|
<li>
|
||||||
{# <span class="tip">{{ user.name }}</span></a></li>#}
|
<a href="{{ route('account') }}"><i data-feather="user"></i>
|
||||||
|
<span class="tip">{{ user.name }}</span></a>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<form action="{{ route('logout') }}" method="POST">
|
<form action="{{ route('logout') }}" method="POST">
|
||||||
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
|
<button><i data-feather="log-out"></i> <span class="tip">Logout</span></button>
|
||||||
|
104
views/auth/account.njk
Normal file
104
views/auth/account.njk
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
{% extends 'layouts/base.njk' %}
|
||||||
|
{% import 'macros.njk' as macros %}
|
||||||
|
|
||||||
|
{% set title = 'Account' %}
|
||||||
|
{% set decription = 'Manage your account settings and data.' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="container">
|
||||||
|
<div class="panel">
|
||||||
|
<h2><i class="fas fa-user"></i> Personal information</h2>
|
||||||
|
|
||||||
|
{% if display_email_warning and emails | length <= 0 %}
|
||||||
|
{{ macros.message('warning', 'To avoid losing access to your account, please add an email address.') }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for field in user.getPersonalInfoFields() %}
|
||||||
|
<p>{{ field.name }}: {{ field.value }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<p>Contact email: {{ main_email.email }} <a href="#emails">More...</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2><i data-feather="key"></i> {% if has_password %}Change{% else %}Set{% endif %} password</h2>
|
||||||
|
|
||||||
|
<form action="{{ route('change-password') }}" method="POST">
|
||||||
|
{% if has_password %}
|
||||||
|
{{ macros.field(_locals, 'password', 'current_password', null, 'Current password') }}
|
||||||
|
{% endif %}
|
||||||
|
{{ macros.field(_locals, 'password', 'new_password', null, 'New password') }}
|
||||||
|
{{ macros.field(_locals, 'password', 'new_password_confirmation', null, 'New password confirmation') }}
|
||||||
|
|
||||||
|
<button type="submit"><i data-feather="save"></i> Save</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCsrfToken) }}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2 id="emails"><i data-feather="shield"></i> Email addresses</h2>
|
||||||
|
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{% for email in emails %}
|
||||||
|
{% if email.id == user.main_email_id %}
|
||||||
|
<tr>
|
||||||
|
<td>Main</td>
|
||||||
|
<td>{{ email.email }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for email in emails %}
|
||||||
|
{% if email.id != user.main_email_id %}
|
||||||
|
<tr>
|
||||||
|
<td>Secondary</td>
|
||||||
|
<td>{{ email.email }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form action="{{ route('set-main-email') }}" method="POST">
|
||||||
|
<input type="hidden" name="id" value="{{ email.id }}">
|
||||||
|
|
||||||
|
<button class="warning"
|
||||||
|
onclick="return confirm('Are you sure you want to set {{ email.email }} as your main address?');">
|
||||||
|
<i data-feather="refresh-ccw"></i> <span class="tip">Set as main address</span>
|
||||||
|
</button>
|
||||||
|
{{ macros.csrf(getCsrfToken) }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="{{ route('remove-email') }}" method="POST">
|
||||||
|
<input type="hidden" name="id" value="{{ email.id }}">
|
||||||
|
|
||||||
|
<button class="danger"
|
||||||
|
onclick="return confirm('Are you sure you want to delete {{ email.email }}?');">
|
||||||
|
<i data-feather="trash"></i> <span class="tip">Remove</span>
|
||||||
|
</button>
|
||||||
|
{{ macros.csrf(getCsrfToken) }}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<form action="{{ route('add-email') }}" method="POST" class="sub-panel">
|
||||||
|
<h3>Add an email address:</h3>
|
||||||
|
|
||||||
|
{{ macros.field(_locals, 'email', 'email', null, 'Choose a safe email address', 'An email address we can use to identify you in case you lose access to your account', 'required') }}
|
||||||
|
|
||||||
|
<button><i data-feather="plus"></i> Add email address</button>
|
||||||
|
|
||||||
|
{{ macros.csrf(getCsrfToken) }}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
27
views/mails/add_email.mjml.njk
Normal file
27
views/mails/add_email.mjml.njk
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{% extends 'mails/base_layout.mjml.njk' %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-text mj-class="title">
|
||||||
|
Add this email address on {{ app.name }}
|
||||||
|
</mj-text>
|
||||||
|
<mj-text>
|
||||||
|
Someone wants to add <strong>{{ mail_to }}</strong> to their account.
|
||||||
|
<br><br>
|
||||||
|
<strong>Do not click on this if this is not you!</strong>
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
<mj-button href="{{ link | safe }}">
|
||||||
|
Add <strong>{{ mail_to }}</strong> on {{ app.name }}
|
||||||
|
</mj-button>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block text %}
|
||||||
|
Hi!
|
||||||
|
Someone wants to add {{ mail_to }} to their account.
|
||||||
|
|
||||||
|
To add this email address, please follow this link: {{ link|safe }}
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user