client/users: implement account settings

(Without avatars yet.)
This commit is contained in:
rr- 2016-04-07 22:54:45 +02:00
parent 90d4401024
commit 7871c69aa3
13 changed files with 242 additions and 36 deletions

View file

@ -21,6 +21,9 @@ form ul {
form ul li {
margin-top: 0.5em;
}
form .input {
margin-bottom: 1em;
}
form .buttons {
margin-top: 1em;
}
@ -144,6 +147,7 @@ input[type=checkbox]:focus + .checkbox:before {
/*
* Regular inputs
*/
select,
textarea,
input[type=text],
input[type=email],
@ -161,6 +165,7 @@ input[type=password] {
transition: border-color 0.1s linear, background-color 0.1s linear;
}
select:disabled,
textarea:disabled,
input[type=text]:disabled,
input[type=email]:disabled,
@ -170,6 +175,7 @@ input[type=password]:disabled {
color: var(--input-disabled-text-color);
}
select:focus,
textarea:focus,
input[type=text]:focus,
input[type=email]:focus,
@ -217,6 +223,9 @@ input[type=button]:focus,
input[type=submit]:focus {
outline: 2px solid var(--text-color);
}
select:-moz-focusring {
text-shadow: 0;
}
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;

View file

@ -38,6 +38,7 @@ body {
h1, h2, h3 {
font-weight: normal;
margin-bottom: 1em;
}
a {

View file

@ -26,8 +26,10 @@
#user-registration .info p:first-child {
margin: 0 0 0.5em 0;
}
#user-registration p.hint {
#user .hint,
#user-registration .hint {
margin-top: 0.5em;
margin-bottom: 0;
color: var(--inactive-link-color);
font-size: 80%;
line-height: 120%;
@ -45,6 +47,7 @@
}
#user-summary img {
width: 6em;
height: 6em;
margin: 0 1.5em 1.5em 0;
float: left;
}

View file

@ -1,11 +1,11 @@
<div class='messages'></div>
<div class='content-wrapper' id='user'>
<h1>{{this.name}}</h1>
<h1>{{this.user.name}}</h1>
<nav class='text-nav'><!--
--><ul><!--
--><li data-name='summary'><a href='/user/{{this.name}}'>Summary</a></li><!--
--><li data-name='edit'><a href='/user/{{this.name}}/edit'>Account settings</a></li><!--
--><li data-name='summary'><a href='/user/{{this.user.name}}'>Summary</a></li><!--
-->{{#if this.canEditAnything}}<!--
--><li data-name='edit'><a href='/user/{{this.user.name}}/edit'>Account settings</a></li><!--
-->{{/if}}<!--
--></ul><!--
--></nav>
<div id='user-content-holder'></div>

View file

@ -1,3 +1,45 @@
<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>

View file

@ -16,7 +16,7 @@
</ul>
</nav>
{{#if this.isPrivate}}
{{#if this.isLoggedIn}}
<nav class='plain-nav'>
<p><strong>Only visible to you</strong></p>
<ul>

View file

@ -22,6 +22,11 @@ class Api {
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) {
return new Promise((resolve, reject) => {
let req = requestFactory();

View file

@ -2,7 +2,9 @@
const page = require('page');
const api = require('../api.js');
const config = require('../config.js');
const events = require('../events.js');
const misc = require('../util/misc.js');
const topNavController = require('../controllers/top_nav_controller.js');
const RegistrationView = require('../views/registration_view.js');
const UserView = require('../views/user_view.js');
@ -33,9 +35,29 @@ class UsersController {
createUserRoute() {
topNavController.activate('register');
this.registrationView.render({register: (...args) => {
return this._register(...args);
}});
this.registrationView.render({
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) {
@ -59,34 +81,79 @@ class UsersController {
});
}
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);
});
}
_edit(user, newName, newPassword, newEmail, newRank) {
const data = {};
if (newName) { data.name = newName; }
if (newPassword) { data.password = newPassword; }
if (newEmail) { data.email = newEmail; }
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 {
next();
}
}).catch(response => {
reject();
events.notify(events.Error, response.description);
});
});
}
_show(user, section) {
const isPrivate = api.isLoggedIn() && user.name == api.userName;
if (isPrivate) {
const isLoggedIn = api.isLoggedIn() && api.user.id == user.id;
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');
} else {
topNavController.activate('users');
}
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) {

View file

@ -1,5 +1,16 @@
'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) {
if (!timeString) {
return 'never';
@ -41,4 +52,7 @@ function formatRelativeTime(timeString) {
return future ? 'in ' + text : text + ' ago';
}
module.exports = {formatRelativeTime: formatRelativeTime};
module.exports = {
range: range,
formatRelativeTime: formatRelativeTime,
};

View file

@ -1,4 +1,29 @@
'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
NodeList.prototype[Symbol.iterator] = Array.prototype[Symbol.iterator];

View file

@ -1,5 +1,6 @@
'use strict';
const config = require('../config.js');
const BaseView = require('./base_view.js');
class UserEditView extends BaseView {
@ -9,7 +10,47 @@ class UserEditView extends BaseView {
}
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); });
});
}
}

View file

@ -9,8 +9,7 @@ class UserSummaryView extends BaseView {
}
render(options) {
options.target.innerHTML = this.template({
user: options.user, isPrivate: options.isPrivate});
options.target.innerHTML = this.template(options);
}
}

View file

@ -25,7 +25,7 @@ class UserView extends BaseView {
view = this.summaryView;
}
this.showView(this.template(options.user));
this.showView(this.template(options));
options.target = this.contentHolder.querySelector(
'#user-content-holder');