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/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" }, 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)?; diff --git a/apps/labrinth/src/test/db.rs b/apps/labrinth/src/test/db.rs index e50c710db4..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::PgPool; +use crate::database::{PgPool, models::DBUserId}; /// Static personal access token for use in [`AppendPat`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -33,6 +33,25 @@ 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 @@ -79,5 +98,15 @@ pub async fn add_dummy_data(db: &PgPool) -> Result<()> { .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(()) } 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",