Skip to content

Commit 3e0da33

Browse files
authored
Merge pull request #9359 from Turbo87/publish-notifications-opt-out
Implement publish notifications opt-out
2 parents fe1eeb8 + fc92785 commit 3e0da33

32 files changed

+595
-60
lines changed

app/controllers/settings/profile.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Controller from '@ember/controller';
2+
import { action } from '@ember/object';
3+
import { service } from '@ember/service';
4+
import { tracked } from '@glimmer/tracking';
5+
6+
import { task } from 'ember-concurrency';
7+
8+
export default class extends Controller {
9+
@service notifications;
10+
11+
@tracked publishNotifications;
12+
13+
@action handleNotificationsChange(event) {
14+
this.publishNotifications = event.target.checked;
15+
}
16+
17+
updateNotificationSettings = task(async () => {
18+
try {
19+
await this.model.user.updatePublishNotifications(this.publishNotifications);
20+
} catch {
21+
this.notifications.error(
22+
'Something went wrong while updating your notification settings. Please try again later!',
23+
);
24+
}
25+
});
26+
}

app/models/user.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default class User extends Model {
1616
@attr avatar;
1717
@attr url;
1818
@attr kind;
19+
@attr publish_notifications;
1920

2021
async stats() {
2122
return await waitForPromise(apiAction(this, { method: 'GET', path: 'stats' }));
@@ -34,6 +35,17 @@ export default class User extends Model {
3435
});
3536
}
3637

38+
async updatePublishNotifications(enabled) {
39+
await waitForPromise(apiAction(this, { method: 'PUT', data: { user: { publish_notifications: enabled } } }));
40+
41+
this.store.pushPayload({
42+
user: {
43+
id: this.id,
44+
publish_notifications: enabled,
45+
},
46+
});
47+
}
48+
3749
async resendVerificationEmail() {
3850
return await waitForPromise(apiAction(this, { method: 'PUT', path: 'resend' }));
3951
}

app/routes/settings/profile.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,9 @@ export default class ProfileSettingsRoute extends AuthenticatedRoute {
88
async model() {
99
return { user: this.session.currentUser };
1010
}
11+
12+
setupController(controller, model) {
13+
super.setupController(...arguments);
14+
controller.publishNotifications = model.user.publish_notifications;
15+
}
1116
}

app/styles/settings/profile.module.css

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,43 @@
3434
}
3535

3636
.me-email {
37-
margin-bottom: var(--space-s);
37+
margin-bottom: var(--space-m);
3838
display: flex;
3939
flex-direction: column;
4040
}
41+
42+
.notifications {
43+
margin-bottom: var(--space-s);
44+
}
45+
46+
.checkbox-input {
47+
display: grid;
48+
grid-template:
49+
"checkbox label" auto
50+
"- note" auto /
51+
auto 1fr;
52+
row-gap: var(--space-3xs);
53+
column-gap: var(--space-xs);
54+
}
55+
56+
.label {
57+
grid-area: label;
58+
font-weight: bold;
59+
}
60+
61+
.note {
62+
grid-area: note;
63+
display: block;
64+
font-size: 85%;
65+
}
66+
67+
.buttons {
68+
display: flex;
69+
align-items: center;
70+
gap: var(--space-2xs);
71+
margin-top: var(--space-s);
72+
}
73+
74+
.update-prefs-button {
75+
composes: yellow-button small from '../shared/buttons.module.css';
76+
}

app/templates/settings/profile.hbs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,37 @@
3131
data-test-email-input
3232
/>
3333
</div>
34+
35+
<div local-class="notifications" data-test-notifications>
36+
<h2>Notification Settings</h2>
37+
38+
<label local-class="checkbox-input">
39+
<Input
40+
@type="checkbox"
41+
@checked={{this.publishNotifications}}
42+
disabled={{this.updateNotificationSettings.isRunning}}
43+
{{on "change" this.handleNotificationsChange}}
44+
/>
45+
<span local-class="label">Publish Notifications</span>
46+
<span local-class="note">
47+
Publish notifications are sent to your email address whenever new
48+
versions of a crate that you own are published. These can be useful to
49+
quickly detect compromised accounts or API tokens.
50+
</span>
51+
</label>
52+
53+
<div local-class="buttons">
54+
<button
55+
type="button"
56+
local-class="update-prefs-button"
57+
disabled={{this.updateNotificationSettings.isRunning}}
58+
{{on "click" (perform this.updateNotificationSettings)}}
59+
>
60+
Update preferences
61+
</button>
62+
{{#if this.updateNotificationSettings.isRunning}}
63+
<LoadingSpinner local-class="spinner" data-test-spinner />
64+
{{/if}}
65+
</div>
66+
</div>
3467
</SettingsPage>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { expect, test } from '@/e2e/helper';
2+
3+
test.describe('Acceptance | publish notifications', { tag: '@acceptance' }, () => {
4+
test('unsubscribe and resubscribe', async ({ page, mirage }) => {
5+
await mirage.addHook(server => {
6+
let user = server.create('user');
7+
globalThis.user = user;
8+
authenticateAs(user);
9+
});
10+
11+
await page.goto('/settings/profile');
12+
await expect(page).toHaveURL('/settings/profile');
13+
await expect(page.locator('[data-test-notifications] input[type=checkbox]')).toBeChecked();
14+
15+
await page.click('[data-test-notifications] input[type=checkbox]');
16+
await expect(page.locator('[data-test-notifications] input[type=checkbox]')).not.toBeChecked();
17+
18+
await page.click('[data-test-notifications] button');
19+
await page.waitForFunction(() => globalThis.user.reload().publishNotifications === false);
20+
21+
await page.click('[data-test-notifications] input[type=checkbox]');
22+
await expect(page.locator('[data-test-notifications] input[type=checkbox]')).toBeChecked();
23+
24+
await page.click('[data-test-notifications] button');
25+
await page.waitForFunction(() => globalThis.user.reload().publishNotifications === true);
26+
});
27+
28+
test('loading state', async ({ page, mirage }) => {
29+
await mirage.addHook(server => {
30+
let user = server.create('user');
31+
authenticateAs(user);
32+
globalThis.user = user;
33+
34+
globalThis.deferred = require('rsvp').defer();
35+
server.put('/api/v1/users/:user_id', globalThis.deferred.promise);
36+
});
37+
38+
await page.goto('/settings/profile');
39+
await expect(page).toHaveURL('/settings/profile');
40+
41+
await page.click('[data-test-notifications] input[type=checkbox]');
42+
await page.click('[data-test-notifications] button');
43+
await expect(page.locator('[data-test-notifications] [data-test-spinner]')).toBeVisible();
44+
await expect(page.locator('[data-test-notifications] input[type=checkbox]')).toBeDisabled();
45+
await expect(page.locator('[data-test-notifications] button')).toBeDisabled();
46+
47+
await page.evaluate(async () => globalThis.deferred.resolve());
48+
await expect(page.locator('[data-test-notifications] [data-test-spinner]')).not.toBeVisible();
49+
await expect(page.locator('[data-test-notification-message="error"]')).not.toBeVisible();
50+
});
51+
52+
test('error state', async ({ page, mirage }) => {
53+
await mirage.addHook(server => {
54+
server.logging = true;
55+
let user = server.create('user');
56+
authenticateAs(user);
57+
globalThis.user = user;
58+
59+
server.put('/api/v1/users/:user_id', '', 500);
60+
});
61+
62+
await page.goto('/settings/profile');
63+
await expect(page).toHaveURL('/settings/profile');
64+
65+
await page.click('[data-test-notifications] input[type=checkbox]');
66+
await page.click('[data-test-notifications] button');
67+
await expect(page.locator('[data-test-notifications] [data-test-spinner]')).not.toBeVisible();
68+
await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(
69+
'Something went wrong while updating your notification settings. Please try again later!',
70+
);
71+
});
72+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE users DROP publish_notifications;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
alter table users
2+
add column publish_notifications boolean not null default true;
3+
4+
comment on column users.publish_notifications is 'Whether or not the user wants to receive notifications when a package they own is published';

mirage/factories/user.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export default Factory.extend({
2222
emailVerified: null,
2323
emailVerificationToken: null,
2424
isAdmin: false,
25+
publishNotifications: true,
2526

2627
afterCreate(model) {
2728
if (model.emailVerified === null) {

mirage/route-handlers/users.js

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,25 @@ export function register(server) {
2323
}
2424

2525
let json = JSON.parse(request.requestBody);
26-
if (!json || !json.user || !('email' in json.user)) {
26+
if (!json || !json.user) {
2727
return new Response(400, {}, { errors: [{ detail: 'invalid json request' }] });
2828
}
29-
if (!json.user.email) {
30-
return new Response(400, {}, { errors: [{ detail: 'empty email rejected' }] });
29+
30+
if (json.user.publish_notifications !== undefined) {
31+
user.update({ publishNotifications: json.user.publish_notifications });
3132
}
3233

33-
user.update({
34-
email: json.user.email,
35-
emailVerified: false,
36-
emailVerificationToken: 'secret123',
37-
});
34+
if (json.user.email !== undefined) {
35+
if (!json.user.email) {
36+
return new Response(400, {}, { errors: [{ detail: 'empty email rejected' }] });
37+
}
38+
39+
user.update({
40+
email: json.user.email,
41+
emailVerified: false,
42+
emailVerificationToken: 'secret123',
43+
});
44+
}
3845

3946
return { ok: true };
4047
});

mirage/serializers/user.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export default BaseSerializer.extend({
2424
delete hash.email;
2525
delete hash.email_verified;
2626
delete hash.is_admin;
27+
delete hash.publish_notifications;
2728
} else {
2829
hash.email_verification_sent = hash.email_verified || Boolean(hash.email_verification_token);
2930
}

0 commit comments

Comments
 (0)