From 902279b34a3bf608e90bd601baaf3335fb5b32cf Mon Sep 17 00:00:00 2001 From: Arthur Lefebvre Date: Wed, 6 May 2026 18:00:33 +0200 Subject: [PATCH] Add invite user feature for admin --- demodata.sql | 6 +- front/src/InviteUser.vue | 63 +++++++++++++++++ front/src/LinkCopy.vue | 35 ++++++++++ front/src/UsersManagement.vue | 76 +++----------------- front/src/UsersTable.vue | 89 ++++++++++++++++++++++++ front/src/api.js | 4 ++ front/src/locales/en.json | 13 ++++ front/src/locales/fr.json | 13 ++++ internal/apitest/config_test/test.toml | 2 - internal/apitest/post_inviteuser_test.go | 78 +++++++++++++++++++++ internal/createuser/createuser.go | 41 +++++++++-- internal/dto/in.go | 4 ++ internal/dto/out.go | 7 ++ internal/i18nresource/locale.en.toml | 1 + internal/i18nresource/locale.fr.toml | 1 + internal/model/user.go | 8 ++- internal/query/queryusers.go | 2 +- internal/routes/inviteuserpost.go | 30 ++++++++ internal/routes/userlogin.go | 5 ++ internal/setup/setup.go | 3 + 20 files changed, 398 insertions(+), 83 deletions(-) create mode 100644 front/src/InviteUser.vue create mode 100644 front/src/LinkCopy.vue create mode 100644 front/src/UsersTable.vue create mode 100644 internal/apitest/post_inviteuser_test.go create mode 100644 internal/routes/inviteuserpost.go diff --git a/demodata.sql b/demodata.sql index 4acad53..74f2600 100644 --- a/demodata.sql +++ b/demodata.sql @@ -1,7 +1,7 @@ --users -INSERT INTO users(created_at, name, password) VALUES ('NOW', 'demo','$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, admin, password) VALUES ('NOW', 'admin', 'true', '$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, 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 INSERT INTO static_files(name, path) VALUES ('odingosochateaux.jpg', 'odingosochateaux.jpg'); diff --git a/front/src/InviteUser.vue b/front/src/InviteUser.vue new file mode 100644 index 0000000..f82ebad --- /dev/null +++ b/front/src/InviteUser.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/front/src/LinkCopy.vue b/front/src/LinkCopy.vue new file mode 100644 index 0000000..83196d0 --- /dev/null +++ b/front/src/LinkCopy.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/front/src/UsersManagement.vue b/front/src/UsersManagement.vue index 7454cf2..027321b 100644 --- a/front/src/UsersManagement.vue +++ b/front/src/UsersManagement.vue @@ -1,76 +1,16 @@ - + diff --git a/front/src/UsersTable.vue b/front/src/UsersTable.vue new file mode 100644 index 0000000..755f732 --- /dev/null +++ b/front/src/UsersTable.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/front/src/api.js b/front/src/api.js index a3b7cdd..2010267 100644 --- a/front/src/api.js +++ b/front/src/api.js @@ -151,6 +151,10 @@ export function postCollectionChangePosition(collectionId, itemId, position) { ) } +export function postInviteUser(username) { + return genericPayloadCall('/ws/admin/inviteuser', { username: username }, 'POST') +} + export function deleteCollectionItem(itemId) { return deleteApiCall('/ws/collection/item/' + itemId) } diff --git a/front/src/locales/en.json b/front/src/locales/en.json index d37a80b..50769d5 100644 --- a/front/src/locales/en.json +++ b/front/src/locales/en.json @@ -105,11 +105,24 @@ "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" } } diff --git a/front/src/locales/fr.json b/front/src/locales/fr.json index 5630e0c..1b1b25a 100644 --- a/front/src/locales/fr.json +++ b/front/src/locales/fr.json @@ -105,11 +105,24 @@ "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" } } diff --git a/internal/apitest/config_test/test.toml b/internal/apitest/config_test/test.toml index 7677941..aa6e561 100644 --- a/internal/apitest/config_test/test.toml +++ b/internal/apitest/config_test/test.toml @@ -10,5 +10,3 @@ image-folder-path = "/tmp" # The port to listen on for the server. port = "8080" - -book-description-from-babelio = true diff --git a/internal/apitest/post_inviteuser_test.go b/internal/apitest/post_inviteuser_test.go new file mode 100644 index 0000000..208c10c --- /dev/null +++ b/internal/apitest/post_inviteuser_test.go @@ -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 +} diff --git a/internal/createuser/createuser.go b/internal/createuser/createuser.go index 9b33607..4100e1b 100644 --- a/internal/createuser/createuser.go +++ b/internal/createuser/createuser.go @@ -1,6 +1,8 @@ package createuser import ( + "crypto/rand" + "encoding/base64" "errors" "fmt" "net/http" @@ -14,6 +16,31 @@ import ( "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 func CreateUser(ac appcontext.AppContext, username string, password string) error { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -22,9 +49,10 @@ func CreateUser(ac appcontext.AppContext, username string, password string) erro } user := model.User{ - Name: username, - Password: string(hashedPassword), - Admin: false, + Name: username, + Password: string(hashedPassword), + Admin: false, + Activated: true, } return CreateUserWithHashedPassword(ac, &user) } @@ -103,9 +131,10 @@ func createDefaultUsersFromMap( } user := model.User{ - Name: username, - Password: password, - Admin: isAdmin, + Name: username, + Password: password, + Admin: isAdmin, + Activated: true, } err = CreateUserWithHashedPassword(ac, &user) if err != nil { diff --git a/internal/dto/in.go b/internal/dto/in.go index c2c320d..ef7d00a 100644 --- a/internal/dto/in.go +++ b/internal/dto/in.go @@ -70,3 +70,7 @@ type UserSignup struct { Username string `json:"username" binding:"required,min=2,max=20"` Password string `json:"password" binding:"required,min=6,max=100"` } + +type UserToInvite struct { + Username string `json:"username" binding:"required,min=2,max=20"` +} diff --git a/internal/dto/out.go b/internal/dto/out.go index e7c8317..b95734f 100644 --- a/internal/dto/out.go +++ b/internal/dto/out.go @@ -83,6 +83,13 @@ 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"` +} diff --git a/internal/i18nresource/locale.en.toml b/internal/i18nresource/locale.en.toml index d27ef44..26078c7 100644 --- a/internal/i18nresource/locale.en.toml +++ b/internal/i18nresource/locale.en.toml @@ -1,4 +1,5 @@ InvalidCredentials = "Invalid credentials." +UserNotActivated = "User is not activated." AuthenticationSuccess = "Authentication was a success." ValidationRequired = "This field is required." ValidationTooShort = "This field is too short. It should be at least %s characters." diff --git a/internal/i18nresource/locale.fr.toml b/internal/i18nresource/locale.fr.toml index d23962d..af01ce8 100644 --- a/internal/i18nresource/locale.fr.toml +++ b/internal/i18nresource/locale.fr.toml @@ -1,4 +1,5 @@ InvalidCredentials = "Identifiants invalides." +UserNotActivated = "L'utilisateur n'est pas activé." AuthenticationSuccess = "Connexion réussie." ValidationRequired = "Ce champ est requis." ValidationTooShort = "Ce champ est trop court. Il devrait contenir au moins %s caractères." diff --git a/internal/model/user.go b/internal/model/user.go index bc1977d..2869bab 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -4,7 +4,9 @@ import "gorm.io/gorm" type User struct { gorm.Model - Name string `gorm:"index;uniqueIndex"` - Password string - Admin bool + Name string `gorm:"index;uniqueIndex"` + Password string + Admin bool + InviteToken string + Activated bool } diff --git a/internal/query/queryusers.go b/internal/query/queryusers.go index 56b8389..7e4d7c5 100644 --- a/internal/query/queryusers.go +++ b/internal/query/queryusers.go @@ -25,7 +25,7 @@ func FetchAllUsersCount(db *gorm.DB) (int64, 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.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") diff --git a/internal/routes/inviteuserpost.go b/internal/routes/inviteuserpost.go new file mode 100644 index 0000000..56b0579 --- /dev/null +++ b/internal/routes/inviteuserpost.go @@ -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, + }) +} diff --git a/internal/routes/userlogin.go b/internal/routes/userlogin.go index 8d76ecd..63ceb48 100644 --- a/internal/routes/userlogin.go +++ b/internal/routes/userlogin.go @@ -35,6 +35,11 @@ func PostLoginHandler(ac appcontext.AppContext) { gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "InvalidCredentials")}) return } + if !userDb.Activated { + ac.C.JSON(http.StatusUnauthorized, + gin.H{"error": i18nresource.GetTranslatedMessage(&ac, "UserNotActivated")}) + return + } username = user.Username admin = userDb.Admin } else { diff --git a/internal/setup/setup.go b/internal/setup/setup.go index 3b325bb..4945c22 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -111,6 +111,9 @@ func Setup(config *config.Config) *gin.Engine { 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)