From f4dfad9808d2d461e6a53736547243a8ea5597c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Sun, 19 Jul 2026 17:44:43 -0400 Subject: [PATCH 1/4] feat: require verified email to create projects, collections and organizations --- apps/frontend/src/layouts/default.vue | 34 ++++++++++++++++--- apps/labrinth/src/auth/checks.rs | 10 ++++++ apps/labrinth/src/auth/mod.rs | 2 +- apps/labrinth/src/routes/v3/collections.rs | 6 +++- apps/labrinth/src/routes/v3/organizations.rs | 6 +++- .../src/routes/v3/project_creation.rs | 9 ++++- .../src/routes/v3/project_creation/new.rs | 4 ++- 7 files changed, 62 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 2373c56ced..ad8d2d7ebd 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -443,20 +443,23 @@ :options="[ { id: 'new-project', - action: (event) => $refs.modal_creation.show(event), + action: (event) => requireVerifiedEmail(() => $refs.modal_creation.show(event)), }, { id: 'new-server-project', - action: (event) => $refs.modal_creation.show(event, { type: 'server' }), + action: (event) => + requireVerifiedEmail(() => $refs.modal_creation.show(event, { type: 'server' })), }, { id: 'new-collection', - action: (event) => $refs.modal_collection_creation.show(event), + action: (event) => + requireVerifiedEmail(() => $refs.modal_collection_creation.show(event)), }, { divider: true }, { id: 'new-organization', - action: (event) => $refs.modal_organization_creation.show(event), + action: (event) => + requireVerifiedEmail(() => $refs.modal_organization_creation.show(event)), }, ]" > @@ -777,6 +780,7 @@ import { createHostingIntercomIdentityKey, defineMessages, injectModrinthClient, + injectNotificationManager, injectPageContext, OverflowMenu, providePageContext, @@ -814,6 +818,7 @@ const generatedState = useGeneratedState() const country = useUserCountry() const { formatMessage } = useVIntl() +const { addNotification } = injectNotificationManager() const auth = await useAuth() const user = await useUser() @@ -906,6 +911,19 @@ async function fetchIntercomToken() { }) } +function requireVerifiedEmail(action) { + if (!auth.value.user?.email_verified) { + addNotification({ + title: formatMessage(messages.emailVerificationRequired), + text: formatMessage(messages.verifyEmailBeforePublishing), + type: 'error', + }) + return + } + + action() +} + const navMenuMessages = defineMessages({ home: { id: 'layout.nav.home', @@ -962,6 +980,14 @@ const messages = defineMessages({ id: 'layout.action.publish', defaultMessage: 'Publish', }, + emailVerificationRequired: { + id: 'layout.publish.email-verification-required.title', + defaultMessage: 'Email verification required', + }, + verifyEmailBeforePublishing: { + id: 'layout.publish.email-verification-required.description', + defaultMessage: 'You must verify your email before publishing on Modrinth.', + }, reviewProjects: { id: 'layout.action.review-projects', defaultMessage: 'Project review', diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs index cfb852daa4..499f017f30 100644 --- a/apps/labrinth/src/auth/checks.rs +++ b/apps/labrinth/src/auth/checks.rs @@ -15,6 +15,16 @@ use crate::routes::ApiError; use futures::TryStreamExt; use itertools::Itertools; +pub fn require_verified_email(user: &User) -> Result<(), ApiError> { + if !user.email_verified.unwrap_or(false) { + return Err(ApiError::Auth(eyre::eyre!( + "Please verify your email before publishing!" + ))); + } + + Ok(()) +} + pub trait ValidateAuthorized { fn validate_authorized( &self, diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index e71b82b014..c58a032f13 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -5,7 +5,7 @@ pub mod validate; pub use checks::{ filter_enlisted_projects_ids, filter_enlisted_version_ids, filter_visible_collections, filter_visible_project_ids, - filter_visible_projects, + filter_visible_projects, require_verified_email, }; use serde::{Deserialize, Serialize}; pub use validate::{ diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index a75e83cfc1..0e0747d98d 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -1,5 +1,7 @@ use crate::auth::checks::is_visible_collection; -use crate::auth::{filter_visible_collections, get_user_from_headers}; +use crate::auth::{ + filter_visible_collections, get_user_from_headers, require_verified_email, +}; use crate::database::PgPool; use crate::database::models::{ collection_item, generate_collection_id, project_item, @@ -76,6 +78,8 @@ pub async fn collection_create( .await? .1; + require_verified_email(¤t_user)?; + let limits = UserLimits::get_for_collections(¤t_user, &client).await?; if limits.current >= limits.max { diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 8b71d75912..6d10fafc00 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -2,7 +2,9 @@ use std::collections::HashMap; use super::ApiError; use crate::auth::checks::is_visible_organization; -use crate::auth::{filter_visible_projects, get_user_from_headers}; +use crate::auth::{ + filter_visible_projects, get_user_from_headers, require_verified_email, +}; use crate::database::PgPool; use crate::database::models::team_item::DBTeamMember; use crate::database::models::{ @@ -131,6 +133,8 @@ pub async fn organization_create( .await? .1; + require_verified_email(¤t_user)?; + let limits = UserLimits::get_for_organizations(¤t_user, &pool).await?; if limits.current >= limits.max { diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index 3640c34014..e3239dbf35 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -1,5 +1,7 @@ use super::version_creation::{InitialVersionData, try_create_version_fields}; -use crate::auth::{AuthenticationError, get_user_from_headers}; +use crate::auth::{ + AuthenticationError, get_user_from_headers, require_verified_email, +}; use crate::database::PgPool; use crate::database::PgTransaction; use crate::database::models::loader_fields::{ @@ -107,6 +109,9 @@ impl From for CreateError { crate::routes::ApiError::CustomAuthentication(err) => { Self::CustomAuthenticationError(err) } + crate::routes::ApiError::Auth(err) => { + Self::CustomAuthenticationError(format!("{err:#}")) + } crate::routes::ApiError::InvalidInput(err) | crate::routes::ApiError::Validation(err) => { Self::InvalidInput(err) @@ -491,6 +496,8 @@ async fn project_create_inner( ) .await?; + require_verified_email(¤t_user)?; + let limits = UserLimits::get_for_projects(¤t_user, pool).await?; if limits.current >= limits.max { return Err(CreateError::LimitReached); diff --git a/apps/labrinth/src/routes/v3/project_creation/new.rs b/apps/labrinth/src/routes/v3/project_creation/new.rs index 968f2ba308..9cc8bf02bc 100644 --- a/apps/labrinth/src/routes/v3/project_creation/new.rs +++ b/apps/labrinth/src/routes/v3/project_creation/new.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use validator::Validate; use crate::{ - auth::get_user_from_headers, + auth::{get_user_from_headers, require_verified_email}, database::{ PgPool, models::{ @@ -140,6 +140,8 @@ pub async fn create( .await .map_err(ApiError::from)?; + require_verified_email(&user)?; + let limits = UserLimits::get_for_projects(&user, &db) .await .map_err(ApiError::from)?; From dd1ecdccf9dff2b5153b27bd6c2a7f4509f64620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Sun, 19 Jul 2026 18:18:03 -0400 Subject: [PATCH 2/4] fix(labrinth): fix tests --- apps/labrinth/src/test/db.rs | 40 +++++++++++++++++++++++++--- apps/labrinth/src/test/dummy_data.rs | 2 +- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/labrinth/src/test/db.rs b/apps/labrinth/src/test/db.rs index e50c710db4..c7c47c18e0 100644 --- a/apps/labrinth/src/test/db.rs +++ b/apps/labrinth/src/test/db.rs @@ -1,7 +1,7 @@ use crate::database::Executor; use eyre::{Context, Result}; -use crate::database::PgPool; +use crate::database::{models::DBUserId, PgPool}; /// Static personal access token for use in [`AppendPat`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -33,6 +33,28 @@ impl AppendPat for actix_web::test::TestRequest { } } +/// Mark a user's email as verified without using the email verification flow. +/// +/// # Errors +/// +/// Errors if the user does not exist or could not be updated. +pub async fn mark_email_verified( + db: &PgPool, + user_id: DBUserId, +) -> Result<()> { + let result = sqlx::query!( + "UPDATE users SET email_verified = TRUE WHERE id = $1", + user_id.0, + ) + .execute(db) + .await + .wrap_err("failed to mark user email as verified")?; + + eyre::ensure!(result.rows_affected() == 1, "user does not exist"); + + Ok(()) +} + /// Dummy [`DBUserId`]s. /// /// [`DBUserId`]: crate::database::models::DBUserId @@ -76,8 +98,18 @@ pub async fn add_dummy_data(db: &PgPool) -> Result<()> { ) .as_str(), ) - .await - .wrap_err("failed to add dummy data")?; + .await + .wrap_err("failed to add dummy data")?; + + for user_id in [ + user_id::ADMIN, + user_id::MODERATOR, + user_id::USER, + user_id::FRIEND, + user_id::ENEMY, + ] { + mark_email_verified(db, user_id).await?; + } - Ok(()) + Ok(()) } diff --git a/apps/labrinth/src/test/dummy_data.rs b/apps/labrinth/src/test/dummy_data.rs index 547c58abea..73000cfa77 100644 --- a/apps/labrinth/src/test/dummy_data.rs +++ b/apps/labrinth/src/test/dummy_data.rs @@ -21,7 +21,7 @@ use super::{ use super::{database::USER_USER_ID, get_json_val_str}; -pub const DUMMY_DATA_UPDATE: i64 = 7; +pub const DUMMY_DATA_UPDATE: i64 = 8; pub const DUMMY_CATEGORIES: &[&str] = &[ "combat", From 828c96b2e6bcdb21ea6ecabc291fc4614d32554e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Sun, 19 Jul 2026 18:43:59 -0400 Subject: [PATCH 3/4] chore: fmt --- apps/labrinth/src/test/db.rs | 49 +++++++++++++++++------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/apps/labrinth/src/test/db.rs b/apps/labrinth/src/test/db.rs index c7c47c18e0..500cb2700e 100644 --- a/apps/labrinth/src/test/db.rs +++ b/apps/labrinth/src/test/db.rs @@ -1,7 +1,7 @@ use crate::database::Executor; use eyre::{Context, Result}; -use crate::database::{models::DBUserId, PgPool}; +use crate::database::{PgPool, models::DBUserId}; /// Static personal access token for use in [`AppendPat`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -38,21 +38,18 @@ impl AppendPat for actix_web::test::TestRequest { /// # Errors /// /// Errors if the user does not exist or could not be updated. -pub async fn mark_email_verified( - db: &PgPool, - user_id: DBUserId, -) -> Result<()> { - let result = sqlx::query!( - "UPDATE users SET email_verified = TRUE WHERE id = $1", - user_id.0, - ) - .execute(db) - .await - .wrap_err("failed to mark user email as verified")?; +pub async fn mark_email_verified(db: &PgPool, user_id: DBUserId) -> Result<()> { + let result = sqlx::query!( + "UPDATE users SET email_verified = TRUE WHERE id = $1", + user_id.0, + ) + .execute(db) + .await + .wrap_err("failed to mark user email as verified")?; - eyre::ensure!(result.rows_affected() == 1, "user does not exist"); + eyre::ensure!(result.rows_affected() == 1, "user does not exist"); - Ok(()) + Ok(()) } /// Dummy [`DBUserId`]s. @@ -98,18 +95,18 @@ pub async fn add_dummy_data(db: &PgPool) -> Result<()> { ) .as_str(), ) - .await - .wrap_err("failed to add dummy data")?; + .await + .wrap_err("failed to add dummy data")?; - for user_id in [ - user_id::ADMIN, - user_id::MODERATOR, - user_id::USER, - user_id::FRIEND, - user_id::ENEMY, - ] { - mark_email_verified(db, user_id).await?; - } + for user_id in [ + user_id::ADMIN, + user_id::MODERATOR, + user_id::USER, + user_id::FRIEND, + user_id::ENEMY, + ] { + mark_email_verified(db, user_id).await?; + } - Ok(()) + Ok(()) } From 9e4f300fcfc09d883eadcb4acd01ab2ccfae0fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-X=2E=20T=2E?= Date: Sun, 19 Jul 2026 21:56:13 -0400 Subject: [PATCH 4/4] chore: intl --- apps/frontend/src/locales/en-US/index.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 6073103af0..a299b8195b 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -2672,6 +2672,12 @@ "layout.nav.upgrade-to-modrinth-plus": { "message": "Upgrade to Modrinth+" }, + "layout.publish.email-verification-required.description": { + "message": "You must verify your email before publishing on Modrinth." + }, + "layout.publish.email-verification-required.title": { + "message": "Email verification required" + }, "moderation.exclude-technical-review": { "message": "Exclude TR" },