From d7953bd046e9ac3d7fecb93e16ba235528211917 Mon Sep 17 00:00:00 2001 From: Atif Ali Date: Tue, 11 Aug 2026 18:16:25 +0500 Subject: [PATCH] fix(coderd): use service account wording in account notifications (#27536) --- ...568_service_account_notifications.down.sql | 35 ++++++++ ...00568_service_account_notifications.up.sql | 40 +++++++++ coderd/notifications/notifications_test.go | 80 ++++++++++++++++++ ...AccountActivatedServiceAccount.html.golden | 82 ++++++++++++++++++ ...erAccountCreatedServiceAccount.html.golden | 83 +++++++++++++++++++ ...countCreatedWithoutAccountType.html.golden | 82 ++++++++++++++++++ ...erAccountDeletedServiceAccount.html.golden | 82 ++++++++++++++++++ ...AccountSuspendedServiceAccount.html.golden | 83 +++++++++++++++++++ .../TemplateUserAccountActivated.json.golden | 1 + ...AccountActivatedServiceAccount.json.golden | 31 +++++++ .../TemplateUserAccountCreated.json.golden | 1 + ...erAccountCreatedServiceAccount.json.golden | 31 +++++++ ...countCreatedWithoutAccountType.json.golden | 30 +++++++ .../TemplateUserAccountDeleted.json.golden | 1 + ...erAccountDeletedServiceAccount.json.golden | 31 +++++++ .../TemplateUserAccountSuspended.json.golden | 1 + ...AccountSuspendedServiceAccount.json.golden | 31 +++++++ coderd/users.go | 15 ++++ coderd/users_test.go | 2 + docs/admin/monitoring/notifications/index.md | 2 + enterprise/coderd/users_test.go | 64 ++++++++++++++ .../CreateUserPage/CreateUserPage.stories.tsx | 76 ++++++++++++++--- .../pages/CreateUserPage/CreateUserPage.tsx | 10 ++- 23 files changed, 881 insertions(+), 13 deletions(-) create mode 100644 coderd/database/migrations/000568_service_account_notifications.down.sql create mode 100644 coderd/database/migrations/000568_service_account_notifications.up.sql create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivatedServiceAccount.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedServiceAccount.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedWithoutAccountType.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeletedServiceAccount.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspendedServiceAccount.html.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivatedServiceAccount.json.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedServiceAccount.json.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedWithoutAccountType.json.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeletedServiceAccount.json.golden create mode 100644 coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspendedServiceAccount.json.golden diff --git a/coderd/database/migrations/000568_service_account_notifications.down.sql b/coderd/database/migrations/000568_service_account_notifications.down.sql new file mode 100644 index 0000000000..dd94ecd59b --- /dev/null +++ b/coderd/database/migrations/000568_service_account_notifications.down.sql @@ -0,0 +1,35 @@ +-- User account created +UPDATE notification_templates +SET + title_template = E'User account "{{.Labels.created_account_name}}" created', + body_template = E'New user account **{{.Labels.created_account_name}}** has been created.\n\n' || + E'This new user account was created {{if .Labels.created_account_user_name}}for **{{.Labels.created_account_user_name}}** {{end}}by **{{.Labels.initiator}}**.' +WHERE + id = '4e19c0ac-94e1-4532-9515-d1801aa283b2'; + +-- User account deleted +UPDATE notification_templates +SET + title_template = E'User account "{{.Labels.deleted_account_name}}" deleted', + body_template = E'User account **{{.Labels.deleted_account_name}}** has been deleted.\n\n' || + E'The deleted account {{if .Labels.deleted_account_user_name}}belonged to **{{.Labels.deleted_account_user_name}}** and {{end}}was deleted by **{{.Labels.initiator}}**.' +WHERE + id = 'f44d9314-ad03-4bc8-95d0-5cad491da6b6'; + +-- User account suspended +UPDATE notification_templates +SET + title_template = E'User account "{{.Labels.suspended_account_name}}" suspended', + body_template = E'User account **{{.Labels.suspended_account_name}}** has been suspended.\n\n' || + E'The account {{if .Labels.suspended_account_user_name}}belongs to **{{.Labels.suspended_account_user_name}}** and it {{end}}was suspended by **{{.Labels.initiator}}**.' +WHERE + id = 'b02ddd82-4733-4d02-a2d7-c36f3598997d'; + +-- User account activated +UPDATE notification_templates +SET + title_template = E'User account "{{.Labels.activated_account_name}}" activated', + body_template = E'User account **{{.Labels.activated_account_name}}** has been activated.\n\n' || + E'The account {{if .Labels.activated_account_user_name}}belongs to **{{.Labels.activated_account_user_name}}** and it {{ end }}was activated by **{{.Labels.initiator}}**.' +WHERE + id = '9f5af851-8408-4e73-a7a1-c6502ba46689'; diff --git a/coderd/database/migrations/000568_service_account_notifications.up.sql b/coderd/database/migrations/000568_service_account_notifications.up.sql new file mode 100644 index 0000000000..227d58df15 --- /dev/null +++ b/coderd/database/migrations/000568_service_account_notifications.up.sql @@ -0,0 +1,40 @@ +-- The account lifecycle notifications also fire for service accounts, so the +-- copy branches on an account type label. Messages enqueued before this +-- migration carry no such label and render the user wording. + +-- User account created +UPDATE notification_templates +SET + title_template = E'{{ if eq .Labels.account_type "service" }}Service{{ else }}User{{ end }} account "{{.Labels.created_account_name}}" created', + body_template = E'{{ $account := "user" }}{{ if eq .Labels.account_type "service" }}{{ $account = "service" }}{{ end }}' || + E'New {{ $account }} account **{{.Labels.created_account_name}}** has been created.\n\n' || + E'This new {{ $account }} account was created {{if .Labels.created_account_user_name}}for **{{.Labels.created_account_user_name}}** {{end}}by **{{.Labels.initiator}}**.' +WHERE + id = '4e19c0ac-94e1-4532-9515-d1801aa283b2'; + +-- User account deleted +UPDATE notification_templates +SET + title_template = E'{{ if eq .Labels.account_type "service" }}Service{{ else }}User{{ end }} account "{{.Labels.deleted_account_name}}" deleted', + body_template = E'{{ if eq .Labels.account_type "service" }}Service{{ else }}User{{ end }} account **{{.Labels.deleted_account_name}}** has been deleted.\n\n' || + E'The deleted account {{if .Labels.deleted_account_user_name}}belonged to **{{.Labels.deleted_account_user_name}}** and {{end}}was deleted by **{{.Labels.initiator}}**.' +WHERE + id = 'f44d9314-ad03-4bc8-95d0-5cad491da6b6'; + +-- User account suspended +UPDATE notification_templates +SET + title_template = E'{{ if eq .Labels.account_type "service" }}Service{{ else }}User{{ end }} account "{{.Labels.suspended_account_name}}" suspended', + body_template = E'{{ if eq .Labels.account_type "service" }}Service{{ else }}User{{ end }} account **{{.Labels.suspended_account_name}}** has been suspended.\n\n' || + E'The account {{if .Labels.suspended_account_user_name}}belongs to **{{.Labels.suspended_account_user_name}}** and it {{end}}was suspended by **{{.Labels.initiator}}**.' +WHERE + id = 'b02ddd82-4733-4d02-a2d7-c36f3598997d'; + +-- User account activated +UPDATE notification_templates +SET + title_template = E'{{ if eq .Labels.account_type "service" }}Service{{ else }}User{{ end }} account "{{.Labels.activated_account_name}}" activated', + body_template = E'{{ if eq .Labels.account_type "service" }}Service{{ else }}User{{ end }} account **{{.Labels.activated_account_name}}** has been activated.\n\n' || + E'The account {{if .Labels.activated_account_user_name}}belongs to **{{.Labels.activated_account_user_name}}** and it {{ end }}was activated by **{{.Labels.initiator}}**.' +WHERE + id = '9f5af851-8408-4e73-a7a1-c6502ba46689'; diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 2c76d0e2df..5cb42b5200 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -859,6 +859,38 @@ func TestNotificationTemplates_Golden(t *testing.T) { { name: "TemplateUserAccountCreated", id: notifications.TemplateUserAccountCreated, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "created_account_name": "bobby", + "created_account_user_name": "William Tables", + "initiator": "rob", + "account_type": "user", + }, + }, + }, + { + name: "TemplateUserAccountCreatedServiceAccount", + id: notifications.TemplateUserAccountCreated, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "created_account_name": "ci-bot", + "created_account_user_name": "CI Bot", + "initiator": "rob", + "account_type": "service", + }, + }, + }, + { + // Messages enqueued before the account_type label existed + // must still render the user wording. + name: "TemplateUserAccountCreatedWithoutAccountType", + id: notifications.TemplateUserAccountCreated, payload: types.MessagePayload{ UserName: "Bobby", UserEmail: "bobby@coder.com", @@ -881,6 +913,22 @@ func TestNotificationTemplates_Golden(t *testing.T) { "deleted_account_name": "bobby", "deleted_account_user_name": "William Tables", "initiator": "rob", + "account_type": "user", + }, + }, + }, + { + name: "TemplateUserAccountDeletedServiceAccount", + id: notifications.TemplateUserAccountDeleted, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "deleted_account_name": "ci-bot", + "deleted_account_user_name": "CI Bot", + "initiator": "rob", + "account_type": "service", }, }, }, @@ -895,6 +943,22 @@ func TestNotificationTemplates_Golden(t *testing.T) { "suspended_account_name": "bobby", "suspended_account_user_name": "William Tables", "initiator": "rob", + "account_type": "user", + }, + }, + }, + { + name: "TemplateUserAccountSuspendedServiceAccount", + id: notifications.TemplateUserAccountSuspended, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "suspended_account_name": "ci-bot", + "suspended_account_user_name": "CI Bot", + "initiator": "rob", + "account_type": "service", }, }, }, @@ -909,6 +973,22 @@ func TestNotificationTemplates_Golden(t *testing.T) { "activated_account_name": "bobby", "activated_account_user_name": "William Tables", "initiator": "rob", + "account_type": "user", + }, + }, + }, + { + name: "TemplateUserAccountActivatedServiceAccount", + id: notifications.TemplateUserAccountActivated, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "activated_account_name": "ci-bot", + "activated_account_user_name": "CI Bot", + "initiator": "rob", + "account_type": "service", }, }, }, diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivatedServiceAccount.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivatedServiceAccount.html.golden new file mode 100644 index 0000000000..1a1cdda5c8 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountActivatedServiceAccount.html.golden @@ -0,0 +1,82 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Service account "ci-bot" activated +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +Service account ci-bot has been activated. + +The account belongs to CI Bot and it was activated by rob. + + +View accounts: http://test.com/deployment/users?filter=3Dstatus%3Aactive + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Service account "ci-bot" activated + + +
+
+ 3D"Cod= +
+

+ Service account "ci-bot" activated +

+
+

Hi Bobby,

+

Service account ci-bot has been activated.

+ +

The account belongs to CI Bot and it was activated by <= +strong>rob.

+
+
+ =20 + + View accounts + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedServiceAccount.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedServiceAccount.html.golden new file mode 100644 index 0000000000..3a04131d8d --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedServiceAccount.html.golden @@ -0,0 +1,83 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Service account "ci-bot" created +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +New service account ci-bot has been created. + +This new service account was created for CI Bot by rob. + + +View accounts: http://test.com/deployment/users?filter=3Dstatus%3Aactive + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Service account "ci-bot" created + + +
+
+ 3D"Cod= +
+

+ Service account "ci-bot" created +

+
+

Hi Bobby,

+

New service account ci-bot has been created. + +

This new service account was created for CI Bot by rob.

+
+
+ =20 + + View accounts + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedWithoutAccountType.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedWithoutAccountType.html.golden new file mode 100644 index 0000000000..6fc619e412 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountCreatedWithoutAccountType.html.golden @@ -0,0 +1,82 @@ +From: system@coder.com +To: bobby@coder.com +Subject: User account "bobby" created +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +New user account bobby has been created. + +This new user account was created for William Tables by rob. + + +View accounts: http://test.com/deployment/users?filter=3Dstatus%3Aactive + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + User account "bobby" created + + +
+
+ 3D"Cod= +
+

+ User account "bobby" created +

+
+

Hi Bobby,

+

New user account bobby has been created.

+ +

This new user account was created for William Tables by= + rob.

+
+
+ =20 + + View accounts + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeletedServiceAccount.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeletedServiceAccount.html.golden new file mode 100644 index 0000000000..782b792936 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountDeletedServiceAccount.html.golden @@ -0,0 +1,82 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Service account "ci-bot" deleted +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +Service account ci-bot has been deleted. + +The deleted account belonged to CI Bot and was deleted by rob. + + +View accounts: http://test.com/deployment/users?filter=3Dstatus%3Aactive + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Service account "ci-bot" deleted + + +
+
+ 3D"Cod= +
+

+ Service account "ci-bot" deleted +

+
+

Hi Bobby,

+

Service account ci-bot has been deleted.

+ +

The deleted account belonged to CI Bot and was deleted = +by rob.

+
+
+ =20 + + View accounts + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspendedServiceAccount.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspendedServiceAccount.html.golden new file mode 100644 index 0000000000..090edd32da --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateUserAccountSuspendedServiceAccount.html.golden @@ -0,0 +1,83 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Service account "ci-bot" suspended +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +Service account ci-bot has been suspended. + +The account belongs to CI Bot and it was suspended by rob. + + +View suspended accounts: http://test.com/deployment/users?filter=3Dstatus%3= +Asuspended + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Service account "ci-bot" suspended + + +
+
+ 3D"Cod= +
+

+ Service account "ci-bot" suspended +

+
+

Hi Bobby,

+

Service account ci-bot has been suspended.

+ +

The account belongs to CI Bot and it was suspended by <= +strong>rob.

+
+
+ =20 + + View suspended accounts + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivated.json.golden index 5f0522d400..2658e77700 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivated.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivated.json.golden @@ -16,6 +16,7 @@ } ], "labels": { + "account_type": "user", "activated_account_name": "bobby", "activated_account_user_name": "William Tables", "initiator": "rob" diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivatedServiceAccount.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivatedServiceAccount.json.golden new file mode 100644 index 0000000000..11e0bb5200 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountActivatedServiceAccount.json.golden @@ -0,0 +1,31 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "User account activated", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View accounts", + "url": "http://test.com/deployment/users?filter=status%3Aactive" + } + ], + "labels": { + "account_type": "service", + "activated_account_name": "ci-bot", + "activated_account_user_name": "CI Bot", + "initiator": "rob" + }, + "data": null, + "targets": null + }, + "title": "Service account \"ci-bot\" activated", + "title_markdown": "Service account \"ci-bot\" activated", + "body": "Service account ci-bot has been activated.\n\nThe account belongs to CI Bot and it was activated by rob.", + "body_markdown": "Service account **ci-bot** has been activated.\n\nThe account belongs to **CI Bot** and it was activated by **rob**." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreated.json.golden index 6da7b6d33e..d1646ea04e 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreated.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreated.json.golden @@ -16,6 +16,7 @@ } ], "labels": { + "account_type": "user", "created_account_name": "bobby", "created_account_user_name": "William Tables", "initiator": "rob" diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedServiceAccount.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedServiceAccount.json.golden new file mode 100644 index 0000000000..3b4f7b8807 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedServiceAccount.json.golden @@ -0,0 +1,31 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "User account created", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View accounts", + "url": "http://test.com/deployment/users?filter=status%3Aactive" + } + ], + "labels": { + "account_type": "service", + "created_account_name": "ci-bot", + "created_account_user_name": "CI Bot", + "initiator": "rob" + }, + "data": null, + "targets": null + }, + "title": "Service account \"ci-bot\" created", + "title_markdown": "Service account \"ci-bot\" created", + "body": "New service account ci-bot has been created.\n\nThis new service account was created for CI Bot by rob.", + "body_markdown": "New service account **ci-bot** has been created.\n\nThis new service account was created for **CI Bot** by **rob**." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedWithoutAccountType.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedWithoutAccountType.json.golden new file mode 100644 index 0000000000..6da7b6d33e --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountCreatedWithoutAccountType.json.golden @@ -0,0 +1,30 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "User account created", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View accounts", + "url": "http://test.com/deployment/users?filter=status%3Aactive" + } + ], + "labels": { + "created_account_name": "bobby", + "created_account_user_name": "William Tables", + "initiator": "rob" + }, + "data": null, + "targets": null + }, + "title": "User account \"bobby\" created", + "title_markdown": "User account \"bobby\" created", + "body": "New user account bobby has been created.\n\nThis new user account was created for William Tables by rob.", + "body_markdown": "New user account **bobby** has been created.\n\nThis new user account was created for **William Tables** by **rob**." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeleted.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeleted.json.golden index 7f65accd17..50b6ed55e8 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeleted.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeleted.json.golden @@ -16,6 +16,7 @@ } ], "labels": { + "account_type": "user", "deleted_account_name": "bobby", "deleted_account_user_name": "William Tables", "initiator": "rob" diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeletedServiceAccount.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeletedServiceAccount.json.golden new file mode 100644 index 0000000000..b98fbf6583 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountDeletedServiceAccount.json.golden @@ -0,0 +1,31 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "User account deleted", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View accounts", + "url": "http://test.com/deployment/users?filter=status%3Aactive" + } + ], + "labels": { + "account_type": "service", + "deleted_account_name": "ci-bot", + "deleted_account_user_name": "CI Bot", + "initiator": "rob" + }, + "data": null, + "targets": null + }, + "title": "Service account \"ci-bot\" deleted", + "title_markdown": "Service account \"ci-bot\" deleted", + "body": "Service account ci-bot has been deleted.\n\nThe deleted account belonged to CI Bot and was deleted by rob.", + "body_markdown": "Service account **ci-bot** has been deleted.\n\nThe deleted account belonged to **CI Bot** and was deleted by **rob**." +} \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspended.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspended.json.golden index 41b87f30ba..d31f9fafe8 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspended.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspended.json.golden @@ -16,6 +16,7 @@ } ], "labels": { + "account_type": "user", "initiator": "rob", "suspended_account_name": "bobby", "suspended_account_user_name": "William Tables" diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspendedServiceAccount.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspendedServiceAccount.json.golden new file mode 100644 index 0000000000..3f44e7db5b --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateUserAccountSuspendedServiceAccount.json.golden @@ -0,0 +1,31 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "User account suspended", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View suspended accounts", + "url": "http://test.com/deployment/users?filter=status%3Asuspended" + } + ], + "labels": { + "account_type": "service", + "initiator": "rob", + "suspended_account_name": "ci-bot", + "suspended_account_user_name": "CI Bot" + }, + "data": null, + "targets": null + }, + "title": "Service account \"ci-bot\" suspended", + "title_markdown": "Service account \"ci-bot\" suspended", + "body": "Service account ci-bot has been suspended.\n\nThe account belongs to CI Bot and it was suspended by rob.", + "body_markdown": "Service account **ci-bot** has been suspended.\n\nThe account belongs to **CI Bot** and it was suspended by **rob**." +} \ No newline at end of file diff --git a/coderd/users.go b/coderd/users.go index a9151bd6c6..b987266197 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -740,6 +740,7 @@ func (api *API) deleteUser(rw http.ResponseWriter, r *http.Request) { "deleted_account_name": user.Username, "deleted_account_user_name": user.Name, "initiator": accountDeleter.Name, + "account_type": accountTypeLabel(user), }, "api-users-delete", user.ID, @@ -1078,6 +1079,7 @@ func (api *API) notifyUserStatusChanged(ctx context.Context, actingUserName stri "suspended_account_name": targetUser.Username, "suspended_account_user_name": targetUser.Name, "initiator": actingUserName, + "account_type": accountTypeLabel(targetUser), } data = map[string]any{ "user": map[string]any{"id": targetUser.ID, "name": targetUser.Name, "email": targetUser.Email}, @@ -1089,6 +1091,7 @@ func (api *API) notifyUserStatusChanged(ctx context.Context, actingUserName stri "activated_account_name": targetUser.Username, "activated_account_user_name": targetUser.Name, "initiator": actingUserName, + "account_type": accountTypeLabel(targetUser), } data = map[string]any{ "user": map[string]any{"id": targetUser.ID, "name": targetUser.Name, "email": targetUser.Email}, @@ -2038,6 +2041,8 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return user, xerrors.Errorf("find user admins: %w", err) } + accountType := accountTypeLabel(user) + for _, u := range userAdmins { if u.ID == user.ID { // If the new user is an admin, don't notify them about themselves. @@ -2052,6 +2057,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create "created_account_name": user.Username, "created_account_user_name": user.Name, "initiator": req.accountCreatorName, + "account_type": accountType, }, map[string]any{ "user": map[string]any{"id": user.ID, "name": user.Name, "email": user.Email}, @@ -2066,6 +2072,15 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create return user, err } +// accountTypeLabel returns the notification label value that account lifecycle +// templates branch on to describe the account as a user or a service account. +func accountTypeLabel(u database.User) string { + if u.IsServiceAccount { + return "service" + } + return "user" +} + // findUserAdmins fetches all users with user admin permission including owners. func findUserAdmins(ctx context.Context, store database.Store) ([]database.GetUsersRow, error) { userAdmins, err := store.GetUsers(ctx, database.GetUsersParams{ diff --git a/coderd/users_test.go b/coderd/users_test.go index 1b3916fe9b..12d45d1bd2 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -743,6 +743,7 @@ func TestNotifyDeletedUser(t *testing.T) { require.Equal(t, user.Username, notifyEnq.Sent()[1].Labels["deleted_account_name"]) require.Equal(t, user.Name, notifyEnq.Sent()[1].Labels["deleted_account_user_name"]) require.Equal(t, firstUser.Name, notifyEnq.Sent()[1].Labels["initiator"]) + require.Equal(t, "user", notifyEnq.Sent()[1].Labels["account_type"]) }) t.Run("UserAdminNotified", func(t *testing.T) { @@ -1131,6 +1132,7 @@ func TestNotifyCreatedUser(t *testing.T) { require.Equal(t, firstUser.UserID, sent[0].UserID) require.Contains(t, sent[0].Targets, user.ID) require.Equal(t, user.Username, sent[0].Labels["created_account_name"]) + require.Equal(t, "user", sent[0].Labels["account_type"]) require.IsType(t, map[string]any{}, sent[0].Data["user"]) userData := sent[0].Data["user"].(map[string]any) diff --git a/docs/admin/monitoring/notifications/index.md b/docs/admin/monitoring/notifications/index.md index 9ab8ce9123..8a01d066ff 100644 --- a/docs/admin/monitoring/notifications/index.md +++ b/docs/admin/monitoring/notifications/index.md @@ -40,6 +40,8 @@ These notifications are sent to users with **owner** and **user admin** roles: - User account deleted - User account suspended +These account notifications cover both user and service accounts. + These notifications are sent to users themselves: - User account suspended diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index 564065d259..5dff396cae 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -11,6 +11,8 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/codersdk" @@ -779,3 +781,65 @@ func TestEnterprisePostUser(t *testing.T) { require.NotEqual(t, user1.ID, user2.ID) }) } + +// TestServiceAccountNotifications asserts that every account lifecycle +// notification describes a service account as such, so the wording cannot +// regress to "user account" for one of the events. +func TestServiceAccountNotifications(t *testing.T) { + t.Parallel() + + notifyEnq := ¬ificationstest.FakeEnqueuer{} + client, first := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + NotificationsEnqueuer: notifyEnq, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureServiceAccounts: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitLong) + + // requireServiceAccountNotification asserts the owner was notified about the + // service account, and that the notification carries the service wording. + requireServiceAccountNotification := func(t *testing.T, templateID uuid.UUID, serviceAccountID uuid.UUID) { + t.Helper() + + sent := notifyEnq.Sent(notificationstest.WithTemplateID(templateID)) + require.Len(t, sent, 1) + require.Equal(t, first.UserID, sent[0].UserID) + require.Contains(t, sent[0].Targets, serviceAccountID) + require.Equal(t, "service", sent[0].Labels["account_type"]) + } + + //nolint:gocritic // The owner is the only user admin, so it is the recipient under test. + serviceAccount, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{ + OrganizationIDs: []uuid.UUID{first.OrganizationID}, + Username: "service-acct-lifecycle", + UserLoginType: codersdk.LoginTypeNone, + ServiceAccount: true, + }) + require.NoError(t, err) + requireServiceAccountNotification(t, notifications.TemplateUserAccountCreated, serviceAccount.ID) + + // Service accounts are created dormant, so activation comes before + // suspension. + notifyEnq.Clear() + //nolint:gocritic // Only the owner can change another account's status here. + _, err = client.UpdateUserStatus(ctx, serviceAccount.Username, codersdk.UserStatusActive) + require.NoError(t, err) + requireServiceAccountNotification(t, notifications.TemplateUserAccountActivated, serviceAccount.ID) + + notifyEnq.Clear() + //nolint:gocritic // Only the owner can change another account's status here. + _, err = client.UpdateUserStatus(ctx, serviceAccount.Username, codersdk.UserStatusSuspended) + require.NoError(t, err) + requireServiceAccountNotification(t, notifications.TemplateUserAccountSuspended, serviceAccount.ID) + + notifyEnq.Clear() + //nolint:gocritic // Only the owner can delete another account here. + require.NoError(t, client.DeleteUser(ctx, serviceAccount.ID)) + requireServiceAccountNotification(t, notifications.TemplateUserAccountDeleted, serviceAccount.ID) +} diff --git a/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx b/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx index 9bbb9f0ed9..84b00173f6 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { spyOn, userEvent, within } from "storybook/test"; +import { expect, spyOn, userEvent, within } from "storybook/test"; import { API } from "#/api/api"; import { rolesQueryKey } from "#/api/queries/roles"; import { authMethodsQueryKey } from "#/api/queries/users"; @@ -28,7 +28,10 @@ type Story = StoryObj; export const ShowsSuccessNotificationOnSubmit: Story = { beforeEach: () => { - spyOn(API, "createUser").mockResolvedValue(MockUserMember); + spyOn(API, "createUser").mockResolvedValue({ + ...MockUserMember, + username: "someuser", + }); }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -40,6 +43,31 @@ export const ShowsSuccessNotificationOnSubmit: Story = { }, }; +export const ShowsServiceAccountSuccessNotificationOnSubmit: Story = { + parameters: { + features: ["service_accounts"], + }, + beforeEach: () => { + spyOn(API, "createUser").mockResolvedValue({ + ...MockUserMember, + username: "someuser", + is_service_account: true, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + await fillForm(canvas, user, "service account"); + + await expect(API.createUser).toHaveBeenCalledWith( + expect.objectContaining({ service_account: true }), + ); + await within(document.body).findByText( + 'Service account "someuser" created successfully.', + ); + }, +}; + export const ShowsErrorWhenUserCreationFails: Story = { beforeEach: () => { spyOn(API, "createUser").mockRejectedValue( @@ -54,20 +82,48 @@ export const ShowsErrorWhenUserCreationFails: Story = { }, }; +export const ShowsErrorWhenServiceAccountCreationFails: Story = { + parameters: { + features: ["service_accounts"], + }, + beforeEach: () => { + // An API error without a message falls back to our own copy. + spyOn(API, "createUser").mockRejectedValue(mockApiError({ message: "" })); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const user = userEvent.setup(); + await fillForm(canvas, user, "service account"); + await within(document.body).findByText( + 'Failed to create service account "someuser".', + ); + }, +}; + async function fillForm( canvas: ReturnType, user: ReturnType, + loginType: "password" | "service account" = "password", ) { - await user.type(await canvas.findByLabelText("Username"), "someuser"); - await user.type(canvas.getByLabelText(/email/i), "someone@coder.com"); - + const isPasswordLogin = loginType === "password"; const body = within(document.body); - await user.click(canvas.getByTestId("login-type-input")); - await user.click(await body.findByRole("option", { name: /password/i })); - await user.type( - await canvas.findByTestId("password-input"), - "SomeSecurePassword!", + await user.type(await canvas.findByLabelText("Username"), "someuser"); + if (isPasswordLogin) { + await user.type(canvas.getByLabelText(/email/i), "someone@coder.com"); + } + + await user.click(canvas.getByTestId("login-type-input")); + await user.click( + await body.findByRole("option", { name: new RegExp(loginType, "i") }), ); + + if (isPasswordLogin) { + await user.type( + await canvas.findByTestId("password-input"), + "SomeSecurePassword!", + ); + } + await user.click(canvas.getByRole("button", { name: /save/i })); } diff --git a/site/src/pages/CreateUserPage/CreateUserPage.tsx b/site/src/pages/CreateUserPage/CreateUserPage.tsx index 8d2e12cfd6..37ee4639a1 100644 --- a/site/src/pages/CreateUserPage/CreateUserPage.tsx +++ b/site/src/pages/CreateUserPage/CreateUserPage.tsx @@ -46,13 +46,17 @@ const CreateUserPage: FC = () => { }, }, ); + const requestedAccount = user.service_account + ? "service account" + : "user"; toast.promise(mutation, { - loading: `Creating user "${user.username}"...`, - success: `User "${user.username}" created successfully.`, + loading: `Creating ${requestedAccount} "${user.username}"...`, + success: (created) => + `${created.is_service_account ? "Service account" : "User"} "${created.username}" created successfully.`, error: (e) => ({ message: getErrorMessage( e, - `Failed to create user "${user.username}".`, + `Failed to create ${requestedAccount} "${user.username}".`, ), description: getErrorDetail(e), }),