added user signup feature

This commit is contained in:
2025-09-26 23:57:36 +02:00
parent 2f0a9b5127
commit 57355fe9ac
15 changed files with 242 additions and 14 deletions

View File

@@ -57,6 +57,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -1823,6 +1824,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1945,6 +1947,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001737", "caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211", "electron-to-chromium": "^1.5.211",
@@ -2274,6 +2277,7 @@
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -2335,6 +2339,7 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"eslint-config-prettier": "bin/cli.js" "eslint-config-prettier": "bin/cli.js"
}, },
@@ -3374,6 +3379,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -3769,6 +3775,7 @@
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -3955,6 +3962,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz",
"integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.21", "@vue/compiler-dom": "3.5.21",
"@vue/compiler-sfc": "3.5.21", "@vue/compiler-sfc": "3.5.21",

View File

@@ -31,9 +31,9 @@
<div class="navbar-end"> <div class="navbar-end">
<div class="navbar-item"> <div class="navbar-item">
<div class="buttons"> <div class="buttons">
<a class="button is-primary"> <RouterLink to="/signup" class="button is-primary">
<strong>Sign up</strong> <strong>Sign up</strong>
</a> </RouterLink>
<a class="button is-light"> <a class="button is-light">
Log in Log in
</a> </a>

71
front/src/SignUp.vue Normal file
View File

@@ -0,0 +1,71 @@
<script setup>
import { ref, reactive, computed } from 'vue'
import { postBook, postSignup } from './api.js'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter();
const user = ref({
username: "",
password: ""
});
const errors = ref(null)
const userError = computed(() => {
return extractErrorFromField("Username");
})
const passwordError = computed(() => {
return extractErrorFromField("Password");
})
function extractErrorFromField(fieldName) {
if (errors.value === null) {
return "";
}
const titleErr = errors.value.find((e) => e["field"] === fieldName);
if (typeof titleErr !== 'undefined') {
return titleErr.error
} else {
return "";
}
}
function onSubmit(e) {
postSignup(user)
.then((res) => {
if (res.ok) {
router.push('/');
return;
} else {
res.json().then((json) => (errors.value = json));
}
})
}
</script>
<template>
<form @submit.prevent="onSubmit">
<div class="field">
<label class="label">Username</label>
<div class="control">
<input :class="'input ' + (userError ? 'is-danger' : '')" type="text" minlength="2" maxlength="20"
required v-model="user.username" placeholder="Username">
</div>
<p v-if="userError" class="help is-danger">{{userError}}</p>
</div>
<div class="field">
<label class="label">Password</label>
<div class="control">
<input :class="'input ' + (passwordError ? 'is-danger' : '')" type="password" minlength="6"
maxlength="100" v-model="user.password" required placeholder="Password">
</div>
<p v-if="passwordError" class="help is-danger">{{passwordError}}</p>
</div>
<div class="field">
<div class="control">
<button class="button is-link">Submit</button>
</div>
</div>
</form>
</template>
<style scoped></style>

View File

@@ -27,3 +27,13 @@ export function postBook(book) {
body: JSON.stringify(book.value) body: JSON.stringify(book.value)
}) })
} }
export function postSignup(user) {
return fetch(baseUrl + '/auth/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(user.value)
})
}

View File

@@ -3,11 +3,13 @@ import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue' import App from './App.vue'
import BooksBrowser from './BooksBrowser.vue' import BooksBrowser from './BooksBrowser.vue'
import AddBook from './AddBook.vue' import AddBook from './AddBook.vue'
import SignUp from './SignUp.vue'
const routes = [ const routes = [
{ path: '/', component: BooksBrowser }, { path: '/', component: BooksBrowser },
{ path: '/add', component: AddBook }, { path: '/add', component: AddBook },
{ path: '/signup', component: SignUp },
] ]
export const router = createRouter({ export const router = createRouter({

View File

@@ -5,3 +5,8 @@ type bookPostCreate struct {
Author string `json:"author" binding:"max=100"` Author string `json:"author" binding:"max=100"`
Rating int `json:"rating" binding:"min=0,max=10"` Rating int `json:"rating" binding:"min=0,max=10"`
} }
type userPostCreate struct {
Username string `json:"username" binding:"required,min=2,max=20"`
Password string `json:"password" binding:"required,min=6,max=100"`
}

View File

@@ -1,9 +1,27 @@
package api package api
import "git.artlef.fr/PersonalLibraryManager/internal/model" import (
"git.artlef.fr/PersonalLibraryManager/internal/model"
"golang.org/x/crypto/bcrypt"
)
func (b bookPostCreate) toBook() model.Book { func (b bookPostCreate) toBook() model.Book {
return model.Book{Title: b.Title, return model.Book{
Title: b.Title,
Author: b.Author, Author: b.Author,
Rating: b.Rating} Rating: b.Rating,
}
}
func (u userPostCreate) toUser() (model.User, error) {
user := model.User{
Name: u.Username,
Password: "",
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
if err != nil {
return user, err
}
user.Password = string(hashedPassword)
return user, nil
} }

View File

@@ -20,15 +20,47 @@ func PostBookHandler(c *gin.Context, db *gorm.DB) {
var book bookPostCreate var book bookPostCreate
err := c.ShouldBindJSON(&book) err := c.ShouldBindJSON(&book)
if err != nil { if err != nil {
manageBindingError(c, err)
return
}
bookDb := book.toBook()
err = db.Model(&model.Book{}).Save(&bookDb).Error
if err != nil {
manageDefaultError(c, err)
return
}
c.String(200, "Success")
}
func PostUserHandler(c *gin.Context, db *gorm.DB) {
var user userPostCreate
err := c.ShouldBindJSON(&user)
if err != nil {
manageBindingError(c, err)
return
}
userDb, err := user.toUser()
if err != nil {
manageDefaultError(c, err)
return
}
err = db.Model(&model.User{}).Save(&userDb).Error
if err != nil {
manageDefaultError(c, err)
return
}
c.String(200, "Success")
}
func manageBindingError(c *gin.Context, err error) {
var ve validator.ValidationErrors var ve validator.ValidationErrors
if errors.As(err, &ve) { if errors.As(err, &ve) {
c.JSON(http.StatusBadRequest, getValidationErrors(&ve)) c.JSON(http.StatusBadRequest, getValidationErrors(&ve))
} else { } else {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) manageDefaultError(c, err)
} }
return }
}
bookDb := book.toBook() func manageDefaultError(c *gin.Context, err error) {
db.Model(&model.Book{}).Save(&bookDb) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
c.String(200, "Success")
} }

View File

@@ -27,6 +27,8 @@ func computeValidationMessage(fe *validator.FieldError) string {
switch tag { switch tag {
case "required": case "required":
return fmt.Sprintf("%s is required.", (*fe).Field()) return fmt.Sprintf("%s is required.", (*fe).Field())
case "min":
return fmt.Sprintf("%s is not long enough. It should be at least %s characters.", (*fe).Field(), (*fe).Param())
case "max": case "max":
return fmt.Sprintf("%s is too long. It should be under %s characters.", (*fe).Field(), (*fe).Param()) return fmt.Sprintf("%s is too long. It should be under %s characters.", (*fe).Field(), (*fe).Param())
default: default:

View File

@@ -19,6 +19,7 @@ func Initdb(databasePath string, demoDataPath string) *gorm.DB {
} }
// Migrate the schema // Migrate the schema
db.AutoMigrate(&model.Book{}) db.AutoMigrate(&model.Book{})
db.AutoMigrate(&model.User{})
var book model.Book var book model.Book
queryResult := db.Limit(1).Find(&book) queryResult := db.Limit(1).Find(&book)
if queryResult.RowsAffected == 0 && demoDataPath != "" { if queryResult.RowsAffected == 0 && demoDataPath != "" {

View File

@@ -4,7 +4,7 @@ import "gorm.io/gorm"
type Book struct { type Book struct {
gorm.Model gorm.Model
Title string `json:"title"` Title string `json:"title" gorm:"not null"`
Author string `json:"author"` Author string `json:"author"`
Rating int `json:"rating"` Rating int `json:"rating"`
} }

9
internal/model/user.go Normal file
View File

@@ -0,0 +1,9 @@
package model
import "gorm.io/gorm"
type User struct {
gorm.Model
Name string `gorm:"index;uniqueIndex"`
Password string
}

View File

@@ -25,5 +25,8 @@ func setup(config *config.Config) *gin.Engine {
r.POST("/book", func(c *gin.Context) { r.POST("/book", func(c *gin.Context) {
api.PostBookHandler(c, db) api.PostBookHandler(c, db)
}) })
r.POST("/auth/signup", func(c *gin.Context) {
api.PostUserHandler(c, db)
})
return r return r
} }

67
user_test.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPostUserHandler_Working(t *testing.T) {
userJson :=
`{
"username": "artlef",
"password": "123456789"
}`
testPostUserHandler(t, userJson, 200)
}
func TestPostUserHandler_UsernameTooSmall(t *testing.T) {
userJson :=
`{
"username": "a",
"password": "123456789"
}`
testPostUserHandler(t, userJson, 400)
}
func TestPostUserHandler_UsernameTooBig(t *testing.T) {
userJson :=
`{
"username": "thisusernameistoolong",
"password": "123456789"
}`
testPostUserHandler(t, userJson, 400)
}
func TestPostUserHandler_PasswordTooSmall(t *testing.T) {
userJson :=
`{
"username": "thisusernameisok",
"password": "lol"
}`
testPostUserHandler(t, userJson, 400)
}
func TestPostUserHandler_PasswordTooBig(t *testing.T) {
userJson :=
`{
"username": "thisusernameisok",
"password": "According to all known laws of aviation, there is no way a bee should be able to fly. Its wings are too small to get its fat little body off the ground."
}`
testPostUserHandler(t, userJson, 400)
}
func testPostUserHandler(t *testing.T, userJson string, expectedCode int) {
router := testSetup()
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/auth/signup",
strings.NewReader(string(userJson)))
router.ServeHTTP(w, req)
assert.Equal(t, expectedCode, w.Code)
}