fix(notifications): use full user so notifications show display name

Notifications and emails showed the acting user's auto-generated
username instead of their display Name.

The doer attached to notification events was built straight from the
JWT via user.GetFromAuth, which only carries id + username (Name is
never set in GetUserFromClaims). Notifications render n.Doer.GetName(),
which falls back to the username when Name is empty, so every "assigned
you", "mentioned you", task-deleted, project-created and team-member
notification rendered the username.

Resolve the full user from the database at the event-producing dispatch
sites. doerFromAuth now re-fetches the user (with Name) and is reused by
all the notification doers; account-status errors are swallowed so flows
acting on behalf of disabled accounts (e.g. user deletion deleting that
user's tasks) keep working while still carrying the display name.

Fixes #2720
This commit is contained in:
kolaente 2026-06-17 19:44:36 +02:00 committed by kolaente
parent 80bb9aadc1
commit 5236e0c306
11 changed files with 120 additions and 24 deletions

View File

@ -76,6 +76,18 @@ func ClearDispatchedEvents() {
dispatchedTestEvents = nil
}
// GetDispatchedEvents returns all dispatched test events matching the given name, letting tests
// assert on the event payload (not just that it was dispatched).
func GetDispatchedEvents(eventName string) []Event {
var events []Event
for _, testEvent := range dispatchedTestEvents {
if testEvent.Name() == eventName {
events = append(events, testEvent)
}
}
return events
}
// CountDispatchedEvents counts how many events of a specific type have been dispatched.
func CountDispatchedEvents(eventName string) int {
count := 0

View File

@ -21,7 +21,6 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/web"
"xorm.io/xorm"
)
@ -252,10 +251,9 @@ func (b *TaskBucket) Update(s *xorm.Session, a web.Auth) (err error) {
}
if b.Task != nil {
doer, _ := user.GetFromAuth(a)
events.DispatchOnCommit(s, &TaskUpdatedEvent{
Task: b.Task,
Doer: doer,
Doer: doerFromAuth(s, a),
})
}
return nil

View File

@ -1072,7 +1072,7 @@ func CreateProject(s *xorm.Session, project *Project, auth web.Auth, createBackl
events.DispatchOnCommit(s, &ProjectCreatedEvent{
Project: project,
Doer: doer,
Doer: doerFromAuth(s, auth),
})
return nil
}
@ -1219,7 +1219,7 @@ func UpdateProject(s *xorm.Session, project *Project, auth web.Auth, updateProje
events.DispatchOnCommit(s, &ProjectUpdatedEvent{
Project: project,
Doer: doerFromAuth(auth),
Doer: doerFromAuth(s, auth),
})
l, err := GetProjectSimpleByID(s, project.ID)
@ -1450,7 +1450,7 @@ func (p *Project) Delete(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &ProjectDeletedEvent{
Project: fullProject,
Doer: doerFromAuth(a),
Doer: doerFromAuth(s, a),
})
childProjects := []*Project{}

View File

@ -112,7 +112,7 @@ func (tl *TeamProject) Create(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &ProjectSharedWithTeamEvent{
Project: l,
Team: team,
Doer: doerFromAuth(a),
Doer: doerFromAuth(s, a),
})
err = updateProjectLastUpdated(s, l)

View File

@ -118,7 +118,7 @@ func (lu *ProjectUser) Create(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &ProjectSharedWithUserEvent{
Project: l,
User: u,
Doer: doerFromAuth(a),
Doer: doerFromAuth(s, a),
})
err = updateProjectLastUpdated(s, l)

View File

@ -181,7 +181,7 @@ func (la *TaskAssginee) Delete(s *xorm.Session, a web.Auth) (err error) {
return err
}
doer, _ := user.GetFromAuth(a)
doer := doerFromAuth(s, a)
task, err := GetTaskByIDSimple(s, la.TaskID)
if err != nil {
return err
@ -270,7 +270,7 @@ func (t *Task) addNewAssigneeByID(s *xorm.Session, newAssigneeID int64, project
return err
}
doer, _ := user.GetFromAuth(auth)
doer := doerFromAuth(s, auth)
task, err := GetTaskSimple(s, &Task{ID: t.ID})
if err != nil {
return err

View File

@ -0,0 +1,80 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"context"
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/user"
"github.com/stretchr/testify/require"
)
// TestTaskAssignee_DoerHasDisplayName guards against the regression in #2720: the doer attached to
// notification events was built straight from the JWT (id + username only), so notifications and
// emails rendered the auto-generated username instead of the user's display Name. The dispatch sites
// now resolve the full user from the database, so the doer must carry the display Name even when the
// acting auth object only has id + username (as GetUserFromClaims produces).
func TestTaskAssignee_DoerHasDisplayName(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Mimics the partial user GetUserFromClaims builds from a JWT: id + username, no Name.
// user12 has the display name "Name with spaces" in the fixtures and owns project 23.
doer := &user.User{ID: 12, Username: "user12"}
require.Equal(t, "user12", doer.GetName(), "the auth doer must start without a display name")
task := &Task{Title: "assign me", ProjectID: 23}
require.NoError(t, task.Create(s, doer))
events.ClearDispatchedEvents()
ta := &TaskAssginee{TaskID: task.ID, UserID: 12}
require.NoError(t, ta.Create(s, doer))
require.NoError(t, s.Commit())
events.DispatchPending(context.Background(), s)
dispatched := events.GetDispatchedEvents((&TaskAssigneeCreatedEvent{}).Name())
require.Len(t, dispatched, 1)
ev := dispatched[0].(*TaskAssigneeCreatedEvent)
require.NotNil(t, ev.Doer)
require.Equal(t, "Name with spaces", ev.Doer.GetName(),
"notification doer must carry the display Name, not the username")
}
// TestDoerFromAuth_DisabledUser ensures resolving the event doer keeps working when acting on behalf
// of a disabled account (e.g. user deletion deletes that user's tasks). The full user is still
// returned with its display name, the disabled status error is swallowed.
func TestDoerFromAuth_DisabledUser(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// user17 is disabled in the fixtures.
_, err := user.GetUserByID(s, 17)
require.Error(t, err, "fixture user17 is expected to be disabled")
require.True(t, user.IsErrAccountDisabled(err))
doer := doerFromAuth(s, &user.User{ID: 17, Username: "user17"})
require.NotNil(t, doer)
require.Equal(t, int64(17), doer.ID)
}

View File

@ -1462,10 +1462,9 @@ func (t *Task) updateSingleTask(s *xorm.Session, a web.Auth, fields []string) (e
}
t.Updated = nt.Updated
doer, _ := user.GetFromAuth(a)
events.DispatchOnCommit(s, &TaskUpdatedEvent{
Task: t,
Doer: doer,
Doer: doerFromAuth(s, a),
})
return updateProjectLastUpdated(s, &Project{ID: t.ProjectID})
@ -1961,10 +1960,9 @@ func (t *Task) Delete(s *xorm.Session, a web.Auth) (err error) {
return err
}
doer, _ := user.GetFromAuth(a)
events.DispatchOnCommit(s, &TaskDeletedEvent{
Task: fullTask,
Doer: doer,
Doer: doerFromAuth(s, a),
})
err = updateProjectLastUpdated(s, &Project{ID: t.ProjectID})
@ -2032,10 +2030,9 @@ func triggerTaskUpdatedEventForTaskID(s *xorm.Session, auth web.Auth, taskID int
return err
}
doer, _ := user.GetFromAuth(auth)
events.DispatchOnCommit(s, &TaskUpdatedEvent{
Task: &t,
Doer: doer,
Doer: doerFromAuth(s, auth),
})
return nil
}

View File

@ -69,11 +69,10 @@ func (tm *TeamMember) Create(s *xorm.Session, a web.Auth) (err error) {
return err
}
doer, _ := user2.GetFromAuth(a)
events.DispatchOnCommit(s, &TeamMemberAddedEvent{
Team: team,
Member: member,
Doer: doer,
Doer: doerFromAuth(s, a),
})
return nil
}

View File

@ -362,7 +362,7 @@ func (t *Team) Delete(s *xorm.Session, a web.Auth) (err error) {
events.DispatchOnCommit(s, &TeamDeletedEvent{
Team: t,
Doer: doerFromAuth(a),
Doer: doerFromAuth(s, a),
})
return nil
}

View File

@ -22,14 +22,24 @@ import (
"xorm.io/xorm"
)
// doerFromAuth converts the authenticated principal into a user for event
// payloads without re-fetching it. A re-fetch would fail its status check in
// flows acting on behalf of disabled accounts (e.g. user deletion), and the
// event only needs the principal as it authenticated.
func doerFromAuth(a web.Auth) *user.User {
// doerFromAuth resolves the authenticated principal into a full user for event payloads. The JWT
// only carries id + username, so without a re-fetch notifications and emails render the
// auto-generated username instead of the display name (#2720). Status errors (disabled/locked) are
// swallowed because their user is still populated and some flows act on behalf of such accounts
// (e.g. user deletion deletes that user's tasks); the partial principal is used as a last resort.
func doerFromAuth(s *xorm.Session, a web.Auth) *user.User {
if a == nil {
return nil
}
doer, err := GetUserOrLinkShareUser(s, a)
if err != nil && !user.IsErrUserStatusError(err) {
doer = nil
}
if doer != nil && doer.ID != 0 {
return doer
}
if u, is := a.(*user.User); is {
return u
}