client/users: implement account settings
(Without avatars yet.)
This commit is contained in:
parent
90d4401024
commit
7871c69aa3
13 changed files with 242 additions and 36 deletions
|
@ -21,6 +21,9 @@ form ul {
|
||||||
form ul li {
|
form ul li {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
}
|
}
|
||||||
|
form .input {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
form .buttons {
|
form .buttons {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
}
|
}
|
||||||
|
@ -144,6 +147,7 @@ input[type=checkbox]:focus + .checkbox:before {
|
||||||
/*
|
/*
|
||||||
* Regular inputs
|
* Regular inputs
|
||||||
*/
|
*/
|
||||||
|
select,
|
||||||
textarea,
|
textarea,
|
||||||
input[type=text],
|
input[type=text],
|
||||||
input[type=email],
|
input[type=email],
|
||||||
|
@ -161,6 +165,7 @@ input[type=password] {
|
||||||
transition: border-color 0.1s linear, background-color 0.1s linear;
|
transition: border-color 0.1s linear, background-color 0.1s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select:disabled,
|
||||||
textarea:disabled,
|
textarea:disabled,
|
||||||
input[type=text]:disabled,
|
input[type=text]:disabled,
|
||||||
input[type=email]:disabled,
|
input[type=email]:disabled,
|
||||||
|
@ -170,6 +175,7 @@ input[type=password]:disabled {
|
||||||
color: var(--input-disabled-text-color);
|
color: var(--input-disabled-text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select:focus,
|
||||||
textarea:focus,
|
textarea:focus,
|
||||||
input[type=text]:focus,
|
input[type=text]:focus,
|
||||||
input[type=email]:focus,
|
input[type=email]:focus,
|
||||||
|
@ -217,6 +223,9 @@ input[type=button]:focus,
|
||||||
input[type=submit]:focus {
|
input[type=submit]:focus {
|
||||||
outline: 2px solid var(--text-color);
|
outline: 2px solid var(--text-color);
|
||||||
}
|
}
|
||||||
|
select:-moz-focusring {
|
||||||
|
text-shadow: 0;
|
||||||
|
}
|
||||||
button::-moz-focus-inner,
|
button::-moz-focus-inner,
|
||||||
input::-moz-focus-inner {
|
input::-moz-focus-inner {
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|
|
@ -38,6 +38,7 @@ body {
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1, h2, h3 {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
margin-bottom: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
|
@ -26,8 +26,10 @@
|
||||||
#user-registration .info p:first-child {
|
#user-registration .info p:first-child {
|
||||||
margin: 0 0 0.5em 0;
|
margin: 0 0 0.5em 0;
|
||||||
}
|
}
|
||||||
#user-registration p.hint {
|
#user .hint,
|
||||||
|
#user-registration .hint {
|
||||||
margin-top: 0.5em;
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0;
|
||||||
color: var(--inactive-link-color);
|
color: var(--inactive-link-color);
|
||||||
font-size: 80%;
|
font-size: 80%;
|
||||||
line-height: 120%;
|
line-height: 120%;
|
||||||
|
@ -45,6 +47,7 @@
|
||||||
}
|
}
|
||||||
#user-summary img {
|
#user-summary img {
|
||||||
width: 6em;
|
width: 6em;
|
||||||
|
height: 6em;
|
||||||
margin: 0 1.5em 1.5em 0;
|
margin: 0 1.5em 1.5em 0;
|
||||||
float: left;
|
float: left;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
<div class='messages'></div>
|
|
||||||
|
|
||||||
<div class='content-wrapper' id='user'>
|
<div class='content-wrapper' id='user'>
|
||||||
<h1>{{this.name}}</h1>
|
<h1>{{this.user.name}}</h1>
|
||||||
<nav class='text-nav'><!--
|
<nav class='text-nav'><!--
|
||||||
--><ul><!--
|
--><ul><!--
|
||||||
--><li data-name='summary'><a href='/user/{{this.name}}'>Summary</a></li><!--
|
--><li data-name='summary'><a href='/user/{{this.user.name}}'>Summary</a></li><!--
|
||||||
--><li data-name='edit'><a href='/user/{{this.name}}/edit'>Account settings</a></li><!--
|
-->{{#if this.canEditAnything}}<!--
|
||||||
|
--><li data-name='edit'><a href='/user/{{this.user.name}}/edit'>Account settings</a></li><!--
|
||||||
|
-->{{/if}}<!--
|
||||||
--></ul><!--
|
--></ul><!--
|
||||||
--></nav>
|
--></nav>
|
||||||
<div id='user-content-holder'></div>
|
<div id='user-content-holder'></div>
|
||||||
|
|
|
@ -1,3 +1,45 @@
|
||||||
<div id='user-edit'>
|
<div id='user-edit'>
|
||||||
<strong>Placeholder for account settings form</strong>
|
<form>
|
||||||
|
<fieldset class='input'>
|
||||||
|
<ul>
|
||||||
|
{{#if this.canEditName}}
|
||||||
|
<li>
|
||||||
|
<label for='user-name'>User name</label>
|
||||||
|
<input id='user-name' name='name' type='text' value='{{this.user.name}}'/>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.canEditPassword}}
|
||||||
|
<li>
|
||||||
|
<label for='user-password'>Password</label>
|
||||||
|
<input id='user-password' name='password' type='password'/>
|
||||||
|
<p class='hint'>Leave empty to keep the password unchanged.</p>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.canEditEmail}}
|
||||||
|
<li>
|
||||||
|
<label for='user-email'>Email</label>
|
||||||
|
<input id='user-email' name='email' type='email' value='{{this.user.email}}'/>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.canEditRank}}
|
||||||
|
<li>
|
||||||
|
<label for='user-rank'>Rank</label>
|
||||||
|
<select id='user-rank' name='rank'>
|
||||||
|
{{#each this.ranks}}
|
||||||
|
<option value='{{@key}}'>{{this}}</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
</ul>
|
||||||
|
<!-- TODO: avatar -->
|
||||||
|
</fieldset>
|
||||||
|
<fieldset class='messages'></fieldset>
|
||||||
|
<fieldset class='buttons'>
|
||||||
|
<input type='submit' value='Save settings'/>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{{#if this.isPrivate}}
|
{{#if this.isLoggedIn}}
|
||||||
<nav class='plain-nav'>
|
<nav class='plain-nav'>
|
||||||
<p><strong>Only visible to you</strong></p>
|
<p><strong>Only visible to you</strong></p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
@ -22,6 +22,11 @@ class Api {
|
||||||
return this._process(fullUrl, () => request.post(fullUrl).send(data));
|
return this._process(fullUrl, () => request.post(fullUrl).send(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
put(url, data) {
|
||||||
|
const fullUrl = this.getFullUrl(url);
|
||||||
|
return this._process(fullUrl, () => request.put(fullUrl).send(data));
|
||||||
|
}
|
||||||
|
|
||||||
_process(url, requestFactory) {
|
_process(url, requestFactory) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let req = requestFactory();
|
let req = requestFactory();
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
const page = require('page');
|
const page = require('page');
|
||||||
const api = require('../api.js');
|
const api = require('../api.js');
|
||||||
|
const config = require('../config.js');
|
||||||
const events = require('../events.js');
|
const events = require('../events.js');
|
||||||
|
const misc = require('../util/misc.js');
|
||||||
const topNavController = require('../controllers/top_nav_controller.js');
|
const topNavController = require('../controllers/top_nav_controller.js');
|
||||||
const RegistrationView = require('../views/registration_view.js');
|
const RegistrationView = require('../views/registration_view.js');
|
||||||
const UserView = require('../views/user_view.js');
|
const UserView = require('../views/user_view.js');
|
||||||
|
@ -33,11 +35,31 @@ class UsersController {
|
||||||
|
|
||||||
createUserRoute() {
|
createUserRoute() {
|
||||||
topNavController.activate('register');
|
topNavController.activate('register');
|
||||||
this.registrationView.render({register: (...args) => {
|
this.registrationView.render({
|
||||||
|
register: (...args) => {
|
||||||
return this._register(...args);
|
return this._register(...args);
|
||||||
}});
|
}});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadUserRoute(ctx, next) {
|
||||||
|
if (ctx.state.user) {
|
||||||
|
next();
|
||||||
|
} else if (this.user && this.user.name == ctx.params.name) {
|
||||||
|
ctx.state.user = this.user;
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
api.get('/user/' + ctx.params.name).then(response => {
|
||||||
|
ctx.state.user = response.user;
|
||||||
|
ctx.save();
|
||||||
|
this.user = response.user;
|
||||||
|
next();
|
||||||
|
}).catch(response => {
|
||||||
|
this.userView.empty();
|
||||||
|
events.notify(events.Error, response.description);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_register(name, password, email) {
|
_register(name, password, email) {
|
||||||
const data = {
|
const data = {
|
||||||
name: name,
|
name: name,
|
||||||
|
@ -59,34 +81,79 @@ class UsersController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
loadUserRoute(ctx, next) {
|
_edit(user, newName, newPassword, newEmail, newRank) {
|
||||||
if (ctx.state.user) {
|
const data = {};
|
||||||
next();
|
if (newName) { data.name = newName; }
|
||||||
} else if (this.user && this.user.name == ctx.params.name) {
|
if (newPassword) { data.password = newPassword; }
|
||||||
ctx.state.user = this.user;
|
if (newEmail) { data.email = newEmail; }
|
||||||
next();
|
if (newRank) { data.rank = newRank; }
|
||||||
|
/* TODO: avatar */
|
||||||
|
const isLoggedIn = api.isLoggedIn() && api.user.id == user.id;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
api.put('/user/' + user.name, data)
|
||||||
|
.then(response => {
|
||||||
|
const next = () => {
|
||||||
|
resolve();
|
||||||
|
page('/user/' + newName + '/edit');
|
||||||
|
events.notify(events.Success, 'Settings updated');
|
||||||
|
};
|
||||||
|
if (isLoggedIn) {
|
||||||
|
api.login(
|
||||||
|
newName,
|
||||||
|
newPassword || api.userPassword,
|
||||||
|
false)
|
||||||
|
.then(next)
|
||||||
|
.catch(response => {
|
||||||
|
reject();
|
||||||
|
events.notify(
|
||||||
|
events.Error, response.description);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
api.get('/user/' + ctx.params.name).then(response => {
|
|
||||||
ctx.state.user = response.user;
|
|
||||||
ctx.save();
|
|
||||||
this.user = response.user;
|
|
||||||
next();
|
next();
|
||||||
|
}
|
||||||
}).catch(response => {
|
}).catch(response => {
|
||||||
this.userView.empty();
|
reject();
|
||||||
events.notify(events.Error, response.description);
|
events.notify(events.Error, response.description);
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_show(user, section) {
|
_show(user, section) {
|
||||||
const isPrivate = api.isLoggedIn() && user.name == api.userName;
|
const isLoggedIn = api.isLoggedIn() && api.user.id == user.id;
|
||||||
if (isPrivate) {
|
const infix = isLoggedIn ? 'self' : 'any';
|
||||||
|
|
||||||
|
const myRankIdx = api.user ? config.ranks.indexOf(api.user.rank) : 0;
|
||||||
|
const rankNames = Object.values(config.rankNames);
|
||||||
|
let ranks = {};
|
||||||
|
for (let rankIdx of misc.range(config.ranks.length)) {
|
||||||
|
const rankIdentifier = config.ranks[rankIdx];
|
||||||
|
if (rankIdentifier === 'anonymous') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rankIdx > myRankIdx) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ranks[rankIdentifier] = rankNames[rankIdx];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
topNavController.activate('account');
|
topNavController.activate('account');
|
||||||
} else {
|
} else {
|
||||||
topNavController.activate('users');
|
topNavController.activate('users');
|
||||||
}
|
}
|
||||||
this.userView.render({
|
this.userView.render({
|
||||||
user: user, section: section, isPrivate: isPrivate});
|
user: user,
|
||||||
|
section: section,
|
||||||
|
isLoggedIn: isLoggedIn,
|
||||||
|
canEditName: api.hasPrivilege('users:edit:' + infix + ':name'),
|
||||||
|
canEditPassword: api.hasPrivilege('users:edit:' + infix + ':pass'),
|
||||||
|
canEditEmail: api.hasPrivilege('users:edit:' + infix + ':email'),
|
||||||
|
canEditRank: api.hasPrivilege('users:edit:' + infix + ':rank'),
|
||||||
|
canEditAvatar: api.hasPrivilege('users:edit:' + infix + ':avatar'),
|
||||||
|
canEditAnything: api.hasPrivilege('users:edit:' + infix),
|
||||||
|
ranks: ranks,
|
||||||
|
edit: (...args) => { return this._edit(user, ...args); },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
showUserRoute(ctx, next) {
|
showUserRoute(ctx, next) {
|
||||||
|
|
|
@ -1,5 +1,16 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
function* range(start=0, end=null, step=1) {
|
||||||
|
if (end == null) {
|
||||||
|
end = start;
|
||||||
|
start = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = start; i < end; i += step) {
|
||||||
|
yield i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatRelativeTime(timeString) {
|
function formatRelativeTime(timeString) {
|
||||||
if (!timeString) {
|
if (!timeString) {
|
||||||
return 'never';
|
return 'never';
|
||||||
|
@ -41,4 +52,7 @@ function formatRelativeTime(timeString) {
|
||||||
return future ? 'in ' + text : text + ' ago';
|
return future ? 'in ' + text : text + ' ago';
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {formatRelativeTime: formatRelativeTime};
|
module.exports = {
|
||||||
|
range: range,
|
||||||
|
formatRelativeTime: formatRelativeTime,
|
||||||
|
};
|
||||||
|
|
|
@ -1,4 +1,29 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const keys = Reflect.ownKeys;
|
||||||
|
const reduce = Function.bind.call(Function.call, Array.prototype.reduce);
|
||||||
|
const concat = Function.bind.call(Function.call, Array.prototype.concat);
|
||||||
|
const isEnumerable = Function.bind.call(
|
||||||
|
Function.call, Object.prototype.propertyIsEnumerable);
|
||||||
|
|
||||||
|
if (!Object.values) {
|
||||||
|
Object.values = function values(O) {
|
||||||
|
return reduce(keys(O), (v, k) => concat(
|
||||||
|
v, typeof k === 'string' && isEnumerable(O, k) ?
|
||||||
|
[O[k]] :
|
||||||
|
[]), []);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Object.entries) {
|
||||||
|
Object.entries = function entries(O) {
|
||||||
|
return reduce(
|
||||||
|
keys(O), (e, k) =>
|
||||||
|
concat(e, typeof k === 'string' && isEnumerable(O, k) ?
|
||||||
|
[[k, O[k]]] :
|
||||||
|
[]), []);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// fix iterating over NodeList in Chrome and Opera
|
// fix iterating over NodeList in Chrome and Opera
|
||||||
NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
|
NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const config = require('../config.js');
|
||||||
const BaseView = require('./base_view.js');
|
const BaseView = require('./base_view.js');
|
||||||
|
|
||||||
class UserEditView extends BaseView {
|
class UserEditView extends BaseView {
|
||||||
|
@ -9,7 +10,47 @@ class UserEditView extends BaseView {
|
||||||
}
|
}
|
||||||
|
|
||||||
render(options) {
|
render(options) {
|
||||||
options.target.innerHTML = this.template(options.user);
|
options.target.innerHTML = this.template(options);
|
||||||
|
|
||||||
|
const form = options.target.querySelector('form');
|
||||||
|
const rankField = options.target.querySelector('#user-rank');
|
||||||
|
const emailField = options.target.querySelector('#user-email');
|
||||||
|
const userNameField = options.target.querySelector('#user-name');
|
||||||
|
const passwordField = options.target.querySelector('#user-password');
|
||||||
|
|
||||||
|
this.decorateValidator(form);
|
||||||
|
|
||||||
|
if (userNameField) {
|
||||||
|
userNameField.setAttribute(
|
||||||
|
'pattern',
|
||||||
|
config.userNameRegex + /|^$/.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordField) {
|
||||||
|
passwordField.setAttribute(
|
||||||
|
'pattern',
|
||||||
|
config.passwordRegex + /|^$/.source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rankField) {
|
||||||
|
rankField.value = options.user.rank;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: avatar */
|
||||||
|
|
||||||
|
form.addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.clearMessages();
|
||||||
|
this.disableForm(form);
|
||||||
|
options
|
||||||
|
.edit(
|
||||||
|
userNameField.value,
|
||||||
|
passwordField.value,
|
||||||
|
emailField.value,
|
||||||
|
rankField.value)
|
||||||
|
.then(user => { this.enableForm(form); })
|
||||||
|
.catch(() => { this.enableForm(form); });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,7 @@ class UserSummaryView extends BaseView {
|
||||||
}
|
}
|
||||||
|
|
||||||
render(options) {
|
render(options) {
|
||||||
options.target.innerHTML = this.template({
|
options.target.innerHTML = this.template(options);
|
||||||
user: options.user, isPrivate: options.isPrivate});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ class UserView extends BaseView {
|
||||||
view = this.summaryView;
|
view = this.summaryView;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.showView(this.template(options.user));
|
this.showView(this.template(options));
|
||||||
|
|
||||||
options.target = this.contentHolder.querySelector(
|
options.target = this.contentHolder.querySelector(
|
||||||
'#user-content-holder');
|
'#user-content-holder');
|
||||||
|
|
Reference in a new issue