#54 user accounts - account management

This commit is contained in:
Avris 2020-10-15 20:29:56 +02:00
parent 5b97cbab95
commit 63326225a4
7 changed files with 241 additions and 122 deletions

71
components/Account.vue Normal file
View File

@ -0,0 +1,71 @@
<template>
<section>
<Alert type="danger" :message="error"/>
<form @submit.prevent="changeUsername">
<h3 class="h6"><T>user.account.changeUsername.header</T></h3>
<div class="input-group mb-3">
<input type="text" class="form-control" v-model="username"
required minlength="4" maxlength="16"/>
<div class="input-group-append">
<button class="btn btn-outline-primary">
<T>user.account.changeUsername.action</T>
</button>
</div>
</div>
</form>
<div>
<h3 class="h6"><T>user.account.changeEmail.header</T></h3>
<p>{{ email }}</p>
</div>
<p v-if="$user().roles === 'admin'">
<span class="badge badge-primary"><T>user.account.admin</T></span>
</p>
<button class="btn btn-outline-secondary btn-sm" @click="logout">
<Icon v="sign-out"/>
<T>user.logout</T>
</button>
</section>
</template>
<script>
export default {
data() {
return {
username: this.$user().username,
email: this.$user().email,
error: '',
};
},
methods: {
async changeUsername() {
await this.post(`/user/change-username`, {
username: this.username
}, {
headers: {...this.$auth()},
});
},
async post(url, data, options = {}) {
this.error = '';
const response = await this.$axios.$post(url, data, options);
if (response.error) {
this.error = response.error;
return;
}
this.$store.commit('setToken', response.token);
this.$cookies.set('token', this.$store.state.token);
},
logout() {
this.$store.commit('setToken', null);
this.$cookies.removeAll();
}
},
}
</script>

17
components/Alert.vue Normal file
View File

@ -0,0 +1,17 @@
<template>
<div v-if="message" :class="'alert alert-' + type">
<p class="mb-0">
<Icon v="exclamation-triangle"/>
<T>{{message}}</T>
</p>
</div>
</template>
<script>
export default {
props: {
type: { required: true },
message: {},
}
}
</script>

106
components/Login.vue Normal file
View File

@ -0,0 +1,106 @@
<template>
<section>
<Alert type="danger" :message="error"/>
<div v-if="token === null">
<form @submit.prevent="login">
<div class="input-group mb-3">
<input type="text" class="form-control" v-model="usernameOrEmail"
:placeholder="$t('user.login.placeholder')" autofocus required/>
<div class="input-group-append">
<button class="btn btn-primary">
<Icon v="sign-in"/>
<T>user.login.action</T>
</button>
</div>
</div>
</form>
</div>
<div v-else-if="payload && !payload.code">
<div class="alert alert-success">
<p class="mb-0">
<Icon v="envelope-open-text"/>
<T :params="{email: payload.email}">user.login.emailSent</T>
</p>
</div>
<form @submit.prevent="validate">
<div class="input-group mb-3">
<input type="text" class="form-control text-center" v-model="code"
placeholder="000000" autofocus required minlength="0" maxlength="6"
inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
/>
<div class="input-group-append">
<button class="btn btn-primary">
<Icon v="key"/>
<T>user.code.action</T>
</button>
</div>
</div>
</form>
</div>
</section>
</template>
<script>
import jwt from 'jsonwebtoken';
export default {
data() {
return {
token: null,
usernameOrEmail: '',
code: '',
error: '',
};
},
computed: {
payload() {
if (!this.token) {
return null;
}
this.$store.commit('setToken', this.token);
this.$cookies.set('token', this.$store.state.token);
return jwt.verify(this.token, process.env.PUBLIC_KEY, {
algorithm: 'RS256',
audience: process.env.BASE_URL,
issuer: process.env.BASE_URL,
});
}
},
methods: {
async login() {
await this.post(`/user/init`, {
usernameOrEmail: this.usernameOrEmail
});
},
async validate() {
await this.post(`/user/validate`, {
code: this.code
}, {
headers: {
authorization: 'Bearer ' + this.token,
},
});
},
async post(url, data, options = {}) {
this.error = '';
const response = await this.$axios.$post(url, data, options);
this.usernameOrEmail = '';
this.code = '';
if (response.error) {
this.error = response.error;
return;
}
this.token = response.token;
},
},
}
</script>

View File

@ -596,7 +596,17 @@ user:
code:
action: 'Sprawdź'
invalid: 'Kod nieprawidłowy.'
account:
changeUsername:
header: 'Nazwa użytkownika'
action: 'Zmień'
invalid: 'Nazwa użytkownika musi mieć od 4 do 16 znaków i zawierać wyłącznie cyfry, litery, kropkę, myślnik i podłogę.'
taken: 'Ta nazwa użytkownika jest zajęta.'
changeEmail:
header: 'Adres email'
action: 'Zmień'
admin: 'Adminię'
logout: 'Wyloguj'
share: 'Udostępnij'

View File

@ -1,3 +1,6 @@
import Vue from 'vue';
import t from "../src/translator";
export default ({app, store}) => {
const token = app.$cookies.get('token');
if (token) {
@ -6,4 +9,11 @@ export default ({app, store}) => {
app.$cookies.removeAll();
}
}
Vue.prototype.$user = _ => store.state.user;
Vue.prototype.$auth = _ => {
return store.state.token ? {
authorization: 'Bearer ' + store.state.token,
} : {};
};
}

View File

@ -5,129 +5,15 @@
<T>user.headerLong</T>
</h2>
<section>
<div v-if="error" class="alert alert-danger">
<p class="mb-0">
<Icon v="exclamation-triangle"/>
<T>{{error}}</T>
</p>
</div>
<div v-if="$store.state.user">
Logged in as <strong>@{{$store.state.user.username}}</strong>.
<button class="btn btn-outline-secondary btn-sm" @click="logout">
<Icon v="sign-out"/>
Log out
</button>
</div>
<div v-else-if="token === null">
<form @submit.prevent="login">
<div class="input-group mb-3">
<input type="text" class="form-control" v-model="usernameOrEmail"
:placeholder="$t('user.login.placeholder')" autofocus required/>
<div class="input-group-append">
<button class="btn btn-primary">
<Icon v="sign-in"/>
<T>user.login.action</T>
</button>
</div>
</div>
</form>
</div>
<div v-else-if="payload && !payload.code">
<div class="alert alert-success">
<p class="mb-0">
<Icon v="envelope-open-text"/>
<T :params="{email: payload.email}">user.login.emailSent</T>
</p>
</div>
<form @submit.prevent="validate">
<div class="input-group mb-3">
<input type="text" class="form-control text-center" v-model="code"
placeholder="000000" autofocus required minlength="0" maxlength="6"
inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
/>
<div class="input-group-append">
<button class="btn btn-primary">
<Icon v="key"/>
<T>user.code.action</T>
</button>
</div>
</div>
</form>
</div>
</section>
<Account v-if="$user()"/>
<Login v-else/>
</div>
</template>
<script>
import { head } from "../src/helpers";
import jwt from 'jsonwebtoken';
export default {
data() {
return {
token: null,
usernameOrEmail: '',
code: '',
error: '',
};
},
computed: {
payload() {
if (!this.token) {
return null;
}
this.$store.commit('setToken', this.token);
this.$cookies.set('token', this.$store.state.token);
return jwt.verify(this.token, process.env.PUBLIC_KEY, {
algorithm: 'RS256',
audience: process.env.BASE_URL,
issuer: process.env.BASE_URL,
});
}
},
methods: {
async login() {
await this.post(`/user/init`, {
usernameOrEmail: this.usernameOrEmail
});
},
async validate() {
await this.post(`/user/validate`, {
code: this.code
}, {
headers: {
authorization: 'Bearer ' + this.token,
},
});
},
async post(url, data, options = {}) {
this.error = '';
const response = await this.$axios.$post(url, data, options);
this.usernameOrEmail = '';
this.code = '';
if (response.error) {
this.error = response.error;
return;
}
this.token = response.token;
},
logout() {
this.token = null;
this.$store.commit('setToken', null);
this.$cookies.removeAll();
}
},
head() {
return head({
title: this.$t('user.headerLong'),

View File

@ -8,6 +8,8 @@ const mailer = require('./mailer');
const now = Math.floor(Date.now() / 1000);
const USERNAME_CHARS = 'A-Za-zĄĆĘŁŃÓŚŻŹąćęłńóśżź0-9._-';
const getUser = (authorization) => {
if (!authorization || !authorization.startsWith('Bearer ')) {
return null;
@ -105,14 +107,16 @@ const validate = async (db, user, code) => {
return {error: 'user.code.invalid'};
}
return await authenticate(db, user, authenticator);
await invalidateAuthenticator(db, authenticator);
return await authenticate(db, user);
}
const defaultUsername = async (db, email) => {
const base = email.substring(0, email.indexOf('@'))
.padEnd(4, '0')
.substring(0, 12)
.replace(/[^A-Za-z0-9._-]/g, '_');
.replace(new RegExp(`[^${USERNAME_CHARS}]`, 'g'), '_');
let c = 0;
while (true) {
@ -125,7 +129,7 @@ const defaultUsername = async (db, email) => {
}
}
const authenticate = async (db, user, authenticator) => {
const authenticate = async (db, user) => {
let dbUser = await db.get(SQL`SELECT * FROM users WHERE email = ${user.email}`);
if (!dbUser) {
dbUser = {
@ -137,8 +141,6 @@ const authenticate = async (db, user, authenticator) => {
}
}
invalidateAuthenticator(db, authenticator);
return {
token: jwt.sign({
...dbUser,
@ -147,6 +149,21 @@ const authenticate = async (db, user, authenticator) => {
};
}
const changeUsername = async (db, user, username) => {
if (username.length < 4 || username.length > 16 || !username.match(new RegExp(`^[${USERNAME_CHARS}]+$`))) {
return { error: 'user.account.changeUsername.invalid' }
}
const dbUser = await db.get(SQL`SELECT * FROM users WHERE username = ${username}`);
if (dbUser) {
return { error: 'user.account.changeUsername.taken' }
}
await db.get(SQL`UPDATE users SET username = ${username} WHERE email = ${user.email}`);
return await authenticate(db, user);
}
export default async function (req, res, next) {
const db = await dbConnection();
@ -158,6 +175,8 @@ export default async function (req, res, next) {
result = await init(db, req.body.usernameOrEmail)
} else if (req.method === 'POST' && req.url === '/validate' && req.body.code) {
result = await validate(db, user, req.body.code);
} else if (req.method === 'POST' && req.url === '/change-username' && user && user.authenticated && req.body.username) {
result = await changeUsername(db, user, req.body.username);
}
res.setHeader('content-type', 'application/json');