mirror of
https://github.com/knadh/listmonk.git
synced 2024-11-13 02:55:04 +08:00
Add api
type user.
This commit is contained in:
parent
bf0b500bb0
commit
1516bf216f
17 changed files with 300 additions and 120 deletions
89
cmd/users.go
89
cmd/users.go
|
@ -2,12 +2,19 @@ package main
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/knadh/listmonk/internal/utils"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
var (
|
||||
reUsername = regexp.MustCompile("^[a-zA-Z0-9_\\-\\.]+$")
|
||||
)
|
||||
|
||||
// handleGetUsers retrieves users.
|
||||
|
@ -53,22 +60,33 @@ func handleCreateUser(c echo.Context) error {
|
|||
|
||||
u.Username = strings.TrimSpace(u.Username)
|
||||
u.Name = strings.TrimSpace(u.Name)
|
||||
u.Email = strings.TrimSpace(u.Email)
|
||||
email := strings.TrimSpace(u.Email.String)
|
||||
|
||||
// Validate fields.
|
||||
if !strHasLen(u.Username, 1, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if !reUsername.MatchString(u.Username) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if u.Type != models.UserTypeAPI {
|
||||
if !utils.ValidateEmail(email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
|
||||
}
|
||||
if u.PasswordLogin {
|
||||
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
}
|
||||
|
||||
u.Email = null.String{String: email, Valid: true}
|
||||
}
|
||||
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
|
||||
if !strHasLen(u.Username, 1, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
|
||||
if u.PasswordLogin {
|
||||
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
}
|
||||
|
||||
// Create the user in the database.
|
||||
out, err := app.core.CreateUser(u)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -97,34 +115,49 @@ func handleUpdateUser(c echo.Context) error {
|
|||
// Validate.
|
||||
u.Username = strings.TrimSpace(u.Username)
|
||||
u.Name = strings.TrimSpace(u.Name)
|
||||
u.Email = strings.TrimSpace(u.Email)
|
||||
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
email := strings.TrimSpace(u.Email.String)
|
||||
|
||||
// Validate fields.
|
||||
if !strHasLen(u.Username, 1, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
if !reUsername.MatchString(u.Username) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "username"))
|
||||
}
|
||||
|
||||
if u.PasswordLogin {
|
||||
if u.Password.String != "" {
|
||||
if u.Type != models.UserTypeAPI {
|
||||
if !utils.ValidateEmail(email) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "email"))
|
||||
}
|
||||
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"))
|
||||
}
|
||||
} else {
|
||||
// Get the existing user for password validation.
|
||||
user, err := app.core.GetUser(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If password login is enabled, but there's no password in the DB and there's no incoming
|
||||
// password, throw an error.
|
||||
if !user.HasPassword {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
if u.Password.String != "" {
|
||||
if !strHasLen(u.Password.String, 8, stdInputMaxLen) {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
} else {
|
||||
// Get the existing user for password validation.
|
||||
user, err := app.core.GetUser(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If password login is enabled, but there's no password in the DB and there's no incoming
|
||||
// password, throw an error.
|
||||
if !user.HasPassword {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, app.i18n.Ts("globals.messages.invalidFields", "name", "password"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
u.Email = null.String{String: email, Valid: true}
|
||||
}
|
||||
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
|
||||
out, err := app.core.UpdateUser(id, u)
|
||||
|
|
|
@ -600,6 +600,20 @@
|
|||
"code": 59431,
|
||||
"src": "typicons"
|
||||
},
|
||||
{
|
||||
"uid": "77025195d19e048302e8943e2da4cc75",
|
||||
"css": "account-outline",
|
||||
"code": 983059,
|
||||
"src": "custom_icons",
|
||||
"selected": true,
|
||||
"svg": {
|
||||
"path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"account-outline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "f4ad3f6d071a0bfb3a8452b514ed0892",
|
||||
"css": "vector-square",
|
||||
|
@ -838,20 +852,6 @@
|
|||
"account-off"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "77025195d19e048302e8943e2da4cc75",
|
||||
"css": "account-outline",
|
||||
"code": 983059,
|
||||
"src": "custom_icons",
|
||||
"selected": false,
|
||||
"svg": {
|
||||
"path": "M500 166Q568.4 166 617.2 214.8T666 333 617.2 451.2 500 500 382.8 451.2 334 333 382.8 214.8 500 166ZM500 250Q464.8 250 440.4 274.4T416 333 440.4 391.6 500 416 559.6 391.6 584 333 559.6 274.4 500 250ZM500 541Q562.5 541 636.7 560.5 720.7 582 771.5 615.2 834 656.3 834 709V834H166V709Q166 656.3 228.5 615.2 279.3 582 363.3 560.5 437.5 541 500 541ZM500 621.1Q441.4 621.1 378.9 636.7 324.2 652.3 285.2 673.8T246.1 709V753.9H753.9V709Q753.9 695.3 714.8 673.8T621.1 636.7Q558.6 621.1 500 621.1Z",
|
||||
"width": 1000
|
||||
},
|
||||
"search": [
|
||||
"account-outline"
|
||||
]
|
||||
},
|
||||
{
|
||||
"uid": "571120b7ff63feb71df85710d019302c",
|
||||
"css": "account-plus",
|
||||
|
|
2
frontend/src/assets/icons/fontello.css
vendored
2
frontend/src/assets/icons/fontello.css
vendored
|
@ -75,6 +75,7 @@
|
|||
/* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */
|
||||
}
|
||||
|
||||
|
||||
.mdi-view-dashboard-variant-outline:before { content: '\e800'; } /* '' */
|
||||
.mdi-format-list-bulleted-square:before { content: '\e801'; } /* '' */
|
||||
.mdi-newspaper-variant-outline:before { content: '\e802'; } /* '' */
|
||||
|
@ -115,6 +116,7 @@
|
|||
.mdi-email-bounce:before { content: '\e825'; } /* '' */
|
||||
.mdi-speedometer:before { content: '\e826'; } /* '' */
|
||||
.mdi-warning-empty:before { content: '\e827'; } /* '' */
|
||||
.mdi-account-outline:before { content: ''; } /* '\f0013' */
|
||||
.mdi-code:before { content: ''; } /* '\f0169' */
|
||||
.mdi-logout-variant:before { content: ''; } /* '\f05fd' */
|
||||
.mdi-wrench-outline:before { content: ''; } /* '\f0be0' */
|
||||
|
|
Binary file not shown.
|
@ -563,21 +563,21 @@ body.is-noscroll {
|
|||
color: $grey;
|
||||
}
|
||||
|
||||
&.private, &.scheduled, &.paused, &.tx {
|
||||
&.private, &.scheduled, &.paused, &.tx, &.api {
|
||||
$color: #ed7b00;
|
||||
color: $color;
|
||||
background: #fff7e6;
|
||||
border: 1px solid lighten($color, 37%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 37%);
|
||||
}
|
||||
&.public, &.running, &.list, &.campaign, &.super {
|
||||
&.public, &.running, &.list, &.campaign, &.user {
|
||||
$color: $primary;
|
||||
color: lighten($color, 20%);;
|
||||
background: #e6f7ff;
|
||||
border: 1px solid lighten($color, 42%);
|
||||
box-shadow: 1px 1px 0 lighten($color, 42%);
|
||||
}
|
||||
&.finished, &.enabled, &.status-confirmed {
|
||||
&.finished, &.enabled, &.status-confirmed, &.super {
|
||||
$color: $green;
|
||||
color: $color;
|
||||
background: #f6ffed;
|
||||
|
@ -897,6 +897,15 @@ section.users {
|
|||
min-width: 100px !important;
|
||||
}
|
||||
}
|
||||
.user-api-token .copy-text {
|
||||
background: rgba($green, .1);
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
padding: 15px;
|
||||
font-size: 1.2rem;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
|
||||
/* C3 charting lib */
|
||||
|
|
|
@ -33,7 +33,8 @@
|
|||
<div class="column is-4">
|
||||
<b-field :label="$t('globals.fields.status')" label-position="on-border"
|
||||
:message="$t('subscribers.blocklistedHelp')">
|
||||
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required expanded>
|
||||
<b-select v-model="form.status" name="status" :placeholder="$t('globals.fields.status')" required
|
||||
expanded>
|
||||
<option value="enabled">
|
||||
{{ $t('subscribers.status.enabled') }}
|
||||
</option>
|
||||
|
|
|
@ -13,14 +13,28 @@
|
|||
</h4>
|
||||
</header>
|
||||
<section expanded class="modal-card-body">
|
||||
<b-field :label="$t('users.username')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.username" name="username" :ref="'focus'"
|
||||
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off"
|
||||
pattern="[a-zA-Z0-9_\-\.]+$" />
|
||||
</b-field>
|
||||
<div class="columns">
|
||||
<div class="column is-8">
|
||||
<b-field :label="$t('users.username')" label-position="on-border">
|
||||
<b-input :maxlength="200" v-model="form.username" name="username" :ref="'focus'"
|
||||
:placeholder="$t('users.username')" required :message="$t('users.usernameHelp')" autocomplete="off" />
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.type')" label-position="on-border">
|
||||
<b-select v-model="form.type" name="status" required expanded>
|
||||
<option v-if="hasType('user')" value="user">
|
||||
{{ $t('users.type.user') }}
|
||||
</option>
|
||||
<option v-if="hasType('super')" value="super">
|
||||
{{ $t('users.type.super') }}
|
||||
</option>
|
||||
<option v-if="hasType('api')" value="api">
|
||||
{{ $t('users.type.api') }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
<div class="column is-4">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('globals.fields.status')" label-position="on-border">
|
||||
<b-select v-model="form.status" name="status" required expanded>
|
||||
<option value="enabled">
|
||||
|
@ -29,15 +43,12 @@
|
|||
<option value="disabled">
|
||||
{{ $t('users.status.disabled') }}
|
||||
</option>
|
||||
<option value="super">
|
||||
{{ $t('users.status.super') }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-field :label="$t('subscribers.email')" label-position="on-border">
|
||||
<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 />
|
||||
</b-field>
|
||||
|
||||
|
@ -45,33 +56,39 @@
|
|||
<b-input :maxlength="200" v-model="form.name" name="name" :placeholder="$t('globals.fields.name')" />
|
||||
</b-field>
|
||||
|
||||
<b-field>
|
||||
<b-checkbox v-model="form.passwordLogin" :native-value="true">
|
||||
{{ $t('users.passwordEnable') }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
<template v-if="form.type !== 'api'">
|
||||
<b-field>
|
||||
<b-checkbox v-model="form.passwordLogin" :native-value="true">
|
||||
{{ $t('users.passwordEnable') }}
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.password')" label-position="on-border">
|
||||
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
|
||||
type="password" name="password" :placeholder="$t('users.password')"
|
||||
:required="form.passwordLogin && !isEditing" />
|
||||
</b-field>
|
||||
</div>
|
||||
<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" />
|
||||
</b-field>
|
||||
<div class="columns">
|
||||
<div class="column is-6">
|
||||
<b-field :label="$t('users.password')" label-position="on-border">
|
||||
<b-input :disabled="!form.passwordLogin" minlength="8" :maxlength="200" v-model="form.password"
|
||||
type="password" name="password" :placeholder="$t('users.password')"
|
||||
:required="form.passwordLogin && !isEditing" />
|
||||
</b-field>
|
||||
</div>
|
||||
<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" />
|
||||
</b-field>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="apiToken" class="user-api-token">
|
||||
<p>{{ $t('users.apiOneTimeToken') }}</p>
|
||||
<copy-text :text="apiToken" />
|
||||
</div>
|
||||
</section>
|
||||
<footer class="modal-card-foot has-text-right">
|
||||
<b-button @click="$parent.close()">
|
||||
{{ $t('globals.buttons.close') }}
|
||||
</b-button>
|
||||
<b-button native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
|
||||
<b-button v-if="!apiToken" native-type="submit" type="is-primary" :loading="loading.lists" data-cy="btn-save">
|
||||
{{ $t('globals.buttons.save') }}
|
||||
</b-button>
|
||||
</footer>
|
||||
|
@ -105,8 +122,10 @@ export default Vue.extend({
|
|||
name: '',
|
||||
password: '',
|
||||
passwordLogin: false,
|
||||
type: 'user',
|
||||
status: 'enabled',
|
||||
},
|
||||
apiToken: null,
|
||||
};
|
||||
},
|
||||
|
||||
|
@ -118,7 +137,7 @@ export default Vue.extend({
|
|||
}
|
||||
|
||||
if (this.isEditing) {
|
||||
if (this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) {
|
||||
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password && this.form.password !== this.form.password2) {
|
||||
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
|
||||
return;
|
||||
}
|
||||
|
@ -127,7 +146,7 @@ export default Vue.extend({
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.form.passwordLogin && this.form.password !== this.form.password2) {
|
||||
if (this.form.type !== 'api' && this.form.passwordLogin && this.form.password !== this.form.password2) {
|
||||
this.$utils.toast(this.$t('users.passwordMismatch'), 'is-danger');
|
||||
return;
|
||||
}
|
||||
|
@ -139,8 +158,15 @@ export default Vue.extend({
|
|||
const form = { ...this.form, password_login: this.form.passwordLogin };
|
||||
this.$api.createUser(form).then((data) => {
|
||||
this.$emit('finished');
|
||||
this.$parent.close();
|
||||
this.$utils.toast(this.$t('globals.messages.created', { name: data.name }));
|
||||
|
||||
// If the user is an API user, show the one-time token.
|
||||
if (form.type === 'api') {
|
||||
this.apiToken = data.password;
|
||||
return;
|
||||
}
|
||||
|
||||
this.$parent.close();
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -152,6 +178,12 @@ export default Vue.extend({
|
|||
this.$utils.toast(this.$t('globals.messages.updated', { name: data.name }));
|
||||
});
|
||||
},
|
||||
|
||||
hasType(t) {
|
||||
// If the user being edited is API, then the only valid field is API.
|
||||
// Otherwise, all fields are valid except API.
|
||||
return !this.$props.isEditing || (this.form.type === 'api' ? t === 'api' : t !== 'api');
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
|
|
@ -36,35 +36,37 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<b-table-column v-slot="props" field="type" :label="$t('users.type')" sortable>
|
||||
<b-tag :class="{ [props.row.type]: props.row.status === 'enabled' }">
|
||||
{{ $t(`users.type.${props.row.type}`) }}
|
||||
</b-tag>
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="username" :label="$t('users.username')" header-class="cy-username" sortable
|
||||
:td-attrs="$utils.tdID">
|
||||
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
|
||||
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
|
||||
{{ props.row.username }}
|
||||
<div class="has-text-grey is-size-7">{{ props.row.name }}</div>
|
||||
</a>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="status" :label="$t('globals.fields.status')" header-class="cy-status"
|
||||
sortable :td-attrs="$utils.tdID">
|
||||
<b-tag :class="{ 'is-small': true, [props.row.status]: true }">
|
||||
<b-tag :class="{ [props.row.status]: true }">
|
||||
{{ $t(`users.status.${props.row.status}`) }}
|
||||
</b-tag>
|
||||
</b-table-column>
|
||||
<b-table-column v-slot="props" field="name" :label="$t('globals.fields.name')" header-class="cy-name" sortable
|
||||
:td-attrs="$utils.tdID">
|
||||
<div>
|
||||
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)">
|
||||
{{ props.row.name }}
|
||||
</a>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
<b-table-column v-slot="props" field="name" :label="$t('subscribers.email')" header-class="cy-name" sortable
|
||||
:td-attrs="$utils.tdID">
|
||||
<div>
|
||||
<a :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)">
|
||||
<a v-if="props.row.email" :href="`/users/${props.row.id}`" @click.prevent="showEditForm(props.row)"
|
||||
:class="{ 'has-text-grey': props.row.status === 'disabled' }">
|
||||
{{ props.row.email }}
|
||||
</a>
|
||||
<template v-else>
|
||||
—
|
||||
</template>
|
||||
</div>
|
||||
</b-table-column>
|
||||
|
||||
|
@ -119,6 +121,12 @@ import { mapState } from 'vuex';
|
|||
import EmptyPlaceholder from '../components/EmptyPlaceholder.vue';
|
||||
import UserForm from './UserForm.vue';
|
||||
|
||||
const TYPE_ICONS = {
|
||||
user: 'account-outline',
|
||||
super: 'account-check-outline',
|
||||
api: 'link-variant',
|
||||
};
|
||||
|
||||
export default Vue.extend({
|
||||
components: {
|
||||
EmptyPlaceholder,
|
||||
|
@ -201,6 +209,8 @@ export default Vue.extend({
|
|||
},
|
||||
);
|
||||
},
|
||||
|
||||
getTypeIcon: (typ) => TYPE_ICONS[typ],
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
|
|
@ -599,15 +599,19 @@
|
|||
"users.logout": "Logout",
|
||||
"users.lastLogin": "Last login",
|
||||
"users.newUser": "New user",
|
||||
"users.type": "Type",
|
||||
"users.type.user": "User",
|
||||
"users.type.super": "Super Admin",
|
||||
"users.type.api": "API",
|
||||
"users.status.enabled": "Enabled",
|
||||
"users.status.disabled": "Disabled",
|
||||
"users.status.super": "Super admin",
|
||||
"users.username": "Username",
|
||||
"users.usernameHelp": "Used with password login",
|
||||
"users.password": "Password",
|
||||
"users.invalidLogin": "Invalid username or password",
|
||||
"users.passwordRepeat": "Repeat password",
|
||||
"users.passwordEnable": "Enable logging in with password",
|
||||
"users.passwordEnable": "Enable password login",
|
||||
"users.passwordMismatch": "Passwords don't match",
|
||||
"users.apiOneTimeToken": "Copy the API access token now. It will not be shown again.",
|
||||
"users.cantDelete": "User(s) couldn't be deleted. There has to be at least one 'super' user."
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ package auth
|
|||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
|
@ -49,8 +50,8 @@ type Config struct {
|
|||
}
|
||||
|
||||
type Auth struct {
|
||||
tokens map[string]struct{}
|
||||
mut sync.RWMutex
|
||||
tokens map[string][]byte
|
||||
sync.RWMutex
|
||||
|
||||
cfg oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
|
@ -81,22 +82,25 @@ func New(cfg Config) *Auth {
|
|||
}
|
||||
}
|
||||
|
||||
// SetTokens remembers a list of string API tokens that are used for authenticating
|
||||
// API queries.
|
||||
func (o *Auth) SetTokens(tokens []string) {
|
||||
o.mut.Lock()
|
||||
defer o.mut.Unlock()
|
||||
// SetTokens caches tokens for authenticating API client calls.
|
||||
func (o *Auth) SetAPITokens(tokens map[string][]byte) {
|
||||
o.Lock()
|
||||
defer o.Unlock()
|
||||
|
||||
o.tokens = make(map[string]struct{}, len(tokens))
|
||||
for _, t := range tokens {
|
||||
o.tokens[t] = struct{}{}
|
||||
o.tokens = make(map[string][]byte, len(tokens))
|
||||
for user, token := range tokens {
|
||||
o.tokens[user] = []byte{}
|
||||
copy(o.tokens[user], token)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckToken validates an API token.
|
||||
func (o *Auth) CheckToken(token string) bool {
|
||||
_, ok := o.tokens[token]
|
||||
return ok
|
||||
// CheckAPIToken validates an API user+token.
|
||||
func (o *Auth) CheckAPIToken(user string, token []byte) bool {
|
||||
o.RLock()
|
||||
t, ok := o.tokens[user]
|
||||
o.RUnlock()
|
||||
|
||||
return ok && subtle.ConstantTimeCompare(t, token) == 1
|
||||
}
|
||||
|
||||
// HandleOIDCCallback is the HTTP handler that handles the post-OIDC provider redirect callback.
|
||||
|
|
|
@ -4,9 +4,11 @@ import (
|
|||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/knadh/listmonk/internal/utils"
|
||||
"github.com/knadh/listmonk/models"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/lib/pq"
|
||||
"gopkg.in/volatiletech/null.v6"
|
||||
)
|
||||
|
||||
// GetUsers retrieves all users.
|
||||
|
@ -21,10 +23,14 @@ func (c *Core) GetUsers() ([]models.User, error) {
|
|||
if u.Password.String != "" {
|
||||
u.HasPassword = true
|
||||
u.PasswordLogin = true
|
||||
u.Password.String = ""
|
||||
u.Password.Valid = false
|
||||
u.Password = null.String{}
|
||||
|
||||
out[n] = u
|
||||
}
|
||||
|
||||
if u.Type == models.UserTypeAPI {
|
||||
out[n].Email = null.String{}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
|
@ -50,17 +56,38 @@ func (c *Core) GetUser(id int) (models.User, error) {
|
|||
// CreateUser creates a new user.
|
||||
func (c *Core) CreateUser(u models.User) (models.User, error) {
|
||||
var out models.User
|
||||
if err := c.q.CreateUser.Get(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Status); err != nil {
|
||||
|
||||
// If it's an API user, generate a random token for password
|
||||
// and set the e-mail to default.
|
||||
if u.Type == models.UserTypeAPI {
|
||||
// Generate a random admin password.
|
||||
tk, err := utils.GenerateRandomString(32)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
|
||||
u.Email = null.String{String: u.Username + "@api", Valid: true}
|
||||
u.PasswordLogin = false
|
||||
u.Password = null.String{String: tk, Valid: true}
|
||||
}
|
||||
|
||||
if err := c.q.CreateUser.Get(&out, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.Status); err != nil {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorCreating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
// Hide the password field in the response except for when the user type is an API token,
|
||||
// where the frontend shows the token on the UI just once.
|
||||
if u.Type != models.UserTypeAPI {
|
||||
u.Password = null.String{Valid: false}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a given user.
|
||||
func (c *Core) UpdateUser(id int, u models.User) (models.User, error) {
|
||||
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Status)
|
||||
res, err := c.q.UpdateUser.Exec(id, u.Username, u.PasswordLogin, u.Password, u.Email, u.Name, u.Type, u.Status)
|
||||
if err != nil {
|
||||
return models.User{}, echo.NewHTTPError(http.StatusInternalServerError,
|
||||
c.i18n.Ts("globals.messages.errorUpdating", "name", "{globals.terms.user}", "error", pqErrMsg(err)))
|
||||
|
|
|
@ -15,8 +15,12 @@ func V3_1_0(db *sqlx.DB, fs stuffbin.FileSystem, ko *koanf.Koanf, lo *log.Logger
|
|||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN
|
||||
CREATE TYPE user_type AS ENUM ('user', 'super', 'api');
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_status') THEN
|
||||
CREATE TYPE user_status AS ENUM ('enabled', 'disabled', 'super');
|
||||
CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
|
|
34
internal/utils/utils.go
Normal file
34
internal/utils/utils.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/mail"
|
||||
)
|
||||
|
||||
// ValidateEmail validates whether the given string is a correctly formed e-mail address.
|
||||
func ValidateEmail(email string) bool {
|
||||
// Since `mail.ParseAddress` parses an email address which can also contain an optional name component,
|
||||
// here we check if incoming email string is same as the parsed email.Address. So this eliminates
|
||||
// any valid email address with name and also valid address with empty name like `<abc@example.com>`.
|
||||
em, err := mail.ParseAddress(email)
|
||||
if err != nil || em.Address != email {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GenerateRandomString generates a cryptographically random, alphanumeric string of length n.
|
||||
func GenerateRandomString(n int) (string, error) {
|
||||
const dictionary = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
var bytes = make([]byte, n)
|
||||
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for k, v := range bytes {
|
||||
bytes[k] = dictionary[v%byte(len(dictionary))]
|
||||
}
|
||||
|
||||
return string(bytes), nil
|
||||
}
|
|
@ -56,8 +56,9 @@ const (
|
|||
ListOptinDouble = "double"
|
||||
|
||||
// User.
|
||||
UserTypeSuperadmin = "superadmin"
|
||||
UserTypeSuperadmin = "super"
|
||||
UserTypeUser = "user"
|
||||
UserTypeAPI = "api"
|
||||
UserStatusEnabled = "enabled"
|
||||
UserStatusDisabled = "disabled"
|
||||
|
||||
|
@ -151,8 +152,9 @@ type User struct {
|
|||
Username string `db:"username" json:"username"`
|
||||
Password null.String `db:"password" json:"password,omitempty"`
|
||||
PasswordLogin bool `db:"password_login" json:"password_login"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Email null.String `db:"email" json:"email"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Type string `db:"type" json:"type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
LoggedInAt null.Time `db:"loggedin_at" json:"loggedin_at"`
|
||||
|
||||
|
|
|
@ -108,11 +108,12 @@ 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"`
|
||||
LoginUser *sqlx.Stmt `query:"login-user"`
|
||||
CreateUser *sqlx.Stmt `query:"create-user"`
|
||||
UpdateUser *sqlx.Stmt `query:"update-user"`
|
||||
DeleteUsers *sqlx.Stmt `query:"delete-users"`
|
||||
GetUsers *sqlx.Stmt `query:"get-users"`
|
||||
GetAPITokens *sqlx.Stmt `query:"get-api-tokens"`
|
||||
LoginUser *sqlx.Stmt `query:"login-user"`
|
||||
}
|
||||
|
||||
// CompileSubscriberQueryTpl takes an arbitrary WHERE expressions
|
||||
|
|
21
queries.sql
21
queries.sql
|
@ -1028,7 +1028,18 @@ SELECT JSON_BUILD_OBJECT('version', (SELECT VERSION()),
|
|||
'size_mb', (SELECT ROUND(pg_database_size((SELECT CURRENT_DATABASE()))/(1024^2)))) AS info;
|
||||
|
||||
-- name: create-user
|
||||
INSERT INTO users (username, password_login, password, email, name, status) VALUES($1, $2, (CASE WHEN $2 AND $3 != '' THEN CRYPT($3, GEN_SALT('bf')) ELSE NULL END), $4, $5, $6) RETURNING *;
|
||||
INSERT INTO users (username, password_login, password, email, name, type, status)
|
||||
VALUES($1, $2, (
|
||||
CASE
|
||||
-- For user types with password_login enabled, bcrypt and store the hash of the password.
|
||||
WHEN $6::user_type != 'api' AND $2 AND $3 != ''
|
||||
THEN CRYPT($3, GEN_SALT('bf'))
|
||||
WHEN $6 = 'api'
|
||||
-- For APIs, store the password (token) as-is.
|
||||
THEN $3
|
||||
ELSE NULL
|
||||
END
|
||||
), $4, $5, $6, $7) RETURNING *;
|
||||
|
||||
-- name: update-user
|
||||
UPDATE users SET
|
||||
|
@ -1037,18 +1048,22 @@ UPDATE users SET
|
|||
password=(CASE WHEN $3 = TRUE THEN (CASE WHEN $4 != '' THEN CRYPT($4, GEN_SALT('bf')) ELSE password END) ELSE NULL END),
|
||||
email=(CASE WHEN $5 != '' THEN $5 ELSE email END),
|
||||
name=(CASE WHEN $6 != '' THEN $6 ELSE name END),
|
||||
status=(CASE WHEN $7 != '' THEN $7::user_status ELSE status END)
|
||||
type=(CASE WHEN $7 != '' THEN $7::user_type ELSE type END),
|
||||
status=(CASE WHEN $8 != '' THEN $8::user_status ELSE status END)
|
||||
WHERE id=$1;
|
||||
|
||||
-- name: delete-users
|
||||
WITH u AS (
|
||||
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND status='super'
|
||||
SELECT COUNT(*) AS num FROM users WHERE NOT(id = ANY($1)) AND type='super'
|
||||
)
|
||||
DELETE FROM users WHERE id = ALL($1) AND (SELECT num FROM u) > 0;
|
||||
|
||||
-- name: get-users
|
||||
SELECT * FROM users WHERE $1=0 OR id=$1 ORDER BY created_at;
|
||||
|
||||
-- name: get-api-tokens
|
||||
SELECT username, password FROM users WHERE status='enabled' AND type='api';
|
||||
|
||||
-- name: login-user
|
||||
WITH u AS (
|
||||
SELECT * FROM users WHERE username=$1 AND status != 'disabled' AND password_login = TRUE
|
||||
|
|
|
@ -7,9 +7,10 @@ DROP TYPE IF EXISTS campaign_type CASCADE; CREATE TYPE campaign_type AS ENUM ('r
|
|||
DROP TYPE IF EXISTS content_type CASCADE; CREATE TYPE content_type AS ENUM ('richtext', 'html', 'plain', 'markdown');
|
||||
DROP TYPE IF EXISTS bounce_type CASCADE; CREATE TYPE bounce_type AS ENUM ('soft', 'hard', 'complaint');
|
||||
DROP TYPE IF EXISTS template_type CASCADE; CREATE TYPE template_type AS ENUM ('campaign', 'tx');
|
||||
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled', 'super');
|
||||
DROP TYPE IF EXISTS user_type CASCADE; CREATE TYPE user_type AS ENUM ('user', 'super', 'api');
|
||||
DROP TYPE IF EXISTS user_status CASCADE; CREATE TYPE user_status AS ENUM ('enabled', 'disabled');
|
||||
|
||||
CREATE EXTENSION pgcrypto;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- subscribers
|
||||
DROP TABLE IF EXISTS subscribers CASCADE;
|
||||
|
@ -308,6 +309,7 @@ CREATE TABLE users (
|
|||
password TEXT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
type user_type NOT NULL DEFAULT 'user',
|
||||
status user_status NOT NULL DEFAULT 'disabled',
|
||||
loggedin_at TIMESTAMP WITH TIME ZONE NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
|
|
Loading…
Reference in a new issue