Add an user management page for admins
This commit is contained in:
@@ -100,6 +100,14 @@ onMounted(() => {
|
|||||||
<RouterLink v-if="authStore.user" to="/add" class="navbar-item" activeClass="is-active">
|
<RouterLink v-if="authStore.user" to="/add" class="navbar-item" activeClass="is-active">
|
||||||
{{ $t('navbar.addbook') }}
|
{{ $t('navbar.addbook') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.user && authStore.user.admin"
|
||||||
|
to="/admin/users"
|
||||||
|
class="navbar-item"
|
||||||
|
activeClass="is-active"
|
||||||
|
>
|
||||||
|
{{ $t('navbar.usersmgt') }}
|
||||||
|
</RouterLink>
|
||||||
<div
|
<div
|
||||||
v-if="authStore.user && appInfo && !appInfo.demoMode"
|
v-if="authStore.user && appInfo && !appInfo.demoMode"
|
||||||
class="navbar-item is-hidden-desktop"
|
class="navbar-item is-hidden-desktop"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const passwordError = computed(() => {
|
|||||||
return extractFormErrorFromField('Password', errors.value)
|
return extractFormErrorFromField('Password', errors.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function onSubmit(e) {
|
async function onSubmit() {
|
||||||
const res = await postLogin(user)
|
const res = await postLogin(user)
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
let json = await res.json()
|
let json = await res.json()
|
||||||
@@ -36,7 +36,7 @@ async function onSubmit(e) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login(username, json) {
|
async function login(username, json) {
|
||||||
useAuthStore().login({ username: username, token: json['token'] })
|
useAuthStore().login({ username: username, admin: json['admin'], token: json['token'] })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
76
front/src/UsersManagement.vue
Normal file
76
front/src/UsersManagement.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { getUsers } from './api.js'
|
||||||
|
|
||||||
|
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 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.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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.boolcell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.numbercell {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -118,6 +118,11 @@ export function getBookCall(id) {
|
|||||||
return userFetch('/ws/book/' + id)
|
return userFetch('/ws/book/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUsers(data, error, limit, offset) {
|
||||||
|
const queryParams = new URLSearchParams({ limit: limit, offset: offset })
|
||||||
|
return useFetch(data, error, '/ws/admin/users' + '?' + queryParams.toString())
|
||||||
|
}
|
||||||
|
|
||||||
export function postBook(book) {
|
export function postBook(book) {
|
||||||
return genericPayloadCall('/ws/book', book.value, 'POST')
|
return genericPayloadCall('/ws/book', book.value, 'POST')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"logout": "Log out",
|
"logout": "Log out",
|
||||||
"signup": "Sign up",
|
"signup": "Sign up",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
"usersmgt": "Users Management",
|
||||||
"login": "Log In"
|
"login": "Log In"
|
||||||
},
|
},
|
||||||
"barcode": {
|
"barcode": {
|
||||||
@@ -100,5 +101,15 @@
|
|||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
"error": "Error when loading collection: {error}"
|
"error": "Error when loading collection: {error}"
|
||||||
|
},
|
||||||
|
"usersmanagement": {
|
||||||
|
"error": "Error when loading users: {error}",
|
||||||
|
"name": "Name",
|
||||||
|
"admin": "Is Admin ?",
|
||||||
|
"addedbooks": "Added Books",
|
||||||
|
"addedbookshelp": "Number of books the user created or imported.",
|
||||||
|
"books": "Books Number",
|
||||||
|
"bookshelp": "Total number of books of the user.",
|
||||||
|
"loading": "Loading..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"logout": "Se déconnecter",
|
"logout": "Se déconnecter",
|
||||||
"signup": "S'inscrire",
|
"signup": "S'inscrire",
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
|
"usersmgt": "Gestion Des Utilisateurs",
|
||||||
"login": "Se connecter"
|
"login": "Se connecter"
|
||||||
},
|
},
|
||||||
"barcode": {
|
"barcode": {
|
||||||
@@ -100,5 +101,15 @@
|
|||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
"error": "Erreur pendant le chargement de la liste : {error}"
|
"error": "Erreur pendant le chargement de la liste : {error}"
|
||||||
|
},
|
||||||
|
"usersmanagement": {
|
||||||
|
"error": "Erreur pendant le chargement des utilisateurs: {error}",
|
||||||
|
"name": "Nom",
|
||||||
|
"admin": "Administrateur ?",
|
||||||
|
"addedbooks": "Livres Ajoutés",
|
||||||
|
"addedbookshelp": "Nombre de livres créés ou importés par l'utilisateur.",
|
||||||
|
"books": "Nombre De Livres",
|
||||||
|
"bookshelp": "Le nombre total de livres de cet utilisateur.",
|
||||||
|
"loading": "Chargement..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ScanBook from './ScanBook.vue'
|
|||||||
import SearchBook from './SearchBook.vue'
|
import SearchBook from './SearchBook.vue'
|
||||||
import ImportInventaire from './ImportInventaire.vue'
|
import ImportInventaire from './ImportInventaire.vue'
|
||||||
import InstanceBrowser from './InstanceBrowser.vue'
|
import InstanceBrowser from './InstanceBrowser.vue'
|
||||||
|
import UsersManagement from './UsersManagement.vue'
|
||||||
import { useAuthStore } from './auth.store'
|
import { useAuthStore } from './auth.store'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
@@ -30,6 +31,7 @@ const routes = [
|
|||||||
{ path: '/add', component: BookFormEdit },
|
{ path: '/add', component: BookFormEdit },
|
||||||
{ path: '/signup', component: SignUp },
|
{ path: '/signup', component: SignUp },
|
||||||
{ path: '/login', component: LogIn },
|
{ path: '/login', component: LogIn },
|
||||||
|
{ path: '/admin/users', component: UsersManagement },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
41
internal/apitest/get_users_test.go
Normal file
41
internal/apitest/get_users_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package apitest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.artlef.fr/bibliomane/internal/dto"
|
||||||
|
"git.artlef.fr/bibliomane/internal/testutils"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetUsersHandler_OK(t *testing.T) {
|
||||||
|
status, result := testFetchUsers(t, "15", "0")
|
||||||
|
assert.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Equal(t, int64(3), result.Count)
|
||||||
|
assert.Equal(t, 3, len(result.Users))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsersHandler_Limit(t *testing.T) {
|
||||||
|
status, result := testFetchUsers(t, "2", "0")
|
||||||
|
assert.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Equal(t, int64(3), result.Count)
|
||||||
|
assert.Equal(t, 2, len(result.Users))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsersHandler_Forbidden(t *testing.T) {
|
||||||
|
status, _ := testutils.TestFetchModel[dto.UsersGet](t, "/ws/admin/users", "10", "0")
|
||||||
|
assert.Equal(t, http.StatusForbidden, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsersHandler_BookCountOK(t *testing.T) {
|
||||||
|
status, result := testFetchUsers(t, "1", "1")
|
||||||
|
assert.Equal(t, http.StatusOK, status)
|
||||||
|
assert.Equal(t, "demo2", result.Users[0].Name)
|
||||||
|
assert.Equal(t, int64(1), result.Users[0].AddedBooksCount)
|
||||||
|
assert.Equal(t, int64(3), result.Users[0].UserBooksCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFetchUsers(t *testing.T, limit string, offset string) (int, dto.UsersGet) {
|
||||||
|
return testutils.TestFetchAdminModel[dto.UsersGet](t, "/ws/admin/users", limit, offset)
|
||||||
|
}
|
||||||
@@ -73,3 +73,16 @@ type CollectionListBookItemGet struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
CoverPath string `json:"coverPath"`
|
CoverPath string `json:"coverPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UsersGet struct {
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Users []UserGet `json:"users"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGet struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Admin bool `json:"admin"`
|
||||||
|
AddedBooksCount int64 `json:"addedbookscount"`
|
||||||
|
UserBooksCount int64 `json:"userbookscount"`
|
||||||
|
}
|
||||||
|
|||||||
33
internal/query/queryusers.go
Normal file
33
internal/query/queryusers.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.artlef.fr/bibliomane/internal/dto"
|
||||||
|
"git.artlef.fr/bibliomane/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func FetchAllUsers(db *gorm.DB, limit int, offset int) ([]dto.UserGet, error) {
|
||||||
|
var books []dto.UserGet
|
||||||
|
query := fetchAllUserQuery(db)
|
||||||
|
query = query.Limit(limit)
|
||||||
|
query = query.Offset(offset)
|
||||||
|
query = query.Order("users.id DESC")
|
||||||
|
res := query.Find(&books)
|
||||||
|
return books, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchAllUsersCount(db *gorm.DB) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
query := fetchAllUserQuery(db)
|
||||||
|
res := query.Count(&count)
|
||||||
|
return count, res.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAllUserQuery(db *gorm.DB) *gorm.DB {
|
||||||
|
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.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.Group("users.id, users.name")
|
||||||
|
return query
|
||||||
|
}
|
||||||
@@ -48,5 +48,5 @@ func PostLoginHandler(ac appcontext.AppContext) {
|
|||||||
gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)})
|
gin.H{"error": fmt.Errorf("Error when generating JWT token: %w", err)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ac.C.JSON(http.StatusOK, gin.H{"message": i18nresource.GetTranslatedMessage(&ac, "AuthenticationSuccess"), "token": jwtToken})
|
ac.C.JSON(http.StatusOK, gin.H{"message": i18nresource.GetTranslatedMessage(&ac, "AuthenticationSuccess"), "admin": admin, "token": jwtToken})
|
||||||
}
|
}
|
||||||
|
|||||||
34
internal/routes/usersget.go
Normal file
34
internal/routes/usersget.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.artlef.fr/bibliomane/internal/appcontext"
|
||||||
|
"git.artlef.fr/bibliomane/internal/dto"
|
||||||
|
"git.artlef.fr/bibliomane/internal/myvalidator"
|
||||||
|
"git.artlef.fr/bibliomane/internal/query"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetUsersHandler(ac appcontext.AppContext) {
|
||||||
|
limit, err := ac.GetQueryLimit()
|
||||||
|
if err != nil {
|
||||||
|
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
offset, err := ac.GetQueryOffset()
|
||||||
|
if err != nil {
|
||||||
|
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := query.FetchAllUsers(ac.Db, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count, err := query.FetchAllUsersCount(ac.Db)
|
||||||
|
if err != nil {
|
||||||
|
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ac.C.JSON(http.StatusOK, dto.UsersGet{Count: count, Users: users})
|
||||||
|
}
|
||||||
@@ -108,6 +108,10 @@ func Setup(config *config.Config) *gin.Engine {
|
|||||||
ws.DELETE("/collection/item/:id", func(c *gin.Context) {
|
ws.DELETE("/collection/item/:id", func(c *gin.Context) {
|
||||||
routes.DeleteCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
routes.DeleteCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
||||||
})
|
})
|
||||||
|
ws.GET("/admin/users", func(c *gin.Context) {
|
||||||
|
routes.GetUsersHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
|
||||||
|
})
|
||||||
|
|
||||||
r.Static("/static/bookcover", config.ImageFolderPath)
|
r.Static("/static/bookcover", config.ImageFolderPath)
|
||||||
|
|
||||||
folders := []string{"assets", "css", "image"}
|
folders := []string{"assets", "css", "image"}
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ func ConnectDemo2User(router *gin.Engine) string {
|
|||||||
return connectUser(router, loginJson)
|
return connectUser(router, loginJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ConnectAdminUser(router *gin.Engine) string {
|
||||||
|
loginJson :=
|
||||||
|
`{
|
||||||
|
"username": "admin",
|
||||||
|
"password":"demopw"
|
||||||
|
}`
|
||||||
|
return connectUser(router, loginJson)
|
||||||
|
}
|
||||||
|
|
||||||
func connectUser(router *gin.Engine, loginJson string) string {
|
func connectUser(router *gin.Engine, loginJson string) string {
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req, _ := http.NewRequest("POST", "/ws/auth/login", strings.NewReader(loginJson))
|
req, _ := http.NewRequest("POST", "/ws/auth/login", strings.NewReader(loginJson))
|
||||||
@@ -87,6 +96,18 @@ func TestFetchOneModel[T any](t *testing.T, urlpath string, id string) (int, T)
|
|||||||
|
|
||||||
func TestFetchModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) {
|
func TestFetchModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) {
|
||||||
router := TestSetup()
|
router := TestSetup()
|
||||||
|
token := ConnectDemoUser(router)
|
||||||
|
return testFetchModelWithUser[T](t, urlpath, limit, offset, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchAdminModel[T any](t *testing.T, urlpath string, limit string, offset string) (int, T) {
|
||||||
|
router := TestSetup()
|
||||||
|
token := ConnectAdminUser(router)
|
||||||
|
return testFetchModelWithUser[T](t, urlpath, limit, offset, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFetchModelWithUser[T any](t *testing.T, urlpath string, limit string, offset string, token string) (int, T) {
|
||||||
|
router := TestSetup()
|
||||||
|
|
||||||
u, err := url.Parse(urlpath)
|
u, err := url.Parse(urlpath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,7 +128,6 @@ func TestFetchModel[T any](t *testing.T, urlpath string, limit string, offset st
|
|||||||
q.Set("lang", "fr")
|
q.Set("lang", "fr")
|
||||||
u.RawQuery = q.Encode()
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
token := ConnectDemoUser(router)
|
|
||||||
req, _ := http.NewRequest("GET", u.String(), nil)
|
req, _ := http.NewRequest("GET", u.String(), nil)
|
||||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
Reference in New Issue
Block a user