diff --git a/coderd/database/migrations/000247_notifications_user_suspended.down.sql b/coderd/database/migrations/000247_notifications_user_suspended.down.sql new file mode 100644 index 0000000000000..872638e40773d --- /dev/null +++ b/coderd/database/migrations/000247_notifications_user_suspended.down.sql @@ -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'; diff --git a/coderd/database/migrations/000247_notifications_user_suspended.up.sql b/coderd/database/migrations/000247_notifications_user_suspended.up.sql new file mode 100644 index 0000000000000..4ad91db8bfbd8 --- /dev/null +++ b/coderd/database/migrations/000247_notifications_user_suspended.up.sql @@ -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); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index b340b281e0757..ee143465bfe6b 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -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. diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 8ecae8a904923..20487e8ffe2f4 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -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, diff --git a/coderd/users.go b/coderd/users.go index cde7271ca4e5d..07ec053ca44a7 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -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,9 +872,52 @@ func (api *API) putUserStatus(status database.UserStatus) func(rw http.ResponseW }) return } + 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)) + } - httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(suspendedUser, organizations)) + // 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 diff --git a/coderd/users_test.go b/coderd/users_test.go index 4f44da42ed59b..66eb2f8da1f94 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -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()