mirror of
https://github.com/knadh/listmonk.git
synced 2024-11-13 02:55:04 +08:00
Add user profile APIs and update UI.
This commit is contained in:
parent
6a34ebc629
commit
4997c10b97
11 changed files with 206 additions and 35 deletions
36
cmd/users.go
36
cmd/users.go
|
@ -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})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
||||
|
|
96
frontend/src/views/UserProfile.vue
Normal file
96
frontend/src/views/UserProfile.vue
Normal 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>
|
|
@ -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",
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue