mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: add notification for suspended/activated account (#14367)
* migrations * notify * fix * TestNotifyUserSuspended * TestNotifyUserReactivate * post merge * fix escape * TestNotificationTemplatesCanRender * links and events * notifyEnq * findUserAdmins * notifyUserStatusChanged * go build * your and admin * tests * refactor * 247 * Danny's review
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
DELETE FROM notification_templates WHERE id = 'b02ddd82-4733-4d02-a2d7-c36f3598997d';
|
||||
DELETE FROM notification_templates WHERE id = '6a2f0609-9b69-4d36-a989-9f5925b6cbff';
|
||||
DELETE FROM notification_templates WHERE id = '9f5af851-8408-4e73-a7a1-c6502ba46689';
|
||||
DELETE FROM notification_templates WHERE id = '1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4';
|
||||
@@ -0,0 +1,31 @@
|
||||
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
|
||||
VALUES ('b02ddd82-4733-4d02-a2d7-c36f3598997d', 'User account suspended', E'User account "{{.Labels.suspended_account_name}}" suspended',
|
||||
E'Hi {{.UserName}},\nUser account **{{.Labels.suspended_account_name}}** has been suspended.',
|
||||
'User Events', '[
|
||||
{
|
||||
"label": "View suspended accounts",
|
||||
"url": "{{ base_url }}/deployment/users?filter=status%3Asuspended"
|
||||
}
|
||||
]'::jsonb);
|
||||
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
|
||||
VALUES ('6a2f0609-9b69-4d36-a989-9f5925b6cbff', 'Your account has been suspended', E'Your account "{{.Labels.suspended_account_name}}" has been suspended',
|
||||
E'Hi {{.UserName}},\nYour account **{{.Labels.suspended_account_name}}** has been suspended.',
|
||||
'User Events', '[]'::jsonb);
|
||||
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
|
||||
VALUES ('9f5af851-8408-4e73-a7a1-c6502ba46689', 'User account activated', E'User account "{{.Labels.activated_account_name}}" activated',
|
||||
E'Hi {{.UserName}},\nUser account **{{.Labels.activated_account_name}}** has been activated.',
|
||||
'User Events', '[
|
||||
{
|
||||
"label": "View accounts",
|
||||
"url": "{{ base_url }}/deployment/users?filter=status%3Aactive"
|
||||
}
|
||||
]'::jsonb);
|
||||
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
|
||||
VALUES ('1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4', 'Your account has been activated', E'Your account "{{.Labels.activated_account_name}}" has been activated',
|
||||
E'Hi {{.UserName}},\nYour account **{{.Labels.activated_account_name}}** has been activated.',
|
||||
'User Events', '[
|
||||
{
|
||||
"label": "Open Coder",
|
||||
"url": "{{ base_url }}"
|
||||
}
|
||||
]'::jsonb);
|
||||
@@ -18,6 +18,11 @@ var (
|
||||
var (
|
||||
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
|
||||
TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6")
|
||||
|
||||
TemplateUserAccountSuspended = uuid.MustParse("b02ddd82-4733-4d02-a2d7-c36f3598997d")
|
||||
TemplateUserAccountActivated = uuid.MustParse("9f5af851-8408-4e73-a7a1-c6502ba46689")
|
||||
TemplateYourAccountSuspended = uuid.MustParse("6a2f0609-9b69-4d36-a989-9f5925b6cbff")
|
||||
TemplateYourAccountActivated = uuid.MustParse("1a6a6bea-ee0a-43e2-9e7c-eabdb53730e4")
|
||||
)
|
||||
|
||||
// Template-related events.
|
||||
|
||||
@@ -756,6 +756,46 @@ func TestNotificationTemplatesCanRender(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateUserAccountSuspended",
|
||||
id: notifications.TemplateUserAccountSuspended,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"suspended_account_name": "bobby",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateUserAccountActivated",
|
||||
id: notifications.TemplateUserAccountActivated,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"activated_account_name": "bobby",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateYourAccountSuspended",
|
||||
id: notifications.TemplateYourAccountSuspended,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"suspended_account_name": "bobby",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateYourAccountActivated",
|
||||
id: notifications.TemplateYourAccountActivated,
|
||||
payload: types.MessagePayload{
|
||||
UserName: "bobby",
|
||||
Labels: map[string]string{
|
||||
"activated_account_name": "bobby",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TemplateTemplateDeleted",
|
||||
id: notifications.TemplateTemplateDeleted,
|
||||
|
||||
+52
-4
@@ -845,7 +845,7 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
|
||||
}
|
||||
}
|
||||
|
||||
suspendedUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
|
||||
targetUser, err := api.Database.UpdateUserStatus(ctx, database.UpdateUserStatusParams{
|
||||
ID: user.ID,
|
||||
Status: status,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
@@ -857,7 +857,12 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
|
||||
})
|
||||
return
|
||||
}
|
||||
aReq.New = suspendedUser
|
||||
aReq.New = targetUser
|
||||
|
||||
err = api.notifyUserStatusChanged(ctx, user, status)
|
||||
if err != nil {
|
||||
api.Logger.Warn(ctx, "unable to notify about changed user's status", slog.F("affected_user", user.Username), slog.Error(err))
|
||||
}
|
||||
|
||||
organizations, err := userOrganizationIDs(ctx, api, user)
|
||||
if err != nil {
|
||||
@@ -867,11 +872,54 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations))
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(targetUser, organizations))
|
||||
}
|
||||
}
|
||||
|
||||
func (api *API) notifyUserStatusChanged(ctx context.Context, user database.User, status database.UserStatus) error {
|
||||
var key string
|
||||
var adminTemplateID, personalTemplateID uuid.UUID
|
||||
switch status {
|
||||
case database.UserStatusSuspended:
|
||||
key = "suspended_account_name"
|
||||
adminTemplateID = notifications.TemplateUserAccountSuspended
|
||||
personalTemplateID = notifications.TemplateYourAccountSuspended
|
||||
case database.UserStatusActive:
|
||||
key = "activated_account_name"
|
||||
adminTemplateID = notifications.TemplateUserAccountActivated
|
||||
personalTemplateID = notifications.TemplateYourAccountActivated
|
||||
default:
|
||||
api.Logger.Error(ctx, "user status is not supported", slog.F("username", user.Username), slog.F("user_status", string(status)))
|
||||
return xerrors.Errorf("unable to notify admins as the user's status is unsupported")
|
||||
}
|
||||
|
||||
userAdmins, err := findUserAdmins(ctx, api.Database)
|
||||
if err != nil {
|
||||
api.Logger.Error(ctx, "unable to find user admins", slog.Error(err))
|
||||
}
|
||||
|
||||
// Send notifications to user admins and affected user
|
||||
for _, u := range userAdmins {
|
||||
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, u.ID, adminTemplateID,
|
||||
map[string]string{
|
||||
key: user.Username,
|
||||
}, "api-put-user-status",
|
||||
user.ID,
|
||||
); err != nil {
|
||||
api.Logger.Warn(ctx, "unable to notify about changed user's status", slog.F("affected_user", user.Username), slog.Error(err))
|
||||
}
|
||||
}
|
||||
if _, err := api.NotificationsEnqueuer.Enqueue(ctx, user.ID, personalTemplateID,
|
||||
map[string]string{
|
||||
key: user.Username,
|
||||
}, "api-put-user-status",
|
||||
user.ID,
|
||||
); err != nil {
|
||||
api.Logger.Warn(ctx, "unable to notify user about status change of their account", slog.F("affected_user", user.Username), slog.Error(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// @Summary Update user appearance settings
|
||||
// @ID update-user-appearance-settings
|
||||
// @Security CoderSessionToken
|
||||
|
||||
@@ -374,6 +374,110 @@ func TestDeleteUser(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyUserStatusChanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type expectedNotification struct {
|
||||
TemplateID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
}
|
||||
|
||||
verifyNotificationDispatched := func(notifyEnq *testutil.FakeNotificationsEnqueuer, expectedNotifications []expectedNotification, member codersdk.User, label string) {
|
||||
require.Equal(t, len(expectedNotifications), len(notifyEnq.Sent))
|
||||
|
||||
// Validate that each expected notification is present in notifyEnq.Sent
|
||||
for _, expected := range expectedNotifications {
|
||||
found := false
|
||||
for _, sent := range notifyEnq.Sent {
|
||||
if sent.TemplateID == expected.TemplateID &&
|
||||
sent.UserID == expected.UserID &&
|
||||
slices.Contains(sent.Targets, member.ID) &&
|
||||
sent.Labels[label] == member.Username {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Expected notification not found: %+v", expected)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("Account suspended", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{
|
||||
NotificationsEnqueuer: notifyEnq,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
_, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin())
|
||||
|
||||
member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Email: "another@user.org",
|
||||
Username: "someone-else",
|
||||
Password: "SomeSecurePassword!",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
notifyEnq.Clear()
|
||||
|
||||
// when
|
||||
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
|
||||
require.NoError(t, err)
|
||||
|
||||
// then
|
||||
verifyNotificationDispatched(notifyEnq, []expectedNotification{
|
||||
{TemplateID: notifications.TemplateUserAccountSuspended, UserID: firstUser.UserID},
|
||||
{TemplateID: notifications.TemplateUserAccountSuspended, UserID: userAdmin.ID},
|
||||
{TemplateID: notifications.TemplateYourAccountSuspended, UserID: member.ID},
|
||||
}, member, "suspended_account_name")
|
||||
})
|
||||
|
||||
t.Run("Account reactivated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// given
|
||||
notifyEnq := &testutil.FakeNotificationsEnqueuer{}
|
||||
adminClient := coderdtest.New(t, &coderdtest.Options{
|
||||
NotificationsEnqueuer: notifyEnq,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, adminClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
_, userAdmin := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID, rbac.RoleUserAdmin())
|
||||
|
||||
member, err := adminClient.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
Email: "another@user.org",
|
||||
Username: "someone-else",
|
||||
Password: "SomeSecurePassword!",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusSuspended)
|
||||
require.NoError(t, err)
|
||||
|
||||
notifyEnq.Clear()
|
||||
|
||||
// when
|
||||
_, err = adminClient.UpdateUserStatus(context.Background(), member.Username, codersdk.UserStatusActive)
|
||||
require.NoError(t, err)
|
||||
|
||||
// then
|
||||
verifyNotificationDispatched(notifyEnq, []expectedNotification{
|
||||
{TemplateID: notifications.TemplateUserAccountActivated, UserID: firstUser.UserID},
|
||||
{TemplateID: notifications.TemplateUserAccountActivated, UserID: userAdmin.ID},
|
||||
{TemplateID: notifications.TemplateYourAccountActivated, UserID: member.ID},
|
||||
}, member, "activated_account_name")
|
||||
})
|
||||
}
|
||||
|
||||
func TestNotifyDeletedUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user