Add invite user feature for admin
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
--users
|
--users
|
||||||
INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
INSERT INTO users(created_at, name, activated, password) VALUES ('NOW', 'demo','true','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
||||||
INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo2','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
INSERT INTO users(created_at, name, activated, password) VALUES ('NOW', 'demo2','true','$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
||||||
INSERT INTO users(created_at, name, admin, password) VALUES ('NOW', 'admin', 'true', '$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
INSERT INTO users(created_at, name, admin, activated, password) VALUES ('NOW', 'admin', 'true', 'true', '$2a$10$7mfCBxBwIzXDU6r9az26o.zPX/r6IlNZVfU9zxSoLVtc0kRPimzba');
|
||||||
|
|
||||||
-- cover
|
-- cover
|
||||||
INSERT INTO static_files(name, path) VALUES ('odingosochateaux.jpg', 'odingosochateaux.jpg');
|
INSERT INTO static_files(name, path) VALUES ('odingosochateaux.jpg', 'odingosochateaux.jpg');
|
||||||
|
|||||||
63
front/src/InviteUser.vue
Normal file
63
front/src/InviteUser.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { postInviteUser, extractFormErrorFromField } from './api.js'
|
||||||
|
|
||||||
|
const emit = defineEmits(['invited'])
|
||||||
|
|
||||||
|
const error = ref(null)
|
||||||
|
const username = ref('')
|
||||||
|
|
||||||
|
const titleError = computed(() => {
|
||||||
|
return extractFormErrorFromField('Username', error.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorExtracted = computed(() => {
|
||||||
|
if (error && error.value && error.value) {
|
||||||
|
return error.value['error']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function inviteUser() {
|
||||||
|
postInviteUser(username.value).then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
username.value = ''
|
||||||
|
emit('invited')
|
||||||
|
} else {
|
||||||
|
res.json().then((json) => {
|
||||||
|
error.value = json
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="subtitle">
|
||||||
|
{{ $t('inviteuser.title') }}
|
||||||
|
</h2>
|
||||||
|
<div class="field has-addons">
|
||||||
|
<div class="control">
|
||||||
|
<input
|
||||||
|
:class="'input is-medium ' + (error ? 'is-danger' : '')"
|
||||||
|
type="text"
|
||||||
|
maxlength="20"
|
||||||
|
v-model="username"
|
||||||
|
@keyup.enter="inviteUser"
|
||||||
|
:placeholder="$t('inviteuser.placeholder')"
|
||||||
|
/>
|
||||||
|
<p v-if="titleError" class="help is-danger">{{ titleError }}</p>
|
||||||
|
<p v-else-if="errorExtracted" class="help is-danger">{{ errorExtracted }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button @click="inviteUser" class="button is-medium">
|
||||||
|
<span class="icon" :title="$t('inviteuser.invite')">
|
||||||
|
<b-icon-plus />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
35
front/src/LinkCopy.vue
Normal file
35
front/src/LinkCopy.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useTemplateRef, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
token: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const linkElement = useTemplateRef('link')
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
function onCopy() {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(linkElement.value.href)
|
||||||
|
.then(() => (copied.value = true))
|
||||||
|
.catch((e) => console.log(e))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="centered">
|
||||||
|
<a ref="link" :href="'/signup?invite=' + token">
|
||||||
|
{{ $t('linkcopy.link') }}
|
||||||
|
</a>
|
||||||
|
<button @click="onCopy" class="ml-2 button is-small">
|
||||||
|
<span v-if="!copied" class="icon" :title="$t('linkcopy.copy')">
|
||||||
|
<b-icon-copy />
|
||||||
|
</span>
|
||||||
|
<span v-else class="icon" :title="$t('linkcopy.copied')">
|
||||||
|
<b-icon-check />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -1,76 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getUsers } from './api.js'
|
|
||||||
|
|
||||||
const limit = 50
|
import UsersTable from './UsersTable.vue'
|
||||||
const pageNumber = ref(1)
|
import InviteUser from './InviteUser.vue'
|
||||||
|
|
||||||
const offset = computed(() => (pageNumber.value - 1) * limit)
|
//used to refresh
|
||||||
|
const tableKey = ref(0)
|
||||||
const data = ref(null)
|
|
||||||
const error = ref(null)
|
|
||||||
|
|
||||||
let totalUsersNumber = computed(() =>
|
|
||||||
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,
|
|
||||||
)
|
|
||||||
let pageTotal = computed(() => Math.ceil(totalUsersNumber.value / limit))
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
|
|
||||||
function fetchData() {
|
|
||||||
getUsers(data, error, limit, offset.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageChange(newPageNumber) {
|
|
||||||
pageNumber.value = newPageNumber
|
|
||||||
data.value = null
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="error">{{ $t('usersmanagement.error', { error: error.message }) }}</div>
|
<InviteUser @invited="tableKey += 1" class="mb-5" />
|
||||||
<div v-else-if="data">
|
<UsersTable :key="tableKey" />
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<th>#</th>
|
|
||||||
<th>{{ $t('usersmanagement.name') }}</th>
|
|
||||||
<th>{{ $t('usersmanagement.admin') }}</th>
|
|
||||||
<th>
|
|
||||||
<abbr :title="$t('usersmanagement.addedbookshelp')">{{
|
|
||||||
$t('usersmanagement.addedbooks')
|
|
||||||
}}</abbr>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<abbr :title="$t('usersmanagement.bookshelp')">{{ $t('usersmanagement.books') }}</abbr>
|
|
||||||
</th>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="user in data.users" :key="user.id">
|
|
||||||
<th class="numbercell">{{ user.id }}</th>
|
|
||||||
<td>{{ user.name }}</td>
|
|
||||||
<td class="boolcell"><input type="checkbox" disabled :checked="user.admin" /></td>
|
|
||||||
<td class="numbercell">{{ user.addedbookscount }}</td>
|
|
||||||
<td class="numbercell">{{ user.userbookscount }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<Pagination
|
|
||||||
:pageNumber="pageNumber"
|
|
||||||
:pageTotal="pageTotal"
|
|
||||||
maxItemDisplayed="11"
|
|
||||||
@pageChange="pageChange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else>{{ $t('usersmanagement.loading') }}</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
.boolcell {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.numbercell {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
89
front/src/UsersTable.vue
Normal file
89
front/src/UsersTable.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { getUsers } from './api.js'
|
||||||
|
import LinkCopy from './LinkCopy.vue'
|
||||||
|
|
||||||
|
const limit = 50
|
||||||
|
const pageNumber = ref(1)
|
||||||
|
|
||||||
|
const offset = computed(() => (pageNumber.value - 1) * limit)
|
||||||
|
|
||||||
|
const data = ref(null)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
let totalUsersNumber = computed(() =>
|
||||||
|
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,
|
||||||
|
)
|
||||||
|
let pageTotal = computed(() => Math.ceil(totalUsersNumber.value / limit))
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
|
||||||
|
function fetchData() {
|
||||||
|
getUsers(data, error, limit, offset.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageChange(newPageNumber) {
|
||||||
|
pageNumber.value = newPageNumber
|
||||||
|
data.value = null
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="error">{{ $t('usersmanagement.error', { error: error.message }) }}</div>
|
||||||
|
<div v-else-if="data">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ $t('usersmanagement.name') }}</th>
|
||||||
|
<th>{{ $t('usersmanagement.active') }}</th>
|
||||||
|
<th>{{ $t('usersmanagement.admin') }}</th>
|
||||||
|
<th>
|
||||||
|
<abbr :title="$t('usersmanagement.addedbookshelp')">{{
|
||||||
|
$t('usersmanagement.addedbooks')
|
||||||
|
}}</abbr>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<abbr :title="$t('usersmanagement.bookshelp')">{{ $t('usersmanagement.books') }}</abbr>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<abbr :title="$t('usersmanagement.invitelinkhelp')">{{
|
||||||
|
$t('usersmanagement.invitelink')
|
||||||
|
}}</abbr>
|
||||||
|
</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="user in data.users" :key="user.id">
|
||||||
|
<th class="numbercell">{{ user.id }}</th>
|
||||||
|
<td>{{ user.name }}</td>
|
||||||
|
<td class="boolcell"><input type="checkbox" disabled :checked="user.activated" /></td>
|
||||||
|
<td class="boolcell"><input type="checkbox" disabled :checked="user.admin" /></td>
|
||||||
|
<td class="numbercell">{{ user.addedbookscount }}</td>
|
||||||
|
<td class="numbercell">{{ user.userbookscount }}</td>
|
||||||
|
<td>
|
||||||
|
<LinkCopy v-if="user.invitetoken" :token="user.invitetoken" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<Pagination
|
||||||
|
:pageNumber="pageNumber"
|
||||||
|
:pageTotal="pageTotal"
|
||||||
|
maxItemDisplayed="11"
|
||||||
|
@pageChange="pageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else>{{ $t('usersmanagement.loading') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.boolcell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numbercell {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -151,6 +151,10 @@ export function postCollectionChangePosition(collectionId, itemId, position) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function postInviteUser(username) {
|
||||||
|
return genericPayloadCall('/ws/admin/inviteuser', { username: username }, 'POST')
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteCollectionItem(itemId) {
|
export function deleteCollectionItem(itemId) {
|
||||||
return deleteApiCall('/ws/collection/item/' + itemId)
|
return deleteApiCall('/ws/collection/item/' + itemId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,11 +105,24 @@
|
|||||||
"usersmanagement": {
|
"usersmanagement": {
|
||||||
"error": "Error when loading users: {error}",
|
"error": "Error when loading users: {error}",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"active": "Active ?",
|
||||||
"admin": "Is Admin ?",
|
"admin": "Is Admin ?",
|
||||||
"addedbooks": "Added Books",
|
"addedbooks": "Added Books",
|
||||||
"addedbookshelp": "Number of books the user created or imported.",
|
"addedbookshelp": "Number of books the user created or imported.",
|
||||||
|
"invitelink": "Invite Link",
|
||||||
|
"invitelinkhelp": "Send this link to the user so they can create their account.",
|
||||||
"books": "Books Number",
|
"books": "Books Number",
|
||||||
"bookshelp": "Total number of books of the user.",
|
"bookshelp": "Total number of books of the user.",
|
||||||
"loading": "Loading..."
|
"loading": "Loading..."
|
||||||
|
},
|
||||||
|
"linkcopy": {
|
||||||
|
"link": "Link",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied"
|
||||||
|
},
|
||||||
|
"inviteuser": {
|
||||||
|
"title": "Invite user",
|
||||||
|
"placeholder": "Username",
|
||||||
|
"invite": "Invite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,11 +105,24 @@
|
|||||||
"usersmanagement": {
|
"usersmanagement": {
|
||||||
"error": "Erreur pendant le chargement des utilisateurs: {error}",
|
"error": "Erreur pendant le chargement des utilisateurs: {error}",
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
|
"active": "Activé ?",
|
||||||
"admin": "Administrateur ?",
|
"admin": "Administrateur ?",
|
||||||
"addedbooks": "Livres Ajoutés",
|
"addedbooks": "Livres Ajoutés",
|
||||||
"addedbookshelp": "Nombre de livres créés ou importés par l'utilisateur.",
|
"addedbookshelp": "Nombre de livres créés ou importés par l'utilisateur.",
|
||||||
|
"invitelink": "Lien d'invitation",
|
||||||
|
"invitelinkhelp": "Lien à envoyer à l'utilisateur pour l'activation de son compte.",
|
||||||
"books": "Nombre De Livres",
|
"books": "Nombre De Livres",
|
||||||
"bookshelp": "Le nombre total de livres de cet utilisateur.",
|
"bookshelp": "Le nombre total de livres de cet utilisateur.",
|
||||||
"loading": "Chargement..."
|
"loading": "Chargement..."
|
||||||
|
},
|
||||||
|
"linkcopy": {
|
||||||
|
"link": "Lien",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié"
|
||||||
|
},
|
||||||
|
"inviteuser": {
|
||||||
|
"title": "Inviter un nouvel utilisateur",
|
||||||
|
"placeholder": "Nom d'utilisateur",
|
||||||
|
"invite": "Inviter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,3 @@ image-folder-path = "/tmp"
|
|||||||
|
|
||||||
# The port to listen on for the server.
|
# The port to listen on for the server.
|
||||||
port = "8080"
|
port = "8080"
|
||||||
|
|
||||||
book-description-from-babelio = true
|
|
||||||
|
|||||||
78
internal/apitest/post_inviteuser_test.go
Normal file
78
internal/apitest/post_inviteuser_test.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package apitest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.artlef.fr/bibliomane/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInviteUserHandler_OK(t *testing.T) {
|
||||||
|
userJson := `{
|
||||||
|
"username": "ferdinand"
|
||||||
|
}`
|
||||||
|
status, token := testInviteUserHandler(t, userJson)
|
||||||
|
assert.Equal(t, http.StatusOK, status)
|
||||||
|
assert.NotEmpty(t, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteUserHandler_UsernameMissing(t *testing.T) {
|
||||||
|
userJson := `{
|
||||||
|
"gssgg": "d"
|
||||||
|
}`
|
||||||
|
status, _ := testInviteUserHandler(t, userJson)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteUserHandler_UsernameTooLong(t *testing.T) {
|
||||||
|
userJson := `{
|
||||||
|
"username": "thisusernameistoolong"
|
||||||
|
}`
|
||||||
|
status, _ := testInviteUserHandler(t, userJson)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteUserHandler_Forbidden(t *testing.T) {
|
||||||
|
userJson := `{
|
||||||
|
"username": "ferdinand"
|
||||||
|
}`
|
||||||
|
router := testutils.TestSetup()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
token := testutils.ConnectDemoUser(router)
|
||||||
|
req, _ := http.NewRequest("POST", "/ws/admin/inviteuser",
|
||||||
|
strings.NewReader(userJson))
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInviteUserHandler(t *testing.T, userJson string) (int, string) {
|
||||||
|
router := testutils.TestSetup()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
token := testutils.ConnectAdminUser(router)
|
||||||
|
req, _ := http.NewRequest("POST", "/ws/admin/inviteuser",
|
||||||
|
strings.NewReader(userJson))
|
||||||
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
return w.Code, ""
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &parsed)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
return w.Code, parsed.Token
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package createuser
|
package createuser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,6 +16,31 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func CreateUserToActivate(ac appcontext.AppContext, username string) (model.User, error) {
|
||||||
|
token := generateRandomToken()
|
||||||
|
user := model.User{
|
||||||
|
Name: username,
|
||||||
|
Admin: false,
|
||||||
|
InviteToken: token,
|
||||||
|
Activated: false,
|
||||||
|
}
|
||||||
|
err := CreateUserWithHashedPassword(ac, &user)
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomToken() string {
|
||||||
|
tokenByte := make([]byte, 64)
|
||||||
|
var tokenBuilder strings.Builder
|
||||||
|
rand.Read(tokenByte)
|
||||||
|
encoder := base64.NewEncoder(base64.StdEncoding, &tokenBuilder)
|
||||||
|
encoder.Write(tokenByte)
|
||||||
|
// Must close the encoder when finished to flush any partial blocks.
|
||||||
|
// If you comment out the following line, the last partial block "r"
|
||||||
|
// won't be encoded.
|
||||||
|
encoder.Close()
|
||||||
|
return tokenBuilder.String()
|
||||||
|
}
|
||||||
|
|
||||||
// this method will hash the password
|
// this method will hash the password
|
||||||
func CreateUser(ac appcontext.AppContext, username string, password string) error {
|
func CreateUser(ac appcontext.AppContext, username string, password string) error {
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@@ -22,9 +49,10 @@ func CreateUser(ac appcontext.AppContext, username string, password string) erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
Name: username,
|
Name: username,
|
||||||
Password: string(hashedPassword),
|
Password: string(hashedPassword),
|
||||||
Admin: false,
|
Admin: false,
|
||||||
|
Activated: true,
|
||||||
}
|
}
|
||||||
return CreateUserWithHashedPassword(ac, &user)
|
return CreateUserWithHashedPassword(ac, &user)
|
||||||
}
|
}
|
||||||
@@ -103,9 +131,10 @@ func createDefaultUsersFromMap(
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
Name: username,
|
Name: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
Admin: isAdmin,
|
Admin: isAdmin,
|
||||||
|
Activated: true,
|
||||||
}
|
}
|
||||||
err = CreateUserWithHashedPassword(ac, &user)
|
err = CreateUserWithHashedPassword(ac, &user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -70,3 +70,7 @@ type UserSignup struct {
|
|||||||
Username string `json:"username" binding:"required,min=2,max=20"`
|
Username string `json:"username" binding:"required,min=2,max=20"`
|
||||||
Password string `json:"password" binding:"required,min=6,max=100"`
|
Password string `json:"password" binding:"required,min=6,max=100"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserToInvite struct {
|
||||||
|
Username string `json:"username" binding:"required,min=2,max=20"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ type UserGet struct {
|
|||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Admin bool `json:"admin"`
|
Admin bool `json:"admin"`
|
||||||
|
Activated bool `json:"activated"`
|
||||||
|
InviteToken string `json:"invitetoken"`
|
||||||
AddedBooksCount int64 `json:"addedbookscount"`
|
AddedBooksCount int64 `json:"addedbookscount"`
|
||||||
UserBooksCount int64 `json:"userbookscount"`
|
UserBooksCount int64 `json:"userbookscount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InviteUserPost struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
InvalidCredentials = "Invalid credentials."
|
InvalidCredentials = "Invalid credentials."
|
||||||
|
UserNotActivated = "User is not activated."
|
||||||
AuthenticationSuccess = "Authentication was a success."
|
AuthenticationSuccess = "Authentication was a success."
|
||||||
ValidationRequired = "This field is required."
|
ValidationRequired = "This field is required."
|
||||||
ValidationTooShort = "This field is too short. It should be at least %s characters."
|
ValidationTooShort = "This field is too short. It should be at least %s characters."
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
InvalidCredentials = "Identifiants invalides."
|
InvalidCredentials = "Identifiants invalides."
|
||||||
|
UserNotActivated = "L'utilisateur n'est pas activé."
|
||||||
AuthenticationSuccess = "Connexion réussie."
|
AuthenticationSuccess = "Connexion réussie."
|
||||||
ValidationRequired = "Ce champ est requis."
|
ValidationRequired = "Ce champ est requis."
|
||||||
ValidationTooShort = "Ce champ est trop court. Il devrait contenir au moins %s caractères."
|
ValidationTooShort = "Ce champ est trop court. Il devrait contenir au moins %s caractères."
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import "gorm.io/gorm"
|
|||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
gorm.Model
|
gorm.Model
|
||||||
Name string `gorm:"index;uniqueIndex"`
|
Name string `gorm:"index;uniqueIndex"`
|
||||||
Password string
|
Password string
|
||||||
Admin bool
|
Admin bool
|
||||||
|
InviteToken string
|
||||||
|
Activated bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func FetchAllUsersCount(db *gorm.DB) (int64, error) {
|
|||||||
|
|
||||||
func fetchAllUserQuery(db *gorm.DB) *gorm.DB {
|
func fetchAllUserQuery(db *gorm.DB) *gorm.DB {
|
||||||
query := db.Model(&model.User{})
|
query := db.Model(&model.User{})
|
||||||
query = query.Select("users.id, users.name, users.admin, count(distinct books.id) as added_books_count, count(distinct user_books.id) as user_books_count")
|
query = query.Select("users.id, users.name, users.admin, users.activated, users.invite_token, count(distinct books.id) as added_books_count, count(distinct user_books.id) as user_books_count")
|
||||||
query = query.Joins("left join books on (books.added_by_id = users.id)")
|
query = query.Joins("left join books on (books.added_by_id = users.id)")
|
||||||
query = query.Joins("left join user_books on user_books.user_id = users.id")
|
query = query.Joins("left join user_books on user_books.user_id = users.id")
|
||||||
query = query.Group("users.id, users.name")
|
query = query.Group("users.id, users.name")
|
||||||
|
|||||||
30
internal/routes/inviteuserpost.go
Normal file
30
internal/routes/inviteuserpost.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.artlef.fr/bibliomane/internal/appcontext"
|
||||||
|
"git.artlef.fr/bibliomane/internal/createuser"
|
||||||
|
"git.artlef.fr/bibliomane/internal/dto"
|
||||||
|
"git.artlef.fr/bibliomane/internal/myvalidator"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InviteUserHandler(ac appcontext.AppContext) {
|
||||||
|
var user dto.UserToInvite
|
||||||
|
|
||||||
|
err := ac.C.ShouldBindJSON(&user)
|
||||||
|
if err != nil {
|
||||||
|
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userDb, err := createuser.CreateUserToActivate(ac, user.Username)
|
||||||
|
if err != nil {
|
||||||
|
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ac.C.JSON(http.StatusOK, dto.InviteUserPost{
|
||||||
|
ID: userDb.ID,
|
||||||
|
Token: userDb.InviteToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -35,6 +35,11 @@ func PostLoginHandler(ac appcontext.AppContext) {
|
|||||||
gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "InvalidCredentials")})
|
gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "InvalidCredentials")})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !userDb.Activated {
|
||||||
|
ac.C.JSON(http.StatusUnauthorized,
|
||||||
|
gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "UserNotActivated")})
|
||||||
|
return
|
||||||
|
}
|
||||||
username = user.Username
|
username = user.Username
|
||||||
admin = userDb.Admin
|
admin = userDb.Admin
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ func Setup(config *config.Config) *gin.Engine {
|
|||||||
ws.GET("/admin/users", func(c *gin.Context) {
|
ws.GET("/admin/users", func(c *gin.Context) {
|
||||||
routes.GetUsersHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
routes.GetUsersHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
||||||
})
|
})
|
||||||
|
ws.POST("/admin/inviteuser", func(c *gin.Context) {
|
||||||
|
routes.InviteUserHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
||||||
|
})
|
||||||
|
|
||||||
r.Static("/static/bookcover", config.ImageFolderPath)
|
r.Static("/static/bookcover", config.ImageFolderPath)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user