From c8a586c6f13eb13b74b0fec9e3124a4eb93c203a Mon Sep 17 00:00:00 2001 From: aecsocket Date: Sun, 12 Apr 2026 21:38:04 +0100 Subject: [PATCH 1/9] Backend routes for choosing username in OAuth flow --- .../labrinth/src/database/models/flow_item.rs | 37 +- apps/labrinth/src/routes/internal/flows.rs | 451 +++++++++++------- 2 files changed, 311 insertions(+), 177 deletions(-) diff --git a/apps/labrinth/src/database/models/flow_item.rs b/apps/labrinth/src/database/models/flow_item.rs index b7b853d9d1..76cb24b280 100644 --- a/apps/labrinth/src/database/models/flow_item.rs +++ b/apps/labrinth/src/database/models/flow_item.rs @@ -1,9 +1,9 @@ use super::ids::*; -use crate::auth::AuthProvider; use crate::auth::oauth::uris::OAuthRedirectUris; use crate::database::models::DatabaseError; use crate::database::redis::RedisPool; use crate::models::pats::Scopes; +use crate::{auth::AuthProvider, routes::internal::flows::TempUser}; use chrono::Duration; use rand::Rng; use rand::distributions::Alphanumeric; @@ -22,6 +22,11 @@ pub enum DBFlow { provider: AuthProvider, existing_user_id: Option, }, + OAuthPending { + url: String, + provider: AuthProvider, + user: TempUser, + }, Login2FA { user_id: DBUserId, }, @@ -55,28 +60,38 @@ pub enum DBFlow { } impl DBFlow { - pub async fn insert( + pub async fn insert_with_state( &self, expires: Duration, redis: &RedisPool, - ) -> Result { + state: &str, + ) -> Result<(), DatabaseError> { let mut redis = redis.connect().await?; - let flow = ChaCha20Rng::from_entropy() - .sample_iter(&Alphanumeric) - .take(32) - .map(char::from) - .collect::(); - redis .set_serialized_to_json( FLOWS_NAMESPACE, - &flow, + &state, &self, Some(expires.num_seconds()), ) .await?; - Ok(flow) + Ok(()) + } + + pub async fn insert( + &self, + expires: Duration, + redis: &RedisPool, + ) -> Result { + let state = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect::(); + + self.insert_with_state(expires, redis, &state).await?; + Ok(state) } pub async fn get( diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 4177f8145e..c1073033fd 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -22,7 +22,8 @@ use crate::util::error::Context; use crate::util::ext::get_image_ext; use crate::util::img::upload_image_optimized; use crate::util::validate::validation_errors_to_string; -use actix_web::web::{Data, Query, ServiceConfig, scope}; +use actix_http::header::LOCATION; +use actix_web::web::{Data, Query, Redirect, ServiceConfig, scope}; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; @@ -39,7 +40,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; -use tracing::info; +use tracing::{error, info}; +use url::Url; use validator::Validate; use zxcvbn::Score; @@ -65,7 +67,7 @@ pub fn config(cfg: &mut ServiceConfig) { ); } -#[derive(Debug)] +#[derive(Serialize, Deserialize, Debug)] pub struct TempUser { pub id: String, pub username: String, @@ -85,7 +87,9 @@ impl TempUser { client: &PgPool, file_host: &Arc, redis: &RedisPool, - ) -> Result { + username: String, + sign_up_newsletter: bool, + ) -> Result { if let Some(email) = &self.email && crate::database::models::DBUser::get_by_email(email, client) .await? @@ -97,32 +101,12 @@ impl TempUser { let user_id = crate::database::models::generate_user_id(transaction).await?; - let mut username_increment: i32 = 0; - let mut username = None; - - while username.is_none() { - let test_username = format!( - "{}{}", - self.username, - if username_increment > 0 { - username_increment.to_string() - } else { - "".to_string() - } - ); - - let new_id = crate::database::models::DBUser::get( - &test_username, - client, - redis, - ) - .await?; + let existing_id = DBUser::get(&username, client, redis) + .await + .wrap_err("failed to fetch existing user by id")?; - if new_id.is_none() { - username = Some(test_username); - } else { - username_increment += 1; - } + if existing_id.is_some() { + return Err(AuthenticationError::DuplicateUser); } let (avatar_url, raw_avatar_url) = if let Some(avatar_url) = @@ -166,89 +150,86 @@ impl TempUser { (None, None) }; - if let Some(username) = username { - crate::database::models::DBUser { - id: user_id, - github_id: if provider == AuthProvider::GitHub { - Some( - self.id.clone().parse().map_err(|_| { - AuthenticationError::InvalidCredentials - })?, - ) - } else { - None - }, - discord_id: if provider == AuthProvider::Discord { - Some( - self.id.parse().map_err(|_| { - AuthenticationError::InvalidCredentials - })?, - ) - } else { - None - }, - gitlab_id: if provider == AuthProvider::GitLab { - Some( - self.id.parse().map_err(|_| { - AuthenticationError::InvalidCredentials - })?, - ) - } else { - None - }, - google_id: if provider == AuthProvider::Google { - Some(self.id.clone()) - } else { - None - }, - steam_id: if provider == AuthProvider::Steam { - Some( - self.id.parse().map_err(|_| { - AuthenticationError::InvalidCredentials - })?, - ) - } else { - None - }, - microsoft_id: if provider == AuthProvider::Microsoft { - Some(self.id.clone()) - } else { - None - }, - password: None, - paypal_id: if provider == AuthProvider::PayPal { - Some(self.id) - } else { - None - }, - paypal_country: self.country, - paypal_email: if provider == AuthProvider::PayPal { - self.email.clone() - } else { - None - }, - venmo_handle: None, - stripe_customer_id: None, - totp_secret: None, - username, - email: self.email.clone(), - email_verified: self.email.is_some(), - avatar_url, - raw_avatar_url, - bio: self.bio, - created: Utc::now(), - role: Role::Developer.to_string(), - badges: Badges::default(), - allow_friend_requests: true, - is_subscribed_to_newsletter: false, - } - .insert(transaction) - .await?; - - Ok(user_id) - } else { - Err(AuthenticationError::InvalidCredentials) + DBUser { + id: user_id, + github_id: if provider == AuthProvider::GitHub { + Some( + self.id + .clone() + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + discord_id: if provider == AuthProvider::Discord { + Some( + self.id + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + gitlab_id: if provider == AuthProvider::GitLab { + Some( + self.id + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + google_id: if provider == AuthProvider::Google { + Some(self.id.clone()) + } else { + None + }, + steam_id: if provider == AuthProvider::Steam { + Some( + self.id + .parse() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + } else { + None + }, + microsoft_id: if provider == AuthProvider::Microsoft { + Some(self.id.clone()) + } else { + None + }, + password: None, + paypal_id: if provider == AuthProvider::PayPal { + Some(self.id) + } else { + None + }, + paypal_country: self.country, + paypal_email: if provider == AuthProvider::PayPal { + self.email.clone() + } else { + None + }, + venmo_handle: None, + stripe_customer_id: None, + totp_secret: None, + username, + email: self.email.clone(), + email_verified: self.email.is_some(), + avatar_url, + raw_avatar_url, + bio: self.bio, + created: Utc::now(), + role: Role::Developer.to_string(), + badges: Badges::default(), + allow_friend_requests: true, + is_subscribed_to_newsletter: sign_up_newsletter, } + .insert(transaction) + .await?; + + Ok(user_id) } } @@ -1140,14 +1121,59 @@ pub async fn init( .json(serde_json::json!({ "url": url }))) } -#[get("callback")] +#[get("/callback")] pub async fn auth_callback( req: HttpRequest, Query(query): Query>, client: Data, - file_host: Data>, redis: Data, ) -> Result { + /// Ensures that the OAuth flow is removed from Redis when dropped. + /// + /// A guard is used here since it's safer than manually removing the flow + /// in each branch. + struct FlowGuard { + state: Option, + redis: Data, + } + + impl Drop for FlowGuard { + fn drop(&mut self) { + let Some(state) = self.state.clone() else { + // has been replaced + return; + }; + let redis = self.redis.clone(); + tokio::spawn(async move { + if let Err(err) = DBFlow::remove(&state, &redis).await { + error!("failed to remove DB flow state: {err:#}"); + } + }); + } + } + + impl FlowGuard { + /// Prevents this guard from removing `state` when dropped, instead + /// replacing the flow for `state` with the new given `flow`. + pub async fn replace_with( + mut self, + flow: DBFlow, + ) -> Result<(), ApiError> { + let state = self + .state + .clone() + .expect("`self` should not be dropped yet"); + let redis = self.redis.clone(); + self.state = None; + + flow.insert_with_state(Duration::minutes(10), &redis, &state) + .await + .wrap_internal_err("failed to insert new flow state")?; + + Ok(()) + } + } + let state_string = query .get("state") .ok_or_else(|| AuthenticationError::InvalidCredentials)? @@ -1173,9 +1199,10 @@ pub async fn auth_callback( ))); }; - DBFlow::remove(&state, &redis) - .await - .wrap_err("failed to remove flow")?; + let flow_guard = FlowGuard { + state: Some(state.clone()), + redis: redis.clone(), + }; let token = provider .get_token(query) @@ -1271,63 +1298,79 @@ pub async fn auth_callback( Ok(HttpResponse::TemporaryRedirect() .append_header(("Location", &*url)) .json(serde_json::json!({ "url": url }))) - } else { - let user_id = if let Some(user_id) = user_id_opt { - let user = crate::database::models::DBUser::get_id( - user_id, &**client, &redis, - ) - .await? - .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - - if user.totp_secret.is_some() { - let flow = DBFlow::Login2FA { user_id: user.id } - .insert(Duration::minutes(30), &redis) - .await?; + } else if let Some(user_id) = user_id_opt { + let user = crate::database::models::DBUser::get_id( + user_id, &**client, &redis, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - let redirect_url = format!( - "{}{}error=2fa_required&flow={}", - url, - if url.contains('?') { "&" } else { "?" }, - flow - ); + if user.totp_secret.is_some() { + let flow = DBFlow::Login2FA { user_id: user.id } + .insert(Duration::minutes(30), &redis) + .await?; - return Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*redirect_url)) - .json(serde_json::json!({ "url": redirect_url }))); - } + let redirect_url = format!( + "{}{}error=2fa_required&flow={}", + url, + if url.contains('?') { "&" } else { "?" }, + flow + ); - user_id + Ok(HttpResponse::TemporaryRedirect() + .append_header((LOCATION, &*redirect_url)) + .json(serde_json::json!({ "url": redirect_url }))) } else { - oauth_user - .create_account( - provider, - &mut transaction, - &client, - &file_host, - &redis, - ) - .await? - }; - - let session = - issue_session(req, user_id, &mut transaction, &redis, None) - .await?; - transaction.commit().await?; + let session = + issue_session(req, user_id, &mut transaction, &redis, None) + .await?; + transaction.commit().await?; + + let redirect_url = format!( + "{}{}code={}{}", + url, + if url.contains('?') { '&' } else { '?' }, + session.session, + if user_id_opt.is_none() { + "&new_account=true" + } else { + "" + } + ); - let redirect_url = format!( - "{}{}code={}{}", - url, - if url.contains('?') { '&' } else { '?' }, - session.session, - if user_id_opt.is_none() { - "&new_account=true" - } else { - "" - } - ); + Ok(HttpResponse::TemporaryRedirect() + .append_header((LOCATION, &*redirect_url)) + .json(serde_json::json!({ "url": redirect_url }))) + } + } else { + // user doesn't already exist; the user wants to create a new Modrinth account + // linked to their OAuth account. + // for this, we redirect them to a frontend page which lets them set a username, + // then frontend will redirect them back to us (`/create/oauth`), with the same + // state parameter, and their chosen settings (username, subscribe to newsletter). + + flow_guard + .replace_with(DBFlow::OAuthPending { + url, + provider, + user: oauth_user, + }) + .await + .wrap_err("failed to replace flow for state")?; + + let mut url = format!("{}/auth/create/oauth", &ENV.SITE_URL) + .parse::() + .expect("create OAuth account URL should be a valid URL"); + url.query_pairs_mut() + .append_pair("state", &state) + .append_pair( + "requires_dob", + &requires_dob(provider).to_string(), + ); + let redirect_url = url.to_string(); Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*redirect_url)) + .append_header((LOCATION, &*redirect_url)) .json(serde_json::json!({ "url": redirect_url }))) } } @@ -1336,6 +1379,81 @@ pub async fn auth_callback( Ok(res?) } +fn requires_dob(provider: AuthProvider) -> bool { + matches!( + provider, + AuthProvider::GitHub | AuthProvider::GitLab | AuthProvider::Steam + ) +} + +#[derive(Deserialize, Validate)] +struct NewOAuthAccount { + // keep in sync with NewAccount + #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] + pub username: String, + pub state: String, + pub challenge: String, + pub sign_up_newsletter: bool, +} + +#[get("/create/oauth")] +async fn create_oauth_account( + req: HttpRequest, + db: Data, + file_host: Data>, + redis: Data, + web::Json(new_account): web::Json, +) -> Result { + if !check_hcaptcha(&req, &new_account.challenge).await? { + return Err(ApiError::Turnstile); + } + + let flow = DBFlow::get(&new_account.state, &redis) + .await + .wrap_internal_err("failed to fetch flow state")? + .wrap_request_err("no flow for state")?; + + let DBFlow::OAuthPending { + url, + provider, + user, + } = flow + else { + return Err(ApiError::Internal(eyre!("invalid flow kind"))); + }; + + let mut txn = db + .begin() + .await + .wrap_internal_err("failed to begin transaction")?; + + let user_id = user + .create_account( + provider, + &mut txn, + &db, + &file_host, + &redis, + new_account.username, + new_account.sign_up_newsletter, + ) + .await?; + + let session = issue_session(req, user_id, &mut txn, &redis, None).await?; + txn.commit().await?; + + let mut redirect_url = url + .parse::() + .wrap_internal_err("invalid redirect URL")?; + redirect_url + .query_pairs_mut() + .append_pair("code", &session.session) + .append_pair("new_account", "true"); + let redirect_url = redirect_url.to_string(); + + Ok(Redirect::to(redirect_url)) +} + #[derive(Deserialize)] pub struct DeleteAuthProvider { pub provider: AuthProvider, @@ -1427,6 +1545,7 @@ pub async fn check_sendy_subscription( #[derive(Deserialize, Validate)] pub struct NewAccount { + // keep in sync with NewOAuthAccount #[validate(length(min = 1, max = 39), regex(path = *crate::util::validate::RE_URL_SAFE))] pub username: String, #[validate(length(min = 8, max = 256))] From 64f87551fffe2ce6d1f870b0dc742bf0c2a446df Mon Sep 17 00:00:00 2001 From: aecsocket Date: Mon, 13 Apr 2026 14:19:22 +0100 Subject: [PATCH 2/9] fix up oauth flow routes --- apps/labrinth/src/routes/internal/flows.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index c1073033fd..ea1f3ff274 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -51,6 +51,7 @@ pub fn config(cfg: &mut ServiceConfig) { .service(init) .service(auth_callback) .service(delete_auth_provider) + .service(create_oauth_account) .service(create_account_with_password) .service(login_password) .service(login_2fa) @@ -1396,7 +1397,7 @@ struct NewOAuthAccount { pub sign_up_newsletter: bool, } -#[get("/create/oauth")] +#[post("/create/oauth")] async fn create_oauth_account( req: HttpRequest, db: Data, From fe3aba52ab068ac2369e2d66d18d9264407d29aa Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 14 Apr 2026 12:35:13 +0100 Subject: [PATCH 3/9] improve URL-related OAuth code --- .../labrinth/src/database/models/flow_item.rs | 5 +- apps/labrinth/src/routes/internal/flows.rs | 51 +++++++------------ 2 files changed, 22 insertions(+), 34 deletions(-) diff --git a/apps/labrinth/src/database/models/flow_item.rs b/apps/labrinth/src/database/models/flow_item.rs index 76cb24b280..a000057f6e 100644 --- a/apps/labrinth/src/database/models/flow_item.rs +++ b/apps/labrinth/src/database/models/flow_item.rs @@ -10,6 +10,7 @@ use rand::distributions::Alphanumeric; use rand_chacha::ChaCha20Rng; use rand_chacha::rand_core::SeedableRng; use serde::{Deserialize, Serialize}; +use url::Url; const FLOWS_NAMESPACE: &str = "flows"; @@ -18,12 +19,12 @@ const FLOWS_NAMESPACE: &str = "flows"; pub enum DBFlow { OAuth { user_id: Option, - url: String, + url: Url, provider: AuthProvider, existing_user_id: Option, }, OAuthPending { - url: String, + url: Url, provider: AuthProvider, user: TempUser, }, diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index ea1f3ff274..f7e41d60ac 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1025,7 +1025,7 @@ impl AuthProvider { #[derive(Serialize, Deserialize)] pub struct AuthorizationInit { - pub url: String, + pub url: Url, #[serde(default)] pub provider: AuthProvider, pub token: Option, @@ -1041,7 +1041,7 @@ pub struct Authorization { // Init link takes us to GitHub API and calls back to callback endpoint with a code and state // http://localhost:8000/auth/init?url=https://modrinth.com -#[get("init")] +#[get("/init")] pub async fn init( req: HttpRequest, Query(info): Query, // callback url @@ -1077,9 +1077,7 @@ pub async fn init( "Starting authentication flow" ); - let url = - url::Url::parse(&info.url).map_err(|_| AuthenticationError::Url)?; - + let url = info.url; let domain = url.host_str().ok_or(AuthenticationError::Url)?; if !ENV .ALLOWED_CALLBACK_URLS @@ -1109,7 +1107,7 @@ pub async fn init( let state = DBFlow::OAuth { user_id, - url: info.url, + url, provider: info.provider, existing_user_id, } @@ -1262,7 +1260,7 @@ pub async fn auth_callback( .wrap_err("failed to clear user caches")?; return Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*url)) + .append_header(("Location", url.as_str())) .json(serde_json::json!({ "url": url }))); } @@ -1297,7 +1295,7 @@ pub async fn auth_callback( .await?; Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*url)) + .append_header(("Location", url.as_str())) .json(serde_json::json!({ "url": url }))) } else if let Some(user_id) = user_id_opt { let user = crate::database::models::DBUser::get_id( @@ -1311,15 +1309,14 @@ pub async fn auth_callback( .insert(Duration::minutes(30), &redis) .await?; - let redirect_url = format!( - "{}{}error=2fa_required&flow={}", - url, - if url.contains('?') { "&" } else { "?" }, - flow - ); + let mut redirect_url = url.clone(); + redirect_url + .query_pairs_mut() + .append_pair("error", "2fa_required") + .append_pair("flow", &flow); Ok(HttpResponse::TemporaryRedirect() - .append_header((LOCATION, &*redirect_url)) + .append_header((LOCATION, redirect_url.as_str())) .json(serde_json::json!({ "url": redirect_url }))) } else { let session = @@ -1327,20 +1324,13 @@ pub async fn auth_callback( .await?; transaction.commit().await?; - let redirect_url = format!( - "{}{}code={}{}", - url, - if url.contains('?') { '&' } else { '?' }, - session.session, - if user_id_opt.is_none() { - "&new_account=true" - } else { - "" - } - ); + let mut redirect_url = url.clone(); + redirect_url + .query_pairs_mut() + .append_pair("code", &session.session); Ok(HttpResponse::TemporaryRedirect() - .append_header((LOCATION, &*redirect_url)) + .append_header((LOCATION, redirect_url.as_str())) .json(serde_json::json!({ "url": redirect_url }))) } } else { @@ -1443,13 +1433,10 @@ async fn create_oauth_account( let session = issue_session(req, user_id, &mut txn, &redis, None).await?; txn.commit().await?; - let mut redirect_url = url - .parse::() - .wrap_internal_err("invalid redirect URL")?; + let mut redirect_url = url.clone(); redirect_url .query_pairs_mut() - .append_pair("code", &session.session) - .append_pair("new_account", "true"); + .append_pair("code", &session.session); let redirect_url = redirect_url.to_string(); Ok(Redirect::to(redirect_url)) From 7ea0635d865447ac9ada376c7c0073272efc201f Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 14 Apr 2026 18:20:14 +0100 Subject: [PATCH 4/9] Use user-provided callback addr instead of SELF_ADDR --- apps/labrinth/src/routes/internal/flows.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index f7e41d60ac..5679288368 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -237,11 +237,11 @@ impl TempUser { impl AuthProvider { pub fn get_redirect_url( &self, + raw_redirect_url: Url, state: String, ) -> Result { let self_addr = &ENV.SELF_ADDR; - let raw_redirect_uri = format!("{self_addr}/v2/auth/callback"); - let redirect_uri = urlencoding::encode(&raw_redirect_uri); + let redirect_uri = urlencoding::encode(raw_redirect_url.as_str()); Ok(match self { AuthProvider::GitHub => { @@ -1107,17 +1107,17 @@ pub async fn init( let state = DBFlow::OAuth { user_id, - url, + url: url.clone(), provider: info.provider, existing_user_id, } .insert(Duration::minutes(30), &redis) .await?; - let url = info.provider.get_redirect_url(state)?; + let provider_redirect_url = info.provider.get_redirect_url(url, state)?; Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*url)) - .json(serde_json::json!({ "url": url }))) + .append_header(("Location", &*provider_redirect_url)) + .json(serde_json::json!({ "url": provider_redirect_url }))) } #[get("/callback")] From 7484afa18ee024b74d5a52cc46af91dc826ca048 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 14 Apr 2026 19:16:14 +0100 Subject: [PATCH 5/9] Revert "Use user-provided callback addr instead of SELF_ADDR" This reverts commit 7ea0635d865447ac9ada376c7c0073272efc201f. --- apps/labrinth/src/routes/internal/flows.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 5679288368..f7e41d60ac 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -237,11 +237,11 @@ impl TempUser { impl AuthProvider { pub fn get_redirect_url( &self, - raw_redirect_url: Url, state: String, ) -> Result { let self_addr = &ENV.SELF_ADDR; - let redirect_uri = urlencoding::encode(raw_redirect_url.as_str()); + let raw_redirect_uri = format!("{self_addr}/v2/auth/callback"); + let redirect_uri = urlencoding::encode(&raw_redirect_uri); Ok(match self { AuthProvider::GitHub => { @@ -1107,17 +1107,17 @@ pub async fn init( let state = DBFlow::OAuth { user_id, - url: url.clone(), + url, provider: info.provider, existing_user_id, } .insert(Duration::minutes(30), &redis) .await?; - let provider_redirect_url = info.provider.get_redirect_url(url, state)?; + let url = info.provider.get_redirect_url(state)?; Ok(HttpResponse::TemporaryRedirect() - .append_header(("Location", &*provider_redirect_url)) - .json(serde_json::json!({ "url": provider_redirect_url }))) + .append_header(("Location", &*url)) + .json(serde_json::json!({ "url": url }))) } #[get("/callback")] From 66f3c39c13bdc5aa3d14da9bce92702608e173c3 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Tue, 14 Apr 2026 19:24:30 +0100 Subject: [PATCH 6/9] fix flow --- apps/labrinth/src/routes/internal/flows.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index f7e41d60ac..004a678263 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -1342,17 +1342,16 @@ pub async fn auth_callback( flow_guard .replace_with(DBFlow::OAuthPending { - url, + url: url.clone(), provider, user: oauth_user, }) .await .wrap_err("failed to replace flow for state")?; - let mut url = format!("{}/auth/create/oauth", &ENV.SITE_URL) - .parse::() - .expect("create OAuth account URL should be a valid URL"); - url.query_pairs_mut() + let mut redirect_url = url.clone(); + redirect_url + .query_pairs_mut() .append_pair("state", &state) .append_pair( "requires_dob", From 57b4f2108092401e16f6b37899dae49ee18608b5 Mon Sep 17 00:00:00 2001 From: tdgao Date: Fri, 17 Apr 2026 09:45:32 -0600 Subject: [PATCH 7/9] fix: backend response for create oauth account --- apps/labrinth/src/routes/internal/flows.rs | 23 +++++++++------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 004a678263..e531e824eb 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -23,7 +23,7 @@ use crate::util::ext::get_image_ext; use crate::util::img::upload_image_optimized; use crate::util::validate::validation_errors_to_string; use actix_http::header::LOCATION; -use actix_web::web::{Data, Query, Redirect, ServiceConfig, scope}; +use actix_web::web::{Data, Query, ServiceConfig, scope}; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use argon2::password_hash::SaltString; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; @@ -1336,9 +1336,9 @@ pub async fn auth_callback( } else { // user doesn't already exist; the user wants to create a new Modrinth account // linked to their OAuth account. - // for this, we redirect them to a frontend page which lets them set a username, - // then frontend will redirect them back to us (`/create/oauth`), with the same - // state parameter, and their chosen settings (username, subscribe to newsletter). + // for this, we redirect them to a frontend page which lets them set a username. + // then frontend will call `/create/oauth` with the same state parameter and + // chosen settings (username, subscribe to newsletter), and handle navigation. flow_guard .replace_with(DBFlow::OAuthPending { @@ -1358,7 +1358,7 @@ pub async fn auth_callback( &requires_dob(provider).to_string(), ); - let redirect_url = url.to_string(); + let redirect_url = redirect_url.to_string(); Ok(HttpResponse::TemporaryRedirect() .append_header((LOCATION, &*redirect_url)) .json(serde_json::json!({ "url": redirect_url }))) @@ -1393,7 +1393,7 @@ async fn create_oauth_account( file_host: Data>, redis: Data, web::Json(new_account): web::Json, -) -> Result { +) -> Result { if !check_hcaptcha(&req, &new_account.challenge).await? { return Err(ApiError::Turnstile); } @@ -1404,7 +1404,7 @@ async fn create_oauth_account( .wrap_request_err("no flow for state")?; let DBFlow::OAuthPending { - url, + url: _url, provider, user, } = flow @@ -1430,15 +1430,10 @@ async fn create_oauth_account( .await?; let session = issue_session(req, user_id, &mut txn, &redis, None).await?; + let res = crate::models::sessions::Session::from(session, true, None); txn.commit().await?; - let mut redirect_url = url.clone(); - redirect_url - .query_pairs_mut() - .append_pair("code", &session.session); - let redirect_url = redirect_url.to_string(); - - Ok(Redirect::to(redirect_url)) + Ok(HttpResponse::Ok().json(res)) } #[derive(Deserialize)] From c19ce11ea4171d3c31584d44f3ce2d49736e2f51 Mon Sep 17 00:00:00 2001 From: Truman Gao <106889354+tdgao@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:57:17 -0600 Subject: [PATCH 8/9] feat: new auth flow (#5840) * update auth with new designs * refactor: auth.js to auth.ts * refactor: componentize auth pages * fix: auth pages height * feat: initial implementation of new sign-in oauth * fix create account flow * fix checkbox * remove hard coded username * implement create user validation endpoint and add more specific error responses * feat: implement under 13 DOB guard and email/password validation route * fix: TOCTOU issue --- apps/frontend/src/app.vue | 2 + .../src/components/ui/auth/CreateAccount.vue | 266 ++++++++++++ .../src/components/ui/{ => auth}/HCaptcha.vue | 0 .../src/components/ui/auth/SignIn.vue | 295 +++++++++++++ .../src/components/ui/auth/SignUp.vue | 223 ++++++++++ .../ui/create/ProjectCreateModal.vue | 4 +- .../LegacyPaypalDetailsStage.vue | 3 +- .../TremendousDetailsStage.vue | 2 +- .../ui/moderation/ModerationReportCard.vue | 3 +- .../ui/moderation/ModerationTechRevCard.vue | 2 +- .../stages/SelectPublishedModpack.vue | 2 +- apps/frontend/src/composables/auth.js | 157 ------- apps/frontend/src/composables/auth.ts | 172 ++++++++ apps/frontend/src/layouts/default.vue | 2 +- apps/frontend/src/locales/en-US/index.json | 29 +- apps/frontend/src/pages/[type]/[id].vue | 2 +- .../pages/[type]/[id]/settings/versions.vue | 2 +- .../pages/[type]/[id]/version/[version].vue | 2 +- .../src/pages/[type]/[id]/versions.vue | 2 +- apps/frontend/src/pages/auth.vue | 27 +- apps/frontend/src/pages/auth/authorize.vue | 1 - apps/frontend/src/pages/auth/create/oauth.vue | 156 +++++++ .../src/pages/auth/reset-password.vue | 2 +- apps/frontend/src/pages/auth/sign-in.vue | 196 ++------- apps/frontend/src/pages/auth/sign-up.vue | 251 ++++------- .../src/pages/dashboard/organizations.vue | 1 - apps/frontend/src/pages/settings/account.vue | 2 +- apps/frontend/src/pages/user/[id].vue | 2 +- apps/frontend/src/providers/setup/auth.ts | 2 +- apps/labrinth/src/auth/mod.rs | 20 +- apps/labrinth/src/routes/internal/flows.rs | 403 +++++++++++++----- .../src/modules/labrinth/auth/v2.ts | 33 ++ .../api-client/src/modules/labrinth/types.ts | 17 + packages/ui/src/components/base/Checkbox.vue | 2 +- 34 files changed, 1609 insertions(+), 676 deletions(-) create mode 100644 apps/frontend/src/components/ui/auth/CreateAccount.vue rename apps/frontend/src/components/ui/{ => auth}/HCaptcha.vue (100%) create mode 100644 apps/frontend/src/components/ui/auth/SignIn.vue create mode 100644 apps/frontend/src/components/ui/auth/SignUp.vue delete mode 100644 apps/frontend/src/composables/auth.js create mode 100644 apps/frontend/src/composables/auth.ts create mode 100644 apps/frontend/src/pages/auth/create/oauth.vue diff --git a/apps/frontend/src/app.vue b/apps/frontend/src/app.vue index ebac15cd62..901314b7f1 100644 --- a/apps/frontend/src/app.vue +++ b/apps/frontend/src/app.vue @@ -13,6 +13,8 @@ import { I18nDebugPanel, NotificationPanel } from '@modrinth/ui' import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts' import { setupProviders } from '~/providers/setup.ts' +import { useAuth } from './composables/auth' + const auth = await useAuth() setupProviders(auth) diff --git a/apps/frontend/src/components/ui/auth/CreateAccount.vue b/apps/frontend/src/components/ui/auth/CreateAccount.vue new file mode 100644 index 0000000000..67e318f49e --- /dev/null +++ b/apps/frontend/src/components/ui/auth/CreateAccount.vue @@ -0,0 +1,266 @@ + + + diff --git a/apps/frontend/src/components/ui/HCaptcha.vue b/apps/frontend/src/components/ui/auth/HCaptcha.vue similarity index 100% rename from apps/frontend/src/components/ui/HCaptcha.vue rename to apps/frontend/src/components/ui/auth/HCaptcha.vue diff --git a/apps/frontend/src/components/ui/auth/SignIn.vue b/apps/frontend/src/components/ui/auth/SignIn.vue new file mode 100644 index 0000000000..09a969b89a --- /dev/null +++ b/apps/frontend/src/components/ui/auth/SignIn.vue @@ -0,0 +1,295 @@ + + + diff --git a/apps/frontend/src/components/ui/auth/SignUp.vue b/apps/frontend/src/components/ui/auth/SignUp.vue new file mode 100644 index 0000000000..e12ebd14b1 --- /dev/null +++ b/apps/frontend/src/components/ui/auth/SignUp.vue @@ -0,0 +1,223 @@ + + + diff --git a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue index 77f51ac7b2..3f3aef438a 100644 --- a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue +++ b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue @@ -164,9 +164,7 @@ defineExpose({ show, }) -const auth = (await useAuth()) as Ref<{ - user: { id: string; username: string; avatar_url: string } | null -}> +const auth = await useAuth() const messages = defineMessages({ title: { diff --git a/apps/frontend/src/components/ui/dashboard/withdraw-stages/LegacyPaypalDetailsStage.vue b/apps/frontend/src/components/ui/dashboard/withdraw-stages/LegacyPaypalDetailsStage.vue index 6278f19a2f..24974b3bba 100644 --- a/apps/frontend/src/components/ui/dashboard/withdraw-stages/LegacyPaypalDetailsStage.vue +++ b/apps/frontend/src/components/ui/dashboard/withdraw-stages/LegacyPaypalDetailsStage.vue @@ -120,7 +120,7 @@ import { computed, onMounted, ref, watch } from 'vue' import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue' import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue' -import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js' +import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.ts' import { useWithdrawContext } from '@/providers/creator-withdraw.ts' const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } = @@ -193,7 +193,6 @@ async function saveVenmoHandle() { }, }) - // @ts-expect-error auth.js is not typed await useAuth(auth.value.token) initialVenmoHandle.value = venmoHandle.value.trim() diff --git a/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue b/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue index f9e6d66223..3d2f1eda5f 100644 --- a/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue +++ b/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue @@ -358,8 +358,8 @@ import { computed, onMounted, ref, watch } from 'vue' import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue' import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue' -import { useAuth } from '@/composables/auth.js' import { useWithdrawContext } from '@/providers/creator-withdraw.ts' +import { useAuth } from '~/composables/auth.ts' const debug = useDebugLogger('TremendousDetailsStage') const { withdrawData, maxWithdrawAmount, availableMethods, paymentOptions, calculateFees } = diff --git a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue index ef77f3146f..e1a23d1c5b 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationReportCard.vue @@ -190,7 +190,6 @@ import { LinkIcon, } from '@modrinth/assets' import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation' -import { type OverflowMenuOption, useFormatDateTime } from '@modrinth/ui' import { Avatar, ButtonStyled, @@ -198,6 +197,8 @@ import { getProjectTypeIcon, injectNotificationManager, OverflowMenu, + type OverflowMenuOption, + useFormatDateTime, useRelativeTime, } from '@modrinth/ui' import { formatProjectType } from '@modrinth/utils' diff --git a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue index 97e83c06ff..bb309d7c07 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue @@ -26,11 +26,11 @@ import { getProjectTypeIcon, injectModrinthClient, injectNotificationManager, + NavTabs, OverflowMenu, type OverflowMenuOption, useFormatDateTime, } from '@modrinth/ui' -import { NavTabs } from '@modrinth/ui' import { capitalizeString, formatProjectType, diff --git a/apps/frontend/src/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue b/apps/frontend/src/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue index 16fd0fa9b6..1346fe17dd 100644 --- a/apps/frontend/src/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue +++ b/apps/frontend/src/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue @@ -87,7 +87,7 @@ const currentProjectId = computed(() => projectV3.value?.id) const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext() const { labrinth } = injectModrinthClient() const { addNotification } = injectNotificationManager() -const auth = (await useAuth()) as { user?: { id: string } } +const auth = await useAuth() interface VersionInfo { id: string diff --git a/apps/frontend/src/composables/auth.js b/apps/frontend/src/composables/auth.js deleted file mode 100644 index e69d6a4d52..0000000000 --- a/apps/frontend/src/composables/auth.js +++ /dev/null @@ -1,157 +0,0 @@ -export const useAuth = async (oldToken = null) => { - const auth = useState('auth', () => ({ - user: null, - token: '', - headers: {}, - })) - - if (!auth.value.user || oldToken) { - auth.value = await initAuth(oldToken) - } - - return auth -} - -export const initAuth = async (oldToken = null) => { - const auth = { - user: null, - token: '', - } - - if (oldToken === 'none') { - return auth - } - - const route = useRoute() - const authCookie = useCookie('auth-token', { - maxAge: 60 * 60 * 24 * 365 * 10, - sameSite: 'lax', - secure: true, - httpOnly: false, - path: '/', - }) - - if (oldToken) { - authCookie.value = oldToken - } - - if (route.query.code && !route.fullPath.includes('new_account=true')) { - authCookie.value = route.query.code - } - - if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') { - const redirect = route.path.startsWith('/auth/') ? null : route.fullPath - - await navigateTo( - `/auth/welcome?authToken=${route.query.code}${ - redirect ? `&redirect=${encodeURIComponent(redirect)}` : '' - }`, - ) - } - - if (authCookie.value) { - auth.token = authCookie.value - - if (!auth.token || !auth.token.startsWith('mra_')) { - return auth - } - - try { - auth.user = await useBaseFetch( - 'user', - { - headers: { - Authorization: auth.token, - }, - }, - true, - ) - } catch { - /* empty */ - } - } - - if (!auth.user && auth.token) { - try { - const session = await useBaseFetch( - 'session/refresh', - { - method: 'POST', - headers: { - Authorization: auth.token, - }, - }, - true, - ) - - auth.token = session.session - authCookie.value = auth.token - - auth.user = await useBaseFetch( - 'user', - { - headers: { - Authorization: auth.token, - }, - }, - true, - ) - } catch { - authCookie.value = null - } - } - - return auth -} - -export const getSignInRedirectPath = (route) => { - const fullPath = route.fullPath - if (fullPath === '/auth' || fullPath.startsWith('/auth/')) { - return '/dashboard' - } - return fullPath -} - -export const getSignInRouteObj = (route, redirectOverride) => ({ - path: '/auth/sign-in', - query: { - redirect: redirectOverride ?? getSignInRedirectPath(route), - }, -}) - -export const getAuthUrl = (provider, redirect = '/dashboard') => { - const config = useRuntimeConfig() - const route = useNativeRoute() - - const fullURL = route.query.launcher - ? getLauncherRedirectUrl(route) - : `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}` - - return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}` -} - -export const removeAuthProvider = async (provider) => { - startLoading() - - const auth = await useAuth() - - await useBaseFetch('auth/provider', { - method: 'DELETE', - body: { - provider, - }, - }) - - await useAuth(auth.value.token) - - stopLoading() -} - -export const getLauncherRedirectUrl = (route) => { - const usesLocalhostRedirectionScheme = - ['4', '6'].includes(route.query.ipver) && Number(route.query.port) < 65536 - - return usesLocalhostRedirectionScheme - ? `http://${route.query.ipver === '4' ? '127.0.0.1' : '[::1]'}:${route.query.port}` - : `https://launcher-files.modrinth.com` -} diff --git a/apps/frontend/src/composables/auth.ts b/apps/frontend/src/composables/auth.ts new file mode 100644 index 0000000000..1b8588cdae --- /dev/null +++ b/apps/frontend/src/composables/auth.ts @@ -0,0 +1,172 @@ +import type { Labrinth } from '@modrinth/api-client' +import type { LocationQueryValue, RouteLocationNormalizedLoaded } from 'vue-router' + +import type { CookieOptions } from '#app' + +type AuthState = { + user: Labrinth.Users.v2.User | null + token: string +} + +type QueryValue = LocationQueryValue | LocationQueryValue[] | undefined +type FullPathRoute = Pick +type LauncherRoute = Pick + +const AUTH_COOKIE_OPTIONS = { + maxAge: 60 * 60 * 24 * 365 * 10, + sameSite: 'lax', + secure: true, + httpOnly: false, + path: '/', +} satisfies CookieOptions + +const getQueryString = (value: QueryValue) => { + if (Array.isArray(value)) { + return value[0] ?? null + } + return value ?? null +} + +export const useAuth = async (oldToken: string | null | undefined = null) => { + const auth = useState('auth', () => ({ + user: null, + token: '', + })) + + if (!auth.value.user || oldToken) { + auth.value = await initAuth(oldToken) + } + + return auth +} + +export const initAuth = async (oldToken: string | null | undefined = null) => { + const auth: AuthState = { + user: null, + token: '', + } + + if (oldToken === 'none') { + return auth + } + + const route = useRoute() + const authCookie = useCookie('auth-token', AUTH_COOKIE_OPTIONS) + const authCode = getQueryString(route.query.code) + + if (oldToken) { + authCookie.value = oldToken + } + + if (authCode) { + authCookie.value = authCode + } + + if (authCookie.value) { + auth.token = authCookie.value + + if (!auth.token || !auth.token.startsWith('mra_')) { + return auth + } + + try { + auth.user = (await useBaseFetch( + 'user', + { + headers: { + Authorization: auth.token, + }, + }, + true, + )) as Labrinth.Users.v2.User + } catch { + /* empty */ + } + } + + if (!auth.user && auth.token) { + try { + const session = (await useBaseFetch( + 'session/refresh', + { + method: 'POST', + headers: { + Authorization: auth.token, + }, + }, + true, + )) as { session: string } + + auth.token = session.session + authCookie.value = auth.token + + auth.user = (await useBaseFetch( + 'user', + { + headers: { + Authorization: auth.token, + }, + }, + true, + )) as Labrinth.Users.v2.User + } catch { + authCookie.value = null + } + } + + return auth +} + +export const getSignInRedirectPath = (route: FullPathRoute) => { + const fullPath = route.fullPath + if (fullPath === '/auth' || fullPath.startsWith('/auth/')) { + return '/dashboard' + } + return fullPath +} + +export const getSignInRouteObj = (route: FullPathRoute, redirectOverride?: string | null) => ({ + path: '/auth/sign-in', + query: { + redirect: redirectOverride ?? getSignInRedirectPath(route), + }, +}) + +export const getAuthUrl = (provider: string, redirect = '/dashboard') => { + const config = useRuntimeConfig() + const route = useNativeRoute() + const launcher = getQueryString(route.query.launcher) + + const fullURL = launcher + ? getLauncherRedirectUrl(route) + : `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}` + + return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}` +} + +export const removeAuthProvider = async (provider: string) => { + startLoading() + + const auth = await useAuth() + + await useBaseFetch('auth/provider', { + method: 'DELETE', + body: { + provider, + }, + }) + + await useAuth(auth.value.token) + + stopLoading() +} + +export const getLauncherRedirectUrl = (route: LauncherRoute) => { + const ipver = getQueryString(route.query.ipver) + const port = Number(getQueryString(route.query.port)) + const usesLocalhostRedirectionScheme = ['4', '6'].includes(ipver ?? '') && port < 65536 + + return usesLocalhostRedirectionScheme + ? `http://${ipver === '4' ? '127.0.0.1' : '[::1]'}:${port}` + : 'https://launcher-files.modrinth.com' +} diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index f0bf302991..ec03c4ab7c 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -762,7 +762,7 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal. import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue' import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue' import ModrinthFooter from '~/components/ui/ModrinthFooter.vue' -import { getSignInRouteObj } from '~/composables/auth.js' +import { getSignInRouteObj } from '~/composables/auth.ts' import { errors as generatedStateErrors } from '~/generated/state.json' import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts' diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index e9053abac1..c3f3154b12 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -224,6 +224,9 @@ "auth.authorize.redirect-url": { "message": "You will be redirected to {url}" }, + "auth.continue-with-provider": { + "message": "Continue with {provider}" + }, "auth.reset-password.method-choice.action": { "message": "Send recovery email" }, @@ -267,25 +270,28 @@ "message": "Enter code..." }, "auth.sign-in.additional-options": { - "message": "Forgot password?Create an account" + "message": "Forgot password • Don't have an account? Sign up" + }, + "auth.sign-in.continue-with-email": { + "message": "Continue with Email" }, "auth.sign-in.sign-in-with": { - "message": "Sign in with" + "message": "Sign into Modrinth" }, "auth.sign-in.title": { "message": "Sign In" }, - "auth.sign-in.use-password": { - "message": "Or use a password" - }, - "auth.sign-up.action.create-account": { - "message": "Create account" + "auth.sign-up.continue-with-email": { + "message": "Continue with Email" }, "auth.sign-up.legal-dislaimer": { "message": "By creating an account, you agree to Modrinth's Terms and Privacy Policy." }, - "auth.sign-up.notification.password-mismatch.text": { - "message": "Passwords do not match!" + "auth.sign-up.show-fewer-options": { + "message": "Show fewer options" + }, + "auth.sign-up.show-other-options": { + "message": "Show other options" }, "auth.sign-up.sign-in-option.title": { "message": "Already have an account?" @@ -296,11 +302,8 @@ "auth.sign-up.title": { "message": "Sign Up" }, - "auth.sign-up.title.create-account": { - "message": "Or create an account yourself" - }, "auth.sign-up.title.sign-up-with": { - "message": "Sign up with" + "message": "Create an Account" }, "auth.verify-email.action.account-settings": { "message": "Account settings" diff --git a/apps/frontend/src/pages/[type]/[id].vue b/apps/frontend/src/pages/[type]/[id].vue index 4dadc62270..213f68da50 100644 --- a/apps/frontend/src/pages/[type]/[id].vue +++ b/apps/frontend/src/pages/[type]/[id].vue @@ -1140,7 +1140,7 @@ import MessageBanner from '~/components/ui/MessageBanner.vue' import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue' import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue' import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue' -import { getSignInRouteObj } from '~/composables/auth.js' +import { getSignInRouteObj } from '~/composables/auth.ts' import { saveFeatureFlags } from '~/composables/featureFlags.ts' import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project' import { versionQueryOptions } from '~/composables/queries/version' diff --git a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue index 20b7d65943..1c3094a64f 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue @@ -308,7 +308,7 @@ import { import { useTemplateRef } from 'vue' import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue' -import { getSignInRouteObj } from '~/composables/auth.js' +import { getSignInRouteObj } from '~/composables/auth.ts' import { reportVersion } from '~/utils/report-helpers.ts' const route = useRoute() diff --git a/apps/frontend/src/pages/[type]/[id]/version/[version].vue b/apps/frontend/src/pages/[type]/[id]/version/[version].vue index ff6decdfc8..a0478f9373 100644 --- a/apps/frontend/src/pages/[type]/[id]/version/[version].vue +++ b/apps/frontend/src/pages/[type]/[id]/version/[version].vue @@ -443,7 +443,7 @@ import { formatBytes, renderHighlightedString } from '@modrinth/utils' import Breadcrumbs from '~/components/ui/Breadcrumbs.vue' import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue' import Modal from '~/components/ui/Modal.vue' -import { getSignInRouteObj } from '~/composables/auth.js' +import { getSignInRouteObj } from '~/composables/auth.ts' import { useImageUpload } from '~/composables/image-upload.ts' import { inferVersionInfo } from '~/helpers/infer' import { createDataPackVersion } from '~/helpers/package.js' diff --git a/apps/frontend/src/pages/[type]/[id]/versions.vue b/apps/frontend/src/pages/[type]/[id]/versions.vue index 3d67f1ac88..0866511808 100644 --- a/apps/frontend/src/pages/[type]/[id]/versions.vue +++ b/apps/frontend/src/pages/[type]/[id]/versions.vue @@ -269,7 +269,7 @@ import { import { onMounted, useTemplateRef } from 'vue' import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue' -import { getSignInRouteObj } from '~/composables/auth.js' +import { getSignInRouteObj } from '~/composables/auth.ts' import { reportVersion } from '~/utils/report-helpers.ts' const route = useRoute() diff --git a/apps/frontend/src/pages/auth.vue b/apps/frontend/src/pages/auth.vue index 205866bcee..900b9f411f 100644 --- a/apps/frontend/src/pages/auth.vue +++ b/apps/frontend/src/pages/auth.vue @@ -8,14 +8,15 @@ useSeoMeta({ })