From 6f2d34336914f7cf9d792534533f577776854428 Mon Sep 17 00:00:00 2001
From: Kailan Blanks
Date: Sat, 26 Jul 2025 15:02:22 +0100
Subject: [PATCH] Allow users to add multiple email addresses to their account
---
.gitignore | 1 +
Cargo.lock | 47 ++-
Cargo.toml | 6 +-
app/components/email-input.hbs | 147 +++-----
app/components/email-input.js | 60 ++-
app/components/email-input.module.css | 49 ++-
app/controllers/settings/profile.js | 2 +
app/models/user.js | 48 ++-
app/routes/confirm.js | 12 +-
app/routes/settings/profile.js | 1 +
app/styles/settings/profile.module.css | 20 +
app/templates/crate/settings/index.hbs | 6 +-
.../settings/email-notifications.hbs | 2 +-
app/templates/settings/profile.hbs | 40 +-
crates/crates_io_cdn_logs/Cargo.toml | 2 +-
crates/crates_io_database/src/models/email.rs | 68 ++--
crates/crates_io_github/src/lib.rs | 12 +
e2e/acceptance/email-change.spec.ts | 179 ---------
e2e/acceptance/email-confirmation.spec.ts | 16 +-
e2e/acceptance/email.spec.ts | 312 ++++++++++++++++
e2e/acceptance/publish-notifications.spec.ts | 6 +-
e2e/acceptance/settings/settings.spec.ts | 5 +-
e2e/routes/crate/settings.spec.ts | 4 +-
.../settings/new-trusted-publisher.spec.ts | 4 +-
eslint.config.mjs | 2 -
package.json | 14 +-
packages/crates-io-msw/handlers/emails/add.js | 36 ++
.../crates-io-msw/handlers/emails/add.test.js | 39 ++
.../crates-io-msw/handlers/emails/confirm.js | 23 ++
.../confirm.test.js} | 23 +-
.../crates-io-msw/handlers/emails/delete.js | 39 ++
.../handlers/emails/delete.test.js | 66 ++++
.../handlers/{users => emails}/resend.js | 8 +-
.../handlers/{users => emails}/resend.test.js | 12 +-
.../handlers/emails/set-primary.js | 36 ++
.../handlers/emails/set-primary.test.js | 50 +++
.../trustpub/github-configs/create.js | 2 +-
.../trustpub/github-configs/create.test.js | 6 +-
.../trustpub/github-configs/delete.test.js | 2 +-
.../trustpub/github-configs/list.test.js | 4 +-
packages/crates-io-msw/handlers/users.js | 9 +-
.../handlers/users/confirm-email.js | 16 -
.../crates-io-msw/handlers/users/me.test.js | 23 +-
.../crates-io-msw/handlers/users/update.js | 15 -
.../handlers/users/update.test.js | 47 +--
packages/crates-io-msw/index.js | 2 +
.../crates-io-msw/models/api-token.test.js | 4 +-
.../models/crate-owner-invitation.test.js | 8 +-
.../models/crate-ownership.test.js | 4 +-
packages/crates-io-msw/models/email.js | 22 ++
packages/crates-io-msw/models/email.test.js | 19 +
.../crates-io-msw/models/msw-session.test.js | 4 +-
packages/crates-io-msw/models/user.js | 7 +-
packages/crates-io-msw/models/user.test.js | 8 +-
packages/crates-io-msw/serializers/email.js | 9 +
packages/crates-io-msw/serializers/user.js | 8 +-
pnpm-lock.yaml | 246 +++++++------
src/controllers/session.rs | 90 ++++-
src/controllers/user.rs | 5 +-
src/controllers/user/email_verification.rs | 154 ++++++--
src/controllers/user/emails.rs | 198 ++++++++++
src/controllers/user/me.rs | 51 ++-
...fication__tests__legacy_happy_path-3.snap} | 2 +-
src/controllers/user/update.rs | 80 ++--
src/router.rs | 6 +
...o__openapi__tests__openapi_snapshot-2.snap | 346 +++++++++++++++++-
...ates_io__tests__routes__me__get__me-4.snap | 9 +
...ates_io__tests__routes__me__get__me-6.snap | 9 +
src/tests/routes/users/email_verification.rs | 54 +++
src/tests/routes/users/emails.rs | 220 +++++++++++
src/tests/routes/users/mod.rs | 2 +
...ers__email_verification__happy_path-5.snap | 40 ++
src/tests/routes/users/update.rs | 2 +
...ates_io__tests__user__confirm_email-2.snap | 14 +
...tests__user__email_legacy_get_and_put.snap | 32 ++
...__user__initial_github_login_succeeds.snap | 32 ++
src/tests/user.rs | 194 ++++++++--
src/tests/util.rs | 17 +
src/views.rs | 76 +++-
tests/acceptance/email-change-test.js | 177 ---------
tests/acceptance/email-confirmation-test.js | 15 +-
tests/acceptance/email-test.js | 248 +++++++++++++
.../acceptance/publish-notifications-test.js | 4 +-
tests/acceptance/settings/settings-test.js | 5 +-
tests/models/trustpub-github-config-test.js | 6 +-
tests/models/user-test.js | 104 +++++-
tests/routes/crate/settings-test.js | 4 +-
.../settings/new-trusted-publisher-test.js | 4 +-
88 files changed, 3054 insertions(+), 1008 deletions(-)
delete mode 100644 e2e/acceptance/email-change.spec.ts
create mode 100644 e2e/acceptance/email.spec.ts
create mode 100644 packages/crates-io-msw/handlers/emails/add.js
create mode 100644 packages/crates-io-msw/handlers/emails/add.test.js
create mode 100644 packages/crates-io-msw/handlers/emails/confirm.js
rename packages/crates-io-msw/handlers/{users/confirm-email.test.js => emails/confirm.test.js} (51%)
create mode 100644 packages/crates-io-msw/handlers/emails/delete.js
create mode 100644 packages/crates-io-msw/handlers/emails/delete.test.js
rename packages/crates-io-msw/handlers/{users => emails}/resend.js (61%)
rename packages/crates-io-msw/handlers/{users => emails}/resend.test.js (56%)
create mode 100644 packages/crates-io-msw/handlers/emails/set-primary.js
create mode 100644 packages/crates-io-msw/handlers/emails/set-primary.test.js
delete mode 100644 packages/crates-io-msw/handlers/users/confirm-email.js
create mode 100644 packages/crates-io-msw/models/email.js
create mode 100644 packages/crates-io-msw/models/email.test.js
create mode 100644 packages/crates-io-msw/serializers/email.js
create mode 100644 src/controllers/user/emails.rs
rename src/controllers/user/snapshots/{crates_io__controllers__user__email_verification__tests__happy_path-3.snap => crates_io__controllers__user__email_verification__tests__legacy_happy_path-3.snap} (95%)
create mode 100644 src/tests/routes/users/email_verification.rs
create mode 100644 src/tests/routes/users/emails.rs
create mode 100644 src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap
create mode 100644 src/tests/snapshots/crates_io__tests__user__confirm_email-2.snap
create mode 100644 src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap
create mode 100644 src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap
delete mode 100644 tests/acceptance/email-change-test.js
create mode 100644 tests/acceptance/email-test.js
diff --git a/.gitignore b/.gitignore
index 1947c24fc9d..b18dca911f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,7 @@ src/schema.rs.orig
# insta
*.pending-snap
+*.snap.new
# playwright
/test-results/
diff --git a/Cargo.lock b/Cargo.lock
index d6820fe695c..65e888a3147 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -448,9 +448,9 @@ dependencies = [
[[package]]
name = "aws-ip-ranges"
-version = "0.1324.0"
+version = "0.1319.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf2ab8cb8f0e83f3dcb14fc097922e4010dfdcbbbb516b61e7483fa8f9afdd22"
+checksum = "557251972192645620ef73a840584cf82d06d0c3df01a123316b0e882546f97b"
dependencies = [
"serde",
"serde_json",
@@ -505,9 +505,9 @@ dependencies = [
[[package]]
name = "aws-sdk-cloudfront"
-version = "1.85.0"
+version = "1.84.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21774717a6b71d721e4f20ec0a304eb3d24eae03eea0416e7f8e59b6366dd45d"
+checksum = "0dee22add34c4109f89ecb2c1b4d48b870f1693e8b290161b1fd9a59d5027580"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -527,9 +527,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sqs"
-version = "1.77.0"
+version = "1.76.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a6dcd63dffae1f7872df42ce9f1f0f785f8c9a3d3e880ef6c171d0415439df"
+checksum = "837b4b181a1306a5a67d85218fa7d7298e340041d9bd81ae6919b9ec4f86ca97"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -582,9 +582,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
-version = "0.62.2"
+version = "0.62.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43c82ba4cab184ea61f6edaafc1072aad3c2a17dcf4c0fce19ac5694b90d8b5f"
+checksum = "99335bec6cdc50a346fda1437f9fefe33abf8c99060739a546a16457f2862ca9"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -649,9 +649,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
-version = "1.8.5"
+version = "1.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "660f70d9d8af6876b4c9aa8dcb0dbaf0f89b04ee9a4455bea1b4ba03b15f26f6"
+checksum = "c3aaec682eb189e43c8a19c3dab2fe54590ad5f2cc2d26ab27608a20f2acf81c"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -673,9 +673,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
-version = "1.8.5"
+version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "937a49ecf061895fca4a6dd8e864208ed9be7546c0527d04bc07d502ec5fba1c"
+checksum = "9852b9226cb60b78ce9369022c0df678af1cac231c882d5da97a0c4e03be6e67"
dependencies = [
"aws-smithy-async",
"aws-smithy-types",
@@ -725,9 +725,9 @@ dependencies = [
[[package]]
name = "aws-types"
-version = "1.3.8"
+version = "1.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b069d19bf01e46298eaedd7c6f283fe565a59263e53eebec945f3e6398f42390"
+checksum = "8a322fec39e4df22777ed3ad8ea868ac2f94cd15e1a55f6ee8d8d6305057689a"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -1773,9 +1773,9 @@ dependencies = [
[[package]]
name = "criterion"
-version = "0.7.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928"
+checksum = "3bf7af66b0989381bd0be551bd7cc91912a655a58c6918420c9527b1fd8b4679"
dependencies = [
"anes",
"cast",
@@ -1797,12 +1797,12 @@ dependencies = [
[[package]]
name = "criterion-plot"
-version = "0.6.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338"
+checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [
"cast",
- "itertools 0.13.0",
+ "itertools 0.10.5",
]
[[package]]
@@ -3377,6 +3377,15 @@ version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.11.0"
diff --git a/Cargo.toml b/Cargo.toml
index a76a4a521ea..e3a4de87164 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -52,9 +52,9 @@ astral-tokio-tar = "=0.5.2"
async-compression = { version = "=0.4.27", default-features = false, features = ["gzip", "tokio"] }
async-trait = "=0.1.88"
aws-credential-types = { version = "=1.2.4", features = ["hardcoded-credentials"] }
-aws-ip-ranges = "=0.1324.0"
-aws-sdk-cloudfront = "=1.85.0"
-aws-sdk-sqs = "=1.77.0"
+aws-ip-ranges = "=0.1319.0"
+aws-sdk-cloudfront = "=1.84.0"
+aws-sdk-sqs = "=1.76.0"
axum = { version = "=0.8.4", features = ["macros", "matched-path"] }
axum-extra = { version = "=0.11.0", features = ["erased-json", "query", "typed-header"] }
base64 = "=0.22.1"
diff --git a/app/components/email-input.hbs b/app/components/email-input.hbs
index d212ffdf4b0..e28db2ef722 100644
--- a/app/components/email-input.hbs
+++ b/app/components/email-input.hbs
@@ -1,101 +1,64 @@
- {{#unless @user.email}}
-
-
- Please add your email address. We will only use
- it to contact you about your account. We promise we'll never share it!
-
+ {{#if this.email.id }}
+
+
+
+ {{ this.email.email }}
+
+ {{#if this.email.verified}}
+ Verified
+ {{#if this.email.primary }}
+ Primary email
+ {{/if}}
+ {{else}}
+ {{#if this.email.verification_email_sent}}
+ Unverified - email sent
+ {{else}}
+ Unverified
+ {{/if}}
+ {{/if}}
+
+
- {{/unless}}
-
- {{#if this.isEditing }}
-
-
- Email
-
-
+
+ {{#unless this.email.verified}}
+
+ {{#if this.disableResend}}
+ Sent!
+ {{else if this.email.verification_email_sent}}
+ Resend
+ {{else}}
+ Verify
+ {{/if}}
+
+ {{/unless}}
+ {{#if (and (not this.email.primary) this.email.verified)}}
+
+ Mark as primary
+
+ {{/if}}
+ {{#if @canDelete}}
+
+ Remove
+
+ {{/if}}
+
{{else}}
-
-
-
Email
-
-
-
- {{ @user.email }}
- {{#if @user.email_verified}}
- Verified!
- {{/if}}
-
-
+
- {{#if (and @user.email (not @user.email_verified))}}
-
-
- {{#if @user.email_verification_sent}}
-
We have sent a verification email to your address.
- {{/if}}
-
Your email has not yet been verified.
-
-
-
- {{#if this.disableResend}}
- Sent!
- {{else if @user.email_verification_sent}}
- Resend
- {{else}}
- Send verification email
- {{/if}}
-
-
-
- {{/if}}
+
+
{{/if}}
-
\ No newline at end of file
+
diff --git a/app/components/email-input.js b/app/components/email-input.js
index 993958d9bef..5e4e2c90b46 100644
--- a/app/components/email-input.js
+++ b/app/components/email-input.js
@@ -8,13 +8,18 @@ import { task } from 'ember-concurrency';
export default class EmailInput extends Component {
@service notifications;
+ @tracked email = this.args.email || { email: '', id: null };
+ @tracked isValid = false;
@tracked value;
- @tracked isEditing = false;
@tracked disableResend = false;
+ @action validate(event) {
+ this.isValid = event.target.value.trim().length !== 0 && event.target.checkValidity();
+ }
+
resendEmailTask = task(async () => {
try {
- await this.args.user.resendVerificationEmail();
+ await this.args.user.resendVerificationEmail(this.email.id);
this.disableResend = true;
} catch (error) {
let detail = error.errors?.[0]?.detail;
@@ -26,30 +31,47 @@ export default class EmailInput extends Component {
}
});
- @action
- editEmail() {
- this.value = this.args.user.email;
- this.isEditing = true;
- }
+ deleteEmailTask = task(async () => {
+ try {
+ await this.args.user.deleteEmail(this.email.id);
+ } catch (error) {
+ console.error('Error deleting email:', error);
+ let detail = error.errors?.[0]?.detail;
+ if (detail && !detail.startsWith('{')) {
+ this.notifications.error(`Error in deleting email: ${detail}`);
+ } else {
+ this.notifications.error('Unknown error in deleting email');
+ }
+ }
+ });
saveEmailTask = task(async () => {
- let userEmail = this.value;
- let user = this.args.user;
-
try {
- await user.changeEmail(userEmail);
-
- this.isEditing = false;
- this.disableResend = false;
+ this.email = await this.args.user.addEmail(this.value);
+ this.disableResend = true;
+ await this.args.onAddEmail?.();
} catch (error) {
let detail = error.errors?.[0]?.detail;
- let msg =
- detail && !detail.startsWith('{')
- ? `An error occurred while saving this email, ${detail}`
- : 'An unknown error occurred while saving this email.';
+ if (detail && !detail.startsWith('{')) {
+ this.notifications.error(`Error in saving email: ${detail}`);
+ } else {
+ console.error('Error saving email:', error);
+ this.notifications.error('Unknown error in saving email');
+ }
+ }
+ });
- this.notifications.error(`Error in saving email: ${msg}`);
+ markAsPrimaryTask = task(async () => {
+ try {
+ await this.args.user.updatePrimaryEmail(this.email.id);
+ } catch (error) {
+ let detail = error.errors?.[0]?.detail;
+ if (detail && !detail.startsWith('{')) {
+ this.notifications.error(`Error in marking email as primary: ${detail}`);
+ } else {
+ this.notifications.error('Unknown error in marking email as primary');
+ }
}
});
}
diff --git a/app/components/email-input.module.css b/app/components/email-input.module.css
index 1313e662caa..14522ac9c10 100644
--- a/app/components/email-input.module.css
+++ b/app/components/email-input.module.css
@@ -1,7 +1,3 @@
-.friendly-message {
- margin-top: 0;
-}
-
.row {
width: 100%;
border: 1px solid var(--gray-border);
@@ -9,6 +5,7 @@
padding: var(--space-2xs) var(--space-s);
display: flex;
align-items: center;
+ justify-content: space-between;
&:last-child {
border-bottom-width: 1px;
@@ -22,12 +19,41 @@
}
.email-column {
+ padding: var(--space-xs) 0;
+}
+
+.email-column dd {
+ margin: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3xs);
flex: 20;
}
+.email-column .badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3xs);
+}
+
+.badge {
+ padding: var(--space-4xs) var(--space-2xs);
+ background-color: var(--main-bg-dark);
+ font-size: 0.8rem;
+ border-radius: 100px;
+}
+
.verified {
- color: green;
- font-weight: bold;
+ background-color: var(--green800);
+ color: var(--grey200);
+}
+
+.pending-verification {
+ background-color: light-dark(var(--orange-200), var(--orange-500));
+}
+
+.unverified {
+ background-color: light-dark(var(--orange-300), var(--orange-600));
}
.email-form {
@@ -38,13 +64,22 @@
}
.input {
- width: 400px;
+ background-color: var(--main-bg);
+ border: 0;
+ flex: 1;
+ margin: calc(var(--space-3xs) * -1) calc(var(--space-2xs) * -1);
+ padding: var(--space-3xs) var(--space-2xs);
margin-right: var(--space-xs);
}
+.input:focus {
+ outline: none;
+}
+
.actions {
display: flex;
align-items: center;
+ gap: var(--space-3xs);
}
.save-button {
diff --git a/app/controllers/settings/profile.js b/app/controllers/settings/profile.js
index ad0649ce9a4..b16f86e8372 100644
--- a/app/controllers/settings/profile.js
+++ b/app/controllers/settings/profile.js
@@ -8,6 +8,8 @@ import { task } from 'ember-concurrency';
export default class extends Controller {
@service notifications;
+ @tracked isAddingEmail = false;
+
@tracked publishNotifications;
@action handleNotificationsChange(event) {
diff --git a/app/models/user.js b/app/models/user.js
index 1c7b51d72d7..62dc2f62481 100644
--- a/app/models/user.js
+++ b/app/models/user.js
@@ -7,9 +7,7 @@ import { apiAction } from '@mainmatter/ember-api-actions';
export default class User extends Model {
@service store;
- @attr email;
- @attr email_verified;
- @attr email_verification_sent;
+ @attr emails;
@attr name;
@attr is_admin;
@attr login;
@@ -22,15 +20,45 @@ export default class User extends Model {
return await waitForPromise(apiAction(this, { method: 'GET', path: 'stats' }));
}
- async changeEmail(email) {
- await waitForPromise(apiAction(this, { method: 'PUT', data: { user: { email } } }));
+ async addEmail(emailAddress) {
+ let email = await waitForPromise(
+ apiAction(this, {
+ method: 'POST',
+ path: 'emails',
+ data: { email: emailAddress },
+ }),
+ );
this.store.pushPayload({
user: {
id: this.id,
- email,
- email_verified: false,
- email_verification_sent: true,
+ emails: [...this.emails, email],
+ },
+ });
+ }
+
+ async resendVerificationEmail(emailId) {
+ return await waitForPromise(apiAction(this, { method: 'PUT', path: `emails/${emailId}/resend` }));
+ }
+
+ async deleteEmail(emailId) {
+ await waitForPromise(apiAction(this, { method: 'DELETE', path: `emails/${emailId}` }));
+
+ this.store.pushPayload({
+ user: {
+ id: this.id,
+ emails: this.emails.filter(email => email.id !== emailId),
+ },
+ });
+ }
+
+ async updatePrimaryEmail(emailId) {
+ await waitForPromise(apiAction(this, { method: 'PUT', path: `emails/${emailId}/set_primary` }));
+
+ this.store.pushPayload({
+ user: {
+ id: this.id,
+ emails: this.emails.map(email => ({ ...email, primary: email.id === emailId })),
},
});
}
@@ -45,8 +73,4 @@ export default class User extends Model {
},
});
}
-
- async resendVerificationEmail() {
- return await waitForPromise(apiAction(this, { method: 'PUT', path: 'resend' }));
- }
}
diff --git a/app/routes/confirm.js b/app/routes/confirm.js
index 1ef7fc2142e..16ecf8dbac8 100644
--- a/app/routes/confirm.js
+++ b/app/routes/confirm.js
@@ -11,14 +11,22 @@ export default class ConfirmRoute extends Route {
async model(params) {
try {
- await ajax(`/api/v1/confirm/${params.email_token}`, { method: 'PUT', body: '{}' });
+ let response = await ajax(`/api/v1/confirm/${params.email_token}`, { method: 'PUT', body: '{}' });
// wait for the `GET /api/v1/me` call to complete before
// trying to update the Ember Data store
await this.session.loadUserTask.last;
if (this.session.currentUser) {
- this.store.pushPayload({ user: { id: this.session.currentUser.id, email_verified: true } });
+ this.store.pushPayload({
+ user: {
+ id: this.session.currentUser.id,
+ emails: [
+ ...this.session.currentUser.emails.filter(email => email.id !== response.email.id),
+ response.email,
+ ].sort((a, b) => a.id - b.id),
+ },
+ });
}
this.notifications.success('Thank you for confirming your email! :)');
diff --git a/app/routes/settings/profile.js b/app/routes/settings/profile.js
index bb4faabbd53..84f54e18a16 100644
--- a/app/routes/settings/profile.js
+++ b/app/routes/settings/profile.js
@@ -12,5 +12,6 @@ export default class ProfileSettingsRoute extends AuthenticatedRoute {
setupController(controller, model) {
super.setupController(...arguments);
controller.publishNotifications = model.user.publish_notifications;
+ controller.primaryEmailId = model.user.emails.find(email => email.primary)?.id;
}
}
diff --git a/app/styles/settings/profile.module.css b/app/styles/settings/profile.module.css
index bdabd5a791c..ee59e7071f4 100644
--- a/app/styles/settings/profile.module.css
+++ b/app/styles/settings/profile.module.css
@@ -53,6 +53,26 @@
column-gap: var(--space-xs);
}
+.friendly-message {
+ margin: 0;
+ margin-bottom: var(--space-s);
+}
+
+.email-selector {
+ display: flex;
+ flex-direction: column;
+ margin-bottom: var(--space-s);
+}
+
+.select-label {
+ margin-bottom: var(--space-3xs);
+}
+
+.add-email {
+ margin-top: var(--space-xs);
+ display: flex;
+}
+
.label {
grid-area: label;
font-weight: bold;
diff --git a/app/templates/crate/settings/index.hbs b/app/templates/crate/settings/index.hbs
index f5321ee8be2..1f37235150b 100644
--- a/app/templates/crate/settings/index.hbs
+++ b/app/templates/crate/settings/index.hbs
@@ -47,7 +47,11 @@
{{/if}}
- {{user.email}}
+ {{#each user.emails as |email|}}
+ {{#if email.primary}}
+ {{email.email}}
+ {{/if}}
+ {{/each}}
Remove
diff --git a/app/templates/settings/email-notifications.hbs b/app/templates/settings/email-notifications.hbs
index e0c78191e29..41045a69e16 100644
--- a/app/templates/settings/email-notifications.hbs
+++ b/app/templates/settings/email-notifications.hbs
@@ -49,4 +49,4 @@
{{/if}}
-
\ No newline at end of file
+
diff --git a/app/templates/settings/profile.hbs b/app/templates/settings/profile.hbs
index cc33b80ec67..23ca904e415 100644
--- a/app/templates/settings/profile.hbs
+++ b/app/templates/settings/profile.hbs
@@ -25,13 +25,38 @@
-
User Email
-
+
User Emails
+ {{#if (eq this.model.user.emails.length 0)}}
+
+ Please add your email address. We will only use
+ it to contact you about your account. We promise we'll never share it!
+
+ {{/if}}
+ {{#each this.model.user.emails as |email|}}
+
+ {{/each}}
+ {{#if this.isAddingEmail}}
+
+ {{else}}
+
+
+ Add new email
+
+
+ {{/if}}
+ {{#unless (lt this.model.user.emails.length 1)}}
Notification Settings
@@ -44,7 +69,7 @@
/>
Publish Notifications
- Publish notifications are sent to your email address whenever new
+ Publish notifications are sent to your primary email address whenever new
versions of a crate that you own are published. These can be useful to
quickly detect compromised accounts or API tokens.
@@ -64,4 +89,5 @@
{{/if}}
-
\ No newline at end of file
+ {{/unless}}
+
diff --git a/crates/crates_io_cdn_logs/Cargo.toml b/crates/crates_io_cdn_logs/Cargo.toml
index cb7597f31d0..2a5e69f7a21 100644
--- a/crates/crates_io_cdn_logs/Cargo.toml
+++ b/crates/crates_io_cdn_logs/Cargo.toml
@@ -22,7 +22,7 @@ tracing = "=0.1.41"
[dev-dependencies]
claims = "=0.8.0"
clap = { version = "=4.5.41", features = ["derive"] }
-criterion = { version = "=0.7.0", features = ["async_tokio"] }
+criterion = { version = "=0.6.0", features = ["async_tokio"] }
insta = "=1.43.1"
tokio = { version = "=1.46.1", features = ["fs", "macros", "rt", "rt-multi-thread"] }
tracing-subscriber = { version = "=0.3.19", features = ["env-filter"] }
diff --git a/crates/crates_io_database/src/models/email.rs b/crates/crates_io_database/src/models/email.rs
index a75d30780f3..4e879486d49 100644
--- a/crates/crates_io_database/src/models/email.rs
+++ b/crates/crates_io_database/src/models/email.rs
@@ -1,5 +1,7 @@
use bon::Builder;
+use chrono::{DateTime, Utc};
use diesel::prelude::*;
+use diesel::upsert::on_constraint;
use diesel_async::{AsyncPgConnection, RunQueryDsl};
use secrecy::SecretString;
@@ -16,6 +18,17 @@ pub struct Email {
pub primary: bool,
#[diesel(deserialize_as = String, serialize_as = String)]
pub token: SecretString,
+ pub token_generated_at: Option>,
+}
+
+impl Email {
+ pub async fn find(conn: &mut AsyncPgConnection, id: i32) -> QueryResult {
+ emails::table
+ .find(id)
+ .select(Email::as_select())
+ .first(conn)
+ .await
+ }
}
#[derive(Debug, Insertable, AsChangeset, Builder)]
@@ -38,52 +51,19 @@ impl NewEmail<'_> {
.await
}
- /// Inserts the email into the database and returns it, unless the user already has a
- /// primary email, in which case it will do nothing and return `None`.
- pub async fn insert_primary_if_missing(
+ /// Inserts the email into the database and returns the email record,
+ /// or does nothing if it already exists and returns `None`.
+ pub async fn insert_if_missing(
&self,
conn: &mut AsyncPgConnection,
) -> QueryResult> {
- // Check if the user already has a primary email
- let primary_count = emails::table
- .filter(emails::user_id.eq(self.user_id))
- .filter(emails::primary.eq(true))
- .count()
- .get_result::(conn)
- .await?;
-
- if primary_count > 0 {
- return Ok(None); // User already has a primary email
- }
-
- self.insert(conn).await.map(Some)
- }
-
- // Inserts an email for the user, replacing the primary email if it exists.
- pub async fn insert_or_update_primary(
- &self,
- conn: &mut AsyncPgConnection,
- ) -> QueryResult {
- // Attempt to update an existing primary email
- let updated_email = diesel::update(
- emails::table
- .filter(emails::user_id.eq(self.user_id))
- .filter(emails::primary.eq(true)),
- )
- .set((
- emails::email.eq(self.email),
- emails::verified.eq(self.verified),
- ))
- .returning(Email::as_returning())
- .get_result(conn)
- .await
- .optional()?;
-
- if let Some(email) = updated_email {
- Ok(email)
- } else {
- // Otherwise, insert a new email
- self.insert(conn).await
- }
+ diesel::insert_into(emails::table)
+ .values(self)
+ .on_conflict(on_constraint("unique_user_email"))
+ .do_nothing()
+ .returning(Email::as_returning())
+ .get_result(conn)
+ .await
+ .optional()
}
}
diff --git a/crates/crates_io_github/src/lib.rs b/crates/crates_io_github/src/lib.rs
index e7656bd70b5..d352618e95a 100644
--- a/crates/crates_io_github/src/lib.rs
+++ b/crates/crates_io_github/src/lib.rs
@@ -20,6 +20,7 @@ type Result = std::result::Result;
#[async_trait]
pub trait GitHubClient: Send + Sync {
async fn current_user(&self, auth: &AccessToken) -> Result;
+ async fn current_user_emails(&self, auth: &AccessToken) -> Result>;
async fn get_user(&self, name: &str, auth: &AccessToken) -> Result;
async fn org_by_name(&self, org_name: &str, auth: &AccessToken) -> Result;
async fn team_by_name(
@@ -103,6 +104,10 @@ impl GitHubClient for RealGitHubClient {
self.request("/user", auth).await
}
+ async fn current_user_emails(&self, auth: &AccessToken) -> Result> {
+ self.request("/user/emails", auth).await
+ }
+
async fn get_user(&self, name: &str, auth: &AccessToken) -> Result {
let url = format!("/users/{name}");
self.request(&url, auth).await
@@ -197,6 +202,13 @@ pub struct GitHubUser {
pub name: Option,
}
+#[derive(Debug, Deserialize)]
+pub struct GitHubEmail {
+ pub email: String,
+ pub primary: bool,
+ pub verified: bool,
+}
+
#[derive(Debug, Deserialize)]
pub struct GitHubOrganization {
pub id: i32, // unique GH id (needed for membership queries)
diff --git a/e2e/acceptance/email-change.spec.ts b/e2e/acceptance/email-change.spec.ts
deleted file mode 100644
index b152bf2bf12..00000000000
--- a/e2e/acceptance/email-change.spec.ts
+++ /dev/null
@@ -1,179 +0,0 @@
-import { expect, test } from '@/e2e/helper';
-import { http, HttpResponse } from 'msw';
-
-test.describe('Acceptance | Email Change', { tag: '@acceptance' }, () => {
- test('happy path', async ({ page, msw }) => {
- let user = msw.db.user.create({ email: 'old@email.com' });
- await msw.authenticateAs(user);
-
- await page.goto('/settings/profile');
- await expect(page).toHaveURL('/settings/profile');
- const emailInput = page.locator('[data-test-email-input]');
- await expect(emailInput).toBeVisible();
- await expect(emailInput.locator('[data-test-no-email]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-email-address]')).toContainText('old@email.com');
- await expect(emailInput.locator('[data-test-verified]')).toBeVisible();
- await expect(emailInput.locator('[data-test-not-verified]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-verification-sent]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-resend-button]')).toHaveCount(0);
-
- await emailInput.locator('[data-test-edit-button]').click();
- await expect(emailInput.locator('[data-test-input]')).toHaveValue('old@email.com');
- await expect(emailInput.locator('[data-test-save-button]')).toBeEnabled();
- await expect(emailInput.locator('[data-test-cancel-button]')).toBeEnabled();
-
- await emailInput.locator('[data-test-input]').fill('');
- await expect(emailInput.locator('[data-test-input]')).toHaveValue('');
- await expect(emailInput.locator('[data-test-save-button]')).toBeDisabled();
-
- await emailInput.locator('[data-test-input]').fill('new@email.com');
- await expect(emailInput.locator('[data-test-input]')).toHaveValue('new@email.com');
- await expect(emailInput.locator('[data-test-save-button]')).toBeEnabled();
-
- await emailInput.locator('[data-test-save-button]').click();
- await expect(emailInput.locator('[data-test-email-address]')).toContainText('new@email.com');
- await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible();
- await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible();
- await expect(emailInput.locator('[data-test-resend-button]')).toBeEnabled();
-
- user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
- await expect(user.email).toBe('new@email.com');
- await expect(user.emailVerified).toBe(false);
- await expect(user.emailVerificationToken).toBeDefined();
- });
-
- test('happy path with `email: null`', async ({ page, msw }) => {
- let user = msw.db.user.create({ email: undefined });
- await msw.authenticateAs(user);
-
- await page.goto('/settings/profile');
- await expect(page).toHaveURL('/settings/profile');
- const emailInput = page.locator('[data-test-email-input]');
- await expect(emailInput).toBeVisible();
- await expect(emailInput.locator('[data-test-no-email]')).toBeVisible();
- await expect(emailInput.locator('[data-test-email-address]')).toHaveText('');
- await expect(emailInput.locator('[data-test-not-verified]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-verification-sent]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-resend-button]')).toHaveCount(0);
-
- await emailInput.locator('[data-test-edit-button]').click();
- await expect(emailInput.locator('[data-test-input]')).toHaveValue('');
- await expect(emailInput.locator('[data-test-save-button]')).toBeDisabled();
- await expect(emailInput.locator('[data-test-cancel-button]')).toBeEnabled();
-
- await emailInput.locator('[data-test-input]').fill('new@email.com');
- await expect(emailInput.locator('[data-test-input]')).toHaveValue('new@email.com');
- await expect(emailInput.locator('[data-test-save-button]')).toBeEnabled();
-
- await emailInput.locator('[data-test-save-button]').click();
- await expect(emailInput.locator('[data-test-no-email]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-email-address]')).toContainText('new@email.com');
- await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible();
- await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible();
- await expect(emailInput.locator('[data-test-resend-button]')).toBeEnabled();
-
- user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
- await expect(user.email).toBe('new@email.com');
- await expect(user.emailVerified).toBe(false);
- await expect(user.emailVerificationToken).toBeDefined();
- });
-
- test('cancel button', async ({ page, msw }) => {
- let user = msw.db.user.create({ email: 'old@email.com' });
- await msw.authenticateAs(user);
-
- await page.goto('/settings/profile');
- const emailInput = page.locator('[data-test-email-input]');
- await emailInput.locator('[data-test-edit-button]').click();
- await emailInput.locator('[data-test-input]').fill('new@email.com');
- await expect(emailInput.locator('[data-test-invalid-email-warning]')).toHaveCount(0);
-
- await emailInput.locator('[data-test-cancel-button]').click();
- await expect(emailInput.locator('[data-test-email-address]')).toContainText('old@email.com');
- await expect(emailInput.locator('[data-test-verified]')).toBeVisible();
- await expect(emailInput.locator('[data-test-not-verified]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-verification-sent]')).toHaveCount(0);
-
- user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
- await expect(user.email).toBe('old@email.com');
- await expect(user.emailVerified).toBe(true);
- await expect(user.emailVerificationToken).toBe(null);
- });
-
- test('server error', async ({ page, msw }) => {
- let user = msw.db.user.create({ email: 'old@email.com' });
- await msw.authenticateAs(user);
-
- let error = HttpResponse.json({}, { status: 500 });
- await msw.worker.use(http.put('/api/v1/users/:user_id', () => error));
-
- await page.goto('/settings/profile');
- const emailInput = page.locator('[data-test-email-input]');
- await emailInput.locator('[data-test-edit-button]').click();
- await emailInput.locator('[data-test-input]').fill('new@email.com');
-
- await emailInput.locator('[data-test-save-button]').click();
- await expect(emailInput.locator('[data-test-input]')).toHaveValue('new@email.com');
- await expect(emailInput.locator('[data-test-email-address]')).toHaveCount(0);
- await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(
- 'Error in saving email: An unknown error occurred while saving this email.',
- );
-
- user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
- await expect(user.email).toBe('old@email.com');
- await expect(user.emailVerified).toBe(true);
- await expect(user.emailVerificationToken).toBe(null);
- });
-
- test.describe('Resend button', function () {
- test('happy path', async ({ page, msw }) => {
- let user = msw.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' });
- await msw.authenticateAs(user);
-
- await page.goto('/settings/profile');
- await expect(page).toHaveURL('/settings/profile');
- const emailInput = page.locator('[data-test-email-input]');
- await expect(emailInput).toBeVisible();
- await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
- await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible();
- await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible();
- const button = emailInput.locator('[data-test-resend-button]');
- await expect(button).toBeEnabled();
- await expect(button).toHaveText('Resend');
-
- await button.click();
- await expect(button).toBeDisabled();
- await expect(button).toHaveText('Sent!');
- });
-
- test('server error', async ({ page, msw }) => {
- let user = msw.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' });
- await msw.authenticateAs(user);
-
- let error = HttpResponse.json({}, { status: 500 });
- await msw.worker.use(http.put('/api/v1/users/:user_id/resend', () => error));
-
- await page.goto('/settings/profile');
- await expect(page).toHaveURL('/settings/profile');
- const emailInput = page.locator('[data-test-email-input]');
- await expect(emailInput).toBeVisible();
- await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
- await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0);
- await expect(emailInput.locator('[data-test-not-verified]')).toBeVisible();
- await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible();
- const button = emailInput.locator('[data-test-resend-button]');
- await expect(button).toBeEnabled();
- await expect(button).toHaveText('Resend');
-
- await button.click();
- await expect(button).toBeEnabled();
- await expect(button).toHaveText('Resend');
- await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(
- 'Unknown error in resending message',
- );
- });
- });
-});
diff --git a/e2e/acceptance/email-confirmation.spec.ts b/e2e/acceptance/email-confirmation.spec.ts
index 034a5d144dc..07c1cc28796 100644
--- a/e2e/acceptance/email-confirmation.spec.ts
+++ b/e2e/acceptance/email-confirmation.spec.ts
@@ -2,35 +2,37 @@ import { expect, test } from '@/e2e/helper';
test.describe('Acceptance | Email Confirmation', { tag: '@acceptance' }, () => {
test('unauthenticated happy path', async ({ page, msw }) => {
- let user = msw.db.user.create({ emailVerificationToken: 'badc0ffee' });
+ let email = msw.db.email.create({ verified: false, token: 'badc0ffee' });
+ let user = msw.db.user.create({ emails: [email] });
+ await expect(email.verified).toBe(false);
await page.goto('/confirm/badc0ffee');
- await expect(user.emailVerified).toBe(false);
await expect(page).toHaveURL('/');
await expect(page.locator('[data-test-notification-message="success"]')).toBeVisible();
user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
- await expect(user.emailVerified).toBe(true);
+ await expect(user.emails[0].verified).toBe(true);
});
test('authenticated happy path', async ({ page, msw, ember }) => {
- let user = msw.db.user.create({ emailVerificationToken: 'badc0ffee' });
+ let email = msw.db.email.create({ token: 'badc0ffee' });
+ let user = msw.db.user.create({ emails: [email] });
await msw.authenticateAs(user);
+ await expect(email.verified).toBe(false);
await page.goto('/confirm/badc0ffee');
- await expect(user.emailVerified).toBe(false);
await expect(page).toHaveURL('/');
await expect(page.locator('[data-test-notification-message="success"]')).toBeVisible();
const emailVerified = await ember.evaluate(owner => {
const { currentUser } = owner.lookup('service:session');
- return currentUser.email_verified;
+ return currentUser.emails[0].verified;
});
expect(emailVerified).toBe(true);
user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
- await expect(user.emailVerified).toBe(true);
+ await expect(user.emails[0].verified).toBe(true);
});
test('error case', async ({ page }) => {
diff --git a/e2e/acceptance/email.spec.ts b/e2e/acceptance/email.spec.ts
new file mode 100644
index 00000000000..91f7a9203a3
--- /dev/null
+++ b/e2e/acceptance/email.spec.ts
@@ -0,0 +1,312 @@
+import { expect, test } from '@/e2e/helper';
+import { http, HttpResponse } from 'msw';
+
+test.describe('Acceptance | Email Management', { tag: '@acceptance' }, () => {
+ test.describe('Add email', () => {
+ test('happy path', async ({ page, msw }) => {
+ let user = msw.db.user.create({ emails: [msw.db.email.create({ email: 'old@email.com', verified: true })] });
+ await msw.authenticateAs(user);
+
+ await page.goto('/settings/profile');
+ await expect(page).toHaveURL('/settings/profile');
+
+ const existingEmail = page.locator('[data-test-email-input]:nth-of-type(1)');
+ await expect(existingEmail.locator('[data-test-email-address]')).toContainText('old@email.com');
+ await expect(existingEmail.locator('[data-test-verified]')).toBeVisible();
+ await expect(existingEmail.locator('[data-test-unverified]')).toHaveCount(0);
+ await expect(existingEmail.locator('[data-test-verification-sent]')).toHaveCount(0);
+ await expect(existingEmail.locator('[data-test-resend-button]')).toHaveCount(0);
+ await expect(existingEmail.locator('[data-test-remove-button]')).toHaveCount(0);
+
+ await expect(page.locator('[data-test-add-email-button]')).toBeVisible();
+ await expect(page.locator('[data-test-add-email-input]')).not.toBeVisible();
+
+ await page.locator('[data-test-add-email-button]').click();
+
+ const addEmailForm = page.locator('[data-test-add-email-input]');
+ const inputField = addEmailForm.locator('[data-test-input]');
+ const submitButton = addEmailForm.locator('[data-test-save-button]');
+
+ await expect(addEmailForm).toBeVisible();
+ await expect(addEmailForm.locator('[data-test-no-email]')).toHaveCount(0);
+ await expect(addEmailForm.locator('[data-test-unverified]')).toHaveCount(0);
+ await expect(addEmailForm.locator('[data-test-verified]')).toHaveCount(0);
+ await expect(addEmailForm.locator('[data-test-verification-sent]')).toHaveCount(0);
+ await expect(addEmailForm.locator('[data-test-resend-button]')).toHaveCount(0);
+ await expect(inputField).toContainText('');
+ await expect(submitButton).toBeDisabled();
+
+ await inputField.fill('');
+ await expect(inputField).toHaveValue('');
+ await expect(submitButton).toBeDisabled();
+
+ await inputField.fill('notanemail');
+ await expect(inputField).toHaveValue('notanemail');
+ await expect(submitButton).toBeDisabled();
+
+ await inputField.fill('new@email.com');
+ await expect(inputField).toHaveValue('new@email.com');
+ await expect(submitButton).toBeEnabled();
+
+ await submitButton.click();
+ const createdEmail = page.locator('[data-test-email-input]:nth-of-type(2)');
+ await expect(createdEmail.locator('[data-test-email-address]')).toContainText('new@email.com');
+ await expect(createdEmail.locator('[data-test-verified]')).toHaveCount(0);
+ await expect(createdEmail.locator('[data-test-unverified]')).toHaveCount(0);
+ await expect(createdEmail.locator('[data-test-verification-sent]')).toBeVisible();
+ await expect(createdEmail.locator('[data-test-resend-button]')).toBeEnabled();
+
+ user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
+ await expect(user.emails.length).toBe(2);
+ await expect(user.emails[0].email).toBe('old@email.com');
+ await expect(user.emails[1].email).toBe('new@email.com');
+ await expect(user.emails[1].verified).toBe(false);
+ });
+
+ test('happy path with no previous emails', async ({ page, msw }) => {
+ let user = msw.db.user.create({ emails: [] });
+ await msw.authenticateAs(user);
+
+ await page.goto('/settings/profile');
+ await expect(page).toHaveURL('/settings/profile');
+
+ const addEmailButton = page.locator('[data-test-add-email-button]');
+ const addEmailForm = page.locator('[data-test-add-email-input]');
+ const addEmailInput = addEmailForm.locator('[data-test-input]');
+
+ await expect(page.locator('[data-test-email-input]')).toHaveCount(0);
+ await expect(page.locator('[data-test-add-email-input]')).toHaveCount(0);
+ await expect(addEmailButton).toBeVisible();
+
+ await addEmailButton.click();
+ await expect(addEmailForm).toBeVisible();
+ await expect(addEmailForm.locator('[data-test-input]')).toContainText('');
+ await addEmailInput.fill('new@email.com');
+ await expect(addEmailInput).toHaveValue('new@email.com');
+ await addEmailForm.locator('[data-test-save-button]').click();
+
+ const createdEmail = page.locator('[data-test-email-input]:nth-of-type(1)');
+ await expect(createdEmail.locator('[data-test-email-address]')).toContainText('new@email.com');
+ await expect(createdEmail.locator('[data-test-verified]')).toHaveCount(0);
+ await expect(createdEmail.locator('[data-test-unverified]')).toHaveCount(0);
+ await expect(createdEmail.locator('[data-test-verification-sent]')).toBeVisible();
+ await expect(createdEmail.locator('[data-test-resend-button]')).toBeEnabled();
+
+ user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
+ await expect(user.emails.length).toBe(1);
+ await expect(user.emails[0].email).toBe('new@email.com');
+ await expect(user.emails[0].verified).toBe(false);
+ });
+
+ test('server error', async ({ page, msw }) => {
+ let user = msw.db.user.create({ emails: [] });
+ await msw.authenticateAs(user);
+
+ let error = HttpResponse.json({}, { status: 500 });
+ await msw.worker.use(http.post('/api/v1/users/:user_id/emails', () => error));
+
+ await page.goto('/settings/profile');
+
+ const addEmailForm = page.locator('[data-test-add-email-input]');
+
+ await page.locator('[data-test-add-email-button]').click();
+ await addEmailForm.locator('[data-test-input]').fill('new@email.com');
+ await addEmailForm.locator('[data-test-save-button]').click();
+
+ await expect(page.locator('[data-test-email-input]')).toHaveCount(0);
+ await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(
+ 'Unknown error in saving email',
+ );
+
+ user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
+ await expect(user.emails.length).toBe(0);
+ });
+ });
+
+ test.describe('Remove email', () => {
+ test('happy path', async ({ page, msw }) => {
+ let user = msw.db.user.create({
+ emails: [msw.db.email.create({ email: 'john@doe.com' }), msw.db.email.create({ email: 'jane@doe.com' })],
+ });
+ await msw.authenticateAs(user);
+
+ await page.goto('/settings/profile');
+ await expect(page).toHaveURL('/settings/profile');
+ const emailInputs = page.locator('[data-test-email-input]');
+
+ await expect(emailInputs).toHaveCount(2);
+ const firstEmailInput = emailInputs.nth(0);
+ const secondEmailInput = emailInputs.nth(1);
+
+ await expect(firstEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
+ await expect(secondEmailInput.locator('[data-test-email-address]')).toContainText('jane@doe.com');
+
+ await expect(firstEmailInput.locator('[data-test-remove-button]')).toBeVisible();
+ await expect(secondEmailInput.locator('[data-test-remove-button]')).toBeVisible();
+
+ await secondEmailInput.locator('[data-test-remove-button]').click();
+ await expect(emailInputs).toHaveCount(1);
+ await expect(firstEmailInput.locator('[data-test-remove-button]')).toHaveCount(0);
+
+ user = msw.db.user.findFirst({ where: { id: { equals: user.id } } });
+ await expect(user.emails.length).toBe(1);
+ await expect(user.emails[0].email).toBe('john@doe.com');
+ });
+
+ test('cannot remove primary email', async ({ page, msw }) => {
+ let user = msw.db.user.create({
+ emails: [
+ msw.db.email.create({ email: 'primary@doe.com', primary: true }),
+ msw.db.email.create({ email: 'john@doe.com' }),
+ ],
+ });
+ await msw.authenticateAs(user);
+
+ await page.goto('/settings/profile');
+ await expect(page).toHaveURL('/settings/profile');
+
+ const emailInputs = page.locator('[data-test-email-input]');
+ await expect(emailInputs).toHaveCount(2);
+ const primaryEmailInput = emailInputs.nth(0);
+ const johnEmailInput = emailInputs.nth(1);
+
+ await expect(primaryEmailInput.locator('[data-test-email-address]')).toContainText('primary@doe.com');
+ await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
+
+ await expect(primaryEmailInput.locator('[data-test-remove-button]')).toBeDisabled();
+ await expect(primaryEmailInput.locator('[data-test-remove-button]')).toHaveAttribute(
+ 'title',
+ 'Cannot delete primary email',
+ );
+ await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeVisible();
+ });
+
+ test('no delete button when only one email', async ({ page, msw }) => {
+ let user = msw.db.user.create({
+ emails: [
+ msw.db.email.create({
+ email: 'john@doe.com',
+ }),
+ ],
+ });
+ await msw.authenticateAs(user);
+
+ await page.goto('/settings/profile');
+ await expect(page).toHaveURL('/settings/profile');
+ const emailInput = page.locator('[data-test-email-input]');
+ await expect(emailInput).toBeVisible();
+ await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
+ await expect(emailInput.locator('[data-test-remove-button]')).toHaveCount(0);
+ });
+
+ test('server error', async ({ page, msw }) => {
+ let user = msw.db.user.create({
+ emails: [msw.db.email.create({ email: 'john@doe.com' }), msw.db.email.create({ email: 'jane@doe.com' })],
+ });
+ await msw.authenticateAs(user);
+
+ let error = HttpResponse.json({}, { status: 500 });
+ await msw.worker.use(http.delete('/api/v1/users/:user_id/emails/:email_id', () => error));
+
+ await page.goto('/settings/profile');
+
+ const emailInputs = page.locator('[data-test-email-input]');
+ await expect(emailInputs).toHaveCount(2);
+ const johnEmailInput = emailInputs.nth(0);
+ await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
+ await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeEnabled();
+ await johnEmailInput.locator('[data-test-remove-button]').click();
+ await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(
+ 'Unknown error in deleting email',
+ );
+ await expect(johnEmailInput.locator('[data-test-remove-button]')).toBeEnabled();
+ });
+ });
+
+ test.describe('Resend verification email', function () {
+ test('happy path', async ({ page, msw }) => {
+ let user = msw.db.user.create({
+ emails: [msw.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })],
+ });
+ await msw.authenticateAs(user);
+
+ await page.goto('/settings/profile');
+
+ const emailInput = page.locator('[data-test-email-input]:nth-of-type(1)');
+ await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
+
+ const resendButton = emailInput.locator('[data-test-resend-button]');
+ await expect(resendButton).toBeEnabled();
+ await expect(resendButton).toContainText('Resend');
+ await expect(emailInput.locator('[data-test-verified]')).toHaveCount(0);
+ await expect(emailInput.locator('[data-test-unverified]')).toHaveCount(0);
+ await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible();
+
+ await resendButton.click();
+ await expect(emailInput.locator('[data-test-verification-sent]')).toBeVisible();
+ await expect(resendButton).toContainText('Sent!');
+ await expect(resendButton).toBeDisabled();
+ });
+
+ test('server error', async ({ page, msw }) => {
+ let user = msw.db.user.create({
+ emails: [msw.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })],
+ });
+ await msw.authenticateAs(user);
+
+ let error = HttpResponse.json({}, { status: 500 });
+ await msw.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/resend', () => error));
+
+ await page.goto('/settings/profile');
+
+ const emailInput = page.locator('[data-test-email-input]:nth-of-type(1)');
+ await expect(emailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
+
+ const resendButton = emailInput.locator('[data-test-resend-button]');
+ await expect(resendButton).toBeEnabled();
+
+ await resendButton.click();
+ await expect(page.locator('[data-test-notification-message="error"]')).toHaveText(
+ 'Unknown error in resending message',
+ );
+ await expect(resendButton).toBeEnabled();
+ });
+ });
+
+ test.describe('Switch primary email', () => {
+ test('happy path', async ({ page, msw }) => {
+ let user = msw.db.user.create({
+ emails: [
+ msw.db.email.create({ email: 'john@doe.com', verified: true, primary: true }),
+ msw.db.email.create({ email: 'jane@doe.com', verified: true, primary: false }),
+ ],
+ });
+ await msw.authenticateAs(user);
+
+ await page.goto('/settings/profile');
+
+ const emailInputs = page.locator('[data-test-email-input]');
+ await expect(emailInputs).toHaveCount(2);
+
+ const johnEmailInput = emailInputs.nth(0);
+ const janeEmailInput = emailInputs.nth(1);
+
+ await expect(johnEmailInput.locator('[data-test-email-address]')).toContainText('john@doe.com');
+ await expect(janeEmailInput.locator('[data-test-email-address]')).toContainText('jane@doe.com');
+
+ const johnMarkPrimaryButton = johnEmailInput.locator('[data-test-primary-button]');
+ const janeMarkPrimaryButton = janeEmailInput.locator('[data-test-primary-button]');
+
+ await expect(johnEmailInput.locator('[data-test-primary]')).toBeVisible();
+ await expect(janeEmailInput.locator('[data-test-primary]')).toHaveCount(0);
+ await expect(johnMarkPrimaryButton).toHaveCount(0);
+ await expect(janeMarkPrimaryButton).toBeEnabled();
+
+ await janeMarkPrimaryButton.click();
+ await expect(johnEmailInput.locator('[data-test-primary]')).toHaveCount(0);
+ await expect(janeEmailInput.locator('[data-test-primary]')).toBeVisible();
+ await expect(johnMarkPrimaryButton).toBeEnabled();
+ await expect(janeMarkPrimaryButton).toHaveCount(0);
+ });
+ });
+});
diff --git a/e2e/acceptance/publish-notifications.spec.ts b/e2e/acceptance/publish-notifications.spec.ts
index 2d801fff89c..2f8f5ceab32 100644
--- a/e2e/acceptance/publish-notifications.spec.ts
+++ b/e2e/acceptance/publish-notifications.spec.ts
@@ -4,7 +4,7 @@ import { http, HttpResponse } from 'msw';
test.describe('Acceptance | publish notifications', { tag: '@acceptance' }, () => {
test('unsubscribe and resubscribe', async ({ page, msw }) => {
- let user = msw.db.user.create();
+ let user = msw.db.user.create({ emails: [msw.db.email.create()] });
await msw.authenticateAs(user);
await page.goto('/settings/profile');
@@ -27,7 +27,7 @@ test.describe('Acceptance | publish notifications', { tag: '@acceptance' }, () =
});
test('loading state', async ({ page, msw }) => {
- let user = msw.db.user.create();
+ let user = msw.db.user.create({ emails: [msw.db.email.create()] });
await msw.authenticateAs(user);
let deferred = defer();
@@ -48,7 +48,7 @@ test.describe('Acceptance | publish notifications', { tag: '@acceptance' }, () =
});
test('error state', async ({ page, msw }) => {
- let user = msw.db.user.create();
+ let user = msw.db.user.create({ emails: [msw.db.email.create()] });
await msw.authenticateAs(user);
msw.worker.use(http.put('/api/v1/users/:user_id', () => HttpResponse.text('', { status: 500 })));
diff --git a/e2e/acceptance/settings/settings.spec.ts b/e2e/acceptance/settings/settings.spec.ts
index 9adf2c31ea1..366d16d99c4 100644
--- a/e2e/acceptance/settings/settings.spec.ts
+++ b/e2e/acceptance/settings/settings.spec.ts
@@ -2,7 +2,10 @@ import { expect, test } from '@/e2e/helper';
test.describe('Acceptance | Settings', { tag: '@acceptance' }, () => {
test.beforeEach(async ({ msw }) => {
- let user1 = msw.db.user.create({ name: 'blabaere' });
+ let user1 = msw.db.user.create({
+ name: 'blabaere',
+ emails: [msw.db.email.create({ email: 'blabaere@crates.io', primary: true })],
+ });
let user2 = msw.db.user.create({ name: 'thehydroimpulse' });
let team1 = msw.db.team.create({ org: 'org', name: 'blabaere' });
let team2 = msw.db.team.create({ org: 'org', name: 'thehydroimpulse' });
diff --git a/e2e/routes/crate/settings.spec.ts b/e2e/routes/crate/settings.spec.ts
index 2eba1136335..177cb8bc3b0 100644
--- a/e2e/routes/crate/settings.spec.ts
+++ b/e2e/routes/crate/settings.spec.ts
@@ -4,7 +4,9 @@ import { http, HttpResponse } from 'msw';
test.describe('Route | crate.settings', { tag: '@routes' }, () => {
async function prepare(msw) {
- let user = msw.db.user.create();
+ let user = msw.db.user.create({
+ emails: [msw.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })],
+ });
let crate = msw.db.crate.create({ name: 'foo' });
msw.db.version.create({ crate });
diff --git a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts
index d69fcf81b0d..cbf0b11cd8d 100644
--- a/e2e/routes/crate/settings/new-trusted-publisher.spec.ts
+++ b/e2e/routes/crate/settings/new-trusted-publisher.spec.ts
@@ -4,7 +4,9 @@ import { defer } from '@/e2e/deferred';
test.describe('Route | crate.settings.new-trusted-publisher', { tag: '@routes' }, () => {
async function prepare(msw) {
- let user = msw.db.user.create();
+ let user = msw.db.user.create({
+ emails: [msw.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })],
+ });
let crate = msw.db.crate.create({ name: 'foo' });
msw.db.version.create({ crate });
diff --git a/eslint.config.mjs b/eslint.config.mjs
index fbb623bbcc6..ad2c0ac3b6e 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -113,8 +113,6 @@ export default [
'unicorn/no-anonymous-default-export': 'off',
// disabled because of false positives related to `EmberArray`
'unicorn/no-array-for-each': 'off',
- // disabled because `toReversed` is not "widely supported" yet
- 'unicorn/no-array-reverse': 'off',
// disabled because it is annoying in some cases...
'unicorn/no-await-expression-member': 'off',
// disabled because we need `null` since JSON has no `undefined`
diff --git a/package.json b/package.json
index 7bf9b1c903e..0749f54a8b8 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"dependencies": {
"@floating-ui/dom": "1.7.2",
"@juggle/resize-observer": "3.4.0",
- "@sentry/ember": "9.42.0",
+ "@sentry/ember": "9.40.0",
"chart.js": "4.5.0",
"date-fns": "4.1.0",
"highlight.js": "11.11.1",
@@ -67,13 +67,13 @@
"@embroider/core": "3.5.7",
"@embroider/webpack": "4.1.1",
"@eslint/eslintrc": "3.3.1",
- "@eslint/js": "9.32.0",
+ "@eslint/js": "9.31.0",
"@glimmer/component": "2.0.0",
"@glimmer/tracking": "1.1.2",
"@mainmatter/ember-api-actions": "0.6.0",
"@percy/cli": "1.31.0",
"@percy/ember": "4.2.0",
- "@percy/playwright": "1.0.9",
+ "@percy/playwright": "1.0.8",
"@playwright/test": "1.54.1",
"@sinonjs/fake-timers": "14.0.0",
"@types/node": "22.16.5",
@@ -116,15 +116,15 @@
"ember-truth-helpers": "4.0.3",
"ember-web-app": "5.0.1",
"ember-window-mock": "1.0.2",
- "eslint": "9.32.0",
+ "eslint": "9.31.0",
"eslint-config-prettier": "10.1.8",
- "eslint-plugin-ember": "12.7.0",
+ "eslint-plugin-ember": "12.6.0",
"eslint-plugin-ember-concurrency": "0.5.1",
"eslint-plugin-import-helpers": "2.0.1",
"eslint-plugin-prettier": "5.5.3",
"eslint-plugin-qunit": "8.2.4",
"eslint-plugin-qunit-dom": "0.2.0",
- "eslint-plugin-unicorn": "60.0.0",
+ "eslint-plugin-unicorn": "59.0.1",
"globals": "16.3.0",
"globby": "14.1.0",
"loader.js": "4.7.0",
@@ -144,7 +144,7 @@
"webpack": "5.100.2"
},
"resolutions": {
- "@babel/runtime": "7.28.2",
+ "@babel/runtime": "7.27.6",
"ember-auto-import": "2.10.0",
"ember-get-config": "2.1.1",
"ember-inflector": "6.0.0",
diff --git a/packages/crates-io-msw/handlers/emails/add.js b/packages/crates-io-msw/handlers/emails/add.js
new file mode 100644
index 00000000000..c76eccde591
--- /dev/null
+++ b/packages/crates-io-msw/handlers/emails/add.js
@@ -0,0 +1,36 @@
+import { http, HttpResponse } from 'msw';
+
+import { db } from '../../index.js';
+import { serializeEmail } from '../../serializers/email.js';
+import { getSession } from '../../utils/session.js';
+
+export default http.post('/api/v1/users/:user_id/emails', async ({ params, request }) => {
+ let { user_id } = params;
+
+ let { user } = getSession();
+ if (!user) {
+ return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 });
+ }
+ if (user.id.toString() !== user_id) {
+ return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 });
+ }
+
+ if (!user) {
+ return HttpResponse.json({ errors: [{ detail: 'User not found.' }] }, { status: 404 });
+ }
+
+ let email = db.email.create({
+ email: (await request.json()).email,
+ verified: false,
+ verification_email_sent: true,
+ primary: false,
+ });
+ db.user.update({
+ where: { id: { equals: user.id } },
+ data: {
+ emails: [...user.emails, email],
+ },
+ });
+
+ return HttpResponse.json(serializeEmail(email));
+});
diff --git a/packages/crates-io-msw/handlers/emails/add.test.js b/packages/crates-io-msw/handlers/emails/add.test.js
new file mode 100644
index 00000000000..9c5f5486866
--- /dev/null
+++ b/packages/crates-io-msw/handlers/emails/add.test.js
@@ -0,0 +1,39 @@
+import { assert, test } from 'vitest';
+
+import { db } from '../../index.js';
+
+test('returns an error for unauthenticated requests', async function () {
+ let response = await fetch('/api/v1/users/1/emails', { method: 'POST' });
+ assert.strictEqual(response.status, 403);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'must be logged in to perform that action' }],
+ });
+});
+
+test('returns an error for requests to a different user', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let response = await fetch('/api/v1/users/512/emails', { method: 'POST' });
+ assert.strictEqual(response.status, 400);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'current user does not match requested user' }],
+ });
+});
+
+test('returns email for valid, authenticated request', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let response = await fetch(`/api/v1/users/${user.id}/emails`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email: 'test@example.com' }),
+ });
+ assert.strictEqual(response.status, 200);
+ let email = await response.json();
+ assert.strictEqual(email.email, 'test@example.com');
+ assert.strictEqual(email.verified, false);
+ assert.strictEqual(email.verification_email_sent, true);
+ assert.strictEqual(email.primary, false);
+});
diff --git a/packages/crates-io-msw/handlers/emails/confirm.js b/packages/crates-io-msw/handlers/emails/confirm.js
new file mode 100644
index 00000000000..824b58c9314
--- /dev/null
+++ b/packages/crates-io-msw/handlers/emails/confirm.js
@@ -0,0 +1,23 @@
+import { http, HttpResponse } from 'msw';
+
+import { db } from '../../index.js';
+import { serializeEmail } from '../../serializers/email.js';
+
+export default http.put('/api/v1/confirm/:token', ({ params }) => {
+ let { token } = params;
+
+ let email = db.email.findFirst({ where: { token: { equals: token } } });
+ if (!email) {
+ return HttpResponse.json({ errors: [{ detail: 'Email belonging to token not found.' }] }, { status: 400 });
+ }
+
+ db.email.update({ where: { id: email.id }, data: { verified: true } });
+
+ return HttpResponse.json({
+ ok: true,
+ email: serializeEmail({
+ ...email,
+ verified: true,
+ }),
+ });
+});
diff --git a/packages/crates-io-msw/handlers/users/confirm-email.test.js b/packages/crates-io-msw/handlers/emails/confirm.test.js
similarity index 51%
rename from packages/crates-io-msw/handlers/users/confirm-email.test.js
rename to packages/crates-io-msw/handlers/emails/confirm.test.js
index 64c7fea7207..e86c4d23374 100644
--- a/packages/crates-io-msw/handlers/users/confirm-email.test.js
+++ b/packages/crates-io-msw/handlers/emails/confirm.test.js
@@ -1,31 +1,34 @@
import { assert, test } from 'vitest';
import { db } from '../../index.js';
+import { serializeEmail } from '../../serializers/email.js';
test('returns `ok: true` for a known token (unauthenticated)', async function () {
- let user = db.user.create({ emailVerificationToken: 'foo' });
- assert.strictEqual(user.emailVerified, false);
+ let email = db.email.create({ token: 'foo' });
+ let user = db.user.create({ emails: [email] });
+ assert.strictEqual(email.verified, false);
let response = await fetch('/api/v1/confirm/foo', { method: 'PUT' });
assert.strictEqual(response.status, 200);
- assert.deepEqual(await response.json(), { ok: true });
+ assert.deepEqual(await response.json(), { ok: true, email: serializeEmail({ ...email, verified: true }) });
- user = db.user.findFirst({ where: { id: user.id } });
- assert.strictEqual(user.emailVerified, true);
+ email = db.email.findFirst({ where: { id: user.emails[0].id } });
+ assert.strictEqual(email.verified, true);
});
test('returns `ok: true` for a known token (authenticated)', async function () {
- let user = db.user.create({ emailVerificationToken: 'foo' });
- assert.strictEqual(user.emailVerified, false);
+ let email = db.email.create({ token: 'foo' });
+ let user = db.user.create({ emails: [email] });
+ assert.strictEqual(email.verified, false);
db.mswSession.create({ user });
let response = await fetch('/api/v1/confirm/foo', { method: 'PUT' });
assert.strictEqual(response.status, 200);
- assert.deepEqual(await response.json(), { ok: true });
+ assert.deepEqual(await response.json(), { ok: true, email: serializeEmail({ ...email, verified: true }) });
- user = db.user.findFirst({ where: { id: user.id } });
- assert.strictEqual(user.emailVerified, true);
+ email = db.email.findFirst({ where: { id: user.emails[0].id } });
+ assert.strictEqual(email.verified, true);
});
test('returns an error for unknown tokens', async function () {
diff --git a/packages/crates-io-msw/handlers/emails/delete.js b/packages/crates-io-msw/handlers/emails/delete.js
new file mode 100644
index 00000000000..c1398dcf1ac
--- /dev/null
+++ b/packages/crates-io-msw/handlers/emails/delete.js
@@ -0,0 +1,39 @@
+import { http, HttpResponse } from 'msw';
+
+import { db } from '../../index.js';
+import { getSession } from '../../utils/session.js';
+
+export default http.delete('/api/v1/users/:user_id/emails/:email_id', ({ params }) => {
+ let { user_id, email_id } = params;
+
+ let { user } = getSession();
+ if (!user) {
+ return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 });
+ }
+ if (user.id.toString() !== user_id) {
+ return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 });
+ }
+
+ let email = db.email.findFirst({ where: { id: { equals: parseInt(email_id) } } });
+ if (!email) {
+ return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 });
+ }
+
+ // Prevent deletion if this is primary email
+ if (email.primary) {
+ return HttpResponse.json(
+ { errors: [{ detail: 'cannot delete primary email, please set another email as primary first' }] },
+ { status: 400 },
+ );
+ }
+
+ // Check how many emails the user has, if this is the only verified email, prevent deletion
+ let userEmails = db.email.findMany({ where: { user_id: { equals: user.id } } });
+ if (userEmails.length === 1) {
+ return HttpResponse.json({ errors: [{ detail: 'Cannot delete your only email address.' }] }, { status: 400 });
+ }
+
+ db.email.delete({ where: { id: { equals: parseInt(email_id) } } });
+
+ return HttpResponse.json({ ok: true });
+});
diff --git a/packages/crates-io-msw/handlers/emails/delete.test.js b/packages/crates-io-msw/handlers/emails/delete.test.js
new file mode 100644
index 00000000000..ae5e4c26697
--- /dev/null
+++ b/packages/crates-io-msw/handlers/emails/delete.test.js
@@ -0,0 +1,66 @@
+import { assert, test } from 'vitest';
+
+import { db } from '../../index.js';
+
+test('returns an error for unauthenticated requests', async function () {
+ let response = await fetch('/api/v1/users/1/emails/1', { method: 'DELETE' });
+ assert.strictEqual(response.status, 403);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'must be logged in to perform that action' }],
+ });
+});
+
+test('returns an error for requests to a different user', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let response = await fetch('/api/v1/users/512/emails/1', { method: 'DELETE' });
+ assert.strictEqual(response.status, 400);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'current user does not match requested user' }],
+ });
+});
+
+test('returns an error for non-existent email', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let response = await fetch(`/api/v1/users/${user.id}/emails/999`, { method: 'DELETE' });
+ assert.strictEqual(response.status, 404);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'Email not found.' }],
+ });
+});
+
+test('prevents deletion of primary email', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let email = db.email.create({ user_id: user.id, email: 'test@example.com', primary: true });
+
+ let response = await fetch(`/api/v1/users/${user.id}/emails/${email.id}`, { method: 'DELETE' });
+ assert.strictEqual(response.status, 400);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'cannot delete primary email, please set another email as primary first' }],
+ });
+});
+
+test('successfully deletes alternate email', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let email1 = db.email.create({ user_id: user.id, email: 'test1@example.com', primary: true });
+ let email2 = db.email.create({ user_id: user.id, email: 'test2@example.com' });
+
+ let response = await fetch(`/api/v1/users/${user.id}/emails/${email2.id}`, { method: 'DELETE' });
+ assert.strictEqual(response.status, 200);
+ assert.deepEqual(await response.json(), { ok: true });
+
+ // Check that email2 was deleted
+ let deletedEmail = db.email.findFirst({ where: { id: { equals: email2.id } } });
+ assert.strictEqual(deletedEmail, null);
+
+ // Check that email1 still exists
+ let remainingEmail = db.email.findFirst({ where: { id: { equals: email1.id } } });
+ assert.strictEqual(remainingEmail.email, 'test1@example.com');
+});
diff --git a/packages/crates-io-msw/handlers/users/resend.js b/packages/crates-io-msw/handlers/emails/resend.js
similarity index 61%
rename from packages/crates-io-msw/handlers/users/resend.js
rename to packages/crates-io-msw/handlers/emails/resend.js
index a9afc9395b3..4ca720edb15 100644
--- a/packages/crates-io-msw/handlers/users/resend.js
+++ b/packages/crates-io-msw/handlers/emails/resend.js
@@ -1,8 +1,9 @@
import { http, HttpResponse } from 'msw';
+import { db } from '../../index.js';
import { getSession } from '../../utils/session.js';
-export default http.put('/api/v1/users/:user_id/resend', ({ params }) => {
+export default http.put('/api/v1/users/:user_id/emails/:email_id/resend', ({ params }) => {
let { user } = getSession();
if (!user) {
return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 });
@@ -12,6 +13,11 @@ export default http.put('/api/v1/users/:user_id/resend', ({ params }) => {
return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 });
}
+ let email = db.email.findFirst({ where: { id: { equals: parseInt(params.email_id) } } });
+ if (!email) {
+ return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 });
+ }
+
// let's pretend that we're sending an email here... :D
return HttpResponse.json({ ok: true });
diff --git a/packages/crates-io-msw/handlers/users/resend.test.js b/packages/crates-io-msw/handlers/emails/resend.test.js
similarity index 56%
rename from packages/crates-io-msw/handlers/users/resend.test.js
rename to packages/crates-io-msw/handlers/emails/resend.test.js
index a260624ff5a..1d52b632743 100644
--- a/packages/crates-io-msw/handlers/users/resend.test.js
+++ b/packages/crates-io-msw/handlers/emails/resend.test.js
@@ -3,27 +3,27 @@ import { assert, test } from 'vitest';
import { db } from '../../index.js';
test('returns `ok`', async function () {
- let user = db.user.create();
+ let user = db.user.create({ emails: [db.email.create({ verified: false })] });
db.mswSession.create({ user });
- let response = await fetch(`/api/v1/users/${user.id}/resend`, { method: 'PUT' });
+ let response = await fetch(`/api/v1/users/${user.id}/emails/${user.emails[0].id}/resend`, { method: 'PUT' });
assert.strictEqual(response.status, 200);
assert.deepEqual(await response.json(), { ok: true });
});
test('returns 403 when not logged in', async function () {
- let user = db.user.create();
+ let user = db.user.create({ emails: [db.email.create({ verified: false })] });
- let response = await fetch(`/api/v1/users/${user.id}/resend`, { method: 'PUT' });
+ let response = await fetch(`/api/v1/users/${user.id}/emails/${user.emails[0].id}/resend`, { method: 'PUT' });
assert.strictEqual(response.status, 403);
assert.deepEqual(await response.json(), { errors: [{ detail: 'must be logged in to perform that action' }] });
});
test('returns 400 when requesting the wrong user id', async function () {
- let user = db.user.create();
+ let user = db.user.create({ emails: [db.email.create({ verified: false })] });
db.mswSession.create({ user });
- let response = await fetch(`/api/v1/users/wrong-id/resend`, { method: 'PUT' });
+ let response = await fetch(`/api/v1/users/wrong-id/emails/${user.emails[0].id}/resend`, { method: 'PUT' });
assert.strictEqual(response.status, 400);
assert.deepEqual(await response.json(), { errors: [{ detail: 'current user does not match requested user' }] });
});
diff --git a/packages/crates-io-msw/handlers/emails/set-primary.js b/packages/crates-io-msw/handlers/emails/set-primary.js
new file mode 100644
index 00000000000..667433a878c
--- /dev/null
+++ b/packages/crates-io-msw/handlers/emails/set-primary.js
@@ -0,0 +1,36 @@
+import { http, HttpResponse } from 'msw';
+
+import { db } from '../../index.js';
+import { getSession } from '../../utils/session.js';
+
+export default http.put('/api/v1/users/:user_id/emails/:email_id/set_primary', async ({ params }) => {
+ let { user_id, email_id } = params;
+
+ let { user } = getSession();
+ if (!user) {
+ return HttpResponse.json({ errors: [{ detail: 'must be logged in to perform that action' }] }, { status: 403 });
+ }
+ if (user.id.toString() !== user_id) {
+ return HttpResponse.json({ errors: [{ detail: 'current user does not match requested user' }] }, { status: 400 });
+ }
+
+ let email = db.email.findFirst({ where: { id: { equals: parseInt(email_id) } } });
+ if (!email) {
+ return HttpResponse.json({ errors: [{ detail: 'Email not found.' }] }, { status: 404 });
+ }
+
+ // Update email to set as primary
+ db.email.update({
+ where: { id: { equals: parseInt(email_id) } },
+ data: { primary: true },
+ });
+ // Update all other emails to remove primary status
+ db.email.updateMany({
+ where: { user_id: { equals: user.id }, id: { notEquals: parseInt(email_id) } },
+ data: { primary: false },
+ });
+
+ let updatedEmail = db.email.findFirst({ where: { id: { equals: parseInt(email_id) } } });
+
+ return HttpResponse.json(updatedEmail);
+});
diff --git a/packages/crates-io-msw/handlers/emails/set-primary.test.js b/packages/crates-io-msw/handlers/emails/set-primary.test.js
new file mode 100644
index 00000000000..40c923d18f6
--- /dev/null
+++ b/packages/crates-io-msw/handlers/emails/set-primary.test.js
@@ -0,0 +1,50 @@
+import { assert, test } from 'vitest';
+
+import { db } from '../../index.js';
+
+test('returns an error for unauthenticated requests', async function () {
+ let response = await fetch('/api/v1/users/1/emails/1/set_primary', { method: 'PUT' });
+ assert.strictEqual(response.status, 403);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'must be logged in to perform that action' }],
+ });
+});
+
+test('returns an error for requests to a different user', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let response = await fetch('/api/v1/users/512/emails/1/set_primary', { method: 'PUT' });
+ assert.strictEqual(response.status, 400);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'current user does not match requested user' }],
+ });
+});
+
+test('returns an error for non-existent email', async function () {
+ let user = db.user.create();
+ db.mswSession.create({ user });
+
+ let response = await fetch(`/api/v1/users/${user.id}/emails/999/set_primary`, { method: 'PUT' });
+ assert.strictEqual(response.status, 404);
+ assert.deepEqual(await response.json(), {
+ errors: [{ detail: 'Email not found.' }],
+ });
+});
+
+test('successfully marks email as primary', async function () {
+ let email = db.email.create({ primary: false });
+ let user = db.user.create({ emails: [email] });
+
+ db.mswSession.create({ user });
+
+ let response = await fetch(`/api/v1/users/${user.id}/emails/${email.id}/set_primary`, { method: 'PUT' });
+ assert.strictEqual(response.status, 200);
+ let updatedEmail = await response.json();
+ assert.strictEqual(updatedEmail.primary, true);
+ assert.strictEqual(updatedEmail.email, 'foo@crates.io');
+
+ // Verify the change was persisted
+ let emailFromDb = db.email.findFirst({ where: { id: { equals: email.id } } });
+ assert.strictEqual(emailFromDb.primary, true);
+});
diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/create.js b/packages/crates-io-msw/handlers/trustpub/github-configs/create.js
index 1e6a7e02a23..04a37f120e1 100644
--- a/packages/crates-io-msw/handlers/trustpub/github-configs/create.js
+++ b/packages/crates-io-msw/handlers/trustpub/github-configs/create.js
@@ -38,7 +38,7 @@ export default http.post('/api/v1/trusted_publishing/github_configs', async ({ r
}
// Check if the user has a verified email
- let hasVerifiedEmail = user.emailVerified;
+ let hasVerifiedEmail = user.emails.some(email => email.verified);
if (!hasVerifiedEmail) {
let detail = 'You must verify your email address to create a Trusted Publishing config';
return HttpResponse.json({ errors: [{ detail }] }, { status: 403 });
diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js b/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js
index e93205fad31..586bb2c6372 100644
--- a/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js
+++ b/packages/crates-io-msw/handlers/trustpub/github-configs/create.test.js
@@ -16,7 +16,7 @@ test('happy path', async function () {
let crate = db.crate.create({ name: 'test-crate' });
db.version.create({ crate });
- let user = db.user.create({ emailVerified: true });
+ let user = db.user.create({ emails: [db.email.create({ verified: true })] });
db.mswSession.create({ user });
// Create crate ownership
@@ -58,7 +58,7 @@ test('happy path with environment', async function () {
let crate = db.crate.create({ name: 'test-crate-env' });
db.version.create({ crate });
- let user = db.user.create({ emailVerified: true });
+ let user = db.user.create({ emails: [db.email.create({ verified: true })] });
db.mswSession.create({ user });
// Create crate ownership
@@ -199,7 +199,7 @@ test('returns 403 if user email is not verified', async function () {
let crate = db.crate.create({ name: 'test-crate-unverified' });
db.version.create({ crate });
- let user = db.user.create({ emailVerified: false });
+ let user = db.user.create({ emails: [db.email.create({ verified: false })] });
db.mswSession.create({ user });
// Create crate ownership
diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js b/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js
index a4e09990b16..c83781fca0e 100644
--- a/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js
+++ b/packages/crates-io-msw/handlers/trustpub/github-configs/delete.test.js
@@ -6,7 +6,7 @@ test('happy path', async function () {
let crate = db.crate.create({ name: 'test-crate' });
db.version.create({ crate });
- let user = db.user.create({ email_verified: true });
+ let user = db.user.create({ emails: [db.email.create({ verified: true })] });
db.mswSession.create({ user });
// Create crate ownership
diff --git a/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js b/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js
index b7394b5a20d..21ad5d554c9 100644
--- a/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js
+++ b/packages/crates-io-msw/handlers/trustpub/github-configs/list.test.js
@@ -6,7 +6,7 @@ test('happy path', async function () {
let crate = db.crate.create({ name: 'test-crate' });
db.version.create({ crate });
- let user = db.user.create({ email_verified: true });
+ let user = db.user.create({ emails: [db.email.create({ verified: true })] });
db.mswSession.create({ user });
// Create crate ownership
@@ -67,7 +67,7 @@ test('happy path with no configs', async function () {
let crate = db.crate.create({ name: 'test-crate-empty' });
db.version.create({ crate });
- let user = db.user.create({ email_verified: true });
+ let user = db.user.create({ emails: [db.email.create({ verified: true })] });
db.mswSession.create({ user });
// Create crate ownership
diff --git a/packages/crates-io-msw/handlers/users.js b/packages/crates-io-msw/handlers/users.js
index 51a9a9e05b6..922d802e1d7 100644
--- a/packages/crates-io-msw/handlers/users.js
+++ b/packages/crates-io-msw/handlers/users.js
@@ -1,7 +1,10 @@
-import confirmEmail from './users/confirm-email.js';
+import addEmail from './emails/add.js';
+import confirmEmail from './emails/confirm.js';
+import deleteEmail from './emails/delete.js';
+import resend from './emails/resend.js';
+import enableNotifications from './emails/set-primary.js';
import getUser from './users/get.js';
import me from './users/me.js';
-import resend from './users/resend.js';
import updateUser from './users/update.js';
-export default [getUser, updateUser, resend, me, confirmEmail];
+export default [getUser, updateUser, resend, me, confirmEmail, addEmail, deleteEmail, enableNotifications];
diff --git a/packages/crates-io-msw/handlers/users/confirm-email.js b/packages/crates-io-msw/handlers/users/confirm-email.js
deleted file mode 100644
index d42c7b13d44..00000000000
--- a/packages/crates-io-msw/handlers/users/confirm-email.js
+++ /dev/null
@@ -1,16 +0,0 @@
-import { http, HttpResponse } from 'msw';
-
-import { db } from '../../index.js';
-
-export default http.put('/api/v1/confirm/:token', ({ params }) => {
- let { token } = params;
-
- let user = db.user.findFirst({ where: { emailVerificationToken: { equals: token } } });
- if (!user) {
- return HttpResponse.json({ errors: [{ detail: 'Email belonging to token not found.' }] }, { status: 400 });
- }
-
- db.user.update({ where: { id: user.id }, data: { emailVerified: true, emailVerificationToken: null } });
-
- return HttpResponse.json({ ok: true });
-});
diff --git a/packages/crates-io-msw/handlers/users/me.test.js b/packages/crates-io-msw/handlers/users/me.test.js
index fba39926019..a8d77dd445c 100644
--- a/packages/crates-io-msw/handlers/users/me.test.js
+++ b/packages/crates-io-msw/handlers/users/me.test.js
@@ -3,7 +3,16 @@ import { assert, test } from 'vitest';
import { db } from '../../index.js';
test('returns the `user` resource including the private fields', async function () {
- let user = db.user.create();
+ let user = db.user.create({
+ emails: [
+ db.email.create({
+ email: 'user-1@crates.io',
+ primary: true,
+ verification_email_sent: true,
+ verified: true,
+ }),
+ ],
+ });
db.mswSession.create({ user });
let response = await fetch('/api/v1/me');
@@ -12,9 +21,15 @@ test('returns the `user` resource including the private fields', async function
user: {
id: 1,
avatar: 'https://avatars1.githubusercontent.com/u/14631425?v=4',
- email: 'user-1@crates.io',
- email_verification_sent: true,
- email_verified: true,
+ emails: [
+ {
+ id: 1,
+ email: 'user-1@crates.io',
+ verified: true,
+ verification_email_sent: true,
+ primary: true,
+ },
+ ],
is_admin: false,
login: 'user-1',
name: 'User 1',
diff --git a/packages/crates-io-msw/handlers/users/update.js b/packages/crates-io-msw/handlers/users/update.js
index 83480ee544a..f8f570fd744 100644
--- a/packages/crates-io-msw/handlers/users/update.js
+++ b/packages/crates-io-msw/handlers/users/update.js
@@ -25,20 +25,5 @@ export default http.put('/api/v1/users/:user_id', async ({ params, request }) =>
});
}
- if (json.user.email !== undefined) {
- if (!json.user.email) {
- return HttpResponse.json({ errors: [{ detail: 'empty email rejected' }] }, { status: 400 });
- }
-
- db.user.update({
- where: { id: { equals: user.id } },
- data: {
- email: json.user.email,
- emailVerified: false,
- emailVerificationToken: 'secret123',
- },
- });
- }
-
return HttpResponse.json({ ok: true });
});
diff --git a/packages/crates-io-msw/handlers/users/update.test.js b/packages/crates-io-msw/handlers/users/update.test.js
index 0456a47d530..52981929949 100644
--- a/packages/crates-io-msw/handlers/users/update.test.js
+++ b/packages/crates-io-msw/handlers/users/update.test.js
@@ -2,21 +2,6 @@ import { assert, test } from 'vitest';
import { db } from '../../index.js';
-test('updates the user with a new email address', async function () {
- let user = db.user.create({ email: 'old@email.com' });
- db.mswSession.create({ user });
-
- let body = JSON.stringify({ user: { email: 'new@email.com' } });
- let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body });
- assert.strictEqual(response.status, 200);
- assert.deepEqual(await response.json(), { ok: true });
-
- user = db.user.findFirst({ where: { id: user.id } });
- assert.strictEqual(user.email, 'new@email.com');
- assert.strictEqual(user.emailVerified, false);
- assert.strictEqual(user.emailVerificationToken, 'secret123');
-});
-
test('updates the `publish_notifications` settings', async function () {
let user = db.user.create();
db.mswSession.create({ user });
@@ -32,52 +17,38 @@ test('updates the `publish_notifications` settings', async function () {
});
test('returns 403 when not logged in', async function () {
- let user = db.user.create({ email: 'old@email.com' });
+ let user = db.user.create({ emails: [db.email.create()] });
+ assert.strictEqual(user.publishNotifications, true);
- let body = JSON.stringify({ user: { email: 'new@email.com' } });
+ let body = JSON.stringify({ user: { publish_notifications: false } });
let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body });
assert.strictEqual(response.status, 403);
assert.deepEqual(await response.json(), { errors: [{ detail: 'must be logged in to perform that action' }] });
user = db.user.findFirst({ where: { id: user.id } });
- assert.strictEqual(user.email, 'old@email.com');
+ assert.strictEqual(user.publishNotifications, true);
});
test('returns 400 when requesting the wrong user id', async function () {
- let user = db.user.create({ email: 'old@email.com' });
+ let user = db.user.create({ emails: [db.email.create()] });
+ assert.strictEqual(user.publishNotifications, true);
db.mswSession.create({ user });
- let body = JSON.stringify({ user: { email: 'new@email.com' } });
+ let body = JSON.stringify({ user: { publish_notifications: false } });
let response = await fetch(`/api/v1/users/wrong-id`, { method: 'PUT', body });
assert.strictEqual(response.status, 400);
assert.deepEqual(await response.json(), { errors: [{ detail: 'current user does not match requested user' }] });
user = db.user.findFirst({ where: { id: user.id } });
- assert.strictEqual(user.email, 'old@email.com');
+ assert.strictEqual(user.publishNotifications, true);
});
test('returns 400 when sending an invalid payload', async function () {
- let user = db.user.create({ email: 'old@email.com' });
+ let user = db.user.create({ emails: [db.email.create()] });
db.mswSession.create({ user });
let body = JSON.stringify({});
let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body });
assert.strictEqual(response.status, 400);
assert.deepEqual(await response.json(), { errors: [{ detail: 'invalid json request' }] });
-
- user = db.user.findFirst({ where: { id: user.id } });
- assert.strictEqual(user.email, 'old@email.com');
-});
-
-test('returns 400 when sending an empty email address', async function () {
- let user = db.user.create({ email: 'old@email.com' });
- db.mswSession.create({ user });
-
- let body = JSON.stringify({ user: { email: '' } });
- let response = await fetch(`/api/v1/users/${user.id}`, { method: 'PUT', body });
- assert.strictEqual(response.status, 400);
- assert.deepEqual(await response.json(), { errors: [{ detail: 'empty email rejected' }] });
-
- user = db.user.findFirst({ where: { id: user.id } });
- assert.strictEqual(user.email, 'old@email.com');
});
diff --git a/packages/crates-io-msw/index.js b/packages/crates-io-msw/index.js
index 5973d4bb81a..e262a10e108 100644
--- a/packages/crates-io-msw/index.js
+++ b/packages/crates-io-msw/index.js
@@ -18,6 +18,7 @@ import crateOwnerInvitation from './models/crate-owner-invitation.js';
import crateOwnership from './models/crate-ownership.js';
import crate from './models/crate.js';
import dependency from './models/dependency.js';
+import email from './models/email.js';
import keyword from './models/keyword.js';
import mswSession from './models/msw-session.js';
import team from './models/team.js';
@@ -51,6 +52,7 @@ export const db = factory({
crateOwnership,
crate,
dependency,
+ email,
keyword,
mswSession,
team,
diff --git a/packages/crates-io-msw/models/api-token.test.js b/packages/crates-io-msw/models/api-token.test.js
index 135b78352b9..d33546e1543 100644
--- a/packages/crates-io-msw/models/api-token.test.js
+++ b/packages/crates-io-msw/models/api-token.test.js
@@ -24,9 +24,7 @@ test('happy path', ({ expect }) => {
"token": "6270739405881613",
"user": {
"avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4",
- "email": "user-1@crates.io",
- "emailVerificationToken": null,
- "emailVerified": true,
+ "emails": [],
"followedCrates": [],
"id": 1,
"isAdmin": false,
diff --git a/packages/crates-io-msw/models/crate-owner-invitation.test.js b/packages/crates-io-msw/models/crate-owner-invitation.test.js
index 32c10b97d52..29079addef0 100644
--- a/packages/crates-io-msw/models/crate-owner-invitation.test.js
+++ b/packages/crates-io-msw/models/crate-owner-invitation.test.js
@@ -56,9 +56,7 @@ test('happy path', ({ expect }) => {
"id": 1,
"invitee": {
"avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4",
- "email": "user-2@crates.io",
- "emailVerificationToken": null,
- "emailVerified": true,
+ "emails": [],
"followedCrates": [],
"id": 2,
"isAdmin": false,
@@ -71,9 +69,7 @@ test('happy path', ({ expect }) => {
},
"inviter": {
"avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4",
- "email": "user-1@crates.io",
- "emailVerificationToken": null,
- "emailVerified": true,
+ "emails": [],
"followedCrates": [],
"id": 1,
"isAdmin": false,
diff --git a/packages/crates-io-msw/models/crate-ownership.test.js b/packages/crates-io-msw/models/crate-ownership.test.js
index 00f6b389824..b40d8fec504 100644
--- a/packages/crates-io-msw/models/crate-ownership.test.js
+++ b/packages/crates-io-msw/models/crate-ownership.test.js
@@ -97,9 +97,7 @@ test('can set `user`', ({ expect }) => {
"team": null,
"user": {
"avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4",
- "email": "user-1@crates.io",
- "emailVerificationToken": null,
- "emailVerified": true,
+ "emails": [],
"followedCrates": [],
"id": 1,
"isAdmin": false,
diff --git a/packages/crates-io-msw/models/email.js b/packages/crates-io-msw/models/email.js
new file mode 100644
index 00000000000..07933fa45bf
--- /dev/null
+++ b/packages/crates-io-msw/models/email.js
@@ -0,0 +1,22 @@
+import { nullable, primaryKey } from '@mswjs/data';
+
+import { applyDefault } from '../utils/defaults.js';
+
+export default {
+ id: primaryKey(Number),
+
+ email: String,
+ verified: Boolean,
+ verification_email_sent: Boolean,
+ primary: Boolean,
+ token: nullable(String),
+
+ preCreate(attrs, counter) {
+ applyDefault(attrs, 'id', () => counter);
+ applyDefault(attrs, 'email', () => `foo@crates.io`);
+ applyDefault(attrs, 'verified', () => false);
+ applyDefault(attrs, 'verification_email_sent', () => false);
+ applyDefault(attrs, 'primary', () => false);
+ applyDefault(attrs, 'token', () => null);
+ },
+};
diff --git a/packages/crates-io-msw/models/email.test.js b/packages/crates-io-msw/models/email.test.js
new file mode 100644
index 00000000000..0e23d7667d9
--- /dev/null
+++ b/packages/crates-io-msw/models/email.test.js
@@ -0,0 +1,19 @@
+import { test } from 'vitest';
+
+import { db } from '../index.js';
+
+test('default are applied', ({ expect }) => {
+ let email = db.email.create();
+ expect(email).toMatchInlineSnapshot(`
+ {
+ "email": "foo@crates.io",
+ "id": 1,
+ "primary": false,
+ "token": null,
+ "verification_email_sent": false,
+ "verified": false,
+ Symbol(type): "email",
+ Symbol(primaryKey): "id",
+ }
+ `);
+});
diff --git a/packages/crates-io-msw/models/msw-session.test.js b/packages/crates-io-msw/models/msw-session.test.js
index 5a6874566c9..50d696e69ce 100644
--- a/packages/crates-io-msw/models/msw-session.test.js
+++ b/packages/crates-io-msw/models/msw-session.test.js
@@ -14,9 +14,7 @@ test('happy path', ({ expect }) => {
"id": 1,
"user": {
"avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4",
- "email": "user-1@crates.io",
- "emailVerificationToken": null,
- "emailVerified": true,
+ "emails": [],
"followedCrates": [],
"id": 1,
"isAdmin": false,
diff --git a/packages/crates-io-msw/models/user.js b/packages/crates-io-msw/models/user.js
index 2d3ce6f22f4..ef68b87b651 100644
--- a/packages/crates-io-msw/models/user.js
+++ b/packages/crates-io-msw/models/user.js
@@ -10,9 +10,7 @@ export default {
login: String,
url: String,
avatar: String,
- email: nullable(String),
- emailVerificationToken: nullable(String),
- emailVerified: Boolean,
+ emails: manyOf('email'),
isAdmin: Boolean,
publishNotifications: Boolean,
@@ -22,11 +20,8 @@ export default {
applyDefault(attrs, 'id', () => counter);
applyDefault(attrs, 'name', () => `User ${attrs.id}`);
applyDefault(attrs, 'login', () => (attrs.name ? dasherize(attrs.name) : `user-${attrs.id}`));
- applyDefault(attrs, 'email', () => `${attrs.login}@crates.io`);
applyDefault(attrs, 'url', () => `https://github.com/${attrs.login}`);
applyDefault(attrs, 'avatar', () => 'https://avatars1.githubusercontent.com/u/14631425?v=4');
- applyDefault(attrs, 'emailVerificationToken', () => null);
- applyDefault(attrs, 'emailVerified', () => Boolean(attrs.email && !attrs.emailVerificationToken));
applyDefault(attrs, 'isAdmin', () => false);
applyDefault(attrs, 'publishNotifications', () => true);
},
diff --git a/packages/crates-io-msw/models/user.test.js b/packages/crates-io-msw/models/user.test.js
index e3db559e569..7870c92444c 100644
--- a/packages/crates-io-msw/models/user.test.js
+++ b/packages/crates-io-msw/models/user.test.js
@@ -7,9 +7,7 @@ test('default are applied', ({ expect }) => {
expect(user).toMatchInlineSnapshot(`
{
"avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4",
- "email": "user-1@crates.io",
- "emailVerificationToken": null,
- "emailVerified": true,
+ "emails": [],
"followedCrates": [],
"id": 1,
"isAdmin": false,
@@ -28,9 +26,7 @@ test('name can be set', ({ expect }) => {
expect(user).toMatchInlineSnapshot(`
{
"avatar": "https://avatars1.githubusercontent.com/u/14631425?v=4",
- "email": "john-doe@crates.io",
- "emailVerificationToken": null,
- "emailVerified": true,
+ "emails": [],
"followedCrates": [],
"id": 1,
"isAdmin": false,
diff --git a/packages/crates-io-msw/serializers/email.js b/packages/crates-io-msw/serializers/email.js
new file mode 100644
index 00000000000..68bd8638c40
--- /dev/null
+++ b/packages/crates-io-msw/serializers/email.js
@@ -0,0 +1,9 @@
+import { serializeModel } from '../utils/serializers.js';
+
+export function serializeEmail(email) {
+ let serialized = serializeModel(email);
+
+ delete serialized.token;
+
+ return serialized;
+}
diff --git a/packages/crates-io-msw/serializers/user.js b/packages/crates-io-msw/serializers/user.js
index 9f3725977af..c902ef69c7d 100644
--- a/packages/crates-io-msw/serializers/user.js
+++ b/packages/crates-io-msw/serializers/user.js
@@ -1,18 +1,16 @@
import { serializeModel } from '../utils/serializers.js';
+import { serializeEmail } from './email.js';
export function serializeUser(user, { removePrivateData = true } = {}) {
let serialized = serializeModel(user);
+ serialized.emails = user.emails.map(email => serializeEmail(email));
if (removePrivateData) {
- delete serialized.email;
- delete serialized.email_verified;
+ delete serialized.emails;
delete serialized.is_admin;
delete serialized.publish_notifications;
- } else {
- serialized.email_verification_sent = serialized.email_verified || Boolean(serialized.email_verification_token);
}
- delete serialized.email_verification_token;
delete serialized.followed_crates;
return serialized;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cf4bcfc145f..04d21d6477c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -5,7 +5,7 @@ settings:
excludeLinksFromLockfile: false
overrides:
- '@babel/runtime': 7.28.2
+ '@babel/runtime': 7.27.6
ember-auto-import: 2.10.0
ember-get-config: 2.1.1
ember-inflector: 6.0.0
@@ -23,8 +23,8 @@ importers:
specifier: 3.4.0
version: 3.4.0
'@sentry/ember':
- specifier: 9.42.0
- version: 9.42.0(ember-cli@6.5.0(ejs@3.1.10)(handlebars@4.7.8)(underscore@1.13.7))(webpack@5.100.2)
+ specifier: 9.40.0
+ version: 9.40.0(ember-cli@6.5.0(ejs@3.1.10)(handlebars@4.7.8)(underscore@1.13.7))(webpack@5.100.2)
chart.js:
specifier: 4.5.0
version: 4.5.0
@@ -55,7 +55,7 @@ importers:
version: 7.28.0(supports-color@8.1.1)
'@babel/eslint-parser':
specifier: 7.28.0
- version: 7.28.0(@babel/core@7.28.0)(eslint@9.32.0)
+ version: 7.28.0(@babel/core@7.28.0)(eslint@9.31.0)
'@babel/plugin-proposal-decorators':
specifier: 7.28.0
version: 7.28.0(@babel/core@7.28.0)
@@ -90,8 +90,8 @@ importers:
specifier: 3.3.1
version: 3.3.1
'@eslint/js':
- specifier: 9.32.0
- version: 9.32.0
+ specifier: 9.31.0
+ version: 9.31.0
'@glimmer/component':
specifier: 2.0.0
version: 2.0.0
@@ -108,8 +108,8 @@ importers:
specifier: 4.2.0
version: 4.2.0
'@percy/playwright':
- specifier: 1.0.9
- version: 1.0.9(playwright-core@1.54.1)
+ specifier: 1.0.8
+ version: 1.0.8(playwright-core@1.54.1)
'@playwright/test':
specifier: 1.54.1
version: 1.54.1
@@ -237,32 +237,32 @@ importers:
specifier: 1.0.2
version: 1.0.2
eslint:
- specifier: 9.32.0
- version: 9.32.0
+ specifier: 9.31.0
+ version: 9.31.0
eslint-config-prettier:
specifier: 10.1.8
- version: 10.1.8(eslint@9.32.0)
+ version: 10.1.8(eslint@9.31.0)
eslint-plugin-ember:
- specifier: 12.7.0
- version: 12.7.0(@babel/core@7.28.0)(eslint@9.32.0)
+ specifier: 12.6.0
+ version: 12.6.0(@babel/core@7.28.0)(eslint@9.31.0)
eslint-plugin-ember-concurrency:
specifier: 0.5.1
- version: 0.5.1(eslint@9.32.0)
+ version: 0.5.1(eslint@9.31.0)
eslint-plugin-import-helpers:
specifier: 2.0.1
- version: 2.0.1(eslint@9.32.0)
+ version: 2.0.1(eslint@9.31.0)
eslint-plugin-prettier:
specifier: 5.5.3
- version: 5.5.3(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2)
+ version: 5.5.3(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.31.0))(eslint@9.31.0)(prettier@3.6.2)
eslint-plugin-qunit:
specifier: 8.2.4
- version: 8.2.4(eslint@9.32.0)
+ version: 8.2.4(eslint@9.31.0)
eslint-plugin-qunit-dom:
specifier: 0.2.0
- version: 0.2.0(eslint@9.32.0)
+ version: 0.2.0(eslint@9.31.0)
eslint-plugin-unicorn:
- specifier: 60.0.0
- version: 60.0.0(eslint@9.32.0)
+ specifier: 59.0.1
+ version: 59.0.1(eslint@9.31.0)
globals:
specifier: 16.3.0
version: 16.3.0
@@ -906,8 +906,8 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0 || ^8.0.0-0 <8.0.0
- '@babel/runtime@7.28.2':
- resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==}
+ '@babel/runtime@7.27.6':
+ resolution: {integrity: sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
@@ -1538,6 +1538,10 @@ packages:
resolution: {integrity: sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/core@0.13.0':
+ resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@eslint/core@0.15.1':
resolution: {integrity: sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1546,20 +1550,20 @@ packages:
resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/js@9.32.0':
- resolution: {integrity: sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==}
+ '@eslint/js@9.31.0':
+ resolution: {integrity: sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/plugin-kit@0.3.3':
- resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==}
+ '@eslint/plugin-kit@0.2.8':
+ resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/plugin-kit@0.3.4':
- resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==}
+ '@eslint/plugin-kit@0.3.3':
+ resolution: {integrity: sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.2':
@@ -1874,8 +1878,8 @@ packages:
resolution: {integrity: sha512-myysetAc2Kz0LsLy1JGHHB7DCsiodeW1u/b71M7kiwWYBWw840hiBEgoUDvJkgJ2Tig3oLoyI4aTqyvTExNu+A==}
engines: {node: '>=14'}
- '@percy/playwright@1.0.9':
- resolution: {integrity: sha512-t74a0hZcAR+ssNpbcL6vnYU5mwEGcdRByLYFb12yFQUq4n250YUAX76jI4OHzH440Tikp84hml4JnbXrvgEmFQ==}
+ '@percy/playwright@1.0.8':
+ resolution: {integrity: sha512-v70IKPVy15mDxis5+of2S62AJHdeG99BlA08oj/XpK68CVyNUSE6I8I54EUm3PbEeKxdZLWTdhX+8I69UkxPjA==}
engines: {node: '>=14'}
peerDependencies:
playwright-core: '>=1'
@@ -2017,32 +2021,32 @@ packages:
resolution: {integrity: sha512-C5DHU6YlKaISB5utGQ+jpsMB57ZtY0uZ8UkD29j855BjqG6eJ98lhA2h/BoJbyPw89RKLP1EEXroy9+5JPoyVw==}
engines: {node: 12.* || >= 14}
- '@sentry-internal/browser-utils@9.42.0':
- resolution: {integrity: sha512-kHDPrLSlb9kMKKUNWVUwMbUjZN3o4aBUux9hRTf2HeDA4Uo8O7Ln4XAC7tMCJ+cB016Z2RnnqH3mLdZV7J72/w==}
+ '@sentry-internal/browser-utils@9.40.0':
+ resolution: {integrity: sha512-Ajvz6jN+EEMKrOHcUv2+HlhbRUh69uXhhRoBjJw8sc61uqA2vv3QWyBSmTRoHdTnLGboT5bKEhHIkzVXb+YgEw==}
engines: {node: '>=18'}
- '@sentry-internal/feedback@9.42.0':
- resolution: {integrity: sha512-7WisZVBKnsr+19CFReFnMHe/Lgd9xqn5CBJfBdRng4hyYSiw988Zdr5xwp2wh1ESM0fxqxy6kSe1NPztIbbiVw==}
+ '@sentry-internal/feedback@9.40.0':
+ resolution: {integrity: sha512-39UbLdGWGvSJ7bAzRnkv91cBdd6fLbdkLVVvqE2ZUfegm7+rH1mRPglmEhw4VE4mQfKZM1zWr/xus2+XPqJcYw==}
engines: {node: '>=18'}
- '@sentry-internal/replay-canvas@9.42.0':
- resolution: {integrity: sha512-rvP2zjfR9x57u8fVFetkwXnZSXazJRLTFDbirFplggkCKeGNTDJmLBsejUNOkwGiXzcui0fuFEQElu2nF97nxw==}
+ '@sentry-internal/replay-canvas@9.40.0':
+ resolution: {integrity: sha512-GLoJ4R4Uipd7Vb+0LzSJA2qCyN1J6YalQIoDuOJTfYyykHvKltds5D8a/5S3Q6d8PcL/nxTn93fynauGEZt2Ow==}
engines: {node: '>=18'}
- '@sentry-internal/replay@9.42.0':
- resolution: {integrity: sha512-teKxrVeT8JOYs9Hd4t0jI0X9NP2Ky6iVgTItN07mUD6yOS9se2ZXzmNzXevoqICX6WsnhHDeWY7krvmJ5QCVEg==}
+ '@sentry-internal/replay@9.40.0':
+ resolution: {integrity: sha512-WrmCvqbLJQC45IFRVN3k0J5pU5NkdX0e9o6XxjcmDiATKk00RHnW4yajnCJ8J1cPR4918yqiJHPX5xpG08BZNA==}
engines: {node: '>=18'}
- '@sentry/browser@9.42.0':
- resolution: {integrity: sha512-85RgFSMDS24JD3nSqA4LpDlVGTxVGwYeqCwI6pRM0CH9pz6G+0OESRhTDccj+rv+kr8vcvWl/LUklJkoswH4kw==}
+ '@sentry/browser@9.40.0':
+ resolution: {integrity: sha512-qz/1Go817vcsbcIwgrz4/T34vi3oQ4UIqikosuaCTI9wjZvK0HyW3QmLvTbAnsE7G7h6+UZsVkpO5R16IQvQhQ==}
engines: {node: '>=18'}
- '@sentry/core@9.42.0':
- resolution: {integrity: sha512-AsfB2eklY09GGsCLC2r0pvh/h3tgr9Co3CB7XisEfzhoQH9RaEb0XeIVLyfo+503ktdlPTjH24j4Zpts4y0Jmg==}
+ '@sentry/core@9.40.0':
+ resolution: {integrity: sha512-cZkuz6BDna6VXSqvlWnrRsaDx4QBKq1PcfQrqhVz8ljs0M7Gcl+Mtj8dCzUxx12fkYM62hQXG72DEGNlAQpH/Q==}
engines: {node: '>=18'}
- '@sentry/ember@9.42.0':
- resolution: {integrity: sha512-1BTxwb1t3scyDenrAk0206EI5Pvt5eYgtOFvPO2VEz5KCChkpgOuaezwJBe0COrI+uLPLZ6y3Hf2xNVRT49Jog==}
+ '@sentry/ember@9.40.0':
+ resolution: {integrity: sha512-BZWqKhkk7CL2sq9WKlIA5JVXq8bxTtldAxnquzmXwBLghOV+mxC+kYMtQaqxnNFTBmSK03t3z9AYkUNraDy0Kg==}
engines: {node: '>=18'}
peerDependencies:
ember-cli: '>=4'
@@ -3110,9 +3114,6 @@ packages:
resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
- change-case@5.4.4:
- resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
-
chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
@@ -4486,8 +4487,8 @@ packages:
peerDependencies:
eslint: 6.* || 7.*
- eslint-plugin-ember@12.7.0:
- resolution: {integrity: sha512-QkKzUzmWjSjscJLNYlkPv1ug5B5/Ec/7/MEEjDZxthzHO9VhnyMZ0shwvCztLTvB5D7LO67E7Zmpwb4YyBoFMA==}
+ eslint-plugin-ember@12.6.0:
+ resolution: {integrity: sha512-axb6l5iUwW08mjSWDY+/aVUG4PS7kJTM+gXOSP1ev9aVy5bphaVJ3yFCSd81BPvqiD8IkCa+K9R6pwhsfLtnfA==}
engines: {node: 18.* || 20.* || >= 21}
peerDependencies:
'@typescript-eslint/parser': '*'
@@ -4525,11 +4526,11 @@ packages:
resolution: {integrity: sha512-rKlLQ/AIKFBNd9Ga8Cg058+iS0xqx2SE5rByyhAga2/ORDAHArfvc3tatAxUvaHzqUsDL0gIez3l2zcFQ9x7Vg==}
engines: {node: ^16.0.0 || ^18.0.0 || >=20.0.0}
- eslint-plugin-unicorn@60.0.0:
- resolution: {integrity: sha512-QUzTefvP8stfSXsqKQ+vBQSEsXIlAiCduS/V1Em+FKgL9c21U/IIm20/e3MFy1jyCf14tHAhqC1sX8OTy6VUCg==}
- engines: {node: ^20.10.0 || >=21.0.0}
+ eslint-plugin-unicorn@59.0.1:
+ resolution: {integrity: sha512-EtNXYuWPUmkgSU2E7Ttn57LbRREQesIP1BiLn7OZLKodopKfDXfBUkC/0j6mpw2JExwf43Uf3qLSvrSvppgy8Q==}
+ engines: {node: ^18.20.0 || ^20.10.0 || >=21.0.0}
peerDependencies:
- eslint: '>=9.29.0'
+ eslint: '>=9.22.0'
eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
@@ -4561,8 +4562,8 @@ packages:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- eslint@9.32.0:
- resolution: {integrity: sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==}
+ eslint@9.31.0:
+ resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
@@ -8522,11 +8523,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/eslint-parser@7.28.0(@babel/core@7.28.0)(eslint@9.32.0)':
+ '@babel/eslint-parser@7.28.0(@babel/core@7.28.0)(eslint@9.31.0)':
dependencies:
'@babel/core': 7.28.0(supports-color@8.1.1)
'@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1
- eslint: 9.32.0
+ eslint: 9.31.0
eslint-visitor-keys: 2.1.0
semver: 6.3.1
@@ -9214,7 +9215,7 @@ snapshots:
'@babel/types': 7.28.1
esutils: 2.0.3
- '@babel/runtime@7.28.2': {}
+ '@babel/runtime@7.27.6': {}
'@babel/template@7.27.2':
dependencies:
@@ -9737,7 +9738,7 @@ snapshots:
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.0)
'@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.28.0)
'@babel/preset-env': 7.28.0(@babel/core@7.28.0)(supports-color@8.1.1)
- '@babel/runtime': 7.28.2
+ '@babel/runtime': 7.27.6
'@babel/traverse': 7.28.0(supports-color@8.1.1)
'@embroider/core': 3.5.7
'@embroider/macros': 1.16.13
@@ -10007,9 +10008,9 @@ snapshots:
'@esbuild/win32-x64@0.25.8':
optional: true
- '@eslint-community/eslint-utils@4.7.0(eslint@9.32.0)':
+ '@eslint-community/eslint-utils@4.7.0(eslint@9.31.0)':
dependencies:
- eslint: 9.32.0
+ eslint: 9.31.0
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
@@ -10024,6 +10025,10 @@ snapshots:
'@eslint/config-helpers@0.3.0': {}
+ '@eslint/core@0.13.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
'@eslint/core@0.15.1':
dependencies:
'@types/json-schema': 7.0.15
@@ -10042,16 +10047,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@eslint/js@9.32.0': {}
+ '@eslint/js@9.31.0': {}
'@eslint/object-schema@2.1.6': {}
- '@eslint/plugin-kit@0.3.3':
+ '@eslint/plugin-kit@0.2.8':
dependencies:
- '@eslint/core': 0.15.1
+ '@eslint/core': 0.13.0
levn: 0.4.1
- '@eslint/plugin-kit@0.3.4':
+ '@eslint/plugin-kit@0.3.3':
dependencies:
'@eslint/core': 0.15.1
levn: 0.4.1
@@ -10573,7 +10578,7 @@ snapshots:
transitivePeerDependencies:
- typescript
- '@percy/playwright@1.0.9(playwright-core@1.54.1)':
+ '@percy/playwright@1.0.8(playwright-core@1.54.1)':
dependencies:
playwright-core: 1.54.1
@@ -10675,40 +10680,40 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@sentry-internal/browser-utils@9.42.0':
+ '@sentry-internal/browser-utils@9.40.0':
dependencies:
- '@sentry/core': 9.42.0
+ '@sentry/core': 9.40.0
- '@sentry-internal/feedback@9.42.0':
+ '@sentry-internal/feedback@9.40.0':
dependencies:
- '@sentry/core': 9.42.0
+ '@sentry/core': 9.40.0
- '@sentry-internal/replay-canvas@9.42.0':
+ '@sentry-internal/replay-canvas@9.40.0':
dependencies:
- '@sentry-internal/replay': 9.42.0
- '@sentry/core': 9.42.0
+ '@sentry-internal/replay': 9.40.0
+ '@sentry/core': 9.40.0
- '@sentry-internal/replay@9.42.0':
+ '@sentry-internal/replay@9.40.0':
dependencies:
- '@sentry-internal/browser-utils': 9.42.0
- '@sentry/core': 9.42.0
+ '@sentry-internal/browser-utils': 9.40.0
+ '@sentry/core': 9.40.0
- '@sentry/browser@9.42.0':
+ '@sentry/browser@9.40.0':
dependencies:
- '@sentry-internal/browser-utils': 9.42.0
- '@sentry-internal/feedback': 9.42.0
- '@sentry-internal/replay': 9.42.0
- '@sentry-internal/replay-canvas': 9.42.0
- '@sentry/core': 9.42.0
+ '@sentry-internal/browser-utils': 9.40.0
+ '@sentry-internal/feedback': 9.40.0
+ '@sentry-internal/replay': 9.40.0
+ '@sentry-internal/replay-canvas': 9.40.0
+ '@sentry/core': 9.40.0
- '@sentry/core@9.42.0': {}
+ '@sentry/core@9.40.0': {}
- '@sentry/ember@9.42.0(ember-cli@6.5.0(ejs@3.1.10)(handlebars@4.7.8)(underscore@1.13.7))(webpack@5.100.2)':
+ '@sentry/ember@9.40.0(ember-cli@6.5.0(ejs@3.1.10)(handlebars@4.7.8)(underscore@1.13.7))(webpack@5.100.2)':
dependencies:
'@babel/core': 7.28.0(supports-color@8.1.1)
'@embroider/macros': 1.18.0
- '@sentry/browser': 9.42.0
- '@sentry/core': 9.42.0
+ '@sentry/browser': 9.40.0
+ '@sentry/core': 9.40.0
ember-auto-import: 2.10.0(webpack@5.100.2)
ember-cli-babel: 8.2.0(@babel/core@7.28.0)
ember-cli-htmlbars: 6.3.0
@@ -12171,8 +12176,6 @@ snapshots:
chalk@5.4.1: {}
- change-case@5.4.4: {}
-
chardet@0.7.0: {}
charenc@0.0.2: {}
@@ -12793,7 +12796,7 @@ snapshots:
date-fns@2.30.0:
dependencies:
- '@babel/runtime': 7.28.2
+ '@babel/runtime': 7.27.6
date-fns@4.1.0: {}
@@ -13076,7 +13079,7 @@ snapshots:
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.0)
'@babel/polyfill': 7.12.1
'@babel/preset-env': 7.28.0(@babel/core@7.28.0)(supports-color@8.1.1)
- '@babel/runtime': 7.28.2
+ '@babel/runtime': 7.27.6
amd-name-resolver: 1.3.1
babel-plugin-debug-macros: 0.3.4(@babel/core@7.28.0)
babel-plugin-ember-data-packages-polyfill: 0.1.2
@@ -13111,7 +13114,7 @@ snapshots:
'@babel/plugin-transform-runtime': 7.28.0(@babel/core@7.28.0)
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.0)
'@babel/preset-env': 7.28.0(@babel/core@7.28.0)(supports-color@8.1.1)
- '@babel/runtime': 7.28.2
+ '@babel/runtime': 7.27.6
amd-name-resolver: 1.3.1
babel-plugin-debug-macros: 0.3.4(@babel/core@7.28.0)
babel-plugin-ember-data-packages-polyfill: 0.1.2
@@ -13564,10 +13567,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- ember-eslint-parser@0.5.9(@babel/core@7.28.0)(eslint@9.32.0):
+ ember-eslint-parser@0.5.9(@babel/core@7.28.0)(eslint@9.31.0):
dependencies:
'@babel/core': 7.28.0(supports-color@8.1.1)
- '@babel/eslint-parser': 7.28.0(@babel/core@7.28.0)(eslint@9.32.0)
+ '@babel/eslint-parser': 7.28.0(@babel/core@7.28.0)(eslint@9.31.0)
'@glimmer/syntax': 0.92.3
content-tag: 2.0.3
eslint-scope: 7.2.2
@@ -14021,22 +14024,22 @@ snapshots:
optionalDependencies:
source-map: 0.6.1
- eslint-config-prettier@10.1.8(eslint@9.32.0):
+ eslint-config-prettier@10.1.8(eslint@9.31.0):
dependencies:
- eslint: 9.32.0
+ eslint: 9.31.0
- eslint-plugin-ember-concurrency@0.5.1(eslint@9.32.0):
+ eslint-plugin-ember-concurrency@0.5.1(eslint@9.31.0):
dependencies:
- eslint: 9.32.0
+ eslint: 9.31.0
- eslint-plugin-ember@12.7.0(@babel/core@7.28.0)(eslint@9.32.0):
+ eslint-plugin-ember@12.6.0(@babel/core@7.28.0)(eslint@9.31.0):
dependencies:
'@ember-data/rfc395-data': 0.0.4
css-tree: 3.1.0
- ember-eslint-parser: 0.5.9(@babel/core@7.28.0)(eslint@9.32.0)
+ ember-eslint-parser: 0.5.9(@babel/core@7.28.0)(eslint@9.31.0)
ember-rfc176-data: 0.3.18
- eslint: 9.32.0
- eslint-utils: 3.0.0(eslint@9.32.0)
+ eslint: 9.31.0
+ eslint-utils: 3.0.0(eslint@9.31.0)
estraverse: 5.3.0
lodash.camelcase: 4.3.0
lodash.kebabcase: 4.1.1
@@ -14045,41 +14048,40 @@ snapshots:
transitivePeerDependencies:
- '@babel/core'
- eslint-plugin-import-helpers@2.0.1(eslint@9.32.0):
+ eslint-plugin-import-helpers@2.0.1(eslint@9.31.0):
dependencies:
- eslint: 9.32.0
+ eslint: 9.31.0
- eslint-plugin-prettier@5.5.3(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.32.0))(eslint@9.32.0)(prettier@3.6.2):
+ eslint-plugin-prettier@5.5.3(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@9.31.0))(eslint@9.31.0)(prettier@3.6.2):
dependencies:
- eslint: 9.32.0
+ eslint: 9.31.0
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.11
optionalDependencies:
'@types/eslint': 9.6.1
- eslint-config-prettier: 10.1.8(eslint@9.32.0)
+ eslint-config-prettier: 10.1.8(eslint@9.31.0)
- eslint-plugin-qunit-dom@0.2.0(eslint@9.32.0):
+ eslint-plugin-qunit-dom@0.2.0(eslint@9.31.0):
dependencies:
- eslint: 9.32.0
+ eslint: 9.31.0
- eslint-plugin-qunit@8.2.4(eslint@9.32.0):
+ eslint-plugin-qunit@8.2.4(eslint@9.31.0):
dependencies:
- eslint-utils: 3.0.0(eslint@9.32.0)
+ eslint-utils: 3.0.0(eslint@9.31.0)
requireindex: 1.2.0
transitivePeerDependencies:
- eslint
- eslint-plugin-unicorn@60.0.0(eslint@9.32.0):
+ eslint-plugin-unicorn@59.0.1(eslint@9.31.0):
dependencies:
'@babel/helper-validator-identifier': 7.27.1
- '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0)
- '@eslint/plugin-kit': 0.3.3
- change-case: 5.4.4
+ '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0)
+ '@eslint/plugin-kit': 0.2.8
ci-info: 4.3.0
clean-regexp: 1.0.0
core-js-compat: 3.44.0
- eslint: 9.32.0
+ eslint: 9.31.0
esquery: 1.6.0
find-up-simple: 1.0.1
globals: 16.3.0
@@ -14107,9 +14109,9 @@ snapshots:
esrecurse: 4.3.0
estraverse: 5.3.0
- eslint-utils@3.0.0(eslint@9.32.0):
+ eslint-utils@3.0.0(eslint@9.31.0):
dependencies:
- eslint: 9.32.0
+ eslint: 9.31.0
eslint-visitor-keys: 2.1.0
eslint-visitor-keys@2.1.0: {}
@@ -14118,16 +14120,16 @@ snapshots:
eslint-visitor-keys@4.2.1: {}
- eslint@9.32.0:
+ eslint@9.31.0:
dependencies:
- '@eslint-community/eslint-utils': 4.7.0(eslint@9.32.0)
+ '@eslint-community/eslint-utils': 4.7.0(eslint@9.31.0)
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.21.0
'@eslint/config-helpers': 0.3.0
'@eslint/core': 0.15.1
'@eslint/eslintrc': 3.3.1
- '@eslint/js': 9.32.0
- '@eslint/plugin-kit': 0.3.4
+ '@eslint/js': 9.31.0
+ '@eslint/plugin-kit': 0.3.3
'@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
@@ -15343,7 +15345,7 @@ snapshots:
is-language-code@3.1.0:
dependencies:
- '@babel/runtime': 7.28.2
+ '@babel/runtime': 7.27.6
is-map@2.0.3: {}
diff --git a/src/controllers/session.rs b/src/controllers/session.rs
index 4237791de64..50c17572cdb 100644
--- a/src/controllers/session.rs
+++ b/src/controllers/session.rs
@@ -2,14 +2,14 @@ use crate::app::AppState;
use crate::email::EmailMessage;
use crate::email::Emails;
use crate::middleware::log_request::RequestLogExt;
-use crate::models::{NewEmail, NewUser, User};
+use crate::models::{Email, NewEmail, NewUser, User};
use crate::schema::users;
use crate::util::diesel::is_read_only_error;
use crate::util::errors::{AppResult, bad_request, server_error};
use crate::views::EncodableMe;
use axum::Json;
use axum::extract::{FromRequestParts, Query};
-use crates_io_github::GitHubUser;
+use crates_io_github::{GitHubEmail, GitHubUser};
use crates_io_session::SessionExtension;
use diesel::prelude::*;
use diesel_async::scoped_futures::ScopedFutureExt;
@@ -49,6 +49,7 @@ pub async fn begin_session(app: AppState, session: SessionExtension) -> Json emails,
+ Err(err) => {
+ warn!("Failed to fetch user emails from GitHub: {err}");
+ // Continue anyway, user may have denied the user:email scope on purpose
+ // but we could have their public email from the user info.
+ if let Some(gh_email) = &ghuser.email {
+ vec![GitHubEmail {
+ email: gh_email.to_string(),
+ primary: true,
+ verified: false,
+ }]
+ } else {
+ vec![]
+ }
+ }
+ };
let mut conn = app.db_write().await?;
- let user = save_user_to_database(&ghuser, token.secret(), &app.emails, &mut conn).await?;
+ let user = save_user_to_database(
+ &ghuser,
+ &mut ghemails,
+ token.secret(),
+ &app.emails,
+ &mut conn,
+ )
+ .await?;
// Log in by setting a cookie and the middleware authentication
session.insert("user_id".to_string(), user.id.to_string());
@@ -128,6 +160,7 @@ pub async fn authorize_session(
pub async fn save_user_to_database(
user: &GitHubUser,
+ user_emails: &mut [GitHubEmail],
access_token: &str,
emails: &Emails,
conn: &mut AsyncPgConnection,
@@ -140,7 +173,7 @@ pub async fn save_user_to_database(
.gh_access_token(access_token)
.build();
- match create_or_update_user(&new_user, user.email.as_deref(), emails, conn).await {
+ match create_or_update_user(&new_user, user_emails, emails, conn).await {
Ok(user) => Ok(user),
Err(error) if is_read_only_error(&error) => {
// If we're in read only mode, we can't update their details
@@ -157,7 +190,7 @@ pub async fn save_user_to_database(
/// and sends a confirmation email to the user.
async fn create_or_update_user(
new_user: &NewUser<'_>,
- email: Option<&str>,
+ user_emails: &mut [GitHubEmail],
emails: &Emails,
conn: &mut AsyncPgConnection,
) -> QueryResult {
@@ -165,15 +198,36 @@ async fn create_or_update_user(
async move {
let user = new_user.insert_or_update(conn).await?;
+ // Count the number of existing emails to determine if we need to
+ // mark the first email address as primary.
+ let mut email_count: i64 = Email::belonging_to(&user).count().get_result(conn).await?;
+
+ // Sort the GitHub emails by primary status so that the primary email is inserted
+ // first, and therefore will be marked as primary in our database.
+ user_emails.sort_by(|a, b| {
+ if a.primary && !b.primary {
+ std::cmp::Ordering::Less
+ } else if !a.primary && b.primary {
+ std::cmp::Ordering::Greater
+ } else {
+ std::cmp::Ordering::Equal
+ }
+ });
+
// To send the user an account verification email
- if let Some(user_email) = email {
+ for user_email in user_emails {
+ email_count += 1; // Increment the count so that we don't mark subsequent emails as primary
+
let new_email = NewEmail::builder()
.user_id(user.id)
- .email(user_email)
- .primary(true)
+ .email(&user_email.email)
+ .verified(user_email.verified) // we can trust GitHub's verification
+ .primary(email_count == 1) // Mark as primary if this is the user's first email
.build();
- if let Some(saved_email) = new_email.insert_primary_if_missing(conn).await? {
+ if let Some(saved_email) = new_email.insert_if_missing(conn).await?
+ && !new_email.verified
+ {
let email = EmailMessage::from_template(
"user_confirm",
context! {
@@ -186,7 +240,7 @@ async fn create_or_update_user(
match email {
Ok(email) => {
// Swallows any error. Some users might insert an invalid email address here.
- let _ = emails.send(user_email, email).await;
+ let _ = emails.send(&saved_email.email, email).await;
}
Err(error) => {
warn!("Failed to render user confirmation email template: {error}");
@@ -243,7 +297,19 @@ mod tests {
id: -1,
avatar_url: None,
};
- let result = save_user_to_database(&gh_user, "arbitrary_token", &emails, &mut conn).await;
+ let mut gh_emails = vec![GitHubEmail {
+ email: gh_user.email.clone().unwrap(),
+ primary: true,
+ verified: false,
+ }];
+ let result = save_user_to_database(
+ &gh_user,
+ &mut gh_emails,
+ "arbitrary_token",
+ &emails,
+ &mut conn,
+ )
+ .await;
assert!(
result.is_ok(),
diff --git a/src/controllers/user.rs b/src/controllers/user.rs
index 4a604d16077..cb5985a3b7d 100644
--- a/src/controllers/user.rs
+++ b/src/controllers/user.rs
@@ -1,8 +1,11 @@
pub mod email_notifications;
pub mod email_verification;
+pub mod emails;
pub mod me;
pub mod other;
pub mod update;
-pub use email_verification::resend_email_verification;
+#[allow(deprecated)]
+pub use email_verification::{resend_email_verification, resend_email_verification_all};
+pub use emails::{create_email, delete_email};
pub use update::update_user;
diff --git a/src/controllers/user/email_verification.rs b/src/controllers/user/email_verification.rs
index 29479db6c84..6b296fb33ff 100644
--- a/src/controllers/user/email_verification.rs
+++ b/src/controllers/user/email_verification.rs
@@ -5,15 +5,26 @@ use crate::email::EmailMessage;
use crate::models::Email;
use crate::util::errors::AppResult;
use crate::util::errors::{BoxedAppError, bad_request};
+use crate::views::EncodableEmail;
+use axum::Json;
use axum::extract::Path;
use crates_io_database::schema::emails;
use diesel::dsl::sql;
use diesel::prelude::*;
+use diesel::result::OptionalExtension;
use diesel_async::scoped_futures::ScopedFutureExt;
use diesel_async::{AsyncConnection, RunQueryDsl};
use http::request::Parts;
use minijinja::context;
use secrecy::ExposeSecret;
+use serde::Serialize;
+
+#[derive(Serialize, utoipa::ToSchema)]
+pub struct EmailConfirmResponse {
+ #[schema(example = true)]
+ ok: bool,
+ email: EncodableEmail,
+}
/// Marks the email belonging to the given token as verified.
#[utoipa::path(
@@ -23,32 +34,38 @@ use secrecy::ExposeSecret;
("email_token" = String, Path, description = "Secret verification token sent to the user's email address"),
),
tag = "users",
- responses((status = 200, description = "Successful Response", body = inline(OkResponse))),
+ responses((status = 200, description = "Successful Response", body = inline(EmailConfirmResponse))),
)]
pub async fn confirm_user_email(
state: AppState,
Path(token): Path,
-) -> AppResult {
+) -> AppResult> {
let mut conn = state.db_write().await?;
- let updated_rows = diesel::update(emails::table.filter(emails::token.eq(&token)))
+ let confirmed_email = diesel::update(emails::table.filter(emails::token.eq(&token)))
.set(emails::verified.eq(true))
- .execute(&mut conn)
- .await?;
-
- if updated_rows == 0 {
- return Err(bad_request("Email belonging to token not found."));
+ .returning(Email::as_returning())
+ .get_result(&mut conn)
+ .await
+ .optional()?;
+
+ if let Some(confirmed_email) = confirmed_email {
+ Ok(Json(EmailConfirmResponse {
+ ok: true,
+ email: confirmed_email.into(),
+ }))
+ } else {
+ Err(bad_request("Email belonging to token not found."))
}
-
- Ok(OkResponse::new())
}
-/// Regenerate and send an email verification token.
+/// Regenerate and send an email verification token for the given email.
#[utoipa::path(
put,
- path = "/api/v1/users/{id}/resend",
+ path = "/api/v1/users/{user_id}/emails/{id}/resend",
params(
- ("id" = i32, Path, description = "ID of the user"),
+ ("user_id" = i32, Path, description = "ID of the user"),
+ ("id" = i32, Path, description = "ID of the email"),
),
security(
("api_token" = []),
@@ -59,7 +76,7 @@ pub async fn confirm_user_email(
)]
pub async fn resend_email_verification(
state: AppState,
- Path(param_user_id): Path,
+ Path((param_user_id, email_id)): Path<(i32, i32)>,
req: Parts,
) -> AppResult {
let mut conn = state.db_write().await?;
@@ -70,16 +87,23 @@ pub async fn resend_email_verification(
return Err(bad_request("current user does not match requested user"));
}
+ // Generate a new token for the email, if it exists and is unverified
conn.transaction(|conn| {
async move {
- let email: Email = diesel::update(Email::belonging_to(auth.user()))
- .set(emails::token.eq(sql("DEFAULT")))
- .returning(Email::as_returning())
- .get_result(conn)
- .await
- .optional()?
- .ok_or_else(|| bad_request("Email could not be found"))?;
-
+ let email: Email = diesel::update(
+ emails::table
+ .filter(emails::id.eq(email_id))
+ .filter(emails::user_id.eq(auth.user_id()))
+ .filter(emails::verified.eq(false)),
+ )
+ .set(emails::token.eq(sql("DEFAULT")))
+ .returning(Email::as_returning())
+ .get_result(conn)
+ .await
+ .optional()?
+ .ok_or_else(|| bad_request("Email not found or already verified"))?;
+
+ // Send the updated token via email
let email_message = EmailMessage::from_template(
"user_confirm",
context! {
@@ -94,7 +118,81 @@ pub async fn resend_email_verification(
.emails
.send(&email.email, email_message)
.await
- .map_err(BoxedAppError::from)
+ .map_err(BoxedAppError::from)?;
+
+ Ok::<(), BoxedAppError>(())
+ }
+ .scope_boxed()
+ })
+ .await?;
+
+ Ok(OkResponse::new())
+}
+
+/// Regenerate and send an email verification token for any unverified email of the current user.
+/// Deprecated endpoint, use `PUT /api/v1/user/{user_id}/emails/{id}/resend` instead.
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/{id}/resend",
+ params(
+ ("id" = i32, Path, description = "ID of the user"),
+ ),
+ security(
+ ("api_token" = []),
+ ("cookie" = []),
+ ),
+ tag = "users",
+ responses((status = 200, description = "Successful Response", body = inline(OkResponse))),
+)]
+#[deprecated]
+pub async fn resend_email_verification_all(
+ state: AppState,
+ Path(param_user_id): Path,
+ req: Parts,
+) -> AppResult {
+ let mut conn = state.db_write().await?;
+ let auth = AuthCheck::default().check(&req, &mut conn).await?;
+
+ // need to check if current user matches user to be updated
+ if auth.user_id() != param_user_id {
+ return Err(bad_request("current user does not match requested user"));
+ }
+
+ conn.transaction(|conn| {
+ async move {
+ let emails: Vec = diesel::update(
+ emails::table
+ .filter(emails::user_id.eq(auth.user_id()))
+ .filter(emails::verified.eq(false)),
+ )
+ .set(emails::token.eq(sql("DEFAULT")))
+ .returning(Email::as_returning())
+ .get_results(conn)
+ .await?;
+
+ if emails.is_empty() {
+ return Err(bad_request("No unverified emails found"));
+ }
+
+ for email in emails {
+ let email_message = EmailMessage::from_template(
+ "user_confirm",
+ context! {
+ user_name => auth.user().gh_login,
+ domain => state.emails.domain,
+ token => email.token.expose_secret()
+ },
+ )
+ .map_err(|_| bad_request("Failed to render email template"))?;
+
+ state
+ .emails
+ .send(&email.email, email_message)
+ .await
+ .map_err(BoxedAppError::from)?;
+ }
+
+ Ok(())
}
.scope_boxed()
})
@@ -109,7 +207,7 @@ mod tests {
use insta::assert_snapshot;
#[tokio::test(flavor = "multi_thread")]
- async fn test_no_auth() {
+ async fn test_legacy_no_auth() {
let (app, anon, user) = TestApp::init().with_user().await;
let url = format!("/api/v1/users/{}/resend", user.as_model().id);
@@ -121,7 +219,7 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
- async fn test_wrong_user() {
+ async fn test_legacy_wrong_user() {
let (app, _anon, user) = TestApp::init().with_user().await;
let user2 = app.db_new_user("bar").await;
@@ -134,9 +232,13 @@ mod tests {
}
#[tokio::test(flavor = "multi_thread")]
- async fn test_happy_path() {
+ async fn test_legacy_happy_path() {
let (app, _anon, user) = TestApp::init().with_user().await;
+ // Create a new email to be verified, inserting directly into the database so that verification is not sent
+ let _new_email = user.db_new_email("bar@example.com", false, false).await;
+
+ // Request a verification email
let url = format!("/api/v1/users/{}/resend", user.as_model().id);
let response = user.put::<()>(&url, "").await;
assert_snapshot!(response.status(), @"200 OK");
diff --git a/src/controllers/user/emails.rs b/src/controllers/user/emails.rs
new file mode 100644
index 00000000000..546a2e07a8c
--- /dev/null
+++ b/src/controllers/user/emails.rs
@@ -0,0 +1,198 @@
+use crate::app::AppState;
+use crate::auth::AuthCheck;
+use crate::controllers::helpers::OkResponse;
+use crate::email::EmailMessage;
+use crate::models::{Email, NewEmail};
+use crate::util::errors::{AppResult, bad_request, not_found, server_error};
+use crate::views::EncodableEmail;
+use axum::Json;
+use axum::extract::{FromRequest, Path};
+use crates_io_database::schema::emails;
+use diesel::prelude::*;
+use diesel_async::RunQueryDsl;
+use http::request::Parts;
+use lettre::Address;
+use minijinja::context;
+use secrecy::ExposeSecret;
+use serde::Deserialize;
+
+#[derive(Deserialize, FromRequest, utoipa::ToSchema)]
+#[from_request(via(Json))]
+pub struct EmailCreate {
+ email: String,
+}
+
+/// Add a new email address to a user profile.
+#[utoipa::path(
+ post,
+ path = "/api/v1/users/{id}/emails",
+ params(
+ ("id" = i32, Path, description = "ID of the user"),
+ ),
+ request_body = inline(EmailCreate),
+ security(
+ ("api_token" = []),
+ ("cookie" = []),
+ ),
+ tag = "users",
+ responses((status = 200, description = "Successful Response", body = EncodableEmail)),
+)]
+pub async fn create_email(
+ state: AppState,
+ Path(param_user_id): Path,
+ req: Parts,
+ email: EmailCreate,
+) -> AppResult> {
+ let mut conn = state.db_write().await?;
+ let auth = AuthCheck::default().check(&req, &mut conn).await?;
+
+ // need to check if current user matches user to be updated
+ if auth.user_id() != param_user_id {
+ return Err(bad_request("current user does not match requested user"));
+ }
+
+ let user_email = email.email.trim();
+
+ if user_email.is_empty() {
+ return Err(bad_request("empty email rejected"));
+ }
+
+ user_email
+ .parse::()
+ .map_err(|_| bad_request("invalid email address"))?;
+
+ // fetch count of user's current emails to determine if we need to mark the new email as primary
+ let email_count: i64 = Email::belonging_to(&auth.user())
+ .count()
+ .get_result(&mut conn)
+ .await
+ .map_err(|_| server_error("Error fetching existing emails"))?;
+
+ let saved_email = NewEmail::builder()
+ .user_id(auth.user().id)
+ .email(user_email)
+ .primary(email_count == 0) // Mark as primary if this is the first email
+ .build()
+ .insert_if_missing(&mut conn)
+ .await
+ .map_err(|e| server_error(format!("{e}")))?;
+
+ let saved_email = match saved_email {
+ Some(email) => email,
+ None => return Err(bad_request("email already exists")),
+ };
+
+ let verification_message = EmailMessage::from_template(
+ "user_confirm",
+ context! {
+ user_name => auth.user().gh_login,
+ domain => state.emails.domain,
+ token => saved_email.token.expose_secret()
+ },
+ )
+ .map_err(|_| server_error("Failed to render email template"))?;
+
+ state
+ .emails
+ .send(&saved_email.email, verification_message)
+ .await?;
+
+ Ok(Json(EncodableEmail::from(saved_email)))
+}
+
+/// Delete an email address from a user profile.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/users/{id}/emails/{email_id}",
+ params(
+ ("id" = i32, Path, description = "ID of the user"),
+ ("email_id" = i32, Path, description = "ID of the email to delete"),
+ ),
+ security(
+ ("api_token" = []),
+ ("cookie" = []),
+ ),
+ tag = "users",
+ responses((status = 200, description = "Successful Response", body = inline(OkResponse))),
+)]
+pub async fn delete_email(
+ state: AppState,
+ Path((param_user_id, email_id)): Path<(i32, i32)>,
+ req: Parts,
+) -> AppResult {
+ let mut conn = state.db_write().await?;
+ let auth = AuthCheck::default().check(&req, &mut conn).await?;
+
+ // need to check if current user matches user to be updated
+ if auth.user_id() != param_user_id {
+ return Err(bad_request("current user does not match requested user"));
+ }
+
+ let email = Email::belonging_to(&auth.user())
+ .filter(emails::id.eq(email_id))
+ .select(Email::as_select())
+ .get_result(&mut conn)
+ .await
+ .map_err(|_| not_found())?;
+
+ if email.primary {
+ return Err(bad_request(
+ "cannot delete primary email, please set another email as primary first",
+ ));
+ }
+
+ diesel::delete(&email)
+ .execute(&mut conn)
+ .await
+ .map_err(|_| server_error("Error in deleting email"))?;
+
+ Ok(OkResponse::new())
+}
+
+/// Mark a specific email address as the primary email. This will cause notifications to be sent to this email address.
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/{id}/emails/{email_id}/set_primary",
+ params(
+ ("id" = i32, Path, description = "ID of the user"),
+ ("email_id" = i32, Path, description = "ID of the email to set as primary"),
+ ),
+ security(
+ ("api_token" = []),
+ ("cookie" = []),
+ ),
+ tag = "users",
+ responses((status = 200, description = "Successful Response", body = inline(OkResponse))),
+)]
+pub async fn set_primary_email(
+ state: AppState,
+ Path((param_user_id, email_id)): Path<(i32, i32)>,
+ req: Parts,
+) -> AppResult {
+ let mut conn = state.db_write().await?;
+ let auth = AuthCheck::default().check(&req, &mut conn).await?;
+
+ // need to check if current user matches user to be updated
+ if auth.user_id() != param_user_id {
+ return Err(bad_request("current user does not match requested user"));
+ }
+
+ let email = Email::belonging_to(&auth.user())
+ .filter(emails::id.eq(email_id))
+ .select(Email::as_select())
+ .get_result(&mut conn)
+ .await
+ .map_err(|_| not_found())?;
+
+ if email.primary {
+ return Err(bad_request("email is already primary"));
+ }
+
+ diesel::sql_query("SELECT promote_email_to_primary($1)")
+ .bind::(email_id)
+ .execute(&mut conn)
+ .await
+ .map_err(|_| server_error("Error in marking email as primary"))?;
+
+ Ok(OkResponse::new())
+}
diff --git a/src/controllers/user/me.rs b/src/controllers/user/me.rs
index 046ebcc86d3..c54076f9b91 100644
--- a/src/controllers/user/me.rs
+++ b/src/controllers/user/me.rs
@@ -4,10 +4,12 @@ use crate::controllers::helpers::Paginate;
use crate::controllers::helpers::pagination::{Paginated, PaginationOptions};
use crate::models::krate::CrateName;
use crate::models::{CrateOwner, Follow, OwnerKind, User, Version, VersionOwnerAction};
-use crate::schema::{crate_owners, crates, emails, follows, users, versions};
+use crate::schema::{crate_owners, crates, follows, users, versions};
use crate::util::errors::AppResult;
use crate::views::{EncodableMe, EncodablePrivateUser, EncodableVersion, OwnedCrate};
use axum::Json;
+use crates_io_database::models::Email;
+use crates_io_database::schema::emails;
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
use futures_util::FutureExt;
@@ -24,31 +26,24 @@ use serde::Serialize;
)]
pub async fn get_authenticated_user(app: AppState, req: Parts) -> AppResult> {
let mut conn = app.db_read_prefer_primary().await?;
- let user_id = AuthCheck::only_cookie()
- .check(&req, &mut conn)
- .await?
- .user_id();
-
- let ((user, verified, email, verification_sent), owned_crates) = tokio::try_join!(
- users::table
- .find(user_id)
- .left_join(emails::table)
- .select((
- User::as_select(),
- emails::verified.nullable(),
- emails::email.nullable(),
- emails::token_generated_at.nullable().is_not_null(),
- ))
- .first::<(User, Option, Option, bool)>(&mut conn)
- .boxed(),
- CrateOwner::by_owner_kind(OwnerKind::User)
- .inner_join(crates::table)
- .filter(crate_owners::owner_id.eq(user_id))
- .select((crates::id, crates::name, crate_owners::email_notifications))
- .order(crates::name.asc())
- .load(&mut conn)
- .boxed()
- )?;
+ let user = AuthCheck::only_cookie().check(&req, &mut conn).await?;
+
+ let emails_query = Email::belonging_to(user.user())
+ .select(Email::as_select())
+ .order(emails::id.asc())
+ .load(&mut conn)
+ .boxed();
+
+ let owned_crates_query = CrateOwner::by_owner_kind(OwnerKind::User)
+ .inner_join(crates::table)
+ .filter(crate_owners::owner_id.eq(user.user_id()))
+ .select((crates::id, crates::name, crate_owners::email_notifications))
+ .order(crates::name.asc())
+ .load(&mut conn)
+ .boxed();
+
+ let (emails, owned_crates): (Vec, Vec<(i32, String, bool)>) =
+ tokio::try_join!(emails_query, owned_crates_query)?;
let owned_crates = owned_crates
.into_iter()
@@ -59,10 +54,8 @@ pub async fn get_authenticated_user(app: AppState, req: Parts) -> AppResult
Subject: crates.io: Please confirm your email address
Content-Type: text/plain; charset=utf-8
diff --git a/src/controllers/user/update.rs b/src/controllers/user/update.rs
index 3021f1829f6..51352a86c31 100644
--- a/src/controllers/user/update.rs
+++ b/src/controllers/user/update.rs
@@ -2,7 +2,7 @@ use crate::app::AppState;
use crate::auth::AuthCheck;
use crate::controllers::helpers::OkResponse;
use crate::email::EmailMessage;
-use crate::models::NewEmail;
+use crate::models::{Email, NewEmail};
use crate::schema::users;
use crate::util::errors::{AppResult, bad_request, server_error};
use axum::Json;
@@ -16,20 +16,27 @@ use secrecy::ExposeSecret;
use serde::Deserialize;
use tracing::warn;
-#[derive(Deserialize)]
+#[derive(Deserialize, utoipa::ToSchema)]
pub struct UserUpdate {
user: User,
}
-#[derive(Deserialize)]
+#[derive(Deserialize, utoipa::ToSchema)]
+#[schema(as = UserUpdateParameters)]
pub struct User {
+ #[deprecated(note = "Use `/api/v1/users/{id}/emails` instead.")]
email: Option,
publish_notifications: Option,
}
/// Update user settings.
///
-/// This endpoint allows users to update their email address and publish notifications settings.
+/// This endpoint allows users to manage publish notifications settings.
+///
+/// You may provide an `email` parameter to add a new email address to the user's profile, but
+/// this is for legacy support only and will be removed in the future.
+///
+/// For managing email addresses, please use the `/api/v1/users/{id}/emails` endpoints instead.
///
/// The `id` parameter needs to match the ID of the currently authenticated user.
#[utoipa::path(
@@ -38,6 +45,7 @@ pub struct User {
params(
("user" = i32, Path, description = "ID of the user"),
),
+ request_body = inline(UserUpdate),
security(
("api_token" = []),
("cookie" = []),
@@ -95,6 +103,7 @@ pub async fn update_user(
}
}
+ #[allow(deprecated)]
if let Some(user_email) = &user_update.user.email {
let user_email = user_email.trim();
@@ -106,38 +115,45 @@ pub async fn update_user(
.parse::()
.map_err(|_| bad_request("invalid email address"))?;
- let new_email = NewEmail::builder()
+ // Check if this is the first email for the user, because if so, we need to mark it as the primary
+ let existing_email_count: i64 = Email::belonging_to(&user)
+ .count()
+ .get_result(&mut conn)
+ .await
+ .map_err(|_| server_error("Error fetching existing emails"))?;
+
+ let saved_email = NewEmail::builder()
.user_id(user.id)
.email(user_email)
- .primary(true)
- .build();
-
- let saved_email = new_email
- .insert_or_update_primary(&mut conn)
+ .primary(existing_email_count < 1) // Mark as primary if this is the first email
+ .build()
+ .insert_if_missing(&mut conn)
.await
- .map_err(|_| server_error("Error in saving email"))?;
-
- // This swallows any errors that occur while attempting to send the email. Some users have
- // an invalid email set in their GitHub profile, and we should let them sign in even though
- // we're trying to silently use their invalid address during signup and can't send them an
- // email. They'll then have to provide a valid email address.
- let email = EmailMessage::from_template(
- "user_confirm",
- context! {
- user_name => user.gh_login,
- domain => state.emails.domain,
- token => saved_email.token.expose_secret()
- },
- );
-
- match email {
- Ok(email) => {
- let _ = state.emails.send(user_email, email).await;
- }
- Err(error) => {
- warn!("Failed to render user confirmation email template: {error}");
+ .map_err(|_| server_error("Error saving email"))?;
+
+ if let Some(saved_email) = saved_email {
+ // This swallows any errors that occur while attempting to send the email. Some users have
+ // an invalid email set in their GitHub profile, and we should let them sign in even though
+ // we're trying to silently use their invalid address during signup and can't send them an
+ // email. They'll then have to provide a valid email address.
+ let email = EmailMessage::from_template(
+ "user_confirm",
+ context! {
+ user_name => user.gh_login,
+ domain => state.emails.domain,
+ token => saved_email.token.expose_secret()
+ },
+ );
+
+ match email {
+ Ok(email) => {
+ let _ = state.emails.send(user_email, email).await;
+ }
+ Err(error) => {
+ warn!("Failed to render user confirmation email template: {error}");
+ }
}
- };
+ }
}
Ok(OkResponse::new())
diff --git a/src/router.rs b/src/router.rs
index 91d598f0cb5..991b5de94ff 100644
--- a/src/router.rs
+++ b/src/router.rs
@@ -81,8 +81,14 @@ pub fn build_axum_router(state: AppState) -> Router<()> {
user::email_notifications::update_email_notifications
))
.routes(routes!(summary::get_summary))
+ .routes(routes!(user::emails::create_email))
+ .routes(routes!(user::emails::delete_email))
+ .routes(routes!(user::emails::set_primary_email))
.routes(routes!(user::email_verification::confirm_user_email))
.routes(routes!(user::email_verification::resend_email_verification))
+ .routes(routes!(
+ user::email_verification::resend_email_verification_all
+ ))
.routes(routes!(site_metadata::get_site_metadata))
// Session management
.routes(routes!(session::begin_session))
diff --git a/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap b/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap
index 2d7e9e9e150..2b1f9ff13f5 100644
--- a/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap
+++ b/src/snapshots/crates_io__openapi__tests__openapi_snapshot-2.snap
@@ -88,6 +88,7 @@ expression: response.json()
]
},
"email": {
+ "deprecated": true,
"description": "The user's primary email address, if set.",
"example": "kate@morgan.dev",
"type": [
@@ -96,15 +97,33 @@ expression: response.json()
]
},
"email_verification_sent": {
+ "deprecated": true,
"description": "Whether the user's has been sent a verification email to their primary email address, if set.",
"example": true,
"type": "boolean"
},
"email_verified": {
+ "deprecated": true,
"description": "Whether the user's primary email address, if set, has been verified.",
"example": true,
"type": "boolean"
},
+ "emails": {
+ "description": "The user's email addresses.",
+ "example": [
+ {
+ "email": "user@example.com",
+ "id": 42,
+ "primary": true,
+ "verification_email_sent": true,
+ "verified": true
+ }
+ ],
+ "items": {
+ "$ref": "#/components/schemas/Email"
+ },
+ "type": "array"
+ },
"id": {
"description": "An opaque identifier for the user.",
"example": 42,
@@ -146,6 +165,7 @@ expression: response.json()
"required": [
"id",
"login",
+ "emails",
"email_verified",
"email_verification_sent",
"is_admin",
@@ -497,6 +517,44 @@ expression: response.json()
],
"type": "object"
},
+ "Email": {
+ "properties": {
+ "email": {
+ "description": "The email address.",
+ "example": "user@example.com",
+ "type": "string"
+ },
+ "id": {
+ "description": "An opaque identifier for the email.",
+ "example": 42,
+ "format": "int32",
+ "type": "integer"
+ },
+ "primary": {
+ "description": "Whether this is the user's primary email address, meaning notifications will be sent here.",
+ "example": true,
+ "type": "boolean"
+ },
+ "verification_email_sent": {
+ "description": "Whether the verification email has been sent.",
+ "example": true,
+ "type": "boolean"
+ },
+ "verified": {
+ "description": "Whether the email address has been verified.",
+ "example": true,
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "id",
+ "email",
+ "verified",
+ "verification_email_sent",
+ "primary"
+ ],
+ "type": "object"
+ },
"EncodableApiTokenWithToken": {
"allOf": [
{
@@ -963,6 +1021,24 @@ expression: response.json()
],
"type": "object"
},
+ "UserUpdateParameters": {
+ "properties": {
+ "email": {
+ "deprecated": true,
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "publish_notifications": {
+ "type": [
+ "boolean",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ },
"Version": {
"properties": {
"audit_actions": {
@@ -1694,13 +1770,17 @@ expression: response.json()
"application/json": {
"schema": {
"properties": {
+ "email": {
+ "$ref": "#/components/schemas/Email"
+ },
"ok": {
"example": true,
"type": "boolean"
}
},
"required": [
- "ok"
+ "ok",
+ "email"
],
"type": "object"
}
@@ -4381,9 +4461,189 @@ expression: response.json()
]
}
},
+ "/api/v1/users/{id}/emails": {
+ "post": {
+ "operationId": "create_email",
+ "parameters": [
+ {
+ "description": "ID of the user",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "email": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "email"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Email"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "security": [
+ {
+ "api_token": []
+ },
+ {
+ "cookie": []
+ }
+ ],
+ "summary": "Add a new email address to a user profile.",
+ "tags": [
+ "users"
+ ]
+ }
+ },
+ "/api/v1/users/{id}/emails/{email_id}": {
+ "delete": {
+ "operationId": "delete_email",
+ "parameters": [
+ {
+ "description": "ID of the user",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ },
+ {
+ "description": "ID of the email to delete",
+ "in": "path",
+ "name": "email_id",
+ "required": true,
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "ok": {
+ "example": true,
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "ok"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "security": [
+ {
+ "api_token": []
+ },
+ {
+ "cookie": []
+ }
+ ],
+ "summary": "Delete an email address from a user profile.",
+ "tags": [
+ "users"
+ ]
+ }
+ },
+ "/api/v1/users/{id}/emails/{email_id}/set_primary": {
+ "put": {
+ "operationId": "set_primary_email",
+ "parameters": [
+ {
+ "description": "ID of the user",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ },
+ {
+ "description": "ID of the email to set as primary",
+ "in": "path",
+ "name": "email_id",
+ "required": true,
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "ok": {
+ "example": true,
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "ok"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "security": [
+ {
+ "api_token": []
+ },
+ {
+ "cookie": []
+ }
+ ],
+ "summary": "Mark a specific email address as the primary email. This will cause notifications to be sent to this email address.",
+ "tags": [
+ "users"
+ ]
+ }
+ },
"/api/v1/users/{id}/resend": {
"put": {
- "operationId": "resend_email_verification",
+ "deprecated": true,
+ "operationId": "resend_email_verification_all",
"parameters": [
{
"description": "ID of the user",
@@ -4425,7 +4685,7 @@ expression: response.json()
"cookie": []
}
],
- "summary": "Regenerate and send an email verification token.",
+ "summary": "Regenerate and send an email verification token for any unverified email of the current user.\nDeprecated endpoint, use `PUT /api/v1/user/{user_id}/emails/{id}/resend` instead.",
"tags": [
"users"
]
@@ -4477,6 +4737,66 @@ expression: response.json()
]
}
},
+ "/api/v1/users/{user_id}/emails/{id}/resend": {
+ "put": {
+ "operationId": "resend_email_verification",
+ "parameters": [
+ {
+ "description": "ID of the user",
+ "in": "path",
+ "name": "user_id",
+ "required": true,
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ },
+ {
+ "description": "ID of the email",
+ "in": "path",
+ "name": "id",
+ "required": true,
+ "schema": {
+ "format": "int32",
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "ok": {
+ "example": true,
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "ok"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ }
+ },
+ "security": [
+ {
+ "api_token": []
+ },
+ {
+ "cookie": []
+ }
+ ],
+ "summary": "Regenerate and send an email verification token for the given email.",
+ "tags": [
+ "users"
+ ]
+ }
+ },
"/api/v1/users/{user}": {
"get": {
"operationId": "find_user",
@@ -4517,7 +4837,7 @@ expression: response.json()
]
},
"put": {
- "description": "This endpoint allows users to update their email address and publish notifications settings.\n\nThe `id` parameter needs to match the ID of the currently authenticated user.",
+ "description": "This endpoint allows users to manage publish notifications settings.\n\nYou may provide an `email` parameter to add a new email address to the user's profile, but\nthis is for legacy support only and will be removed in the future.\n\nFor managing email addresses, please use the `/api/v1/users/{id}/emails` endpoints instead.\n\nThe `id` parameter needs to match the ID of the currently authenticated user.",
"operationId": "update_user",
"parameters": [
{
@@ -4531,6 +4851,24 @@ expression: response.json()
}
}
],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "properties": {
+ "user": {
+ "$ref": "#/components/schemas/UserUpdateParameters"
+ }
+ },
+ "required": [
+ "user"
+ ],
+ "type": "object"
+ }
+ }
+ },
+ "required": true
+ },
"responses": {
"200": {
"content": {
diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap
index 5564b16de5e..aaf6f7e42b2 100644
--- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap
+++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-4.snap
@@ -9,6 +9,15 @@ expression: response.json()
"email": "foo@example.com",
"email_verification_sent": true,
"email_verified": true,
+ "emails": [
+ {
+ "email": "foo@example.com",
+ "id": 1,
+ "primary": true,
+ "verification_email_sent": true,
+ "verified": true
+ }
+ ],
"id": 1,
"is_admin": false,
"login": "foo",
diff --git a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap
index b0ffc3e7fc8..1d17ee10ea8 100644
--- a/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap
+++ b/src/tests/routes/me/snapshots/crates_io__tests__routes__me__get__me-6.snap
@@ -15,6 +15,15 @@ expression: response.json()
"email": "foo@example.com",
"email_verification_sent": true,
"email_verified": true,
+ "emails": [
+ {
+ "email": "foo@example.com",
+ "id": 1,
+ "primary": true,
+ "verification_email_sent": true,
+ "verified": true
+ }
+ ],
"id": 1,
"is_admin": false,
"login": "foo",
diff --git a/src/tests/routes/users/email_verification.rs b/src/tests/routes/users/email_verification.rs
new file mode 100644
index 00000000000..07c4ca28557
--- /dev/null
+++ b/src/tests/routes/users/email_verification.rs
@@ -0,0 +1,54 @@
+use super::emails::MockEmailHelper;
+use crate::tests::util::{RequestHelper, Response, TestApp};
+use insta::assert_snapshot;
+
+pub trait MockEmailVerificationHelper: RequestHelper {
+ async fn resend_confirmation(&self, user_id: i32, email_id: i32) -> Response<()> {
+ let url = format!("/api/v1/users/{user_id}/emails/{email_id}/resend");
+ self.put(&url, &[] as &[u8]).await
+ }
+}
+
+impl MockEmailVerificationHelper for crate::tests::util::MockCookieUser {}
+impl MockEmailVerificationHelper for crate::tests::util::MockAnonymousUser {}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_no_auth() {
+ let (app, anon, user) = TestApp::init().with_user().await;
+
+ let response = anon.resend_confirmation(user.as_model().id, 1).await;
+ assert_snapshot!(response.status(), @"403 Forbidden");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
+
+ assert_eq!(app.emails().await.len(), 0);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_wrong_user() {
+ let (app, _anon, user) = TestApp::init().with_user().await;
+ let user2 = app.db_new_user("bar").await;
+ let response = user.resend_confirmation(user2.as_model().id, 1).await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#);
+ assert_eq!(app.emails().await.len(), 0);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_happy_path() {
+ let (app, _anon, user) = TestApp::init().with_user().await;
+
+ // Add an email to the user
+ let response = user.add_email(user.as_model().id, "user@example.com").await;
+ assert_snapshot!(response.status(), @"200 OK");
+ assert_snapshot!(response.text(), @r#"{"id":2,"email":"user@example.com","verified":false,"verification_email_sent":true,"primary":false}"#);
+
+ let response = user
+ .resend_confirmation(
+ user.as_model().id,
+ response.json()["id"].as_u64().unwrap() as i32,
+ )
+ .await;
+ assert_snapshot!(response.status(), @"200 OK");
+ assert_snapshot!(response.text(), @r#"{"ok":true}"#);
+ assert_snapshot!(app.emails_snapshot().await);
+}
diff --git a/src/tests/routes/users/emails.rs b/src/tests/routes/users/emails.rs
new file mode 100644
index 00000000000..1a172f187a1
--- /dev/null
+++ b/src/tests/routes/users/emails.rs
@@ -0,0 +1,220 @@
+use crate::tests::util::{RequestHelper, Response, TestApp};
+use insta::assert_snapshot;
+use serde_json::json;
+
+pub trait MockEmailHelper: RequestHelper {
+ async fn add_email(&self, user_id: i32, email: &str) -> Response<()> {
+ let body = json!({"email": email});
+ let url = format!("/api/v1/users/{user_id}/emails");
+ self.post(&url, body.to_string()).await
+ }
+
+ async fn delete_email(&self, user_id: i32, email_id: i32) -> Response<()> {
+ let url = format!("/api/v1/users/{user_id}/emails/{email_id}");
+ self.delete(&url).await
+ }
+
+ async fn update_primary_email(&self, user_id: i32, email_id: i32) -> Response<()> {
+ let url = format!("/api/v1/users/{user_id}/emails/{email_id}/set_primary");
+ self.put(&url, "").await
+ }
+}
+
+impl MockEmailHelper for crate::tests::util::MockCookieUser {}
+impl MockEmailHelper for crate::tests::util::MockAnonymousUser {}
+
+/// Given a crates.io user, check that the user can add an email address
+/// to their profile, and that the email address is then returned by the
+/// `/me` endpoint.
+#[tokio::test(flavor = "multi_thread")]
+async fn test_email_add() -> anyhow::Result<()> {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+
+ let json = user.show_me().await;
+ assert_eq!(json.user.emails.len(), 1);
+ assert_eq!(json.user.emails.first().unwrap().email, "foo@example.com");
+
+ let response = user.add_email(json.user.id, "bar@example.com").await;
+ let json = user.show_me().await;
+ assert_snapshot!(response.status(), @"200 OK");
+ assert_snapshot!(response.text(), @r#"{"id":2,"email":"bar@example.com","verified":false,"verification_email_sent":true,"primary":false}"#);
+ assert_eq!(json.user.emails.len(), 2);
+ assert!(
+ json.user
+ .emails
+ .iter()
+ .any(|e| e.email == "bar@example.com")
+ );
+ assert!(
+ json.user
+ .emails
+ .iter()
+ .find(|e| e.email == "foo@example.com")
+ .unwrap()
+ .primary
+ );
+
+ Ok(())
+}
+
+/// Given a crates.io user, check to make sure that the user
+/// cannot add to the database an empty string or null as
+/// their email. If an attempt is made, the emails controller
+/// will return an error indicating that an empty email cannot be
+/// added.
+///
+/// This is checked on the frontend already, but I'd like to
+/// make sure that a user cannot get around that and delete
+/// their email by adding an empty string.
+#[tokio::test(flavor = "multi_thread")]
+async fn test_empty_email_not_added() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ let response = user.add_email(model.id, "").await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"empty email rejected"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_ignore_empty_json() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ let url = format!("/api/v1/users/{}/emails", model.id);
+ let payload = json!({});
+ let response = user.post::<()>(&url, payload.to_string()).await;
+ assert_snapshot!(response.status(), @"422 Unprocessable Entity");
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_ignore_null_email() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ let url = format!("/api/v1/users/{}/emails", model.id);
+ let payload = json!({ "email": null });
+ let response = user.post::<()>(&url, payload.to_string()).await;
+ assert_snapshot!(response.status(), @"422 Unprocessable Entity");
+}
+
+/// Check to make sure that neither other signed in users nor anonymous users can add an
+/// email address to another user's account.
+///
+/// If an attempt is made, the emails controller will return an error indicating that the
+/// current user does not match the requested user.
+#[tokio::test(flavor = "multi_thread")]
+async fn test_other_users_cannot_change_my_email() {
+ let (app, anon, user) = TestApp::init().with_user().await;
+ let another_user = app.db_new_user("not_me").await;
+ let another_user_model = another_user.as_model();
+
+ let response = user
+ .add_email(another_user_model.id, "pineapple@pineapples.pineapple")
+ .await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#);
+
+ let response = anon
+ .add_email(another_user_model.id, "pineapple@pineapples.pineapple")
+ .await;
+ assert_snapshot!(response.status(), @"403 Forbidden");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invalid_email_address() {
+ let (_app, _, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ let response = user.add_email(model.id, "foo").await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"invalid email address"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invalid_json() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ let url = format!("/api/v1/users/{}/emails", model.id);
+ let response = user.post::<()>(&url, r#"{ "user": foo }"#).await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Failed to parse the request body as JSON: user: expected ident at line 1 column 12"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_delete_email_invalid_id() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ let response = user.delete_email(model.id, 0).await;
+ assert_snapshot!(response.status(), @"404 Not Found");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_other_users_cannot_delete_my_email() {
+ let (app, anon, user) = TestApp::init().with_user().await;
+ let another_user = app.db_new_user("not_me").await;
+ let another_user_model = another_user.as_model();
+
+ let response = user.delete_email(another_user_model.id, 0).await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#);
+
+ let response = anon.delete_email(another_user_model.id, 0).await;
+ assert_snapshot!(response.status(), @"403 Forbidden");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_cannot_delete_my_primary_email() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ // Attempt to delete the primary email address
+ let response = user.delete_email(model.id, 1).await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"cannot delete primary email, please set another email as primary first"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_can_delete_an_alternative_email() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ // Add an alternative email address
+ let response = user.add_email(model.id, "potato3@example.com").await;
+ assert_snapshot!(response.status(), @"200 OK");
+ assert_snapshot!(response.text(), @r#"{"id":2,"email":"potato3@example.com","verified":false,"verification_email_sent":true,"primary":false}"#);
+
+ // Attempt to delete the alternative email address
+ let response = user.delete_email(model.id, 2).await;
+ assert_snapshot!(response.status(), @"200 OK");
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_set_primary_invalid_id() {
+ let (_app, _anon, user) = TestApp::init().with_user().await;
+ let model = user.as_model();
+
+ let response = user.update_primary_email(model.id, 0).await;
+ assert_snapshot!(response.status(), @"404 Not Found");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"Not Found"}]}"#);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_other_users_cannot_set_my_primary_email() {
+ let (app, anon, user) = TestApp::init().with_user().await;
+ let another_user = app.db_new_user("not_me").await;
+ let another_user_model = another_user.as_model();
+
+ let response = user.update_primary_email(another_user_model.id, 1).await;
+ assert_snapshot!(response.status(), @"400 Bad Request");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"current user does not match requested user"}]}"#);
+
+ let response = anon.update_primary_email(another_user_model.id, 1).await;
+ assert_snapshot!(response.status(), @"403 Forbidden");
+ assert_snapshot!(response.text(), @r#"{"errors":[{"detail":"this action requires authentication"}]}"#);
+}
diff --git a/src/tests/routes/users/mod.rs b/src/tests/routes/users/mod.rs
index c788314a57b..5e1bfc679a2 100644
--- a/src/tests/routes/users/mod.rs
+++ b/src/tests/routes/users/mod.rs
@@ -1,3 +1,5 @@
+mod email_verification;
+mod emails;
mod read;
mod stats;
pub mod update;
diff --git a/src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap
new file mode 100644
index 00000000000..4d28401107e
--- /dev/null
+++ b/src/tests/routes/users/snapshots/crates_io__tests__routes__users__email_verification__happy_path-5.snap
@@ -0,0 +1,40 @@
+---
+source: src/tests/routes/users/email_verification.rs
+expression: app.emails_snapshot().await
+---
+To: user@example.com
+From: crates.io
+Subject: crates.io: Please confirm your email address
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+
+Hello foo!
+
+Welcome to crates.io. Please click the link below to verify your email address:
+
+https://crates.io/confirm/[confirm-token]
+
+Thank you!
+
+--
+The crates.io Team
+----------------------------------------
+
+To: user@example.com
+From: crates.io
+Subject: crates.io: Please confirm your email address
+Content-Type: text/plain; charset=utf-8
+Content-Transfer-Encoding: quoted-printable
+
+
+Hello foo!
+
+Welcome to crates.io. Please click the link below to verify your email address:
+
+https://crates.io/confirm/[confirm-token]
+
+Thank you!
+
+--
+The crates.io Team
diff --git a/src/tests/routes/users/update.rs b/src/tests/routes/users/update.rs
index 884b1eddab6..ff72ba50f78 100644
--- a/src/tests/routes/users/update.rs
+++ b/src/tests/routes/users/update.rs
@@ -1,3 +1,5 @@
+//! This file tests the legacy email update functionality.
+
use crate::tests::util::{RequestHelper, Response, TestApp};
use http::StatusCode;
use insta::assert_snapshot;
diff --git a/src/tests/snapshots/crates_io__tests__user__confirm_email-2.snap b/src/tests/snapshots/crates_io__tests__user__confirm_email-2.snap
new file mode 100644
index 00000000000..0eb69a21a5e
--- /dev/null
+++ b/src/tests/snapshots/crates_io__tests__user__confirm_email-2.snap
@@ -0,0 +1,14 @@
+---
+source: src/tests/user.rs
+expression: response.json()
+---
+{
+ "email": {
+ "email": "potato2@example.com",
+ "id": 1,
+ "primary": true,
+ "verification_email_sent": true,
+ "verified": true
+ },
+ "ok": true
+}
diff --git a/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap b/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap
new file mode 100644
index 00000000000..e3b739d2aa7
--- /dev/null
+++ b/src/tests/snapshots/crates_io__tests__user__email_legacy_get_and_put.snap
@@ -0,0 +1,32 @@
+---
+source: src/tests/user.rs
+expression: json.user
+---
+{
+ "id": 1,
+ "login": "foo",
+ "emails": [
+ {
+ "id": 1,
+ "email": "foo@example.com",
+ "verified": true,
+ "verification_email_sent": true,
+ "primary": true
+ },
+ {
+ "id": 2,
+ "email": "mango@mangos.mango",
+ "verified": false,
+ "verification_email_sent": true,
+ "primary": false
+ }
+ ],
+ "name": null,
+ "email_verified": true,
+ "email_verification_sent": true,
+ "email": "foo@example.com",
+ "avatar": null,
+ "url": "https://github.com/foo",
+ "is_admin": false,
+ "publish_notifications": true
+}
diff --git a/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap b/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap
new file mode 100644
index 00000000000..825ccd95b3c
--- /dev/null
+++ b/src/tests/snapshots/crates_io__tests__user__initial_github_login_succeeds.snap
@@ -0,0 +1,32 @@
+---
+source: src/tests/user.rs
+expression: json.user
+---
+{
+ "id": 1,
+ "login": "arbitrary_username",
+ "emails": [
+ {
+ "id": 1,
+ "email": "foo@example.com",
+ "verified": true,
+ "verification_email_sent": true,
+ "primary": true
+ },
+ {
+ "id": 2,
+ "email": "bar@example.com",
+ "verified": false,
+ "verification_email_sent": true,
+ "primary": false
+ }
+ ],
+ "name": null,
+ "email_verified": true,
+ "email_verification_sent": true,
+ "email": "foo@example.com",
+ "avatar": null,
+ "url": "https://github.com/arbitrary_username",
+ "is_admin": false,
+ "publish_notifications": true
+}
diff --git a/src/tests/user.rs b/src/tests/user.rs
index 6f758de81e5..d9a20883d83 100644
--- a/src/tests/user.rs
+++ b/src/tests/user.rs
@@ -6,19 +6,18 @@ use crate::tests::util::{MockCookieUser, RequestHelper};
use crate::util::token::HashedToken;
use chrono::{DateTime, Utc};
use claims::assert_ok;
-use crates_io_github::GitHubUser;
+use crates_io_github::{GitHubEmail, GitHubUser};
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
-use insta::assert_snapshot;
+use insta::{assert_json_snapshot, assert_snapshot};
use secrecy::ExposeSecret;
-use serde_json::json;
impl crate::tests::util::MockCookieUser {
async fn confirm_email(&self, email_token: &str) {
let url = format!("/api/v1/confirm/{email_token}");
let response = self.put::<()>(&url, &[] as &[u8]).await;
assert_snapshot!(response.status(), @"200 OK");
- assert_eq!(response.json(), json!({ "ok": true }));
+ assert_json_snapshot!(response.json());
}
}
@@ -38,7 +37,9 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()>
email: None,
avatar_url: None,
};
- assert_ok!(session::save_user_to_database(&gh_user, "bar_token", emails, &mut conn).await);
+ assert_ok!(
+ session::save_user_to_database(&gh_user, &mut [], "bar_token", emails, &mut conn).await
+ );
// Use the original API token to find the now updated user
let hashed_token = assert_ok!(HashedToken::parse(token));
@@ -51,6 +52,50 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()>
Ok(())
}
+#[tokio::test(flavor = "multi_thread")]
+async fn initial_github_login_succeeds() -> anyhow::Result<()> {
+ let (app, _) = TestApp::init().empty().await;
+ let emails = &app.as_inner().emails;
+ let mut conn = app.db_conn().await;
+
+ // Simulate logging in via GitHub
+ let gh_id = next_gh_id();
+ let gh_user = GitHubUser {
+ id: gh_id,
+ login: "arbitrary_username".to_string(),
+ name: None,
+ email: Some("foo@example.com".to_string()),
+ avatar_url: None,
+ };
+ // The primary email is not first in the array to validate that the order does not matter
+ let mut gh_emails = vec![
+ GitHubEmail {
+ email: "bar@example.com".to_string(),
+ verified: false,
+ primary: false,
+ },
+ GitHubEmail {
+ email: gh_user.email.clone().unwrap(),
+ verified: true,
+ primary: true,
+ },
+ ];
+ let u = session::save_user_to_database(
+ &gh_user,
+ &mut gh_emails,
+ "some random token",
+ emails,
+ &mut conn,
+ )
+ .await?;
+
+ let user = MockCookieUser::new(&app, u);
+ let json = user.show_me().await;
+ assert_json_snapshot!(json.user);
+
+ Ok(())
+}
+
/// Given a GitHub user, check that if the user logs in,
/// updates their email, logs out, then logs back in, the
/// email they added to crates.io will not be overwritten
@@ -61,6 +106,7 @@ async fn updating_existing_user_doesnt_change_api_token() -> anyhow::Result<()>
/// send none as the email and we will end up inadvertently
/// deleting their email when they sign back in.
#[tokio::test(flavor = "multi_thread")]
+#[allow(deprecated)]
async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> {
let (app, _) = TestApp::init().empty().await;
let emails = &app.as_inner().emails;
@@ -80,13 +126,14 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> {
};
let u =
- session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
+ session::save_user_to_database(&gh_user, &mut [], "some random token", emails, &mut conn)
+ .await?;
let user_without_github_email = MockCookieUser::new(&app, u);
let json = user_without_github_email.show_me().await;
// Check that the setup is correct and the user indeed has no email
- assert_eq!(json.user.primary_email, None);
+ assert_eq!(json.user.emails.len(), 0);
// Add an email address in crates.io
user_without_github_email
@@ -104,19 +151,23 @@ async fn github_without_email_does_not_overwrite_email() -> anyhow::Result<()> {
};
let u =
- session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
+ session::save_user_to_database(&gh_user, &mut [], "some random token", emails, &mut conn)
+ .await?;
let again_user_without_github_email = MockCookieUser::new(&app, u);
let json = again_user_without_github_email.show_me().await;
+ assert_eq!(json.user.emails[0].email, "apricot@apricots.apricot");
assert_eq!(json.user.primary_email.unwrap(), "apricot@apricots.apricot");
Ok(())
}
/// Given a new user, test that if they sign in with one email, change their email on GitHub, then
-/// sign in again, that the email in crates.io will remain set to the original email used on GitHub.
+/// sign in again, that both emails will be present on their crates.io account, with the original
+/// remaining as the primary email.
#[tokio::test(flavor = "multi_thread")]
+#[allow(deprecated)]
async fn github_with_email_does_not_overwrite_email() -> anyhow::Result<()> {
use crate::schema::emails;
@@ -144,23 +195,45 @@ async fn github_with_email_does_not_overwrite_email() -> anyhow::Result<()> {
email: Some(new_github_email.to_string()),
avatar_url: None,
};
+ let gh_email = GitHubEmail {
+ email: gh_user.email.clone().unwrap_or_default(),
+ verified: true,
+ primary: true,
+ };
- let u =
- session::save_user_to_database(&gh_user, "some random token", &emails, &mut conn).await?;
+ let u = session::save_user_to_database(
+ &gh_user,
+ &mut [gh_email],
+ "some random token",
+ &emails,
+ &mut conn,
+ )
+ .await?;
let user_with_different_email_in_github = MockCookieUser::new(&app, u);
let json = user_with_different_email_in_github.show_me().await;
+ assert!(json.user.emails.iter().any(|e| e.email == new_github_email));
+ assert!(
+ json.user
+ .emails
+ .iter()
+ .find(|e| e.email == original_email)
+ .unwrap()
+ .primary
+ );
assert_eq!(json.user.primary_email, Some(original_email));
Ok(())
}
/// Given a crates.io user, check that the user's email can be
-/// updated in the database (PUT /user/{user_id}), then check
-/// that the updated email is sent back to the user (GET /me).
+/// updated in the database using the legacy endpoint
+/// (PUT /user/{user_id}), then check that the updated email
+/// is included in the user's email list (GET /me).
#[tokio::test(flavor = "multi_thread")]
-async fn test_email_get_and_put() -> anyhow::Result<()> {
+#[allow(deprecated)]
+async fn test_email_legacy_get_and_put() -> anyhow::Result<()> {
let (_app, _anon, user) = TestApp::init().with_user().await;
let json = user.show_me().await;
@@ -169,9 +242,7 @@ async fn test_email_get_and_put() -> anyhow::Result<()> {
user.update_email("mango@mangos.mango").await;
let json = user.show_me().await;
- assert_eq!(json.user.primary_email.unwrap(), "mango@mangos.mango");
- assert!(!json.user.primary_email_verified);
- assert!(json.user.primary_email_verification_sent);
+ assert_json_snapshot!(json.user);
Ok(())
}
@@ -182,6 +253,7 @@ async fn test_email_get_and_put() -> anyhow::Result<()> {
/// requested, check that the response back is ok, and that
/// the email_verified field on user is now set to true.
#[tokio::test(flavor = "multi_thread")]
+#[allow(deprecated)]
async fn test_confirm_user_email() -> anyhow::Result<()> {
use crate::schema::emails;
@@ -201,9 +273,20 @@ async fn test_confirm_user_email() -> anyhow::Result<()> {
email: Some(email.to_string()),
avatar_url: None,
};
+ let gh_email = GitHubEmail {
+ email: gh_user.email.clone().unwrap_or_default(),
+ verified: true,
+ primary: true,
+ };
- let u =
- session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
+ let u = session::save_user_to_database(
+ &gh_user,
+ &mut [gh_email],
+ "some random token",
+ emails,
+ &mut conn,
+ )
+ .await?;
let user = MockCookieUser::new(&app, u);
let user_model = user.as_model();
@@ -216,6 +299,14 @@ async fn test_confirm_user_email() -> anyhow::Result<()> {
user.confirm_email(&email_token).await;
let json = user.show_me().await;
+
+ // Check emails array
+ assert_eq!(json.user.emails.len(), 1);
+ assert_eq!(json.user.emails[0].email, "potato2@example.com");
+ assert!(json.user.emails[0].verified);
+ assert!(json.user.emails[0].primary);
+
+ // Check legacy fields
assert_eq!(json.user.primary_email.unwrap(), "potato2@example.com");
assert!(json.user.primary_email_verified);
assert!(json.user.primary_email_verification_sent);
@@ -223,10 +314,60 @@ async fn test_confirm_user_email() -> anyhow::Result<()> {
Ok(())
}
+/// Given a new user, who has a single email address on GitHub
+/// which is not verified, check that their email is not marked
+/// as verified in the database, and that a verification email
+/// is sent to the user.
+#[tokio::test(flavor = "multi_thread")]
+#[allow(deprecated)]
+async fn test_unverified_email_not_marked_verified() -> anyhow::Result<()> {
+ let (app, _) = TestApp::init().empty().await;
+ let mut conn = app.db_conn().await;
+
+ let email = "potato3@example.com";
+ let emails = &app.as_inner().emails;
+ let gh_user = GitHubUser {
+ id: next_gh_id(),
+ login: "arbitrary_username".to_string(),
+ name: None,
+ email: Some(email.to_string()),
+ avatar_url: None,
+ };
+ let gh_email = GitHubEmail {
+ email: gh_user.email.clone().unwrap_or_default(),
+ verified: false,
+ primary: true,
+ };
+ let u = session::save_user_to_database(
+ &gh_user,
+ &mut [gh_email],
+ "some
+ random token",
+ emails,
+ &mut conn,
+ )
+ .await?;
+ let user = MockCookieUser::new(&app, u);
+ let json = user.show_me().await;
+ // Check emails array
+ assert_eq!(json.user.emails.len(), 1);
+ assert_eq!(json.user.emails[0].email, "potato3@example.com");
+ assert!(!json.user.emails[0].verified);
+ assert!(json.user.emails[0].primary);
+
+ // Check legacy fields
+ assert_eq!(json.user.primary_email.unwrap(), "potato3@example.com");
+ assert!(!json.user.primary_email_verified);
+ assert!(json.user.primary_email_verification_sent);
+
+ Ok(())
+}
+
/// Given a user who existed before we added email confirmation,
/// test that `email_verification_sent` is false so that we don't
/// make the user think we've sent an email when we haven't.
#[tokio::test(flavor = "multi_thread")]
+#[allow(deprecated)]
async fn test_existing_user_email() -> anyhow::Result<()> {
use crate::schema::emails;
use diesel::update;
@@ -247,9 +388,20 @@ async fn test_existing_user_email() -> anyhow::Result<()> {
email: Some(email.to_string()),
avatar_url: None,
};
+ let gh_email = GitHubEmail {
+ email: gh_user.email.clone().unwrap_or_default(),
+ verified: false,
+ primary: true,
+ };
- let u =
- session::save_user_to_database(&gh_user, "some random token", emails, &mut conn).await?;
+ let u = session::save_user_to_database(
+ &gh_user,
+ &mut [gh_email],
+ "some random token",
+ emails,
+ &mut conn,
+ )
+ .await?;
update(Email::belonging_to(&u))
// Users created before we added verification will have
diff --git a/src/tests/util.rs b/src/tests/util.rs
index 6b6bfae932f..0476078368d 100644
--- a/src/tests/util.rs
+++ b/src/tests/util.rs
@@ -316,6 +316,23 @@ impl MockCookieUser {
&self.user
}
+ /// Creates an email for the user, directly inserting it into the database
+ pub async fn db_new_email(
+ &self,
+ email: &str,
+ verified: bool,
+ primary: bool,
+ ) -> crate::models::Email {
+ let mut conn = self.app.db_conn().await;
+ let new_email = crate::models::NewEmail::builder()
+ .user_id(self.user.id)
+ .email(email)
+ .verified(verified)
+ .primary(primary)
+ .build();
+ new_email.insert(&mut conn).await.unwrap()
+ }
+
/// Creates a token and wraps it in a helper struct
///
/// This method updates the database directly
diff --git a/src/views.rs b/src/views.rs
index 38b0c056bb6..cc0ed01c017 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -1,7 +1,8 @@
use crate::external_urls::remove_blocked_urls;
use crate::models::{
- ApiToken, Category, Crate, Dependency, DependencyKind, Keyword, Owner, ReverseDependency, Team,
- TopVersions, TrustpubData, User, Version, VersionDownload, VersionOwnerAction,
+ ApiToken, Category, Crate, Dependency, DependencyKind, Email, Keyword, Owner,
+ ReverseDependency, Team, TopVersions, TrustpubData, User, Version, VersionDownload,
+ VersionOwnerAction,
};
use chrono::{DateTime, Utc};
use crates_io_github as github;
@@ -676,6 +677,17 @@ pub struct EncodablePrivateUser {
#[schema(example = "ghost")]
pub login: String,
+ /// The user's email addresses.
+ #[schema(example = json!([
+ {
+ "id": 42,
+ "email": "user@example.com",
+ "verified": true,
+ "primary": true,
+ "verification_email_sent": true
+ }]))]
+ pub emails: Vec,
+
/// The user's display name, if set.
#[schema(example = "Kate Morgan")]
pub name: Option,
@@ -683,16 +695,23 @@ pub struct EncodablePrivateUser {
/// Whether the user's primary email address, if set, has been verified.
#[schema(example = true)]
#[serde(rename = "email_verified")]
+ #[deprecated(note = "Use `emails` array instead, check that `verified` property is true.")]
pub primary_email_verified: bool,
/// Whether the user's has been sent a verification email to their primary email address, if set.
#[schema(example = true)]
#[serde(rename = "email_verification_sent")]
+ #[deprecated(
+ note = "Use `emails` array instead, check that `token_generated_at` property is not null."
+ )]
pub primary_email_verification_sent: bool,
/// The user's primary email address, if set.
#[schema(example = "kate@morgan.dev")]
#[serde(rename = "email")]
+ #[deprecated(
+ note = "Use `emails` array instead, maximum of one entry will have `primary` property set to true."
+ )]
pub primary_email: Option,
/// The user's avatar URL, if set.
@@ -714,12 +733,7 @@ pub struct EncodablePrivateUser {
impl EncodablePrivateUser {
/// Converts this `User` model into an `EncodablePrivateUser` for JSON serialization.
- pub fn from(
- user: User,
- primary_email: Option,
- primary_email_verified: bool,
- primary_email_verification_sent: bool,
- ) -> Self {
+ pub fn from(user: User, emails: Vec) -> Self {
let User {
id,
name,
@@ -731,11 +745,19 @@ impl EncodablePrivateUser {
} = user;
let url = format!("https://github.com/{gh_login}");
+ let primary_email = emails.iter().find(|e| e.primary);
+ let primary_email_verified = primary_email.map(|e| e.verified).unwrap_or(false);
+ let primary_email_verification_sent =
+ primary_email.and_then(|e| e.token_generated_at).is_some();
+ let primary_email = primary_email.map(|e| e.email.clone());
+
+ #[allow(deprecated)]
EncodablePrivateUser {
id,
- primary_email,
+ emails: emails.into_iter().map(EncodableEmail::from).collect(),
primary_email_verified,
primary_email_verification_sent,
+ primary_email,
avatar: gh_avatar,
login: gh_login,
name,
@@ -746,6 +768,42 @@ impl EncodablePrivateUser {
}
}
+#[derive(Deserialize, Serialize, Debug, utoipa::ToSchema)]
+#[schema(as = Email)]
+pub struct EncodableEmail {
+ /// An opaque identifier for the email.
+ #[schema(example = 42)]
+ pub id: i32,
+
+ /// The email address.
+ #[schema(example = "user@example.com")]
+ pub email: String,
+
+ /// Whether the email address has been verified.
+ #[schema(example = true)]
+ pub verified: bool,
+
+ /// Whether the verification email has been sent.
+ #[schema(example = true)]
+ pub verification_email_sent: bool,
+
+ /// Whether this is the user's primary email address, meaning notifications will be sent here.
+ #[schema(example = true)]
+ pub primary: bool,
+}
+
+impl From for EncodableEmail {
+ fn from(email: Email) -> Self {
+ Self {
+ id: email.id,
+ email: email.email,
+ verified: email.verified,
+ verification_email_sent: email.token_generated_at.is_some(),
+ primary: email.primary,
+ }
+ }
+}
+
#[derive(Deserialize, Serialize, Debug, PartialEq, Eq, utoipa::ToSchema)]
#[schema(as = User)]
pub struct EncodablePublicUser {
diff --git a/tests/acceptance/email-change-test.js b/tests/acceptance/email-change-test.js
deleted file mode 100644
index 285b35ecc41..00000000000
--- a/tests/acceptance/email-change-test.js
+++ /dev/null
@@ -1,177 +0,0 @@
-import { click, currentURL, fillIn } from '@ember/test-helpers';
-import { module, test } from 'qunit';
-
-import { http, HttpResponse } from 'msw';
-
-import { setupApplicationTest } from 'crates-io/tests/helpers';
-
-import { visit } from '../helpers/visit-ignoring-abort';
-
-module('Acceptance | Email Change', function (hooks) {
- setupApplicationTest(hooks);
-
- test('happy path', async function (assert) {
- let user = this.db.user.create({ email: 'old@email.com' });
-
- this.authenticateAs(user);
-
- await visit('/settings/profile');
- assert.strictEqual(currentURL(), '/settings/profile');
- assert.dom('[data-test-email-input]').exists();
- assert.dom('[data-test-email-input] [data-test-no-email]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-email-address]').includesText('old@email.com');
- assert.dom('[data-test-email-input] [data-test-verified]').exists();
- assert.dom('[data-test-email-input] [data-test-not-verified]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-verification-sent]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-resend-button]').doesNotExist();
-
- await click('[data-test-email-input] [data-test-edit-button]');
- assert.dom('[data-test-email-input] [data-test-input]').hasValue('old@email.com');
- assert.dom('[data-test-email-input] [data-test-save-button]').isEnabled();
- assert.dom('[data-test-email-input] [data-test-cancel-button]').isEnabled();
-
- await fillIn('[data-test-email-input] [data-test-input]', '');
- assert.dom('[data-test-email-input] [data-test-input]').hasValue('');
- assert.dom('[data-test-email-input] [data-test-save-button]').isDisabled();
-
- await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com');
- assert.dom('[data-test-email-input] [data-test-input]').hasValue('new@email.com');
- assert.dom('[data-test-email-input] [data-test-save-button]').isEnabled();
-
- await click('[data-test-email-input] [data-test-save-button]');
- assert.dom('[data-test-email-input] [data-test-email-address]').includesText('new@email.com');
- assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-not-verified]').exists();
- assert.dom('[data-test-email-input] [data-test-verification-sent]').exists();
- assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled();
-
- user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
- assert.strictEqual(user.email, 'new@email.com');
- assert.false(user.emailVerified);
- assert.ok(user.emailVerificationToken);
- });
-
- test('happy path with `email: null`', async function (assert) {
- let user = this.db.user.create({ email: undefined });
-
- this.authenticateAs(user);
-
- await visit('/settings/profile');
- assert.strictEqual(currentURL(), '/settings/profile');
- assert.dom('[data-test-email-input]').exists();
- assert.dom('[data-test-email-input] [data-test-no-email]').exists();
- assert.dom('[data-test-email-input] [data-test-email-address]').hasText('');
- assert.dom('[data-test-email-input] [data-test-not-verified]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-verification-sent]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-resend-button]').doesNotExist();
-
- await click('[data-test-email-input] [data-test-edit-button]');
- assert.dom('[data-test-email-input] [data-test-input]').hasValue('');
- assert.dom('[data-test-email-input] [data-test-save-button]').isDisabled();
- assert.dom('[data-test-email-input] [data-test-cancel-button]').isEnabled();
-
- await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com');
- assert.dom('[data-test-email-input] [data-test-input]').hasValue('new@email.com');
- assert.dom('[data-test-email-input] [data-test-save-button]').isEnabled();
-
- await click('[data-test-email-input] [data-test-save-button]');
- assert.dom('[data-test-email-input] [data-test-no-email]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-email-address]').includesText('new@email.com');
- assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-not-verified]').exists();
- assert.dom('[data-test-email-input] [data-test-verification-sent]').exists();
- assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled();
-
- user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
- assert.strictEqual(user.email, 'new@email.com');
- assert.false(user.emailVerified);
- assert.ok(user.emailVerificationToken);
- });
-
- test('cancel button', async function (assert) {
- let user = this.db.user.create({ email: 'old@email.com' });
-
- this.authenticateAs(user);
-
- await visit('/settings/profile');
- await click('[data-test-email-input] [data-test-edit-button]');
- await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com');
- assert.dom('[data-test-email-input] [data-test-invalid-email-warning]').doesNotExist();
-
- await click('[data-test-email-input] [data-test-cancel-button]');
- assert.dom('[data-test-email-input] [data-test-email-address]').includesText('old@email.com');
- assert.dom('[data-test-email-input] [data-test-verified]').exists();
- assert.dom('[data-test-email-input] [data-test-not-verified]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-verification-sent]').doesNotExist();
-
- user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
- assert.strictEqual(user.email, 'old@email.com');
- assert.true(user.emailVerified);
- assert.notOk(user.emailVerificationToken);
- });
-
- test('server error', async function (assert) {
- let user = this.db.user.create({ email: 'old@email.com' });
-
- this.authenticateAs(user);
-
- this.worker.use(http.put('/api/v1/users/:user_id', () => HttpResponse.json({}, { status: 500 })));
-
- await visit('/settings/profile');
- await click('[data-test-email-input] [data-test-edit-button]');
- await fillIn('[data-test-email-input] [data-test-input]', 'new@email.com');
-
- await click('[data-test-email-input] [data-test-save-button]');
- assert.dom('[data-test-email-input] [data-test-input]').hasValue('new@email.com');
- assert.dom('[data-test-email-input] [data-test-email-address]').doesNotExist();
- assert
- .dom('[data-test-notification-message="error"]')
- .hasText('Error in saving email: An unknown error occurred while saving this email.');
-
- user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
- assert.strictEqual(user.email, 'old@email.com');
- assert.true(user.emailVerified);
- assert.notOk(user.emailVerificationToken);
- });
-
- module('Resend button', function () {
- test('happy path', async function (assert) {
- let user = this.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' });
-
- this.authenticateAs(user);
-
- await visit('/settings/profile');
- assert.strictEqual(currentURL(), '/settings/profile');
- assert.dom('[data-test-email-input]').exists();
- assert.dom('[data-test-email-input] [data-test-email-address]').includesText('john@doe.com');
- assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-not-verified]').exists();
- assert.dom('[data-test-email-input] [data-test-verification-sent]').exists();
- assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled().hasText('Resend');
-
- await click('[data-test-email-input] [data-test-resend-button]');
- assert.dom('[data-test-email-input] [data-test-resend-button]').isDisabled().hasText('Sent!');
- });
-
- test('server error', async function (assert) {
- let user = this.db.user.create({ email: 'john@doe.com', emailVerificationToken: 'secret123' });
-
- this.authenticateAs(user);
-
- this.worker.use(http.put('/api/v1/users/:user_id/resend', () => HttpResponse.json({}, { status: 500 })));
-
- await visit('/settings/profile');
- assert.strictEqual(currentURL(), '/settings/profile');
- assert.dom('[data-test-email-input]').exists();
- assert.dom('[data-test-email-input] [data-test-email-address]').includesText('john@doe.com');
- assert.dom('[data-test-email-input] [data-test-verified]').doesNotExist();
- assert.dom('[data-test-email-input] [data-test-not-verified]').exists();
- assert.dom('[data-test-email-input] [data-test-verification-sent]').exists();
- assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled().hasText('Resend');
-
- await click('[data-test-email-input] [data-test-resend-button]');
- assert.dom('[data-test-email-input] [data-test-resend-button]').isEnabled().hasText('Resend');
- assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in resending message');
- });
- });
-});
diff --git a/tests/acceptance/email-confirmation-test.js b/tests/acceptance/email-confirmation-test.js
index 00a6f386a64..9d15a473a5b 100644
--- a/tests/acceptance/email-confirmation-test.js
+++ b/tests/acceptance/email-confirmation-test.js
@@ -9,20 +9,21 @@ module('Acceptance | Email Confirmation', function (hooks) {
setupApplicationTest(hooks);
test('unauthenticated happy path', async function (assert) {
- let user = this.db.user.create({ emailVerificationToken: 'badc0ffee' });
- assert.false(user.emailVerified);
+ let email = this.db.email.create({ verified: false, token: 'badc0ffee' });
+ let user = this.db.user.create({ emails: [email] });
+ assert.false(email.verified);
await visit('/confirm/badc0ffee');
assert.strictEqual(currentURL(), '/');
assert.dom('[data-test-notification-message="success"]').exists();
user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
- assert.true(user.emailVerified);
+ assert.true(user.emails[0].verified);
});
test('authenticated happy path', async function (assert) {
- let user = this.db.user.create({ emailVerificationToken: 'badc0ffee' });
- assert.false(user.emailVerified);
+ let user = this.db.user.create({ emails: [this.db.email.create({ verified: false, token: 'badc0ffee' })] });
+ assert.false(user.emails[0].verified);
this.authenticateAs(user);
@@ -31,10 +32,10 @@ module('Acceptance | Email Confirmation', function (hooks) {
assert.dom('[data-test-notification-message="success"]').exists();
let { currentUser } = this.owner.lookup('service:session');
- assert.true(currentUser.email_verified);
+ assert.true(currentUser.emails[0].verified);
user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
- assert.true(user.emailVerified);
+ assert.true(user.emails[0].verified);
});
test('error case', async function (assert) {
diff --git a/tests/acceptance/email-test.js b/tests/acceptance/email-test.js
new file mode 100644
index 00000000000..d130de85823
--- /dev/null
+++ b/tests/acceptance/email-test.js
@@ -0,0 +1,248 @@
+import { click, currentURL, fillIn } from '@ember/test-helpers';
+import { module, test } from 'qunit';
+
+import { http, HttpResponse } from 'msw';
+
+import { setupApplicationTest } from 'crates-io/tests/helpers';
+
+import { visit } from '../helpers/visit-ignoring-abort';
+
+module('Acceptance | Email Management', function (hooks) {
+ setupApplicationTest(hooks);
+
+ module('Add email', function () {
+ test('happy path', async function (assert) {
+ let user = this.db.user.create({ emails: [this.db.email.create({ email: 'old@email.com' })] });
+ assert.strictEqual(user.emails[0].email, 'old@email.com');
+ assert.false(user.emails[0].verified);
+
+ this.authenticateAs(user);
+
+ await visit('/settings/profile');
+ assert.strictEqual(currentURL(), '/settings/profile');
+ assert.dom('[data-test-add-email-button]').exists();
+ assert.dom('[data-test-add-email-input]').doesNotExist();
+
+ await click('[data-test-add-email-button]');
+ assert.dom('[data-test-add-email-input]').exists();
+ assert.dom('[data-test-add-email-input] [data-test-unverified]').doesNotExist();
+ assert.dom('[data-test-add-email-input] [data-test-verified]').doesNotExist();
+ assert.dom('[data-test-add-email-input] [data-test-verification-sent]').doesNotExist();
+ assert.dom('[data-test-add-email-input] [data-test-resend-button]').doesNotExist();
+
+ await fillIn('[data-test-add-email-input] [data-test-input]', '');
+ assert.dom('[data-test-add-email-input] [data-test-input]').hasValue('');
+ assert.dom('[data-test-add-email-input] [data-test-save-button]').isDisabled();
+
+ await fillIn('[data-test-add-email-input] [data-test-input]', 'notanemail');
+ assert.dom('[data-test-add-email-input] [data-test-input]').hasValue('notanemail');
+ assert.dom('[data-test-add-email-input] [data-test-save-button]').isDisabled();
+
+ await fillIn('[data-test-add-email-input] [data-test-input]', 'new@email.com');
+ assert.dom('[data-test-add-email-input] [data-test-input]').hasValue('new@email.com');
+ assert.dom('[data-test-add-email-input] [data-test-save-button]').isEnabled();
+
+ await click('[data-test-add-email-input] [data-test-save-button]');
+ assert.dom('[data-test-add-email-button]').exists();
+ assert.dom('[data-test-add-email-input]').doesNotExist();
+
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('old@email.com');
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-email-address]').includesText('new@email.com');
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-verified]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-unverified]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-verification-sent]').exists();
+
+ user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
+ assert.strictEqual(user.emails[0].email, 'old@email.com');
+ assert.strictEqual(user.emails[1].email, 'new@email.com');
+ assert.false(user.emails[1].verified);
+ });
+
+ test('happy path with no previous emails', async function (assert) {
+ let user = this.db.user.create({ emails: [] });
+ assert.strictEqual(user.emails.length, 0);
+
+ this.authenticateAs(user);
+
+ await visit('/settings/profile');
+ assert.strictEqual(currentURL(), '/settings/profile');
+ assert.dom('[data-test-add-email-button]').exists();
+ assert.dom('[data-test-add-email-input]').doesNotExist();
+
+ await click('[data-test-add-email-button]');
+ assert.dom('[data-test-add-email-input]').exists();
+
+ await fillIn('[data-test-add-email-input] [data-test-input]', 'new@email.com');
+ await click('[data-test-add-email-input] [data-test-save-button]');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('new@email.com');
+
+ user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
+ assert.strictEqual(user.emails.length, 1);
+ assert.strictEqual(user.emails[0].email, 'new@email.com');
+ });
+
+ test('server error', async function (assert) {
+ let user = this.db.user.create({ emails: [this.db.email.create({ email: 'old@email.com' })] });
+
+ this.authenticateAs(user);
+
+ this.worker.use(http.post('/api/v1/users/:user_id/emails', () => HttpResponse.json({}, { status: 500 })));
+
+ await visit('/settings/profile');
+ await click('[data-test-add-email-button]');
+ assert.dom('[data-test-add-email-input]').exists();
+
+ await fillIn('[data-test-add-email-input] [data-test-input]', 'new@email.com');
+ await click('[data-test-add-email-input] [data-test-save-button]');
+ assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in saving email');
+
+ user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
+ assert.strictEqual(user.emails[0].email, 'old@email.com');
+ assert.strictEqual(user.emails.length, 1);
+ });
+ });
+
+ module('Remove email', function () {
+ test('happy path', async function (assert) {
+ let user = this.db.user.create({
+ emails: [this.db.email.create({ email: 'john@doe.com' }), this.db.email.create({ email: 'jane@doe.com' })],
+ });
+
+ this.authenticateAs(user);
+
+ await visit('/settings/profile');
+ assert.strictEqual(currentURL(), '/settings/profile');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com');
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-email-address]').includesText('jane@doe.com');
+
+ await click('[data-test-email-input]:nth-of-type(2) [data-test-remove-button]');
+ assert.dom('[data-test-email-input]').exists({ count: 1 });
+ assert.dom('[data-test-email-input] [data-test-remove-button]').doesNotExist();
+
+ user = this.db.user.findFirst({ where: { id: { equals: user.id } } });
+ assert.strictEqual(user.emails[0].email, 'john@doe.com');
+ assert.strictEqual(user.emails.length, 1);
+ });
+
+ test('cannot remove primary email', async function (assert) {
+ let user = this.db.user.create({
+ emails: [
+ this.db.email.create({ email: 'primary@doe.com', primary: true }),
+ this.db.email.create({ email: 'john@doe.com' }),
+ ],
+ });
+ this.authenticateAs(user);
+ await visit('/settings/profile');
+ assert.strictEqual(currentURL(), '/settings/profile');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('primary@doe.com');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').isDisabled();
+ assert
+ .dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]')
+ .hasAttribute('title', 'Cannot delete primary email');
+ });
+
+ test('no delete button when only one email', async function (assert) {
+ let user = this.db.user.create({ emails: [this.db.email.create({ email: 'john@doe.com' })] });
+ this.authenticateAs(user);
+ await visit('/settings/profile');
+ assert.strictEqual(currentURL(), '/settings/profile');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').doesNotExist();
+ });
+
+ test('server error', async function (assert) {
+ let user = this.db.user.create({
+ emails: [this.db.email.create({ email: 'john@doe.com' }), this.db.email.create({ email: 'jane@doe.com' })],
+ });
+
+ this.authenticateAs(user);
+
+ this.worker.use(
+ http.delete('/api/v1/users/:user_id/emails/:email_id', () => HttpResponse.json({}, { status: 500 })),
+ );
+
+ await visit('/settings/profile');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').exists();
+ await click('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]');
+ assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in deleting email');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-remove-button]').isEnabled();
+ });
+ });
+
+ module('Resend verification email', function () {
+ test('happy path', async function (assert) {
+ let user = this.db.user.create({
+ emails: [this.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })],
+ });
+
+ this.authenticateAs(user);
+
+ await visit('/settings/profile');
+ assert.strictEqual(currentURL(), '/settings/profile');
+ assert.dom('[data-test-email-input]').exists();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verified]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-unverified]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verification-sent]').exists();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]').isEnabled().hasText('Resend');
+
+ await click('[data-test-email-input] [data-test-resend-button]');
+ assert.dom('[data-test-email-input] [data-test-resend-button]').isDisabled().hasText('Sent!');
+ });
+
+ test('server error', async function (assert) {
+ let user = this.db.user.create({
+ emails: [this.db.email.create({ email: 'john@doe.com', verified: false, verification_email_sent: true })],
+ });
+
+ this.authenticateAs(user);
+
+ this.worker.use(
+ http.put('/api/v1/users/:user_id/emails/:email_id/resend', () => HttpResponse.json({}, { status: 500 })),
+ );
+
+ await visit('/settings/profile');
+ assert.strictEqual(currentURL(), '/settings/profile');
+ assert.dom('[data-test-email-input]').exists();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verified]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-unverified]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-verification-sent]').exists();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]').isEnabled().hasText('Resend');
+
+ await click('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]');
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-resend-button]').isEnabled().hasText('Resend');
+ assert.dom('[data-test-notification-message="error"]').hasText('Unknown error in resending message');
+ });
+ });
+
+ module('Switch primary email', function () {
+ test('happy path', async function (assert) {
+ let user = this.db.user.create({
+ emails: [
+ this.db.email.create({ email: 'john@doe.com', verified: true, primary: true }),
+ this.db.email.create({ email: 'jane@doe.com', verified: true, primary: false }),
+ ],
+ });
+ this.authenticateAs(user);
+
+ await visit('/settings/profile');
+
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-email-address]').includesText('john@doe.com');
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-email-address]').includesText('jane@doe.com');
+
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary]').isVisible();
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary-button]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary-button]').isEnabled();
+
+ await click('[data-test-email-input]:nth-of-type(2) [data-test-primary-button]');
+
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary]').isVisible();
+ assert.dom('[data-test-email-input]:nth-of-type(2) [data-test-primary-button]').doesNotExist();
+ assert.dom('[data-test-email-input]:nth-of-type(1) [data-test-primary-button]').isEnabled();
+ });
+ });
+});
diff --git a/tests/acceptance/publish-notifications-test.js b/tests/acceptance/publish-notifications-test.js
index 383a1237478..23e554dc182 100644
--- a/tests/acceptance/publish-notifications-test.js
+++ b/tests/acceptance/publish-notifications-test.js
@@ -11,7 +11,7 @@ module('Acceptance | publish notifications', function (hooks) {
setupApplicationTest(hooks);
test('unsubscribe and resubscribe', async function (assert) {
- let user = this.db.user.create();
+ let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] });
this.authenticateAs(user);
assert.true(user.publishNotifications);
@@ -36,7 +36,7 @@ module('Acceptance | publish notifications', function (hooks) {
});
test('loading and error state', async function (assert) {
- let user = this.db.user.create();
+ let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] });
let deferred = defer();
this.worker.use(http.put('/api/v1/users/:user_id', () => deferred.promise));
diff --git a/tests/acceptance/settings/settings-test.js b/tests/acceptance/settings/settings-test.js
index cd35b7c150a..283bbe327bd 100644
--- a/tests/acceptance/settings/settings-test.js
+++ b/tests/acceptance/settings/settings-test.js
@@ -14,7 +14,10 @@ module('Acceptance | Settings', function (hooks) {
function prepare(context) {
let { db } = context;
- let user1 = db.user.create({ name: 'blabaere' });
+ let user1 = db.user.create({
+ name: 'blabaere',
+ emails: [db.email.create({ email: 'blabaere@crates.io', primary: true })],
+ });
let user2 = db.user.create({ name: 'thehydroimpulse' });
let team1 = db.team.create({ org: 'org', name: 'blabaere' });
let team2 = db.team.create({ org: 'org', name: 'thehydroimpulse' });
diff --git a/tests/models/trustpub-github-config-test.js b/tests/models/trustpub-github-config-test.js
index 249c0ee2c41..d9778324a55 100644
--- a/tests/models/trustpub-github-config-test.js
+++ b/tests/models/trustpub-github-config-test.js
@@ -64,7 +64,7 @@ module('Model | TrustpubGitHubConfig', function (hooks) {
module('createRecord()', function () {
test('creates a new GitHub config', async function (assert) {
- let user = this.db.user.create({ emailVerified: true });
+ let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] });
this.authenticateAs(user);
let crate = this.db.crate.create();
@@ -103,7 +103,7 @@ module('Model | TrustpubGitHubConfig', function (hooks) {
});
test('returns an error if the user is not an owner of the crate', async function (assert) {
- let user = this.db.user.create({ emailVerified: true });
+ let user = this.db.user.create({ emails: [this.db.email.create({ verified: true })] });
this.authenticateAs(user);
let crate = this.db.crate.create();
@@ -123,7 +123,7 @@ module('Model | TrustpubGitHubConfig', function (hooks) {
});
test('returns an error if the user does not have a verified email', async function (assert) {
- let user = this.db.user.create({ emailVerified: false });
+ let user = this.db.user.create({ emails: [this.db.email.create({ verified: false })] });
this.authenticateAs(user);
let crate = this.db.crate.create();
diff --git a/tests/models/user-test.js b/tests/models/user-test.js
index be4bd853ee7..94881637b57 100644
--- a/tests/models/user-test.js
+++ b/tests/models/user-test.js
@@ -13,34 +13,100 @@ module('Model | User', function (hooks) {
this.store = this.owner.lookup('service:store');
});
- module('changeEmail()', function () {
+ module('addEmail()', function () {
test('happy path', async function (assert) {
- let user = this.db.user.create({ email: 'old@email.com' });
+ let email = this.db.email.create({ email: 'old@email.com' });
+ let user = this.db.user.create({ emails: [email] });
this.authenticateAs(user);
let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
- assert.strictEqual(currentUser.email, 'old@email.com');
- assert.true(currentUser.email_verified);
- assert.true(currentUser.email_verification_sent);
-
- await currentUser.changeEmail('new@email.com');
- assert.strictEqual(currentUser.email, 'new@email.com');
- assert.false(currentUser.email_verified);
- assert.true(currentUser.email_verification_sent);
+ assert.strictEqual(currentUser.emails[0].email, 'old@email.com');
+
+ await currentUser.addEmail('new@email.com');
+ assert.strictEqual(currentUser.emails[1].email, 'new@email.com');
});
test('error handling', async function (assert) {
- let user = this.db.user.create({ email: 'old@email.com' });
+ let email = this.db.email.create({ email: 'old@email.com' });
+ let user = this.db.user.create({ emails: [email] });
this.authenticateAs(user);
let error = HttpResponse.json({}, { status: 500 });
- this.worker.use(http.put('/api/v1/users/:user_id', () => error));
+ this.worker.use(http.post('/api/v1/users/:user_id/emails', () => error));
+
+ let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
+
+ await assert.rejects(currentUser.addEmail('new@email.com'), function (error) {
+ assert.deepEqual(error.errors, [
+ {
+ detail: '{}',
+ status: '500',
+ title: 'The backend responded with an error',
+ },
+ ]);
+ return true;
+ });
+ });
+ });
+
+ module('deleteEmail()', function () {
+ test('happy path', async function (assert) {
+ let email = this.db.email.create({ email: 'old@email.com' });
+ let user = this.db.user.create({ emails: [email] });
+ this.authenticateAs(user);
let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
- await assert.rejects(currentUser.changeEmail('new@email.com'), function (error) {
+ await currentUser.deleteEmail(email.id);
+ assert.false(currentUser.emails.some(e => e.id === email.id));
+ });
+
+ test('error handling', async function (assert) {
+ let email = this.db.email.create({ email: 'old@email.com' });
+ let user = this.db.user.create({ emails: [email] });
+ this.authenticateAs(user);
+
+ let error = HttpResponse.json({}, { status: 500 });
+ this.worker.use(http.delete('/api/v1/users/:user_id/emails/:email_id', () => error));
+
+ let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
+
+ await assert.rejects(currentUser.deleteEmail(email.id), function (error) {
+ assert.deepEqual(error.errors, [
+ {
+ detail: '{}',
+ status: '500',
+ title: 'The backend responded with an error',
+ },
+ ]);
+ return true;
+ });
+ });
+ });
+
+ module('updatePrimaryEmail()', function () {
+ test('happy path', async function (assert) {
+ let email = this.db.email.create({ email: 'old@email.com' });
+ let user = this.db.user.create({ emails: [email] });
+ this.authenticateAs(user);
+
+ let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
+
+ await currentUser.updatePrimaryEmail(email.id, 'new@email.com');
+ assert.strictEqual(currentUser.emails.find(e => e.primary).id, email.id);
+ });
+ test('error handling', async function (assert) {
+ let email = this.db.email.create({ email: 'old@email.com' });
+ let user = this.db.user.create({ emails: [email] });
+ this.authenticateAs(user);
+
+ let error = HttpResponse.json({}, { status: 500 });
+ this.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/set_primary', () => error));
+
+ let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
+ await assert.rejects(currentUser.updatePrimaryEmail(email.id, 'new@email.com'), function (error) {
assert.deepEqual(error.errors, [
{
detail: '{}',
@@ -57,24 +123,26 @@ module('Model | User', function (hooks) {
test('happy path', async function (assert) {
assert.expect(0);
- let user = this.db.user.create({ emailVerificationToken: 'secret123' });
+ let email = this.db.email.create({ token: 'secret123' });
+ let user = this.db.user.create({ emails: [email] });
this.authenticateAs(user);
let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
- await currentUser.resendVerificationEmail();
+ await currentUser.resendVerificationEmail(email.id);
});
test('error handling', async function (assert) {
- let user = this.db.user.create({ emailVerificationToken: 'secret123' });
+ let email = this.db.email.create({ token: 'secret123' });
+ let user = this.db.user.create({ emails: [email] });
this.authenticateAs(user);
let error = HttpResponse.json({}, { status: 500 });
- this.worker.use(http.put('/api/v1/users/:user_id/resend', () => error));
+ this.worker.use(http.put('/api/v1/users/:user_id/emails/:email_id/resend', () => error));
let { currentUser } = await this.owner.lookup('service:session').loadUserTask.perform();
- await assert.rejects(currentUser.resendVerificationEmail(), function (error) {
+ await assert.rejects(currentUser.resendVerificationEmail(email.id), function (error) {
assert.deepEqual(error.errors, [
{
detail: '{}',
diff --git a/tests/routes/crate/settings-test.js b/tests/routes/crate/settings-test.js
index c05da7d94ff..209ff1da981 100644
--- a/tests/routes/crate/settings-test.js
+++ b/tests/routes/crate/settings-test.js
@@ -12,7 +12,9 @@ module('Route | crate.settings', hooks => {
setupApplicationTest(hooks);
function prepare(context) {
- const user = context.db.user.create();
+ const user = context.db.user.create({
+ emails: [context.db.email.create({ email: 'user-1@crates.io', primary: true, verified: true })],
+ });
const crate = context.db.crate.create({ name: 'foo' });
context.db.version.create({ crate });
diff --git a/tests/routes/crate/settings/new-trusted-publisher-test.js b/tests/routes/crate/settings/new-trusted-publisher-test.js
index 157231163ae..7e542499be7 100644
--- a/tests/routes/crate/settings/new-trusted-publisher-test.js
+++ b/tests/routes/crate/settings/new-trusted-publisher-test.js
@@ -14,7 +14,9 @@ module('Route | crate.settings.new-trusted-publisher', hooks => {
setupApplicationTest(hooks);
function prepare(context) {
- let user = context.db.user.create();
+ let user = context.db.user.create({
+ emails: [context.db.email.create({ email: 'user-1@crates.io', verified: true, primary: true })],
+ });
let crate = context.db.crate.create({ name: 'foo' });
context.db.version.create({ crate });