Allow users to change their username every configurable period of time
Closes #22
This commit is contained in:
parent
dcfb0b8f1c
commit
caae753d74
@ -50,5 +50,8 @@
|
||||
magic_link: {
|
||||
validity_period: 20,
|
||||
},
|
||||
approval_mode: false,
|
||||
auth: {
|
||||
approval_mode: false, // Registered accounts need to be approved by an administrator
|
||||
name_change_wait_period: 2592000000, // 30 days
|
||||
},
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ import MakeMagicLinksSessionNotUniqueMigration from "./auth/magic_link/MakeMagic
|
||||
import AddUsedToMagicLinksMigration from "./auth/magic_link/AddUsedToMagicLinksMigration";
|
||||
import packageJson = require('./package.json');
|
||||
import PreviousUrlComponent from "./components/PreviousUrlComponent";
|
||||
import AddNameChangedAtToUsersMigration from "./auth/migrations/AddNameChangedAtToUsersMigration";
|
||||
|
||||
export const MIGRATIONS = [
|
||||
CreateMigrationsTable,
|
||||
@ -40,6 +41,7 @@ export const MIGRATIONS = [
|
||||
AddNameToUsersMigration,
|
||||
MakeMagicLinksSessionNotUniqueMigration,
|
||||
AddUsedToMagicLinksMigration,
|
||||
AddNameChangedAtToUsersMigration,
|
||||
];
|
||||
|
||||
export default class TestApp extends Application {
|
||||
|
@ -12,6 +12,8 @@ import MagicLinkController from "./magic_link/MagicLinkController";
|
||||
import {MailTemplate} from "../mail/Mail";
|
||||
import {ADD_EMAIL_MAIL_TEMPLATE, REMOVE_PASSWORD_MAIL_TEMPLATE} from "../Mails";
|
||||
import AuthMagicLinkActionType from "./magic_link/AuthMagicLinkActionType";
|
||||
import UserNameComponent from "./models/UserNameComponent";
|
||||
import Time from "../Time";
|
||||
|
||||
export default class AccountController extends Controller {
|
||||
|
||||
@ -29,6 +31,10 @@ export default class AccountController extends Controller {
|
||||
public routes(): void {
|
||||
this.get('/', this.getAccount, 'account', RequireAuthMiddleware);
|
||||
|
||||
if (ModelFactory.get(User).hasComponent(UserNameComponent)) {
|
||||
this.post('/change-name', this.postChangeName, 'change-name', RequireAuthMiddleware);
|
||||
}
|
||||
|
||||
if (ModelFactory.get(User).hasComponent(UserPasswordComponent)) {
|
||||
this.post('/change-password', this.postChangePassword, 'change-password', RequireAuthMiddleware);
|
||||
this.post('/remove-password', this.postRemovePassword, 'remove-password', RequireAuthMiddleware);
|
||||
@ -43,15 +49,47 @@ export default class AccountController extends Controller {
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
|
||||
const passwordComponent = user.asOptional(UserPasswordComponent);
|
||||
res.render('auth/account', {
|
||||
|
||||
const nameComponent = user.asOptional(UserNameComponent);
|
||||
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
|
||||
const nameChangedAt = nameComponent?.getNameChangedAt()?.getTime() || Date.now();
|
||||
const nameChangeRemainingTime = new Date(nameChangedAt + nameChangeWaitPeriod);
|
||||
|
||||
res.render('auth/account/account', {
|
||||
main_email: await user.mainEmail.get(),
|
||||
emails: await user.emails.get(),
|
||||
display_email_warning: config.get('app.display_email_warning'),
|
||||
has_password_component: !!passwordComponent,
|
||||
has_password: passwordComponent?.hasPassword(),
|
||||
has_name_component: !!nameComponent,
|
||||
name_change_wait_period: Time.humanizeDuration(nameChangeWaitPeriod, false, true),
|
||||
can_change_name: nameComponent?.canChangeName(),
|
||||
can_change_name_in: Time.humanizeTimeTo(nameChangeRemainingTime),
|
||||
});
|
||||
}
|
||||
|
||||
protected async postChangeName(req: Request, res: Response): Promise<void> {
|
||||
await Validator.validate({
|
||||
'name': new Validator().defined(),
|
||||
}, req.body);
|
||||
|
||||
const user = req.as(RequireAuthMiddleware).getUser();
|
||||
const userNameComponent = user.as(UserNameComponent);
|
||||
|
||||
if (!userNameComponent.setName(req.body.name)) {
|
||||
const nameChangedAt = userNameComponent.getNameChangedAt()?.getTime() || Date.now();
|
||||
const nameChangeWaitPeriod = config.get<number>('auth.name_change_wait_period');
|
||||
req.flash('error', `Your can't change your name until ${new Date(nameChangedAt + nameChangeWaitPeriod)}.`);
|
||||
res.redirect(Controller.route('account'));
|
||||
return;
|
||||
}
|
||||
|
||||
await user.save();
|
||||
|
||||
req.flash('success', `Your name was successfully changed to ${req.body.name}.`);
|
||||
res.redirect(Controller.route('account'));
|
||||
}
|
||||
|
||||
protected async postChangePassword(req: Request, res: Response): Promise<void> {
|
||||
const validationMap = {
|
||||
'new_password': new Validator().defined(),
|
||||
|
@ -141,7 +141,7 @@ export default class AuthGuard {
|
||||
|
||||
if (!user.isApproved()) {
|
||||
await new Mail(this.app.as(NunjucksComponent).getEnvironment(), PENDING_ACCOUNT_REVIEW_MAIL_TEMPLATE, {
|
||||
username: user.asOptional(UserNameComponent)?.name ||
|
||||
username: user.asOptional(UserNameComponent)?.getName() ||
|
||||
(await user.mainEmail.get())?.getOrFail('email') ||
|
||||
'Could not find an identifier',
|
||||
link: config.get<string>('public_url') + Controller.route('accounts-approval'),
|
||||
|
@ -65,7 +65,12 @@ export default class MagicLinkController<A extends Application> extends Controll
|
||||
return await req.as(AuthMiddleware).getAuthGuard().authenticateOrRegister(
|
||||
session, magicLink, !!session.wantsSessionPersistence, undefined, async (connection, user) => {
|
||||
const userNameComponent = user.asOptional(UserNameComponent);
|
||||
if (userNameComponent) userNameComponent.name = magicLink.as(MagicLinkUserNameComponent).username;
|
||||
const magicLinkUserNameComponent = magicLink.asOptional(MagicLinkUserNameComponent);
|
||||
|
||||
if (userNameComponent && magicLinkUserNameComponent?.username) {
|
||||
userNameComponent.setName(magicLinkUserNameComponent.username);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, async (connection, user) => {
|
||||
const callbacks: RegisterCallback[] = [];
|
||||
@ -192,7 +197,7 @@ export default class MagicLinkController<A extends Application> extends Controll
|
||||
|
||||
if (!res.headersSent && user) {
|
||||
// Auth success
|
||||
const name = user.asOptional(UserNameComponent)?.name;
|
||||
const name = user.asOptional(UserNameComponent)?.getName();
|
||||
req.flash('success', `Authentication success. Welcome${name ? `, ${name}` : ''}`);
|
||||
res.redirect(req.getIntendedUrl() || Controller.route('home'));
|
||||
}
|
||||
|
12
src/auth/migrations/AddNameChangedAtToUsersMigration.ts
Normal file
12
src/auth/migrations/AddNameChangedAtToUsersMigration.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import Migration from "../../db/Migration";
|
||||
|
||||
export default class AddNameChangedAtToUsersMigration extends Migration {
|
||||
public async install(): Promise<void> {
|
||||
await this.query(`ALTER TABLE users
|
||||
ADD COLUMN name_changed_at DATETIME`);
|
||||
}
|
||||
|
||||
public async rollback(): Promise<void> {
|
||||
await this.query('ALTER TABLE users DROP COLUMN IF EXISTS name_changed_at');
|
||||
}
|
||||
}
|
@ -9,7 +9,7 @@ import UserNameComponent from "./UserNameComponent";
|
||||
|
||||
export default class User extends Model {
|
||||
public static isApprovalMode(): boolean {
|
||||
return config.get<boolean>('approval_mode') &&
|
||||
return config.get<boolean>('auth.approval_mode') &&
|
||||
MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTableMigration);
|
||||
}
|
||||
|
||||
@ -42,10 +42,10 @@ export default class User extends Model {
|
||||
protected getPersonalInfoFields(): { name: string, value: string }[] {
|
||||
const fields: { name: string, value: string }[] = [];
|
||||
const nameComponent = this.asOptional(UserNameComponent);
|
||||
if (nameComponent && nameComponent.name) {
|
||||
if (nameComponent && nameComponent.hasName()) {
|
||||
fields.push({
|
||||
name: 'Name',
|
||||
value: nameComponent.name,
|
||||
value: nameComponent.getName(),
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
|
@ -1,12 +1,44 @@
|
||||
import ModelComponent from "../../db/ModelComponent";
|
||||
import User from "../models/User";
|
||||
import config from "config";
|
||||
|
||||
export const USERNAME_REGEXP = /^[0-9a-z_-]+$/;
|
||||
|
||||
export default class UserNameComponent extends ModelComponent<User> {
|
||||
public name?: string = undefined;
|
||||
private name?: string = undefined;
|
||||
private name_changed_at?: Date | null = undefined;
|
||||
|
||||
public init(): void {
|
||||
this.setValidation('name').defined().between(3, 64).regexp(USERNAME_REGEXP).unique(this._model);
|
||||
}
|
||||
|
||||
public hasName(): boolean {
|
||||
return !!this.name;
|
||||
}
|
||||
|
||||
public getName(): string {
|
||||
if (!this.name) throw new Error('User has no name.');
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public setName(newName: string): boolean {
|
||||
if (!this.canChangeName()) return false;
|
||||
|
||||
this.name = newName;
|
||||
this.name_changed_at = new Date();
|
||||
return true;
|
||||
}
|
||||
|
||||
public getNameChangedAt(): Date | null {
|
||||
return this.name_changed_at || null;
|
||||
}
|
||||
|
||||
public forgetNameChangeDate(): void {
|
||||
this.name_changed_at = null;
|
||||
}
|
||||
|
||||
public canChangeName(): boolean {
|
||||
return !this.name_changed_at ||
|
||||
Date.now() - this.name_changed_at.getTime() >= config.get<number>('auth.name_change_wait_period');
|
||||
}
|
||||
}
|
||||
|
@ -113,7 +113,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
await user.as(UserPasswordComponent).setPassword(req.body.password);
|
||||
|
||||
// Username
|
||||
user.as(UserNameComponent).name = req.body.identifier;
|
||||
user.as(UserNameComponent).setName(req.body.identifier);
|
||||
|
||||
return callbacks;
|
||||
}, async (connection, user) => {
|
||||
@ -132,7 +132,7 @@ export default class PasswordAuthMethod implements AuthMethod<PasswordAuthProof>
|
||||
|
||||
const user = await passwordAuthProof.getResource();
|
||||
|
||||
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).name}.`);
|
||||
req.flash('success', `Your account was successfully created! Welcome, ${user?.as(UserNameComponent).getName()}.`);
|
||||
res.redirect(req.getIntendedUrl() || Controller.route('home'));
|
||||
}
|
||||
|
||||
|
@ -72,7 +72,7 @@ describe('Register with username and password (password)', () => {
|
||||
.first();
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.as(UserNameComponent).name).toStrictEqual('entrapta');
|
||||
expect(user?.as(UserNameComponent).getName()).toStrictEqual('entrapta');
|
||||
await expect(user?.as(UserPasswordComponent).verifyPassword('darla_is_cute')).resolves.toStrictEqual(true);
|
||||
});
|
||||
|
||||
@ -186,7 +186,7 @@ describe('Register with email (magic_link)', () => {
|
||||
expect(email).toBeDefined();
|
||||
expect(email?.email).toStrictEqual('glimmer@example.org');
|
||||
|
||||
expect(user?.as(UserNameComponent).name).toStrictEqual('glimmer');
|
||||
expect(user?.as(UserNameComponent).getName()).toStrictEqual('glimmer');
|
||||
await expect(user?.as(UserPasswordComponent).verifyPassword('')).resolves.toStrictEqual(false);
|
||||
});
|
||||
|
||||
@ -575,7 +575,7 @@ describe('Authenticate with email and password (password)', () => {
|
||||
expect(email).toBeDefined();
|
||||
expect(email?.email).toStrictEqual('double-trouble@example.org');
|
||||
|
||||
expect(user?.as(UserNameComponent).name).toStrictEqual('double-trouble');
|
||||
expect(user?.as(UserNameComponent).getName()).toStrictEqual('double-trouble');
|
||||
await expect(user?.as(UserPasswordComponent).verifyPassword('trick-or-treat')).resolves.toStrictEqual(true);
|
||||
});
|
||||
|
||||
@ -871,6 +871,89 @@ describe('Change password', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Change username', () => {
|
||||
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: 'password',
|
||||
identifier: 'richard',
|
||||
password: 'a_very_strong_password',
|
||||
password_confirmation: 'a_very_strong_password',
|
||||
terms: 'on',
|
||||
})
|
||||
.expect(302)
|
||||
.expect('Location', '/');
|
||||
});
|
||||
|
||||
test('Initial value of name_changed_at should be the same as created_at', async () => {
|
||||
const user = await User.select().where('name', 'richard').first();
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.as(UserNameComponent).getNameChangedAt()?.getTime()).toBe(user?.created_at?.getTime());
|
||||
});
|
||||
|
||||
test('Cannot change username just after registration', async () => {
|
||||
expect(await User.select().where('name', 'richard').count()).toBe(1);
|
||||
|
||||
await agent.post('/account/change-name')
|
||||
.set('Cookie', cookies)
|
||||
.send({
|
||||
csrf: csrf,
|
||||
name: 'robert',
|
||||
})
|
||||
.expect(302)
|
||||
.expect('Location', '/account/');
|
||||
|
||||
expect(await User.select().where('name', 'richard').count()).toBe(1);
|
||||
});
|
||||
|
||||
test('Can change username after hold period', async () => {
|
||||
// Set last username change to older date
|
||||
const user = await User.select().where('name', 'richard').first();
|
||||
if (user) {
|
||||
user.as(UserNameComponent).forgetNameChangeDate();
|
||||
await user.save();
|
||||
}
|
||||
|
||||
expect(await User.select().where('name', 'richard').count()).toBe(1);
|
||||
expect(await User.select().where('name', 'robert').count()).toBe(0);
|
||||
|
||||
await agent.post('/account/change-name')
|
||||
.set('Cookie', cookies)
|
||||
.send({
|
||||
csrf: csrf,
|
||||
name: 'robert',
|
||||
})
|
||||
.expect(302)
|
||||
.expect('Location', '/account/');
|
||||
|
||||
expect(await User.select().where('name', 'richard').count()).toBe(0);
|
||||
expect(await User.select().where('name', 'robert').count()).toBe(1);
|
||||
});
|
||||
|
||||
test('Cannot change username just after changing username', async () => {
|
||||
expect(await User.select().where('name', 'robert').count()).toBe(1);
|
||||
expect(await User.select().where('name', 'bebert').count()).toBe(0);
|
||||
|
||||
await agent.post('/account/change-name')
|
||||
.set('Cookie', cookies)
|
||||
.send({
|
||||
csrf: csrf,
|
||||
name: 'bebert',
|
||||
})
|
||||
.expect(302)
|
||||
.expect('Location', '/account/');
|
||||
|
||||
expect(await User.select().where('name', 'robert').count()).toBe(1);
|
||||
expect(await User.select().where('name', 'bebert').count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manage email addresses', () => {
|
||||
|
||||
|
@ -22,6 +22,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if has_name_component %}
|
||||
{% include './name_panel.njk' %}
|
||||
{% endif %}
|
||||
|
||||
{% if has_password_component %}
|
||||
{% include './password_panel.njk' %}
|
||||
{% endif %}
|
16
views/auth/account/name_panel.njk
Normal file
16
views/auth/account/name_panel.njk
Normal file
@ -0,0 +1,16 @@
|
||||
<section class="panel">
|
||||
<h2><i data-feather="key"></i> Change name</h2>
|
||||
{% if can_change_name %}
|
||||
<form action="{{ route('change-name') }}" method="POST">
|
||||
{{ macros.field(_locals, 'text', 'name', null, 'New name', null, 'required') }}
|
||||
|
||||
{{ macros.field(_locals, 'checkbox', 'terms', null, 'I understand that I can only change my name once every ' + name_change_wait_period, '', 'required') }}
|
||||
|
||||
<button type="submit"><i data-feather="save"></i> Confirm</button>
|
||||
|
||||
{{ macros.csrf(getCsrfToken) }}
|
||||
</form>
|
||||
{% else %}
|
||||
{{ macros.message('info', 'You can change your name in ' + can_change_name_in) }}
|
||||
{% endif %}
|
||||
</section>
|
Loading…
Reference in New Issue
Block a user