From f7ca3c4f5968b387db3af4541f0df2c4d4d4a594 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 13:21:51 +0200 Subject: [PATCH] feat: create users with a password and an invitation in the admin - password with confirmation on create, optional reset on update that ends the user's sessions; the hash is never returned - send_invite sends one invitation with an activation link after the create commits - organisation picker, avatar upload shared with the user API and the frontend permission editor on the user form - an email held by a deactivated user answers 422 instead of failing on the unique constraint - the privileged-member guard now also refuses a submitted password --- admin_harness_test.go | 137 +++++- admin_users_test.go | 631 +++++++++++++++++++++++++- controllers/admin_registry.go | 6 + controllers/users/config_form.yaml | 5 + controllers/users/config_list.yaml | 3 +- controllers/users_admin_controller.go | 221 ++++++++- lang/en/lang.yaml | 12 + lang/pl/lang.yaml | 12 + models/user/fields.yaml | 41 ++ plugin.go | 2 + views/mail/invite-en.htm | 13 + views/mail/invite.htm | 13 + 12 files changed, 1080 insertions(+), 16 deletions(-) create mode 100644 views/mail/invite-en.htm create mode 100644 views/mail/invite.htm diff --git a/admin_harness_test.go b/admin_harness_test.go index 0c6f7a3..82159cb 100644 --- a/admin_harness_test.go +++ b/admin_harness_test.go @@ -3,15 +3,20 @@ package user import ( "bytes" "context" + "crypto/rand" "database/sql" + "encoding/base64" "encoding/json" "fmt" "io" + "log/slog" + "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -67,6 +72,7 @@ type adminEnv struct { app *backpack.App gdb *gorm.DB bucket *blob.Bucket + mail *mailCapture } // adminAPI builds a path under the admin API prefix. @@ -143,6 +149,10 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv { for key, value := range map[string]any{ "http.body_limits.default_bytes": 1048576, "http.body_limits.upload_bytes": 8388608, + // The log driver renders every mail and hands it to the app logger, + // which mailCapture reads. + "mail.driver": "log", + "mail.from": "noreply@example.test", } { if err := cfg.Set(key, value); err != nil { t.Fatal(err) @@ -152,6 +162,10 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv { if err := lagoon.Publish(app, sqlDB, gdb); err != nil { t.Fatal(err) } + mail := &mailCapture{next: slog.Default().Handler()} + if err := app.Publish(slog.New(mail)); err != nil { + t.Fatal(err) + } bucket := memblob.OpenBucket(nil) t.Cleanup(func() { _ = bucket.Close() }) if err := attach.Publish(app, bucket); err != nil { @@ -174,7 +188,7 @@ func newAdminEnv(t *testing.T, configure ...func(*compass.Config)) *adminEnv { if err != nil { t.Fatal(err) } - return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket} + return &adminEnv{h: h, app: app, gdb: gdb, bucket: bucket, mail: mail} } // admin inserts a backend role holding exactly permissions and a backend @@ -227,27 +241,132 @@ func (e *adminEnv) admin(t *testing.T, permissions ...string) string { // throttle never trips inside one test. func (e *adminEnv) call(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() - var reader io.Reader + var raw []byte if body != nil { - raw, err := json.Marshal(body) - if err != nil { + var err error + if raw, err = json.Marshal(body); err != nil { t.Fatal(err) } - reader = bytes.NewReader(raw) + } + return e.send(t, method, path, token, raw, "application/json", nil) +} + +// send is call with a raw body, its content type and extra headers (for +// example the form session key of a file upload). +func (e *adminEnv) send(t *testing.T, method, path, token string, body []byte, contentType string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) } req := httptest.NewRequest(method, path, reader) req.Host = "example.com" req.RemoteAddr = fmt.Sprintf("198.51.100.%d:4000", adminSeq.Add(1)%250+1) req.Header.Set("Accept-Language", "en") - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } + for name, value := range headers { + req.Header.Set(name, value) + } rec := httptest.NewRecorder() e.h.ServeHTTP(rec, req) return rec } +// sentMail is one message the application sent, as the log mail driver +// reports it: rendered, with the text part. +type sentMail struct { + To []string + Subject string + Text string +} + +// mailCapture is the slog handler behind the app logger: it keeps the +// records of the log mail driver and passes everything else on. +type mailCapture struct { + mu sync.Mutex + sent []sentMail + next slog.Handler +} + +func (m *mailCapture) Enabled(context.Context, slog.Level) bool { return true } + +func (m *mailCapture) Handle(ctx context.Context, record slog.Record) error { + if record.Message != "postcard mail" { + if m.next != nil && m.next.Enabled(ctx, record.Level) { + return m.next.Handle(ctx, record) + } + return nil + } + var mail sentMail + record.Attrs(func(attr slog.Attr) bool { + switch attr.Key { + case "to": + mail.To, _ = attr.Value.Any().([]string) + case "subject": + mail.Subject = attr.Value.String() + case "text": + mail.Text = attr.Value.String() + } + return true + }) + m.mu.Lock() + m.sent = append(m.sent, mail) + m.mu.Unlock() + return nil +} + +func (m *mailCapture) WithAttrs([]slog.Attr) slog.Handler { return m } +func (m *mailCapture) WithGroup(string) slog.Handler { return m } + +// mails returns the messages sent to the address since the env booted. +func (e *adminEnv) mails(to string) []sentMail { + e.mail.mu.Lock() + defer e.mail.mu.Unlock() + var out []sentMail + for _, mail := range e.mail.sent { + for _, recipient := range mail.To { + if recipient == to { + out = append(out, mail) + } + } + } + return out +} + +// adminSessionKey returns a fresh form session key, as the admin SPA makes +// one when it opens a form. +func adminSessionKey(t *testing.T) string { + t.Helper() + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + t.Fatal(err) + } + return base64.RawURLEncoding.EncodeToString(raw) +} + +// upload posts one file to a fileupload field of a user's form under a form +// session key; the next save sent with the same key attaches it. +func (e *adminEnv) upload(t *testing.T, token string, id uint, field, name string, data []byte, sessionKey string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("file_data", name) + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(data); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + return e.send(t, http.MethodPost, userPath(id, "/files/"+field), token, buf.Bytes(), mw.FormDataContentType(), + map[string]string{cabana.SessionKeyHeader: sessionKey}) +} + // expect is call plus a status assertion. func (e *adminEnv) expect(t *testing.T, status int, method, path, token string, body any) *httptest.ResponseRecorder { t.Helper() @@ -525,12 +644,14 @@ func (e *adminEnv) userLogin(t *testing.T, email, password string) (int, string) return rec.Code, body.Token } +// secretKey matches a JSON key that names a password or a one-time code. +var secretKey = regexp.MustCompile(`(?i)"[a-z_]*(password|activation_code|reset_password_code)[a-z_]*"\s*:`) + // assertNoSecret fails when an admin response carries a password key, a // stored hash or the given plain text. func assertNoSecret(t *testing.T, raw string, plain ...string) { t.Helper() - lower := strings.ToLower(raw) - if strings.Contains(lower, `"password`) || strings.Contains(raw, "$2a$") || strings.Contains(raw, "$2y$") { + if secretKey.MatchString(raw) || strings.Contains(raw, "$2a$") || strings.Contains(raw, "$2y$") { t.Fatalf("admin response carries a password: %s", raw) } for _, secret := range plain { diff --git a/admin_users_test.go b/admin_users_test.go index 75a7a90..5b337c4 100644 --- a/admin_users_test.go +++ b/admin_users_test.go @@ -1,7 +1,12 @@ package user import ( + "bytes" + "encoding/json" + "mime/multipart" "net/http" + "net/http/httptest" + "net/url" "reflect" "strconv" "strings" @@ -12,7 +17,10 @@ import ( "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -271,9 +279,12 @@ func TestAdminUsersTracer(t *testing.T) { // Protected columns are never written from a body. env.expect(t, http.StatusOK, http.MethodPut, userPath(inactive.ID, ""), token, map[string]any{ - "name": "Still inactive", "email": inactive.Email, "is_activated": true, "password": "forged-password-1", "permissions": map[string]int{"x": 1}, + "name": "Still inactive", "email": inactive.Email, "is_activated": true, "organisation_id": 987654, + "activation_code": "forged", "deleted_at": "2020-01-01T00:00:00Z", "must_change_password": true, + "has_self_set_password": false, "last_seen": "2020-01-01T00:00:00Z", "id": 1, }) - if got := env.reload(t, inactive.ID); got.IsActivated || got.Password != inactive.Password { + if got := env.reload(t, inactive.ID); got.IsActivated || got.Password != inactive.Password || got.OrganisationID != nil || + got.ActivationCode != nil || got.DeletedAt.Valid || got.MustChangePassword || got.LastSeen != nil || got.ID != inactive.ID { t.Fatalf("a body wrote a protected column: %+v", got) } @@ -351,6 +362,69 @@ func TestAdminPrivilegedMember(t *testing.T) { } member = env.reload(t, member.ID) + // A password submitted for a privileged member is refused before it is + // hashed or any token is invalidated: the old password still signs in. + const attempted = "takeover-password-1" + assertRefused(map[string]any{"name": "New name", "email": member.Email, "password": attempted, "password_confirmation": attempted}, member.ID, "password") + unchanged(member) + if got := env.reload(t, member.ID); got.TokensValidAfter != nil { + t.Fatalf("a refused password reset stamped tokens_valid_after: %v", got.TokensValidAfter) + } + if status, _ := env.userLogin(t, member.Email, "correct-horse"); status != http.StatusOK { + t.Fatalf("old password after a refused reset = %d", status) + } + if status, _ := env.userLogin(t, member.Email, attempted); status != http.StatusUnauthorized { + t.Fatalf("attempted password after a refused reset = %d", status) + } + // Email and password in one body: both fields are named. + assertRefused(map[string]any{"name": "New name", "email": "both-" + stamp + "@example.test", "password": attempted, "password_confirmation": attempted}, member.ID, "email", "password") + unchanged(member) + // Validation runs before the hooks, so a mismatching confirmation is a + // 422; it changes nothing either. + env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(member.ID, ""), limited, + map[string]any{"name": "New name", "email": member.Email, "password": attempted, "password_confirmation": "something-else"}) + unchanged(member) + // An empty password means "unchanged" and is an ordinary update. + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), limited, + map[string]any{"name": "Renamed again", "email": member.Email, "password": "", "password_confirmation": ""}) + if got := env.reload(t, member.ID); got.Name == nil || *got.Name != "Renamed again" || got.Password != member.Password { + t.Fatalf("empty-password update of a privileged member = %+v", got) + } + member = env.reload(t, member.ID) + + // Everything else on a privileged member needs only the users + // permission: organisation, frontend permissions and the avatar. + organisation := models.Organisation{Slug: "guard-" + stamp, Name: "Guard " + stamp} + if err := env.gdb.Create(&organisation).Error; err != nil { + t.Fatal(err) + } + permissionCode := "guard." + stamp + if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab) VALUES (?, 'Guard', 'Guard')`, permissionCode).Error; err != nil { + t.Fatal(err) + } + sessionKey := adminSessionKey(t) + if up := env.upload(t, limited, member.ID, "avatar", "member.jpg", jpegBytes(t), sessionKey); up.Code != http.StatusCreated && up.Code != http.StatusOK { + t.Fatalf("avatar upload for a privileged member = %d %s", up.Code, up.Body.String()) + } + body, err := json.Marshal(map[string]any{ + "name": "Renamed again", "email": member.Email, "organisation": organisation.ID, "permissions": map[string]int{permissionCode: -1}, + }) + if err != nil { + t.Fatal(err) + } + if saved := env.send(t, http.MethodPut, userPath(member.ID, ""), limited, body, "application/json", map[string]string{cabana.SessionKeyHeader: sessionKey}); saved.Code != http.StatusOK { + t.Fatalf("ordinary changes on a privileged member = %d %s", saved.Code, saved.Body.String()) + } + got := env.reload(t, member.ID) + if got.OrganisationID == nil || *got.OrganisationID != organisation.ID || got.Permissions[permissionCode] != -1 || env.hangers(t, member).Files != 1 { + t.Fatalf("ordinary changes on a privileged member were not saved: %+v", got) + } + // Leave the member without an avatar for the delete scenarios below. + if err := env.gdb.Exec(`DELETE FROM system_files WHERE attachment_type = ? AND attachment_id = ?`, member.MorphName(), strconv.FormatUint(uint64(member.ID), 10)).Error; err != nil { + t.Fatal(err) + } + member = env.reload(t, member.ID) + // The delete of a privileged member is refused and nothing changes. for _, target := range []*models.User{member, gone} { rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(target.ID, ""), limited, nil) @@ -378,6 +452,15 @@ func TestAdminPrivilegedMember(t *testing.T) { if got := env.reload(t, member.ID); got.Email != memberEmail { t.Fatalf("manager email change = %q", got.Email) } + const managed = "managed-password-1" + env.expect(t, http.StatusOK, http.MethodPut, userPath(member.ID, ""), manager, + map[string]any{"name": "Managed", "email": memberEmail, "password": managed, "password_confirmation": managed}) + if got := env.reload(t, member.ID); !bouncer.CheckPassword(got.Password, managed) || got.TokensValidAfter == nil { + t.Fatalf("manager password reset = %+v", got) + } + if status, _ := env.userLogin(t, memberEmail, managed); status != http.StatusOK { + t.Fatalf("login with the password the manager set = %d", status) + } // The delete is permanent, so the refusal must leave everything that // hangs off the member in place: throttle rows, memberships, the avatar @@ -385,7 +468,7 @@ func TestAdminPrivilegedMember(t *testing.T) { env.seedThrottle(t, member, "203.0.113.7", false, nil) memberAvatar := env.seedAvatar(t, member) memberHangers := env.hangers(t, member) - if memberHangers.Throttle != 1 || memberHangers.Groups != 2 || memberHangers.Files != 1 { + if memberHangers.Throttle < 1 || memberHangers.Groups != 2 || memberHangers.Files != 1 { t.Fatalf("member seed = %+v", memberHangers) } rec := env.expect(t, http.StatusForbidden, http.MethodDelete, userPath(member.ID, ""), limited, nil) @@ -874,3 +957,545 @@ func TestAdminUserForceDelete(t *testing.T) { t.Fatalf("the delete removed the group: %d %v", groups, err) } } + +// createdID reads the id of a create or update answer. +func createdID(t *testing.T, rec *httptest.ResponseRecorder) uint { + t.Helper() + var data struct { + ID uint `json:"id"` + } + adminInto(t, rec, &data) + if data.ID == 0 { + t.Fatalf("no id in %s", rec.Body.String()) + } + return data.ID +} + +// TestAdminUserPassword covers the password field (D-19): required and +// confirmed on create, optional on update, hashed, never returned, and an +// admin reset ends the user's sessions. +func TestAdminUserPassword(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stamp := adminStamp() + const first, second = "first-password-1", "second-password-2" + email := "password-" + stamp + "@example.test" + + // Create: the hash verifies, the user is not activated, nothing leaks. + created := env.expect(t, http.StatusCreated, http.MethodPost, adminAPI(adminUsersPath), token, map[string]any{ + "name": "Password", "surname": "pw" + stamp, "email": email, "password": first, "password_confirmation": first, "send_invite": false, + }) + assertNoSecret(t, created.Body.String(), first) + id := createdID(t, created) + user := env.reload(t, id) + if !bouncer.CheckPassword(user.Password, first) || user.Password == first { + t.Fatal("the stored password is not a hash of the submitted one") + } + if user.IsActivated || user.ActivatedAt != nil || !user.HasSelfSetPassword || user.TokensValidAfter != nil || user.DeletedAt.Valid { + t.Fatalf("created user = %+v", user) + } + for _, rec := range []*httptest.ResponseRecorder{ + env.expect(t, http.StatusOK, http.MethodGet, userPath(id, ""), token, nil), + env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"?search=pw"+stamp), token, nil), + } { + assertNoSecret(t, rec.Body.String(), first, user.Password) + } + // A request cannot turn the activation on, and cannot store a hash directly. + if status, _ := env.userLogin(t, email, first); status != http.StatusOK { + t.Fatalf("login with the created password = %d", status) + } + + // Create failures answer 422 on the field and create nothing. + failures := []struct { + name string + body map[string]any + field string + }{ + {"confirmation mismatch", map[string]any{"password": first, "password_confirmation": second}, "password"}, + {"missing confirmation", map[string]any{"password": first}, "password"}, + {"missing password", map[string]any{}, "password"}, + {"short password", map[string]any{"password": "short", "password_confirmation": "short"}, "password"}, + {"duplicate email", map[string]any{"email": email, "password": first, "password_confirmation": first}, "email"}, + } + for i, failure := range failures { + body := map[string]any{"name": "Failure", "email": "failure-" + stamp + strconv.Itoa(i) + "@example.test"} + for key, value := range failure.body { + body[key] = value + } + rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, body) + if details := adminDecode(t, rec).Error.Details; len(details[failure.field]) == 0 { + t.Fatalf("%s details = %v", failure.name, details) + } + if strings.Contains(rec.Body.String(), first) { + t.Fatalf("%s echoes the password: %s", failure.name, rec.Body.String()) + } + var n int64 + if err := env.gdb.Raw(`SELECT COUNT(*) FROM users WHERE email = ?`, body["email"]).Scan(&n).Error; err != nil || (n != 0 && failure.field != "email") { + t.Fatalf("%s created a user: %d %v", failure.name, n, err) + } + } + // The email of a deactivated user is taken as well: a 422, not a 500 + // from the unique constraint. + gone := env.seedUser(t, "gone", nil) + if err := env.gdb.Exec(`UPDATE users SET deleted_at = NOW() WHERE id = ?`, gone.ID).Error; err != nil { + t.Fatal(err) + } + rec := env.expect(t, http.StatusUnprocessableEntity, http.MethodPost, adminAPI(adminUsersPath), token, + map[string]any{"name": "Clash", "email": gone.Email, "password": first, "password_confirmation": first}) + if details := adminDecode(t, rec).Error.Details; len(details["email"]) != 1 || details["email"][0] != "The email has already been taken." { + t.Fatalf("deactivated email clash = %s", rec.Body.String()) + } + rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(id, ""), token, map[string]any{"name": "Password", "email": gone.Email}) + if details := adminDecode(t, rec).Error.Details; len(details["email"]) != 1 { + t.Fatalf("deactivated email clash on update = %s", rec.Body.String()) + } + + // Update without a password: only the name changes (RESEARCH Pitfall 1). + for _, body := range []map[string]any{ + {"name": "Renamed", "email": email}, + {"name": "Renamed", "email": email, "password": "", "password_confirmation": ""}, + } { + updated := env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token, body) + assertNoSecret(t, updated.Body.String(), first, user.Password) + if got := env.reload(t, id); got.Password != user.Password || got.TokensValidAfter != nil || got.Name == nil || *got.Name != "Renamed" { + t.Fatalf("an update without a password changed the credentials: %+v", got) + } + } + // A mismatching confirmation on update is refused and changes nothing. + rec = env.expect(t, http.StatusUnprocessableEntity, http.MethodPut, userPath(id, ""), token, + map[string]any{"name": "Renamed", "email": email, "password": second, "password_confirmation": first}) + if details := adminDecode(t, rec).Error.Details; len(details["password"]) == 0 { + t.Fatalf("update mismatch details = %v", details) + } + if got := env.reload(t, id); got.Password != user.Password { + t.Fatal("a refused password reset changed the hash") + } + + // Update with a password: the hash changes, and tokens issued before the + // reset stop working. + _, userToken := env.userLogin(t, email, first) + if fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil); fetch.Code != http.StatusOK { + t.Fatalf("fetch before the reset = %d %s", fetch.Code, fetch.Body.String()) + } + time.Sleep(1100 * time.Millisecond) // token timestamps have one-second resolution + reset := env.expect(t, http.StatusOK, http.MethodPut, userPath(id, ""), token, + map[string]any{"name": "Renamed", "email": email, "password": second, "password_confirmation": second}) + assertNoSecret(t, reset.Body.String(), second) + after := env.reload(t, id) + if !bouncer.CheckPassword(after.Password, second) || bouncer.CheckPassword(after.Password, first) || after.TokensValidAfter == nil { + t.Fatalf("user after the reset = %+v", after) + } + if fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil); fetch.Code != http.StatusUnauthorized { + t.Fatalf("a token from before the reset still works: %d", fetch.Code) + } + if status, _ := env.userLogin(t, email, first); status != http.StatusUnauthorized { + t.Fatalf("the old password still signs in: %d", status) + } + if status, _ := env.userLogin(t, email, second); status != http.StatusOK { + t.Fatalf("the new password does not sign in: %d", status) + } + // The reset did not activate the user or touch anything else. + if after.IsActivated || after.Email != email { + t.Fatalf("the reset changed more than the password: %+v", after) + } +} + +// TestAdminUserInvite covers send_invite (D-19): exactly one invitation +// after the create commits, with a link that activates the user. +func TestAdminUserInvite(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stamp := adminStamp() + const password = "invite-password-1" + create := func(status int, email string, extra map[string]any) *httptest.ResponseRecorder { + t.Helper() + body := map[string]any{"name": "Invited", "email": email, "password": password, "password_confirmation": password} + for key, value := range extra { + body[key] = value + } + return env.expect(t, status, http.MethodPost, adminAPI(adminUsersPath), token, body) + } + + // The form schema turns the checkbox on by default, on create only. + var form struct { + Fields []struct { + Name string `json:"name"` + Type string `json:"type"` + Default any `json:"default"` + Context any `json:"context"` + } `json:"fields"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) + seen := false + for _, field := range form.Fields { + if field.Name == "send_invite" { + seen = field.Type == "checkbox" && field.Default == true && field.Context == "create" + } + } + if !seen { + t.Fatalf("send_invite field = %+v", form.Fields) + } + + invited := "invited-" + stamp + "@example.test" + id := createdID(t, create(http.StatusCreated, invited, map[string]any{"send_invite": true})) + mails := env.mails(invited) + if len(mails) != 1 { + t.Fatalf("invitations sent = %d, want exactly 1: %+v", len(mails), mails) + } + mail := mails[0] + if mail.Subject != "An account has been created for you" || !strings.Contains(mail.Text, "Invited") || + !strings.Contains(mail.Text, invited) || !strings.Contains(mail.Text, "72 hours") || strings.Contains(mail.Text, password) { + t.Fatalf("invitation = %+v", mail) + } + user := env.reload(t, id) + if user.IsActivated || user.ActivationCode == nil || user.ActivationCodeIssuedAt == nil { + t.Fatalf("invited user = %+v", user) + } + // The link carries {id}!{code} and activates through the user API. + start := strings.Index(mail.Text, "https://app.example.test/activate?code=") + if start < 0 { + t.Fatalf("no activation link in %q", mail.Text) + } + link := mail.Text[start:] + link = link[:strings.IndexAny(link, ")\n ")] + parsed, err := url.Parse(link) + if err != nil { + t.Fatal(err) + } + packed := parsed.Query().Get("code") + if packed != strconv.FormatUint(uint64(id), 10)+"!"+*user.ActivationCode { + t.Fatalf("link code = %q", packed) + } + activated := env.call(t, http.MethodPost, "/_user/api/v1/activate-by-code", "", map[string]string{"code": packed}) + if activated.Code != http.StatusOK { + t.Fatalf("activate-by-code = %d %s", activated.Code, activated.Body.String()) + } + if got := env.reload(t, id); !got.IsActivated || got.ActivationCode != nil { + t.Fatalf("user after the invitation link = %+v", got) + } + + // No invitation without the checkbox, whether it is off or not sent. + off := "invite-off-" + stamp + "@example.test" + offID := createdID(t, create(http.StatusCreated, off, map[string]any{"send_invite": false})) + absent := "invite-absent-" + stamp + "@example.test" + create(http.StatusCreated, absent, nil) + if n := len(env.mails(off)) + len(env.mails(absent)); n != 0 { + t.Fatalf("invitations without the checkbox = %d", n) + } + if got := env.reload(t, offID); got.ActivationCode != nil { + t.Fatal("an activation code was issued without an invitation") + } + // A create that fails validation sends nothing. + failed := "invite-failed-" + stamp + "@example.test" + create(http.StatusUnprocessableEntity, failed, map[string]any{"send_invite": true, "password_confirmation": "does-not-match"}) + create(http.StatusUnprocessableEntity, invited, map[string]any{"send_invite": true}) + if n := len(env.mails(failed)); n != 0 || len(env.mails(invited)) != 1 { + t.Fatalf("a failed create sent an invitation: %d, %d", n, len(env.mails(invited))) + } + // send_invite is a create-only field: an update that carries it sends nothing. + env.expect(t, http.StatusOK, http.MethodPut, userPath(offID, ""), token, map[string]any{"name": "Invited", "email": off, "send_invite": true}) + if n := len(env.mails(off)); n != 0 { + t.Fatalf("an update sent an invitation: %d", n) + } +} + +// TestAdminAvatarSharedWithAPI covers D-20: the admin form's avatar field +// and the user API read and write the same attachment. +func TestAdminAvatarSharedWithAPI(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + user := env.seedUser(t, "avatar", nil) + _, userToken := env.userLogin(t, user.Email, "correct-horse") + payload := func() map[string]any { + t.Helper() + rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil) + if rec.Code != http.StatusOK { + t.Fatalf("fetch = %d %s", rec.Code, rec.Body.String()) + } + var body struct { + User map[string]any `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body.User + } + adminFiles := func() []cabana.FileItem { + t.Helper() + var files []cabana.FileItem + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, "/files/avatar"), token, nil), &files) + return files + } + if got := payload(); got["has_avatar"] != false || len(adminFiles()) != 0 { + t.Fatalf("a new user has an avatar: %v", got) + } + + // The form schema describes the field as PHP does: an image, 260 by 260. + var form struct { + Fields []struct { + Name string `json:"name"` + Type string `json:"type"` + Mode string `json:"mode"` + ImageWidth int `json:"imageWidth"` + ImageHeight int `json:"imageHeight"` + } `json:"fields"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) + described := false + for _, field := range form.Fields { + if field.Name == "avatar" { + described = field.Type == "fileupload" && field.Mode == "image" && field.ImageWidth == 260 && field.ImageHeight == 260 + } + } + if !described { + t.Fatalf("avatar field = %+v", form.Fields) + } + + // Admin to app: an upload attached by the form save shows in the payload. + key := adminSessionKey(t) + if up := env.upload(t, token, user.ID, "avatar", "from-admin.jpg", jpegBytes(t), key); up.Code != http.StatusCreated && up.Code != http.StatusOK { + t.Fatalf("admin upload = %d %s", up.Code, up.Body.String()) + } + if got := payload(); got["has_avatar"] != false { + t.Fatal("a pending upload already shows in the user payload") + } + body, err := json.Marshal(map[string]any{"name": "avatar", "email": user.Email}) + if err != nil { + t.Fatal(err) + } + if saved := env.send(t, http.MethodPut, userPath(user.ID, ""), token, body, "application/json", map[string]string{cabana.SessionKeyHeader: key}); saved.Code != http.StatusOK { + t.Fatalf("save with the upload = %d %s", saved.Code, saved.Body.String()) + } + got := payload() + avatarURL, _ := got["avatar_url"].(string) + if got["has_avatar"] != true || avatarURL == "" || strings.Contains(avatarURL, "gravatar") { + t.Fatalf("user payload after the admin upload = %v", got) + } + var row attach.File + if err := env.gdb.Where("attachment_type = ? AND attachment_id = ? AND field = ?", user.MorphName(), strconv.FormatUint(uint64(user.ID), 10), "avatar").Take(&row).Error; err != nil { + t.Fatal(err) + } + if got["avatar"] != row.DiskName || !row.Public() || row.FileName != "from-admin.jpg" { + t.Fatalf("avatar row = %+v payload avatar = %v", row, got["avatar"]) + } + if files := adminFiles(); len(files) != 1 || files[0].ID != row.ID || files[0].Pending { + t.Fatalf("admin file list = %+v", files) + } + + // App to admin: an avatar uploaded through the user API replaces it and + // shows in the admin field. + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + part, err := mw.CreateFormFile("avatar", "from-app.jpg") + if err != nil { + t.Fatal(err) + } + if _, err := part.Write(jpegBytes(t)); err != nil { + t.Fatal(err) + } + if err := mw.Close(); err != nil { + t.Fatal(err) + } + if up := env.send(t, http.MethodPost, "/_user/api/v1/avatar", userToken, buf.Bytes(), mw.FormDataContentType(), nil); up.Code != http.StatusOK { + t.Fatalf("user API upload = %d %s", up.Code, up.Body.String()) + } + files := adminFiles() + if len(files) != 1 || files[0].FileName != "from-app.jpg" || files[0].ID == row.ID { + t.Fatalf("admin file list after the user API upload = %+v", files) + } + if got := payload(); got["has_avatar"] != true { + t.Fatalf("user payload after the user API upload = %v", got) + } +} + +// TestAdminUserFormFields covers the organisation picker (D-22) and the +// frontend permission editor (D-15, D-16) of the user form. +func TestAdminUserFormFields(t *testing.T) { + env := newAdminEnv(t) + token := env.admin(t, permAccessUsers) + stamp := adminStamp() + user := env.seedUser(t, "fields", nil) + _, userToken := env.userLogin(t, user.Email, "correct-horse") + base := map[string]any{"name": "fields", "email": user.Email} + save := func(status int, extra map[string]any) *httptest.ResponseRecorder { + t.Helper() + body := map[string]any{} + for key, value := range base { + body[key] = value + } + for key, value := range extra { + body[key] = value + } + return env.expect(t, status, http.MethodPut, userPath(user.ID, ""), token, body) + } + + // Organisation: a belongsTo picker that writes organisation_id. + first := models.Organisation{Slug: "first-" + stamp, Name: "First " + stamp} + second := models.Organisation{Slug: "second-" + stamp, Name: "Second " + stamp} + for _, organisation := range []*models.Organisation{&first, &second} { + if err := env.gdb.Create(organisation).Error; err != nil { + t.Fatal(err) + } + } + var options []struct { + Value uint `json:"value"` + Label string `json:"label"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/fields/organisation/options?search="+stamp), token, nil), &options) + if len(options) != 2 { + t.Fatalf("organisation options = %+v", options) + } + saved := save(http.StatusOK, map[string]any{"organisation": first.ID}) + if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != first.ID { + t.Fatalf("organisation_id after the save = %v", got.OrganisationID) + } + if !strings.Contains(saved.Body.String(), first.Name) { + t.Fatalf("the record answer lacks the organisation label: %s", saved.Body.String()) + } + save(http.StatusOK, map[string]any{"organisation": second.ID}) + if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID { + t.Fatalf("organisation_id after the change = %v", got.OrganisationID) + } + // An id that is not an organisation is refused; the plain column name is ignored. + save(http.StatusUnprocessableEntity, map[string]any{"organisation": 987654321}) + save(http.StatusOK, map[string]any{"organisation_id": first.ID}) + if got := env.reload(t, user.ID); got.OrganisationID == nil || *got.OrganisationID != second.ID { + t.Fatalf("a plain organisation_id key was written: %v", got.OrganisationID) + } + save(http.StatusOK, map[string]any{"organisation": nil}) + if got := env.reload(t, user.ID); got.OrganisationID != nil { + t.Fatalf("organisation_id after clearing = %v", *got.OrganisationID) + } + + // Permissions: the options are the table's rows, grouped by tab. + read, write, admin := "forum."+stamp+".read", "forum."+stamp+".write", "shop."+stamp+".admin" + if err := env.gdb.Exec(`INSERT INTO golem15_user_frontend_permissions (code, label, tab, comment) VALUES + (?, 'Write', ?, 'May write posts'), (?, 'Read', ?, NULL), (?, 'Administer', ?, NULL)`, + write, "Forum "+stamp, read, "Forum "+stamp, admin, "Shop "+stamp).Error; err != nil { + t.Fatal(err) + } + var form struct { + Fields []struct { + Name string `json:"name"` + Type string `json:"type"` + Mode string `json:"mode"` + Tab string `json:"tab"` + Context any `json:"context"` + Options []struct { + Code string `json:"code"` + Label string `json:"label"` + Tab string `json:"tab"` + Comment string `json:"comment"` + Locked bool `json:"locked"` + } `json:"permissionOptions"` + } `json:"fields"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, adminAPI(adminUsersPath+"/schema/form"), token, nil), &form) + var offered []string + var names []string + for _, field := range form.Fields { + names = append(names, field.Name) + if field.Name != "permissions" { + continue + } + if field.Type != "permissioneditor" || field.Mode != "radio" || field.Tab != "Permissions" || field.Context != "update" { + t.Fatalf("permissions field = %+v", field) + } + for _, option := range field.Options { + if !strings.Contains(option.Code, stamp) { + continue + } + offered = append(offered, option.Tab+"|"+option.Code+"|"+option.Label+"|"+option.Comment) + if option.Locked { + t.Fatalf("a frontend permission is locked: %+v", option) + } + } + } + if want := []string{ + "Forum " + stamp + "|" + read + "|Read|", + "Forum " + stamp + "|" + write + "|Write|May write posts", + "Shop " + stamp + "|" + admin + "|Administer|", + }; !reflect.DeepEqual(offered, want) { + t.Fatalf("permission options = %v, want %v", offered, want) + } + // The form has no login-name, block-mail, guest or groups field (D-03, + // D-18, D-21; groups arrive with their guard). + if want := []string{"name", "surname", "email", "send_invite", "password", "password_confirmation", "organisation", "avatar", "created_ip_address", "last_ip_address", "permissions"}; !reflect.DeepEqual(names, want) { + t.Fatalf("form fields = %v, want %v", names, want) + } + + // Allow and deny are stored as the JSON object PHP writes; inherit is no entry. + save(http.StatusOK, map[string]any{"permissions": map[string]int{read: 1, write: -1, admin: 0}}) + var stored string + if err := env.gdb.Raw(`SELECT permissions FROM users WHERE id = ?`, user.ID).Scan(&stored).Error; err != nil { + t.Fatal(err) + } + var decoded map[string]int + if err := json.Unmarshal([]byte(stored), &decoded); err != nil || !reflect.DeepEqual(decoded, map[string]int{read: 1, write: -1}) { + t.Fatalf("stored permissions = %q (%v)", stored, err) + } + var record struct { + Permissions map[string]int `json:"permissions"` + } + adminInto(t, env.expect(t, http.StatusOK, http.MethodGet, userPath(user.ID, ""), token, nil), &record) + if !reflect.DeepEqual(record.Permissions, map[string]int{read: 1, write: -1}) { + t.Fatalf("record permissions = %v", record.Permissions) + } + // The resolver answers from what the editor stored. + for code, want := range map[string]bool{read: true, write: false, admin: false, "forum." + stamp + ".*": true} { + held, err := classes.UserHasPermission(t.Context(), env.gdb, user.ID, code) + if err != nil || held != want { + t.Fatalf("UserHasPermission(%s) = %v %v, want %v", code, held, err, want) + } + } + // An unknown code and a value outside the set are refused, and nothing changes. + for _, bad := range []map[string]any{ + {"permissions": map[string]int{"unknown." + stamp: 1}}, + {"permissions": map[string]int{read: 2}}, + {"permissions": "all"}, + } { + rec := save(http.StatusUnprocessableEntity, bad) + if details := adminDecode(t, rec).Error.Details; len(details["permissions"]) == 0 { + t.Fatalf("permission refusal details = %v", details) + } + } + if got := env.reload(t, user.ID); !reflect.DeepEqual(got.Permissions, models.PermissionSet{read: 1, write: -1}) { + t.Fatalf("a refused save changed the permissions: %v", got.Permissions) + } + // A save that does not send the field leaves the stored set alone; an + // empty object clears it to NULL. + save(http.StatusOK, nil) + if got := env.reload(t, user.ID); len(got.Permissions) != 2 { + t.Fatalf("a save without the field changed the permissions: %v", got.Permissions) + } + save(http.StatusOK, map[string]any{"permissions": map[string]int{}}) + var isNull bool + if err := env.gdb.Raw(`SELECT permissions IS NULL FROM users WHERE id = ?`, user.ID).Scan(&isNull).Error; err != nil || !isNull { + t.Fatalf("cleared permissions are not NULL: %v", err) + } + + // None of it reaches the user API payload: permissions stays null and + // groups an empty list (D-08). + save(http.StatusOK, map[string]any{"permissions": map[string]int{read: 1}, "organisation": first.ID}) + fetch := env.call(t, http.MethodGet, "/_user/api/v1/fetch", userToken, nil) + if fetch.Code != http.StatusOK { + t.Fatalf("fetch = %d %s", fetch.Code, fetch.Body.String()) + } + var body struct { + User map[string]any `json:"user"` + } + if err := json.Unmarshal(fetch.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if value, ok := body.User["permissions"]; !ok || value != nil { + t.Fatalf("user payload permissions = %v (%v)", value, ok) + } + if groups, ok := body.User["groups"].([]any); !ok || len(groups) != 0 { + t.Fatalf("user payload groups = %v", body.User["groups"]) + } + for _, leaked := range []string{"last_seen", "created_at", "updated_at", "organisation", "organisation_id", read} { + if strings.Contains(fetch.Body.String(), `"`+leaked+`"`) { + t.Fatalf("user payload carries %s: %s", leaked, fetch.Body.String()) + } + } +} diff --git a/controllers/admin_registry.go b/controllers/admin_registry.go index 278c3ba..640d2fc 100644 --- a/controllers/admin_registry.go +++ b/controllers/admin_registry.go @@ -2,6 +2,7 @@ package controllers import ( "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" ) @@ -29,4 +30,9 @@ func init() { var _ pact.HasAdminBulkActions = usersAdminController{} var _ pact.HasAdminRecordActions = usersAdminController{} var _ pact.AdminPartialData = usersAdminController{} + var _ pact.FormVirtualFields = usersAdminController{} + var _ pact.FormBeforeCreate = usersAdminController{} + var _ pact.FormAfterCreate = usersAdminController{} + var _ cabana.FieldRelationProvider = usersAdminController{} + var _ cabana.PermissionEditorProvider = usersAdminController{} } diff --git a/controllers/users/config_form.yaml b/controllers/users/config_form.yaml index c01f5dc..4ffa124 100644 --- a/controllers/users/config_form.yaml +++ b/controllers/users/config_form.yaml @@ -3,6 +3,10 @@ form: ~/plugins/golem15/user/models/user/fields.yaml modelClass: Golem15\User\Models\User defaultRedirect: golem15/user/users +create: + redirect: golem15/user/users/preview/:id + redirectClose: golem15/user/users + update: redirect: golem15/user/users/update/:id redirectClose: golem15/user/users/preview/:id @@ -13,6 +17,7 @@ preview: recordActions: [activate, unban, unsuspend] messages: + create: golem15.user::lang.users.new_user update: backend::lang.messages.form.update saved: backend::lang.messages.form.saved deleteConfirm: golem15.user::lang.users.delete_confirm diff --git a/controllers/users/config_list.yaml b/controllers/users/config_list.yaml index 16df43c..8431c41 100644 --- a/controllers/users/config_list.yaml +++ b/controllers/users/config_list.yaml @@ -9,13 +9,14 @@ showSetup: true filter: config_filter.yaml toolbar: - buttons: [delete] + buttons: [create, delete] search: prompt: backend::lang.list.search_prompt bulkActions: [activate, deactivate, restore, ban, unban] messages: + create: golem15.user::lang.users.new_user empty: golem15.user::lang.users.list_empty deleteConfirm: golem15.user::lang.users.delete_selected_confirm deleted: backend::lang.messages.list.deleted diff --git a/controllers/users_admin_controller.go b/controllers/users_admin_controller.go index ca4dc86..0839b01 100644 --- a/controllers/users_admin_controller.go +++ b/controllers/users_admin_controller.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strconv" + "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" @@ -12,6 +13,7 @@ import ( "git.golem15.com/golem15/summercms/modules/bouncer" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/compass" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "gocloud.dev/blob" "gorm.io/gorm" @@ -125,13 +127,39 @@ func (c usersAdminController) ListRowStates(ctx context.Context, db *gorm.DB, re return out, nil } +// FormVirtualFields are the form fields that are not columns of the form: +// the password with its confirmation, and the create-only invitation +// checkbox. The framework never fills or returns them; the Form hooks read +// them with cabana.VirtualFieldsFromContext. +func (usersAdminController) FormVirtualFields() []string { + return []string{"password", "password_confirmation", "send_invite"} +} + // FormRules are the rules of an admin save. They replace User.Rules(), which // is the register contract and would ask every admin update for a confirmed -// password. -func (usersAdminController) FormRules(_ context.Context, _ string) map[string]string { - return map[string]string{ - "email": "required|between:6,255|email|unique:users", +// password: a create needs a confirmed password, an update takes one only +// when the administrator types it (empty means unchanged). The minimum +// length is golem15.user.password.min_length. +func (c usersAdminController) FormRules(_ context.Context, op string) map[string]string { + length := "between:" + strconv.Itoa(c.configInt("golem15.user.password.min_length", 8)) + ",255" + password := "nullable|" + length + "|confirmed" + if op == "create" { + password = "required|" + length + "|confirmed" } + return map[string]string{ + "email": "required|between:6,255|email|unique:users", + "password": password, + } +} + +// configInt reads a positive integer config value, else fallback. +func (c usersAdminController) configInt(key string, fallback int) int { + if cfg := c.config(); cfg != nil { + if n := cfg.Int(key); n > 0 { + return n + } + } + return fallback } // FilterOptions serves the choices of the groups filter (scope @@ -192,6 +220,191 @@ func (c usersAdminController) FormBeforeUpdate(ctx context.Context, model any) e if err := c.guardCredentials(ctx, user); err != nil { return err } + if err := c.requireFreeEmail(ctx, user); err != nil { + return err + } + changed, err := c.storePassword(ctx, user) + if err != nil { + return err + } + if changed { + // An admin password reset ends every session of the user, as the + // user's own change-password does. + now := time.Now() + user.TokensValidAfter = &now + } + return nil +} + +// FormBeforeCreate checks the email against deactivated users too and stores +// the hash of the submitted password. A user created here starts not +// activated: is_activated is never written from the form, and only the +// activate actions (or the invitation link) set it. +func (c usersAdminController) FormBeforeCreate(ctx context.Context, model any) error { + user, ok := model.(*models.User) + if !ok || user == nil { + return errors.New("users admin: form model is not a user") + } + if err := c.requireFreeEmail(ctx, user); err != nil { + return err + } + if _, err := c.storePassword(ctx, user); err != nil { + return err + } + user.IsActivated = false + // The column defaults to true; only an account created through a social + // login starts without a password of its own. + user.HasSelfSetPassword = true + return nil +} + +// requireFreeEmail refuses an email another user already has, deactivated +// users included: the unique rule leaves deactivated rows out, and the +// database constraint does not. +func (c usersAdminController) requireFreeEmail(ctx context.Context, user *models.User) error { + db := requestDB(ctx, c.db) + if db == nil { + return errors.New("users admin: gorm handle is missing") + } + var taken int64 + err := db.Session(&gorm.Session{NewDB: true}).WithContext(ctx).Unscoped(). + Model(&models.User{}).Where("email = ? AND id <> ?", user.Email, user.ID).Count(&taken).Error + if err != nil { + return err + } + if taken == 0 { + return nil + } + message := "The email has already been taken." + if tr := appTranslator(c.application()); tr != nil { + const key = "lagoon::validate.unique" + if text := tr.Get(ctx, key, map[string]string{"attribute": "email"}); text != "" && text != key { + message = text + } + } + return &cabana.ValidationError{Details: map[string]any{"email": []string{message}}} +} + +// storePassword hashes a submitted password onto the user with +// golem15.user.password.bcrypt_cost and reports whether one was submitted. +// An absent or empty password changes nothing. +func (c usersAdminController) storePassword(ctx context.Context, user *models.User) (bool, error) { + values, _ := cabana.VirtualFieldsFromContext(ctx) + plain, _ := values["password"].(string) + if plain == "" { + return false, nil + } + hashed, err := bouncer.HashPassword(c.configInt("golem15.user.password.bcrypt_cost", 10), plain) + if err != nil { + return false, err + } + user.Password = hashed + return true, nil +} + +// FormAfterCreate sends the invitation when the send_invite checkbox was +// set: an activation code is issued inside the create's transaction, and +// exactly one golem15.user::mail.invite message with the activation link +// leaves after the commit. A create that rolls back sends nothing. +func (c usersAdminController) FormAfterCreate(ctx context.Context, model any) error { + user, ok := model.(*models.User) + if !ok || user == nil { + return errors.New("users admin: form model is not a user") + } + values, _ := cabana.VirtualFieldsFromContext(ctx) + if invite, _ := values["send_invite"].(bool); !invite { + return nil + } + tx, ok := cabana.TxFromContext(ctx) + if !ok { + return errors.New("users admin: a create needs its transaction") + } + code, err := classes.IssueActivationCode(ctx, tx.Session(&gorm.Session{NewDB: true}), user) + if err != nil { + return err + } + app := c.application() + id, email := user.ID, user.Email + vars := mailVars(displayName(user), activationLink(app, id, code), strconv.FormatUint(uint64(id), 10)+"!"+code) + vars["login"], vars["Login"] = email, email + hours := c.configInt("golem15.user.activation.activation_ttl_hours", 72) + vars["hours"], vars["Hours"] = hours, hours + template := mailName(ctx, user, "golem15.user::mail.invite") + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { + sendTemplateVars(ctx, app, template, email, vars) + }) + return nil +} + +// AdminFieldRelations binds the organisation picker. organisation_id is a +// protected column, so the contract opts in to writing it through this one +// field; the submitted id is still checked against the options. +func (usersAdminController) AdminFieldRelations() []cabana.FieldRelationContract { + return []cabana.FieldRelationContract{ + { + Field: "organisation", + Kind: "belongsTo", + NewRelated: func() any { return &models.Organisation{} }, + ForeignKey: "organisation_id", + LabelColumn: "name", + WritableForeignKey: true, + }, + } +} + +// permissionsField is the form's permission editor field. +const permissionsField = "permissions" + +// AdminPermissionOptions offers the rows of +// golem15_user_frontend_permissions, by tab and code. None is locked. +func (c usersAdminController) AdminPermissionOptions(ctx context.Context, field string) ([]cabana.PermissionOption, error) { + if field != permissionsField { + return nil, fmt.Errorf("users admin: unknown permission field %q", field) + } + db := requestDB(ctx, c.db) + if db == nil { + return nil, errors.New("users admin: gorm handle is missing") + } + rows, err := classes.FrontendPermissionOptions(ctx, db) + if err != nil { + return nil, err + } + options := make([]cabana.PermissionOption, 0, len(rows)) + for _, row := range rows { + option := cabana.PermissionOption{Code: row.Code, Label: row.Label, Tab: row.Tab} + if row.Comment != nil { + option.Comment = *row.Comment + } + options = append(options, option) + } + return options, nil +} + +// AdminPermissionValues returns the user's own stored permissions. +func (usersAdminController) AdminPermissionValues(_ context.Context, field string, record any) (map[string]int, error) { + user, ok := record.(*models.User) + if field != permissionsField || !ok || user == nil { + return nil, fmt.Errorf("users admin: cannot read permission field %q", field) + } + values := make(map[string]int, len(user.Permissions)) + for code, value := range user.Permissions { + values[code] = value + } + return values, nil +} + +// AdminSetPermissionValues puts the validated set on the user; the save +// writes the row. Zero values are left out on write (PermissionSet.Value). +func (usersAdminController) AdminSetPermissionValues(_ context.Context, field string, record any, values map[string]int) error { + user, ok := record.(*models.User) + if field != permissionsField || !ok || user == nil { + return fmt.Errorf("users admin: cannot write permission field %q", field) + } + set := make(models.PermissionSet, len(values)) + for code, value := range values { + set[code] = value + } + user.Permissions = set return nil } diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index 5b76e48..4510609 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -10,6 +10,7 @@ plugin: manage_privileged_groups: Manage privileged group membership users: menu_label: Users + new_user: New User list_title: Manage Users list_empty: No users match this view. Clear the filters, or add one with New User. state_deactivated: Deactivated @@ -64,5 +65,16 @@ user: created_ip_address: Created IP Address last_ip_address: Last IP Address status_activated: Activated + send_invite: Send invitation by email + send_invite_comment: Sends a welcome message containing login and password information. + password: Password + password_comment: On a new user, enter the sign-in password. On an existing user, leave empty to keep the current password. + confirm_password: Password Confirmation + confirm_password_comment: Enter the password again to confirm it. + avatar: Avatar + permissions_tab: Permissions group: label: Group +organisation: + label: Organisation + empty_organisation: — No organisation — diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index feb24ae..7b1c801 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -10,6 +10,7 @@ plugin: manage_privileged_groups: Zarządzaj członkostwem w grupach uprzywilejowanych users: menu_label: Użytkownicy + new_user: Nowy użytkownik list_title: Zarządzaj użytkownikami list_empty: Brak użytkowników w tym widoku. Wyczyść filtry lub dodaj użytkownika przyciskiem Nowy użytkownik. state_deactivated: Dezaktywowany @@ -64,5 +65,16 @@ user: created_ip_address: Stworzono z IP last_ip_address: Ostatnie IP status_activated: Aktywowany + send_invite: Wyślij zaproszenie pocztą e-mail + send_invite_comment: Wysyła wiadomość powitalną zawierającą dane logowania i hasło. + password: Hasło + password_comment: Dla nowego użytkownika wpisz hasło do logowania. Dla istniejącego pozostaw puste, aby nie zmieniać hasła. + confirm_password: Potwierdzenie hasła + confirm_password_comment: Wprowadź ponownie hasło, aby je potwierdzić. + avatar: Avatar + permissions_tab: Uprawnienia group: label: Grupa +organisation: + label: Organizacja + empty_organisation: — Brak organizacji — diff --git a/models/user/fields.yaml b/models/user/fields.yaml index 1aa5c86..5329660 100644 --- a/models/user/fields.yaml +++ b/models/user/fields.yaml @@ -14,6 +14,41 @@ fields: type: text span: full tab: golem15.user::lang.user.account + send_invite: + label: golem15.user::lang.user.send_invite + comment: golem15.user::lang.user.send_invite_comment + type: checkbox + default: true + tab: golem15.user::lang.user.account + context: create + password: + label: golem15.user::lang.user.password + comment: golem15.user::lang.user.password_comment + type: password + span: left + tab: golem15.user::lang.user.account + context: [create, update] + password_confirmation: + label: golem15.user::lang.user.confirm_password + comment: golem15.user::lang.user.confirm_password_comment + type: password + span: right + tab: golem15.user::lang.user.account + context: [create, update] + organisation: + label: golem15.user::lang.organisation.label + type: relation + nameFrom: name + span: full + emptyOption: golem15.user::lang.organisation.empty_organisation + tab: golem15.user::lang.user.account + avatar: + label: golem15.user::lang.user.avatar + type: fileupload + mode: image + imageWidth: 260 + imageHeight: 260 + tab: golem15.user::lang.user.account created_ip_address: label: golem15.user::lang.user.created_ip_address type: text @@ -26,3 +61,9 @@ fields: span: right tab: golem15.user::lang.user.account context: preview + permissions: + type: permissioneditor + mode: radio + span: full + tab: golem15.user::lang.user.permissions_tab + context: update diff --git a/plugin.go b/plugin.go index 703e6f6..ea1f46c 100644 --- a/plugin.go +++ b/plugin.go @@ -189,6 +189,8 @@ func (p *Plugin) MailTemplates() []string { "golem15.user::mail.restore-en", "golem15.user::mail.reactivate", "golem15.user::mail.reactivate-en", + "golem15.user::mail.invite", + "golem15.user::mail.invite-en", } } diff --git a/views/mail/invite-en.htm b/views/mail/invite-en.htm new file mode 100644 index 0000000..8de0422 --- /dev/null +++ b/views/mail/invite-en.htm @@ -0,0 +1,13 @@ +subject = "An account has been created for you" +description = "Invite a new user to the website" +layout = "user" +== +Hello **{{ .Name }}**. + +An account has been created for you. Use the link below to activate it: + +[Activate your account]({{ .Link }}) + +This link expires in {{ .Hours }} hours. If it has expired, ask your administrator for a new invitation. + +Your login: `{{ .Login }}` diff --git a/views/mail/invite.htm b/views/mail/invite.htm new file mode 100644 index 0000000..04150aa --- /dev/null +++ b/views/mail/invite.htm @@ -0,0 +1,13 @@ +subject = "Utworzono dla Ciebie konto" +description = "Zaproszenie nowego użytkownika" +layout = "user" +== +Cześć **{{ .Name }}**. + +Utworzono dla Ciebie konto. Aktywuj je, korzystając z poniższego linku: + +[Aktywuj konto]({{ .Link }}) + +Link wygasa po {{ .Hours }} godz. Jeśli wygasł, poproś administratora o nowe zaproszenie. + +Twój login: `{{ .Login }}`