16 Commits

Author SHA1 Message Date
902279b34a Add invite user feature for admin 2026-05-06 18:00:33 +02:00
e29743d5fa Add an user management page for admins 2026-04-30 23:51:11 +02:00
d8d7bc9570 Inventaire API: updated tests 2026-04-28 19:58:29 +02:00
ff8604eac1 Add admin user, and an option to add a user admin on startup 2026-04-28 19:50:35 +02:00
d5281e7d57 Collection form: small tweaks for mobile view 2026-04-28 14:56:21 +02:00
ed99ee772a Release 0.8.0 2026-04-25 21:20:43 +02:00
2f5fc3d0a3 Collections browser: add a button to remove collections 2026-04-25 17:39:01 +02:00
b48c42c40c Collection form: adapt close button to mobile 2026-04-25 15:13:22 +02:00
08a273b500 Collection form: add a button to remove added books 2026-04-24 19:48:38 +02:00
1ae76ed525 Collection form: improve transition on item change 2026-04-22 15:14:17 +02:00
7d867af654 Rework collection form display on mobile 2026-04-22 15:08:50 +02:00
11a23d174e Collection form items: fixed dragover detection when going up 2026-04-14 15:43:28 +02:00
e746e67e89 Collection form items: change dragover method to work on mobile 2026-04-14 15:16:37 +02:00
b47b09eb85 Collection browser: improve display on mobile 2026-04-13 15:11:25 +02:00
8d9431874f Release 0.7.1 2026-04-12 16:58:23 +02:00
3c621c01ce Collection: fixed adding book 2026-04-12 16:57:41 +02:00
49 changed files with 1324 additions and 145 deletions

View File

@@ -28,6 +28,8 @@ Or with a volume, for example if you created a volume named `bibliomane_data`:
`--add-user` or `-a` can be used to create an account on startup. It requires a string following htpasswd format `[username]:[bcrypt hashed password]`. `--add-user` or `-a` can be used to create an account on startup. It requires a string following htpasswd format `[username]:[bcrypt hashed password]`.
Use `--add-admin-user` or `-A` instead to add an user as an admin.
The password can be generated using `htpasswd -nBC10 [username]`. The password can be generated using `htpasswd -nBC10 [username]`.
For example, to create an user account `demo`: For example, to create an user account `demo`:

View File

@@ -1,6 +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, 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');
@@ -132,6 +133,9 @@ INSERT INTO collections(name, user_id) VALUES ('Nouvelles',(SELECT id FROM users
INSERT INTO collections(name, user_id) VALUES ('Non fiction',(SELECT id FROM users WHERE name = 'demo2')); INSERT INTO collections(name, user_id) VALUES ('Non fiction',(SELECT id FROM users WHERE name = 'demo2'));
INSERT INTO collections(name, user_id) VALUES ('Empty',(SELECT id FROM users WHERE name = 'demo')); INSERT INTO collections(name, user_id) VALUES ('Empty',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Lu récemment',(SELECT id FROM users WHERE name = 'demo')); INSERT INTO collections(name, user_id) VALUES ('Lu récemment',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Brouillon',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Traduit de l''anglais',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('À supprimer',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Nord'), 1); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Nord'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Gargantua'), 2); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Gargantua'), 2);
@@ -157,3 +161,14 @@ INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT i
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 6); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 6);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 7); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 7);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Le château'), 8); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Le château'), 8);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Nord'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Gargantua'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Duo'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Un barrage contre le Pacifique'), 4);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Rigodon'), 5);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Brouillon'), (SELECT id FROM books WHERE title = 'Les dieux ont soif'), 6);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Traduit de l''anglais'), (SELECT id FROM books WHERE title = 'Sa majesté des mouches'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Traduit de l''anglais'), (SELECT id FROM books WHERE title = 'Le complot contre l''Amérique'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Traduit de l''anglais'), (SELECT id FROM books WHERE title = 'De sang-froid'), 3);

View File

@@ -1,6 +1,6 @@
{ {
"name": "bibliomane", "name": "bibliomane",
"version": "0.7.0", "version": "0.8.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {

View File

@@ -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"

View File

@@ -130,6 +130,7 @@ async function importInventaireEdition(inventaireid) {
<p>{{ error }}</p> <p>{{ error }}</p>
</div> </div>
<div class="columns no-padding box container has-background-dark"> <div class="columns no-padding box container has-background-dark">
<slot name="left"></slot>
<div class="media column no-margin clickable" @click="openBook"> <div class="media column no-margin clickable" @click="openBook">
<div class="media-left"> <div class="media-left">
<figure class="image mb-3"> <figure class="image mb-3">
@@ -164,7 +165,7 @@ async function importInventaireEdition(inventaireid) {
</button> </button>
</div> </div>
</div> </div>
<slot></slot> <slot name="right"></slot>
</div> </div>
</template> </template>
@@ -208,5 +209,12 @@ img {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
img {
max-height: 100px;
max-width: 100px;
height: auto;
width: auto;
}
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref, useTemplateRef } from 'vue'
import { getCollection, postCollectionChangePosition } from './api.js' import { getCollection, postCollectionChangePosition, deleteCollectionItem } from './api.js'
import CollectionFormElement from './CollectionFormElement.vue' import CollectionFormElement from './CollectionFormElement.vue'
import AddBookToCollection from './AddBookToCollection.vue' import AddBookToCollection from './AddBookToCollection.vue'
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
@@ -17,6 +17,8 @@ const offset = computed(() => (pageNumber.value - 1) * limit)
const data = ref(null) const data = ref(null)
const error = ref(null) const error = ref(null)
const itemRefs = useTemplateRef('items')
const itemIdBeingGrabbed = ref(null) const itemIdBeingGrabbed = ref(null)
const itemIdBeingOvered = ref(null) const itemIdBeingOvered = ref(null)
@@ -40,25 +42,42 @@ function fetchCollection() {
pageChange(1) pageChange(1)
} }
function onDragStart(event, id) { function checkGrabbedPosition(itemId, y) {
event.dataTransfer.effectAllowed = 'move' const itemBeingMoved = itemRefs.value.find((it) => it.id == itemId)
// Custom type to identify a collectionitem drag const itemMovedY = itemBeingMoved.$el.offsetTop + y + itemBeingMoved.$el.offsetHeight / 2
event.dataTransfer.setData('collectionitem', '') itemRefs.value.forEach((it) => {
itemIdBeingGrabbed.value = id if (it.$props.id == itemIdBeingGrabbed.value) {
return
}
let lowerDetectionY = it.$el.offsetTop + it.$el.offsetHeight / 2
let upperDetectionY = it.$el.offsetTop + (3 * it.$el.offsetHeight) / 2
if (it.$props.position < itemBeingMoved.$props.position) {
lowerDetectionY = it.$el.offsetTop
upperDetectionY = it.$el.offsetTop + it.$el.offsetHeight / 2
}
if (lowerDetectionY < itemMovedY && itemMovedY < upperDetectionY) {
itemIdBeingOvered.value = it.$props.id
}
})
} }
function onDragover(event) { function onStopGrab() {
if (event.dataTransfer.types.includes('collectionitem')) { if (itemIdBeingOvered.value != null) {
event.preventDefault() const position = data.value.items.find((it) => it.id == itemIdBeingOvered.value).position
changePosition(itemIdBeingGrabbed.value, position)
} }
itemIdBeingGrabbed.value = null
itemIdBeingOvered.value = null
} }
function onDrop(id, position) { function isDragoverFromAbove(position) {
if (id == itemIdBeingGrabbed.value) { if (itemIdBeingGrabbed.value == null) {
//nothing to do return false
return
} }
changePosition(itemIdBeingGrabbed.value, position) const grabbedItemPosition = data.value.items.find(
(it) => it.id == itemIdBeingGrabbed.value,
).position
return position > grabbedItemPosition
} }
function changePosition(id, position) { function changePosition(id, position) {
@@ -73,9 +92,16 @@ function changePosition(id, position) {
}) })
} }
function onDragend() { function deleteItem(id) {
itemIdBeingGrabbed.value = null deleteCollectionItem(id).then((res) => {
itemIdBeingOvered.value = null if (res.ok) {
getCollection(data, error, props.id, limit, offset.value)
} else {
res.json().then((json) => {
error.value = json
})
}
})
} }
</script> </script>
@@ -84,20 +110,21 @@ function onDragend() {
<div v-if="data"> <div v-if="data">
<h2 class="title">{{ data.name }}</h2> <h2 class="title">{{ data.name }}</h2>
<AddBookToCollection :collection-id="props.id" @created="fetchCollection" /> <AddBookToCollection :collection-id="props.id" @created="fetchCollection" />
<div> <TransitionGroup name="list" tag="div">
<CollectionFormElement <CollectionFormElement
@drop="onDrop(item.id, item.position)"
@dragstart="(e) => onDragStart(e, item.id)"
@dragend="onDragend"
@dragover="onDragover"
@dragenter="itemIdBeingOvered = item.id"
@positionchange="(pos) => changePosition(item.id, pos)" @positionchange="(pos) => changePosition(item.id, pos)"
@startgrab="itemIdBeingGrabbed = item.id"
@grabbing="(y) => checkGrabbedPosition(item.id, y)"
@stopgrab="onStopGrab"
@delete="deleteItem(item.id)"
v-for="item in data.items" v-for="item in data.items"
:key="item.id" :key="item.id"
:is-dragover="itemIdBeingOvered === item.id"
v-bind="item" v-bind="item"
ref="items"
:is-dragover="item.id == itemIdBeingOvered"
:is-dragover-from-above="isDragoverFromAbove(item.position)"
/> />
</div> </TransitionGroup>
<Pagination <Pagination
class="mt-5" class="mt-5"
:pageNumber="pageNumber" :pageNumber="pageNumber"
@@ -108,4 +135,16 @@ function onDragend() {
</div> </div>
</template> </template>
<style></style> <style scoped>
.list-move, /* apply transition to moving elements */
.list-enter-active,
.list-leave-active {
transition: all 0.2s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -3,13 +3,14 @@ import { ref } from 'vue'
import BookListElement from './BookListElement.vue' import BookListElement from './BookListElement.vue'
const props = defineProps({ const props = defineProps({
isDragover: Boolean,
id: Number, id: Number,
position: Number, position: Number,
book: Array, book: Array,
isDragover: Boolean,
isDragoverFromAbove: Boolean,
}) })
const emit = defineEmits('positionchange') const emit = defineEmits(['positionchange', 'startgrab', 'stopgrab', 'grabbing', 'delete'])
const vFocus = { const vFocus = {
mounted: (el) => el.focus(), mounted: (el) => el.focus(),
@@ -18,6 +19,10 @@ const vFocus = {
const isInputtingPosition = ref(false) const isInputtingPosition = ref(false)
const inputtedPosition = ref('') const inputtedPosition = ref('')
const initialGrabPosition = ref(null)
const draggedPosition = ref(null)
function onPositionInput() { function onPositionInput() {
if (inputtedPosition.value != '' && !isNaN(inputtedPosition.value)) { if (inputtedPosition.value != '' && !isNaN(inputtedPosition.value)) {
const parsedPosition = parseInt(inputtedPosition.value) const parsedPosition = parseInt(inputtedPosition.value)
@@ -32,37 +37,89 @@ function clearPositionInput() {
isInputtingPosition.value = false isInputtingPosition.value = false
inputtedPosition.value = '' inputtedPosition.value = ''
} }
function clearGrabVariables() {
initialGrabPosition.value = null
draggedPosition.value = null
}
function onPointerUp() {
clearGrabVariables()
emit('stopgrab')
}
function onPointerDown(e) {
initialGrabPosition.value = e.pageY
e.preventDefault()
emit('startgrab')
}
function onPointerMove(e) {
if (initialGrabPosition.value == null) {
return
}
draggedPosition.value = e.pageY - initialGrabPosition.value
emit('grabbing', draggedPosition.value)
}
</script> </script>
<template> <template>
<div :class="isDragover ? 'dragover' : ''" draggable="true" class="collectionitembox"> <div>
<BookListElement v-bind="props.book"> <div v-if="isDragover && !isDragoverFromAbove" class="dragover" />
<div class="separator" /> <div
<div class="centered"> :style="
<div draggedPosition
v-if="!isInputtingPosition" ? 'transform: translateY(' + draggedPosition + 'px);position:relative;z-index:3'
@click="isInputtingPosition = true" : ''
class="positionindicator centered is-narrow clickable" "
> ref="collectionitembox"
{{ props.position }} class="collectionitembox"
</div> @pointermove.prevent="onPointerMove"
<div v-else> @pointerup="onPointerUp"
<input @pointerleave="clearGrabVariables"
type="text" >
v-model="inputtedPosition" <BookListElement v-bind="props.book">
v-focus <template v-slot:left>
@blur="clearPositionInput" <div class="is-hidden-desktop mobile-delete">
@keyup.enter="onPositionInput" <div @click="$emit('delete')" class="centered closebtn clickable">
size="1" <b-icon-x />
class="positioninput" </div>
:placeholder="props.position" </div>
/> <div class="inputpositionwidget centered">
</div> <div
</div> v-if="!isInputtingPosition"
<div class="separator" /> @click="isInputtingPosition = true"
<div class="positionwidget centered is-narrow"> class="positionindicator centered is-narrow clickable"
<b-icon-list /> >
</div> {{ props.position }}
</BookListElement> </div>
<div v-else>
<input
type="text"
v-model="inputtedPosition"
v-focus
@blur="clearPositionInput"
@keyup.enter="onPositionInput"
size="1"
class="positioninput"
:placeholder="props.position"
/>
</div>
</div>
<div class="separator" />
</template>
<template v-slot:right>
<div class="separator" />
<div class="positionwidget centered is-narrow" @pointerdown="onPointerDown">
<b-icon-list />
</div>
<div @click="$emit('delete')" class="is-hidden-touch centered closebtn clickable">
<b-icon-x />
</div>
</template>
</BookListElement>
</div>
<div v-if="isDragover && isDragoverFromAbove" class="dragover" />
</div> </div>
</template> </template>
@@ -70,6 +127,7 @@ function clearPositionInput() {
.collectionitembox { .collectionitembox {
transition: ease-in-out 0.04s; transition: ease-in-out 0.04s;
display: flex; display: flex;
z-index: 2;
} }
.collectionitembox:hover { .collectionitembox:hover {
@@ -102,10 +160,10 @@ function clearPositionInput() {
color: var(--bulma-scheme-main); color: var(--bulma-scheme-main);
font-size: 48px; font-size: 48px;
margin-left: 30px; margin-left: 30px;
margin-right: 30px;
border-top-right-radius: var(--bulma-box-radius); border-top-right-radius: var(--bulma-box-radius);
border-bottom-right-radius: var(--bulma-box-radius); border-bottom-right-radius: var(--bulma-box-radius);
cursor: grab; cursor: grab;
touch-action: none;
} }
.positionwidget:active { .positionwidget:active {
@@ -116,4 +174,23 @@ function clearPositionInput() {
border: 3px solid var(--bulma-primary); border: 3px solid var(--bulma-primary);
border-radius: 10px; border-radius: 10px;
} }
.closebtn {
height: 40px;
width: 40px;
font-size: 36px;
}
.mobile-delete {
display: flex;
justify-content: flex-end;
height: 10px;
}
@media (max-width: 1024px) {
.positionwidget {
margin-bottom: 10px;
margin-left: 0px;
}
}
</style> </style>

View File

@@ -1,7 +1,13 @@
<script setup> <script setup>
import { getImagePathOrDefault } from './api.js' import { getImagePathOrDefault } from './api.js'
import { useTemplateRef } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const emit = defineEmits(['delete'])
const closeButtonDesktop = useTemplateRef('closeDesktop')
const closeButtonMobile = useTemplateRef('closeMobile')
const props = defineProps({ const props = defineProps({
id: Number, id: Number,
name: String, name: String,
@@ -11,36 +17,62 @@ const props = defineProps({
const router = useRouter() const router = useRouter()
function goToCollection() { function onClick(e) {
const collectionId = props.id if (
router.push(`/collection/${props.id}`) (closeButtonDesktop.value && closeButtonDesktop.value.contains(e.target)) ||
(closeButtonMobile.value && closeButtonMobile.value.contains(e.target))
) {
emit('delete')
} else {
router.push(`/collection/${props.id}`)
}
} }
function setBookOpacity(index) { function setBookOpacityClass(index) {
if (props.books.length > 7 && index > 3) { const length = props.books.length
return 'opacity: ' + (100 - index * 13) + '%;' if (length < 5) {
} else {
return '' return ''
} else if (length < 8) {
if (index < 4) {
return 'opacity-' + index
} else {
return ''
}
} else {
return 'opacity-' + index
} }
} }
</script> </script>
<template> <template>
<div class="collectioncontainer has-background-dark p-2" @click="goToCollection"> <div class="collectioncontainer has-background-dark p-2" @click="onClick">
<div class="collectionheader"> <div class="collectionheader">
<h2 class="subtitle"> <h2 class="namecontainer subtitle">
{{ props.name }} {{ props.name }}
</h2> </h2>
<div class="is-hidden-desktop align-right">
<div ref="closeMobile" @click="$emit('delete')" class="centered closebtn clickable">
<b-icon-x />
</div>
</div>
</div> </div>
<div class="collectionpreviewbooks" v-if="props.books && props.books.length > 0"> <div class="collectionpreviewbooks" v-if="props.books && props.books.length > 0">
<div class="bookpreview mx-1" v-for="(book, index) in props.books" :key="book.id"> <div
:class="index > 3 ? 'is-hidden-touch' : ''"
class="bookpreview mx-1"
v-for="(book, index) in props.books"
:key="book.id"
>
<img <img
:style="setBookOpacity(index)" :class="setBookOpacityClass(index)"
v-bind:src="getImagePathOrDefault(book.coverPath)" v-bind:src="getImagePathOrDefault(book.coverPath)"
v-bind:alt="book.title" v-bind:alt="book.title"
/> />
</div> </div>
</div> </div>
<div ref="closeDesktop" class="is-hidden-touch centered closebtn clickable">
<b-icon-x />
</div>
</div> </div>
</template> </template>
@@ -63,25 +95,88 @@ img {
transition: ease-in-out 0.02s; transition: ease-in-out 0.02s;
} }
.namecontainer {
flex: 1;
margin-bottom: 0px;
}
.collectionheader { .collectionheader {
flex: 1; flex: 1;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center;
} }
.collectionpreviewbooks { .collectionpreviewbooks {
flex: 6; flex: 6;
display: flex; display: flex;
align-items: center;
}
.opacity-1 {
opacity: 100%;
}
.opacity-2 {
opacity: 100%;
}
.opacity-3 {
opacity: 100%;
}
.opacity-4 {
opacity: 70%;
}
.opacity-5 {
opacity: 40%;
}
.opacity-6 {
opacity: 20%;
}
.opacity-7 {
opacity: 10%;
}
.closebtn {
height: 40px;
width: 40px;
font-size: 36px;
}
.align-right {
display: flex;
justify-content: flex-end;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
img { img {
max-height: 50px; max-height: 75px;
max-width: 50px; max-width: 75px;
} }
.collectionpreviewbooks { .collectionpreviewbooks {
flex: 2; flex: 1;
margin-top: 20px;
justify-content: center;
}
.collectioncontainer {
flex-direction: column;
}
.opacity-1 {
opacity: 70%;
}
.opacity-2 {
opacity: 40%;
}
.opacity-3 {
opacity: 10%;
} }
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { getCollections } from './api.js' import { getCollections, deleteCollection } from './api.js'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import CollectionListElement from './CollectionListElement.vue' import CollectionListElement from './CollectionListElement.vue'
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
@@ -35,6 +35,18 @@ function pageChange(newPageNumber) {
function goToCollection(id) { function goToCollection(id) {
router.push(`/collection/${id}`) router.push(`/collection/${id}`)
} }
function removeList(id) {
deleteCollection(id).then((res) => {
if (res.ok) {
fetchData()
} else {
res.json().then((json) => {
error.value = json
})
}
})
}
</script> </script>
<template> <template>
@@ -44,7 +56,7 @@ function goToCollection(id) {
<AddCollection @created="goToCollection" /> <AddCollection @created="goToCollection" />
<div class="collectionslist"> <div class="collectionslist">
<div class="my-2" v-for="collection in data.collections" :key="collection.id"> <div class="my-2" v-for="collection in data.collections" :key="collection.id">
<CollectionListElement v-bind="collection" /> <CollectionListElement @delete="removeList(collection.id)" v-bind="collection" />
</div> </div>
</div> </div>
<Pagination <Pagination

63
front/src/InviteUser.vue Normal file
View 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
View 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>

View File

@@ -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>

View File

@@ -0,0 +1,16 @@
<script setup>
import { ref } from 'vue'
import UsersTable from './UsersTable.vue'
import InviteUser from './InviteUser.vue'
//used to refresh
const tableKey = ref(0)
</script>
<template>
<InviteUser @invited="tableKey += 1" class="mb-5" />
<UsersTable :key="tableKey" />
</template>
<style scoped></style>

89
front/src/UsersTable.vue Normal file
View 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>

View File

@@ -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')
} }
@@ -130,7 +135,7 @@ export function postCollection(collection) {
return genericPayloadCall('/ws/collection', collection, 'POST') return genericPayloadCall('/ws/collection', collection, 'POST')
} }
export function postCollectionAddBook(collectionId, position) { export function postCollectionAddBook(collectionId, bookId) {
return genericPayloadCall( return genericPayloadCall(
'/ws/collection/' + collectionId + '/addbook', '/ws/collection/' + collectionId + '/addbook',
{ bookId: bookId }, { bookId: bookId },
@@ -146,6 +151,18 @@ export function postCollectionChangePosition(collectionId, itemId, position) {
) )
} }
export function postInviteUser(username) {
return genericPayloadCall('/ws/admin/inviteuser', { username: username }, 'POST')
}
export function deleteCollectionItem(itemId) {
return deleteApiCall('/ws/collection/item/' + itemId)
}
export function deleteCollection(id) {
return deleteApiCall('/ws/collection/' + id)
}
export function putBook(id, book) { export function putBook(id, book) {
return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT') return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT')
} }
@@ -234,6 +251,22 @@ export function genericPayloadCall(apiRoute, object, method) {
} }
} }
export function deleteApiCall(apiRoute) {
const { user } = useAuthStore()
if (user != null) {
return fetch(apiRoute, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + user.token,
},
})
} else {
return Promise.resolve()
}
}
export function extractFormErrorFromField(fieldName, errors) { export function extractFormErrorFromField(fieldName, errors) {
if (errors == null || typeof errors == 'undefined' || !Array.isArray(errors)) { if (errors == null || typeof errors == 'undefined' || !Array.isArray(errors)) {
return '' return ''

View File

@@ -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,28 @@
}, },
"collection": { "collection": {
"error": "Error when loading collection: {error}" "error": "Error when loading collection: {error}"
},
"usersmanagement": {
"error": "Error when loading users: {error}",
"name": "Name",
"active": "Active ?",
"admin": "Is Admin ?",
"addedbooks": "Added Books",
"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",
"bookshelp": "Total number of books of the user.",
"loading": "Loading..."
},
"linkcopy": {
"link": "Link",
"copy": "Copy",
"copied": "Copied"
},
"inviteuser": {
"title": "Invite user",
"placeholder": "Username",
"invite": "Invite"
} }
} }

View File

@@ -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,28 @@
}, },
"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",
"active": "Activé ?",
"admin": "Administrateur ?",
"addedbooks": "Livres Ajoutés",
"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",
"bookshelp": "Le nombre total de livres de cet utilisateur.",
"loading": "Chargement..."
},
"linkcopy": {
"link": "Lien",
"copy": "Copier",
"copied": "Copié"
},
"inviteuser": {
"title": "Inviter un nouvel utilisateur",
"placeholder": "Nom d'utilisateur",
"invite": "Inviter"
} }
} }

View File

@@ -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({

View File

@@ -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

View File

@@ -0,0 +1,45 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestDeleteCollectionBookHandler_DelOk(t *testing.T) {
status := testDeleteCollectionBookHandler("23")
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, "6", "10", "0")
assert.Equal(t, int64(5), collection.Count)
assert.Equal(t, uint(1), collection.Items[0].Position)
assert.Equal(t, uint(2), collection.Items[1].Position)
assert.Equal(t, uint(3), collection.Items[2].Position)
assert.Equal(t, uint(4), collection.Items[3].Position)
assert.Equal(t, uint(5), collection.Items[4].Position)
}
func TestDeleteCollectionBookHandler_NotFound(t *testing.T) {
status := testDeleteCollectionBookHandler("425")
assert.Equal(t, http.StatusNotFound, status)
}
func TestDeleteCollectionBookHandler_Unauthorized(t *testing.T) {
status := testDeleteCollectionBookHandler("11")
assert.Equal(t, http.StatusUnauthorized, status)
}
func testDeleteCollectionBookHandler(itemId string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("DELETE", "/ws/collection/item/"+itemId, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -0,0 +1,51 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestDeleteCollectionHandler_EmptyCollection(t *testing.T) {
collectionId := "8"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusOK, status)
getStatus, _ := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, http.StatusNotFound, getStatus)
}
func TestDeleteCollectionHandler_NotEmptyCollection(t *testing.T) {
collectionId := "7"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusOK, status)
getStatus, _ := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, http.StatusNotFound, getStatus)
}
func TestDeleteCollectionHandler_NonExistingCollection(t *testing.T) {
collectionId := "425"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusNotFound, status)
}
func TestDeleteCollectionHandler_ForbiddenCollection(t *testing.T) {
collectionId := "3"
status := testDeleteCollectionHandler(collectionId)
assert.Equal(t, http.StatusUnauthorized, status)
}
func testDeleteCollectionHandler(id string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("DELETE", "/ws/collection/"+id, nil)
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -12,8 +12,8 @@ import (
func TestFetchAllCollections_OK(t *testing.T) { func TestFetchAllCollections_OK(t *testing.T) {
status, res := testFetchCollections(t, "10", "0") status, res := testFetchCollections(t, "10", "0")
assert.Equal(t, http.StatusOK, status) assert.Equal(t, http.StatusOK, status)
assert.Equal(t, int64(4), res.Count) assert.Equal(t, int64(5), res.Count)
assert.Equal(t, 4, len(res.Collections)) assert.Equal(t, 5, len(res.Collections))
} }
func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) { func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) {

View File

@@ -28,4 +28,5 @@ func TestGetAppInfo_Ok(t *testing.T) {
assert.Equal(t, false, appInfo.RegistrationDisabled) assert.Equal(t, false, appInfo.RegistrationDisabled)
assert.Equal(t, false, appInfo.DemoMode) assert.Equal(t, false, appInfo.DemoMode)
assert.Equal(t, false, appInfo.Admin)
} }

View 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)
}

View File

@@ -22,7 +22,7 @@ func TestPostImportBookHandler_Ok(t *testing.T) {
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200) book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
assert.Equal(t, "les Hauts de Hurle-Vent", book.Title) assert.Equal(t, "les Hauts de Hurle-Vent", book.Title)
assert.Equal(t, "Emily Brontë", book.Author) assert.Equal(t, "Emily Brontë", book.Author)
assert.Equal(t, "isbn:9782253004752", book.InventaireId) assert.Equal(t, "inv:31cee958a93ba50697a3fec2a360f437", book.InventaireId)
assert.Equal(t, "/static/bookcover/44abbcbdc1092212c2bae66f5165019dac1e2a7b.webp", book.CoverPath) assert.Equal(t, "/static/bookcover/44abbcbdc1092212c2bae66f5165019dac1e2a7b.webp", book.CoverPath)
} }
@@ -31,7 +31,7 @@ func TestPostImportBookHandler_OkAuthorKey(t *testing.T) {
book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200) book := testGetBook(t, strconv.FormatUint(uint64(id), 10), 200)
assert.Equal(t, "Dr Bloodmoney", book.Title) assert.Equal(t, "Dr Bloodmoney", book.Title)
assert.Equal(t, "Philip K. Dick", book.Author) assert.Equal(t, "Philip K. Dick", book.Author)
assert.Equal(t, "isbn:9782290033630", book.InventaireId) assert.Equal(t, "inv:ba7d376567114bd69a6c8f0a135a89d0", book.InventaireId)
assert.Equal(t, "/static/bookcover/1d1493159d031224a42b37c4417fcbb8c76b00bd.webp", book.CoverPath) assert.Equal(t, "/static/bookcover/1d1493159d031224a42b37c4417fcbb8c76b00bd.webp", book.CoverPath)
} }

View 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
}

View File

@@ -97,7 +97,7 @@ func TestSearchBook_ISBNInventaire(t *testing.T) {
Title: "Les premières enquêtes de Maigret", Title: "Les premières enquêtes de Maigret",
Author: "Georges Simenon", Author: "Georges Simenon",
Description: "roman de Georges Simenon", Description: "roman de Georges Simenon",
InventaireID: "isbn:9782253158400", InventaireID: "inv:cd8af0fc32d2f721d2853d02682dfd44",
IsInventaireEdition: true, IsInventaireEdition: true,
Rating: 0, Rating: 0,
Read: false, Read: false,

View File

@@ -33,6 +33,7 @@ type Config struct {
DemoMode bool `toml:"demo-mode" short:"D" default:"false" help:"Activate demo mode." comment:"Activate demo mode: anyone connecting to the instance will be logged in as a single user."` DemoMode bool `toml:"demo-mode" short:"D" default:"false" help:"Activate demo mode." comment:"Activate demo mode: anyone connecting to the instance will be logged in as a single user."`
DemoUsername string `toml:"demo-username" default:"demo" help:"Name of the single user used for the demo." comment:"Name of the single user used for the demo."` DemoUsername string `toml:"demo-username" default:"demo" help:"Name of the single user used for the demo." comment:"Name of the single user used for the demo."`
AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"` AddUser UserListAsStrings `toml:"add-user" short:"a" help:"Add users on startup following htpasswd bcrypt format." comment:"Add users on startup following htpasswd bcrypt format, example: [\"demo:$2y$10$UHR2646SZo2W.Rhna7bn5eWNLXWJZ/Sa3oLd9RlxlXs57Bwp6isOS\",\"user:$2y$10$3WYUp.VDpzJRywtrxO1s/uWfUIKpTE4yh5B1d2RCef3hvczYbEWTC\"]"`
AddAdminUser UserListAsStrings `toml:"add-admin-user" short:"A" help:"Same as add-user but the added user has admin privilege." comment:"Same as add-user but the added user has admin privilege."`
} }
type UserListAsStrings []string type UserListAsStrings []string

View File

@@ -1,10 +1,11 @@
package createuser package createuser
import ( import (
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strings" "strings"
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
@@ -15,19 +16,51 @@ 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)
if err != nil { if err != nil {
return err return err
} }
return CreateUserWithHashedPassword(ac, username, string(hashedPassword))
user := model.User{
Name: username,
Password: string(hashedPassword),
Admin: false,
Activated: true,
}
return CreateUserWithHashedPassword(ac, &user)
} }
// only call this method with hashed password // only call this method with hashed password
func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, hashedPassword string) error { func CreateUserWithHashedPassword(ac appcontext.AppContext, userToCreate *model.User) error {
var existingUser model.User var existingUser model.User
err := ac.Db.Where("name = ?", username).First(&existingUser).Error err := ac.Db.Where("name = ?", userToCreate.Name).First(&existingUser).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
@@ -37,41 +70,73 @@ func CreateUserWithHashedPassword(ac appcontext.AppContext, username string, has
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "UserAlreadyExists")), Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "UserAlreadyExists")),
} }
} }
user := model.User{ return ac.Db.Model(&model.User{}).Save(&userToCreate).Error
Name: username,
Password: hashedPassword,
}
return ac.Db.Model(&model.User{}).Save(&user).Error
} }
func CreateDefaultUsers(ac appcontext.AppContext) error { func CreateDefaultUsers(ac appcontext.AppContext) error {
usersPasswordMap, err := createNormalUsersMap(ac)
if err != nil {
return err
}
err = createDefaultUsersFromMap(ac, usersPasswordMap, false)
if err != nil {
return err
}
adminUsersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddAdminUser)
if err != nil {
return err
}
return createDefaultUsersFromMap(ac, adminUsersPasswordMap, true)
}
func createNormalUsersMap(ac appcontext.AppContext) (map[string]string, error) {
usersPasswordMap, err := createDefaultUsersMap(ac, ac.Config.AddUser)
if err != nil {
return usersPasswordMap, err
}
_, ok := usersPasswordMap[ac.Config.DemoUsername]
if !ok {
usersPasswordMap[ac.Config.DemoUsername] = ""
}
return usersPasswordMap, nil
}
func createDefaultUsersMap(ac appcontext.AppContext, adduser []string) (map[string]string, error) {
usersPasswordMap := make(map[string]string) usersPasswordMap := make(map[string]string)
var usernames []string for _, s := range adduser {
for _, s := range ac.Config.AddUser {
splittedString := strings.Split(s, ":") splittedString := strings.Split(s, ":")
if len(splittedString) < 2 { if len(splittedString) < 2 {
return fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s) return usersPasswordMap,
fmt.Errorf(i18nresource.GetTranslatedMessage(&ac, "ErrorWhenCreatingUserFromStr"), s)
} }
usernames = append(usernames, splittedString[0])
usersPasswordMap[splittedString[0]] = splittedString[1] usersPasswordMap[splittedString[0]] = splittedString[1]
} }
if !slices.Contains(usernames, ac.Config.DemoUsername) { return usersPasswordMap, nil
usernames = append(usernames, ac.Config.DemoUsername) }
usersPasswordMap[ac.Config.DemoUsername] = ""
} func createDefaultUsersFromMap(
ac appcontext.AppContext,
usersPasswordMap map[string]string,
isAdmin bool) error {
var existingUsers []model.User var existingUsers []model.User
err := ac.Db.Where("name IN ?", usernames).Find(&existingUsers).Error err := ac.Db.Where("name IN ?", mapToArrayKey(usersPasswordMap)).Find(&existingUsers).Error
if err != nil { if err != nil {
return err return err
} }
for _, username := range usernames { for username, password := range usersPasswordMap {
if isInExistingUsers(username, existingUsers) { if isInExistingUsers(username, existingUsers) {
continue continue
} }
err = CreateUserWithHashedPassword(ac, username, usersPasswordMap[username])
user := model.User{
Name: username,
Password: password,
Admin: isAdmin,
Activated: true,
}
err = CreateUserWithHashedPassword(ac, &user)
if err != nil { if err != nil {
return err return err
} }
@@ -87,3 +152,11 @@ func isInExistingUsers(username string, existingUsers []model.User) bool {
} }
return false return false
} }
func mapToArrayKey(m map[string]string) []string {
var a []string
for k := range m {
a = append(a, k)
}
return a
}

View File

@@ -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"`
}

View File

@@ -4,6 +4,7 @@ type AppInfo struct {
RegistrationDisabled bool `json:"registrationDisabled"` RegistrationDisabled bool `json:"registrationDisabled"`
DemoMode bool `json:"demoMode"` DemoMode bool `json:"demoMode"`
DemoUsername string `json:"demoUsername"` DemoUsername string `json:"demoUsername"`
Admin bool `json:"admin"`
} }
type FullBookGet struct { type FullBookGet struct {
@@ -72,3 +73,23 @@ 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"`
Activated bool `json:"activated"`
InviteToken string `json:"invitetoken"`
AddedBooksCount int64 `json:"addedbookscount"`
UserBooksCount int64 `json:"userbookscount"`
}
type InviteUserPost struct {
ID uint `json:"id"`
Token string `json:"token"`
}

View File

@@ -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."

View File

@@ -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."

View File

@@ -1,10 +1,12 @@
package jwtauth package jwtauth
import ( import (
"strconv"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
) )
func GenerateJwtToken(username string) (string, error) { func GenerateJwtToken(username string, admin bool) (string, error) {
var s string var s string
key, err := GetJwtKey() key, err := GetJwtKey()
if err != nil { if err != nil {
@@ -12,8 +14,9 @@ func GenerateJwtToken(username string) (string, error) {
} }
t := jwt.NewWithClaims(jwt.SigningMethodHS256, t := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{ jwt.MapClaims{
"iss": "bibliomane", "iss": "bibliomane",
"sub": username, "sub": username,
"admin": strconv.FormatBool(admin),
}) })
return t.SignedString(key) return t.SignedString(key)
} }

View File

@@ -2,6 +2,7 @@ package middleware
import ( import (
"net/http" "net/http"
"strconv"
"strings" "strings"
"git.artlef.fr/bibliomane/internal/jwtauth" "git.artlef.fr/bibliomane/internal/jwtauth"
@@ -27,29 +28,33 @@ func Auth() gin.HandlerFunc {
return return
} }
username, err := parseUserFromJwt(c) jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization"))
jwtoken, err := jwt.Parse(jwtokenStr,
func(token *jwt.Token) (any, error) {
return jwtauth.GetJwtKey()
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if err != nil { if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, abortError(c)
gin.H{"error": "You must be logged in to access this resource."}) return
}
//check admin rights
if strings.HasPrefix(c.FullPath(), "/ws/admin/") && !hasAdminRights(jwtoken) {
c.AbortWithStatusJSON(http.StatusForbidden,
gin.H{"error": "You do not have the right to access this resource."})
return
}
username, err := jwtoken.Claims.GetSubject()
if err != nil {
abortError(c)
} else { } else {
c.Set("user", username) c.Set("user", username)
} }
} }
} }
func parseUserFromJwt(c *gin.Context) (string, error) {
jwtokenStr := jwtFromBearerToken(c.GetHeader("Authorization"))
jwtoken, parseErr := jwt.Parse(jwtokenStr,
func(token *jwt.Token) (any, error) {
return jwtauth.GetJwtKey()
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
if parseErr != nil {
return "", parseErr
}
return jwtoken.Claims.GetSubject()
}
func jwtFromBearerToken(bearerToken string) string { func jwtFromBearerToken(bearerToken string) string {
splitToken := strings.Split(bearerToken, " ") splitToken := strings.Split(bearerToken, " ")
if len(splitToken) == 2 { if len(splitToken) == 2 {
@@ -58,3 +63,28 @@ func jwtFromBearerToken(bearerToken string) string {
return "" return ""
} }
} }
func hasAdminRights(token *jwt.Token) bool {
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return false
}
raw, ok := claims["admin"]
if !ok {
return false
}
adminStr, ok := raw.(string)
if !ok {
return false
}
isAdmin, err := strconv.ParseBool(adminStr)
if err != nil {
return false
}
return isAdmin
}
func abortError(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusUnauthorized,
gin.H{"error": "You must be logged in to access this resource."})
}

View File

@@ -7,5 +7,4 @@ type Collection struct {
Name string Name string
User User User User
UserID uint UserID uint
Items []CollectionItem
} }

View File

@@ -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
UserBooks []UserBook Admin bool
InviteToken string
Activated bool
} }

View File

@@ -20,6 +20,16 @@ func FetchCollectionHeader(db *gorm.DB, collectionId uint) (CollectionHeader, er
return collection, res.Error return collection, res.Error
} }
func FetchCollectionHeaderFromItem(db *gorm.DB, itemId uint) (CollectionHeader, error) {
var collection CollectionHeader
query := db.Model(&model.Collection{})
query = query.Select("collections.name, collections.user_id")
query = query.Joins("left join collection_items on (collection_items.collection_id = collections.id)")
query = query.Where("collection_items.id = ?", itemId)
res := query.Find(&collection)
return collection, res.Error
}
type CollectionsQueryResult struct { type CollectionsQueryResult struct {
ID uint ID uint
UserID uint UserID uint

View 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, 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 user_books on user_books.user_id = users.id")
query = query.Group("users.id, users.name")
return query
}

View File

@@ -5,12 +5,24 @@ import (
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/myvalidator"
) )
func GetAppInfo(ac appcontext.AppContext) { func GetAppInfo(ac appcontext.AppContext) {
admin := false
_, userIsInContext := ac.C.Get("user")
if userIsInContext {
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
admin = user.Admin
}
ac.C.JSON(http.StatusOK, dto.AppInfo{ ac.C.JSON(http.StatusOK, dto.AppInfo{
RegistrationDisabled: ac.Config.DisableRegistration, RegistrationDisabled: ac.Config.DisableRegistration,
DemoMode: ac.Config.DemoMode, DemoMode: ac.Config.DemoMode,
DemoUsername: ac.Config.DemoUsername, DemoUsername: ac.Config.DemoUsername,
Admin: admin,
}) })
} }

View File

@@ -0,0 +1,66 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin"
)
func DeleteCollectionHandler(ac appcontext.AppContext) {
collectionId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = myvalidator.ValidateId(ac.Db, uint(collectionId), &model.Collection{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collection, err := query.FetchCollectionHeader(ac.Db, uint(collectionId))
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collectionDb := model.Collection{}
collectionDb.ID = uint(collectionId)
err = ac.Db.Where("collection_id = ?", collectionId).Delete(&model.CollectionItem{}).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = ac.Db.Delete(&collectionDb).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = ac.Db.Delete(&collectionDb).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, "Success")
}

View File

@@ -9,6 +9,7 @@ import (
"git.artlef.fr/bibliomane/internal/appcontext" "git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto" "git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/i18nresource" "git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator" "git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query" "git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -21,6 +22,12 @@ func GetCollectionHandler(ac appcontext.AppContext) {
return return
} }
err = myvalidator.ValidateId(ac.Db, uint(collectionId), &model.Collection{})
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
user, err := ac.GetAuthenticatedUser() user, err := ac.GetAuthenticatedUser()
if err != nil { if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)

View File

@@ -0,0 +1,62 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func DeleteCollectionBookHandler(ac appcontext.AppContext) {
collectionItemId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collectionItem model.CollectionItem
err = ac.Db.First(&collectionItem, collectionItemId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
collection, err := query.FetchCollectionHeaderFromItem(ac.Db, collectionItem.ID)
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
err = ac.Db.Delete(&collectionItem).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
//update position on remaining items
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ? AND position > ?", collectionItem.CollectionID, collectionItem.Position)
err = q.UpdateColumn("position", gorm.Expr("position - 1")).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
ac.C.JSON(http.StatusOK, "Success")
}

View 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,
})
}

View File

@@ -12,12 +12,12 @@ import (
"git.artlef.fr/bibliomane/internal/myvalidator" "git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
) )
func PostLoginHandler(ac appcontext.AppContext) { func PostLoginHandler(ac appcontext.AppContext) {
var username string var username string
admin := false
if !ac.Config.DemoMode { if !ac.Config.DemoMode {
var user dto.UserLogin var user dto.UserLogin
@@ -26,30 +26,32 @@ func PostLoginHandler(ac appcontext.AppContext) {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err) myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return return
} }
var userDb model.User
ac.Db.Where("name = ?", user.Username).First(&userDb)
if !ac.Config.DemoMode && !isUserAndPasswordOk(ac.Db, user.Username, user.Password) { if !ac.Config.DemoMode &&
bcrypt.CompareHashAndPassword([]byte(userDb.Password), []byte(user.Password)) != nil {
ac.C.JSON(http.StatusUnauthorized, ac.C.JSON(http.StatusUnauthorized,
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
} else { } else {
username = ac.Config.DemoUsername username = ac.Config.DemoUsername
} }
var jwtToken string var jwtToken string
jwtToken, err := jwtauth.GenerateJwtToken(username) jwtToken, err := jwtauth.GenerateJwtToken(username, admin)
if err != nil { if err != nil {
ac.C.JSON(http.StatusUnauthorized, ac.C.JSON(http.StatusUnauthorized,
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})
}
func isUserAndPasswordOk(db *gorm.DB, username string, password string) bool {
var user model.User
db.Where("name = ?", username).First(&user)
err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
return err == nil
} }

View 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})
}

View File

@@ -102,6 +102,18 @@ func Setup(config *config.Config) *gin.Engine {
ws.POST("/upload/cover", func(c *gin.Context) { ws.POST("/upload/cover", func(c *gin.Context) {
routes.PostUploadBookCoverHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PostUploadBookCoverHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })
ws.DELETE("/collection/:id", func(c *gin.Context) {
routes.DeleteCollectionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.DELETE("/collection/item/:id", func(c *gin.Context) {
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})
})
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)

View File

@@ -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()

View File

@@ -6,7 +6,7 @@ import (
) )
func main() { func main() {
applicationVersion := "0.7.0" applicationVersion := "0.8.0"
c := config.LoadConfig(applicationVersion) c := config.LoadConfig(applicationVersion)
r := setup.Setup(&c) r := setup.Setup(&c)
r.Run(":" + c.Port) r.Run(":" + c.Port)