Add user profile APIs and update UI.

This commit is contained in:
Kailash Nadh 2024-05-30 23:37:20 +05:30
parent 6a34ebc629
commit 4997c10b97
11 changed files with 206 additions and 35 deletions

View file

@ -210,3 +210,39 @@ func handleGetUserProfile(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{user})
}
// handleUpdateUserProfile update's the current user's profile.
func handleUpdateUserProfile(c echo.Context) error {
var (
app = c.Get("app").(*App)
user = c.Get(auth.UserKey).(models.User)
)
u := models.User{}
if err := c.Bind(&u); err != nil {
return err
}
u.PasswordLogin = user.PasswordLogin
u.Name = strings.TrimSpace(u.Name)
email := strings.TrimSpace(u.Email.String)
// Validate fields.
if !utils.ValidateEmail(email) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
}
u.Email = null.String{String: email, Valid: true}
if u.PasswordLogin && u.Password.String != "" {
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
}
}
out, err := app.core.UpdateUser(user.ID, u)
if err != nil {
return err
}
out.Password = null.String{}
return c.JSON(http.StatusOK, okResp{out})
}

View file

@ -14,15 +14,17 @@
@toggleGroup="toggleGroup" @doLogout="doLogout" />
<b-navbar-dropdown v-else>
<template #label>
<div class="avatar">
<img v-if="profile.avatar" src="profile.avatar" alt="" />
<template v-if="profile" #label>
<div class="user-avatar">
<img v-if="profile.avatar" :src="profile.avatar" alt="" />
<span v-else>{{ profile.username[0].toUpperCase() }}</span>
</div>
{{ profile.username }}
</template>
<b-navbar-item href="#">
<a href="#" @click.prevent="doLogout"><b-icon icon="account-outline" /> {{ $t('users.account') }}</a>
<router-link :to="`/user/profile`">
<b-icon icon="account-outline" /> {{ $t('users.profile') }}
</router-link>
</b-navbar-item>
<b-navbar-item href="#">
<a href="#" @click.prevent="doLogout"><b-icon icon="logout-variant" /> {{ $t('users.logout') }}</a>
@ -85,7 +87,7 @@ export default Vue.extend({
data() {
return {
profile: {},
profile: null,
activeItem: {},
activeGroup: {},
windowWidth: window.innerWidth,

View file

@ -482,3 +482,9 @@ export const getUserProfile = () => http.get(
'/api/profile',
{ loading: models.users },
);
export const updateUserProfile = (data) => http.put(
'/api/profile',
data,
{ loading: models.users },
);

View file

@ -88,6 +88,10 @@ section {
&.wrap {
max-width: 1100px;
}
&.section-mini {
max-width: 500px;
}
}
.spinner.is-tiny {
@ -131,6 +135,26 @@ section {
background-color: #efefef;
}
.user-avatar {
img {
display: inline-block;
border-radius: 100%;
width: 24px;
height: 24px;
}
span {
background-color: #ddd;
border-radius: 100%;
width: 24px;
height: 24px;
text-align: center;
display: inline-block;
margin-right: 10px;
font-size: 0.875rem;
line-height: 1.6;
}
}
.copy-text {
color: inherit;
.icon {
@ -172,26 +196,6 @@ section {
.navbar {
box-shadow: 0 0 3px $grey-lighter;
.avatar {
img {
display: inline-block;
border-radius: 100%;
width: 24px;
height: 24px;
}
span {
background-color: #ddd;
border-radius: 100%;
width: 24px;
height: 24px;
text-align: center;
display: inline-block;
margin-right: 10px;
font-size: 0.875rem;
line-height: 1.6;
}
}
}
.navbar-brand {
padding: 0 0 0 25px;
@ -927,7 +931,6 @@ section.users {
color: $green;
}
/* C3 charting lib */
.c3 {
.c3-text.c3-empty {

View file

@ -95,6 +95,12 @@ const routes = [
meta: { title: 'globals.terms.campaign', group: 'campaigns' },
component: () => import('../views/Campaign.vue'),
},
{
path: '/user/profile',
name: 'userProfile',
meta: { title: 'users.profile', group: 'settings' },
component: () => import('../views/UserProfile.vue'),
},
{
path: '/settings',
name: 'settings',

View file

@ -74,7 +74,7 @@
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password2"
type="password" name="password" :required="form.passwordLogin && !isEditing && form.password" />
type="password" name="password2" :required="form.passwordLogin && !isEditing && form.password" />
</b-field>
</div>
</div>

View file

@ -0,0 +1,96 @@
<template>
<section class="user-profile section-mini">
<b-loading v-if="loading.users" :active="loading.users" :is-full-page="false" />
<h1 class="title">
@{{ form.username }}
</h1>
<b-tag :class="{ [form.type]: form.status === 'enabled' }">
{{ $t(`users.type.${form.type}`) }}
</b-tag>
<br /><br /><br />
<form @submit.prevent="onSubmit">
<b-field v-if="form.type !== 'api'" :label="$t('subscribers.email')" label-position="on-border">
<b-input :maxlength="200" v-model="form.email" name="email" :placeholder="$t('subscribers.email')" required
autofocus />
</b-field>
<b-field :label="$t('globals.fields.name')" label-position="on-border">
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
</b-field>
<div v-if="form.passwordLogin" class="columns">
<div class="column is-6">
<b-field :label="$t('users.password')" label-position="on-border">
<b-input minlength="8" :maxlength="200" v-model="form.password" type="password" name="password"
:placeholder="$t('users.password')" />
</b-field>
</div>
<div class="column is-6">
<b-field :label="$t('users.passwordRepeat')" label-position="on-border">
<b-input minlength="8" :maxlength="200" v-model="form.password2" type="password" name="password2" />
</b-field>
</div>
</div>
<b-field expanded>
<b-button type="is-primary" icon-left="content-save-outline" native-type="submit" data-cy="btn-save">
{{ $t('globals.buttons.save') }}
</b-button>
</b-field>
</form>
</section>
</template>
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
export default Vue.extend({
name: 'UserProfile',
data() {
return {
form: {},
};
},
methods: {
onSubmit() {
const params = {
name: this.form.name,
email: this.form.email,
};
if (this.form.passwordLogin) {
if (this.form.password !== this.form.password2) {
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
return;
}
params.password = this.form.password;
params.password2 = this.form.password2;
}
this.$api.updateUserProfile(params).then(() => {
this.form.password = '';
this.form.password2 = '';
this.$utils.toast(this.$t('globals.messages.updated', { name: this.form.username }));
});
},
},
mounted() {
this.$api.getUserProfile().then((data) => {
this.form = data;
});
},
computed: {
...mapState(['loading']),
},
});
</script>

View file

@ -598,7 +598,7 @@
"users.login": "Login",
"users.loginOIDC": "Login with OIDC",
"users.logout": "Logout",
"users.account": "Account",
"users.profile": "Profile",
"users.lastLogin": "Last login",
"users.newUser": "New user",
"users.type": "Type",

View file

@ -99,6 +99,22 @@ func (c *Core) UpdateUser(id int, u models.User) (models.User, error) {
return c.GetUser(id, "", "")
}
// UpdateUserProfile updates the basic fields of a given uesr (name, email, password).
func (c *Core) UpdateUserProfile(id int, u models.User) (models.User, error) {
res, err := c.q.UpdateUserProfile.Exec(id, u.Name, u.Email, u.PasswordLogin, u.Password)
if err != nil {
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
}
if n, _ := res.RowsAffected(); n == 0 {
return models.User{}, echo.NewHTTPError(http.StatusBadRequest,
c.i18n.Ts("globals.messages.notFound", "name", "{globals.terms.user}"))
}
return c.GetUser(id, "", "")
}
// DeleteUsers deletes a given user.
func (c *Core) DeleteUsers(ids []int) error {
res, err := c.q.DeleteUsers.Exec(pq.Array(ids))

View file

@ -108,13 +108,14 @@ type Queries struct {
DeleteBouncesBySubscriber *sqlx.Stmt `query:"delete-bounces-by-subscriber"`
GetDBInfo string `query:"get-db-info"`
CreateUser *sqlx.Stmt `query:"create-user"`
UpdateUser *sqlx.Stmt `query:"update-user"`
DeleteUsers *sqlx.Stmt `query:"delete-users"`
GetUsers *sqlx.Stmt `query:"get-users"`
GetUser *sqlx.Stmt `query:"get-user"`
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
LoginUser *sqlx.Stmt `query:"login-user"`
CreateUser *sqlx.Stmt `query:"create-user"`
UpdateUser *sqlx.Stmt `query:"update-user"`
UpdateUserProfile *sqlx.Stmt `query:"update-user-profile"`
DeleteUsers *sqlx.Stmt `query:"delete-users"`
GetUsers *sqlx.Stmt `query:"get-users"`
GetUser *sqlx.Stmt `query:"get-user"`
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
LoginUser *sqlx.Stmt `query:"login-user"`
}
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions

View file

@ -1079,3 +1079,8 @@ WITH u AS (
SELECT * FROM users WHERE username=$1 AND status != 'disabled' AND password_login = TRUE
)
SELECT * FROM u WHERE CRYPT($2, password) = password;
-- name: update-user-profile
UPDATE users SET name=$2, email=$3,
password=(CASE WHEN $4 = TRUE THEN (CASE WHEN $5 != '' THEN CRYPT($5, GEN_SALT('bf')) ELSE password END) ELSE NULL END)
WHERE id=$1;