diff --git a/migrations/2026-04-24-000001_merchant_onboarding/down.sql b/migrations/2026-04-24-000001_merchant_onboarding/down.sql new file mode 100644 index 00000000..bca2c659 --- /dev/null +++ b/migrations/2026-04-24-000001_merchant_onboarding/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users MODIFY COLUMN merchant_id VARCHAR(255) NOT NULL DEFAULT ''; +DROP TABLE IF EXISTS user_merchants; +ALTER TABLE merchant_account DROP COLUMN merchant_name; diff --git a/migrations/2026-04-24-000001_merchant_onboarding/up.sql b/migrations/2026-04-24-000001_merchant_onboarding/up.sql new file mode 100644 index 00000000..cdcc7088 --- /dev/null +++ b/migrations/2026-04-24-000001_merchant_onboarding/up.sql @@ -0,0 +1,15 @@ +ALTER TABLE users MODIFY COLUMN merchant_id VARCHAR(255) NULL DEFAULT NULL; + +CREATE TABLE user_merchants ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + merchant_id VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'admin', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + UNIQUE KEY uk_user_merchant (user_id, merchant_id) +); + +CREATE INDEX idx_user_merchants_user_id ON user_merchants (user_id); +CREATE INDEX idx_user_merchants_merchant_id ON user_merchants (merchant_id); + +ALTER TABLE merchant_account ADD COLUMN merchant_name VARCHAR(255) NULL; diff --git a/migrations_pg/2026-04-24-000001_merchant_onboarding/down.sql b/migrations_pg/2026-04-24-000001_merchant_onboarding/down.sql new file mode 100644 index 00000000..0ae00d4a --- /dev/null +++ b/migrations_pg/2026-04-24-000001_merchant_onboarding/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users ALTER COLUMN merchant_id SET NOT NULL; +DROP TABLE IF EXISTS user_merchants; +ALTER TABLE merchant_account DROP COLUMN IF EXISTS merchant_name; diff --git a/migrations_pg/2026-04-24-000001_merchant_onboarding/up.sql b/migrations_pg/2026-04-24-000001_merchant_onboarding/up.sql new file mode 100644 index 00000000..380791cf --- /dev/null +++ b/migrations_pg/2026-04-24-000001_merchant_onboarding/up.sql @@ -0,0 +1,15 @@ +ALTER TABLE users ALTER COLUMN merchant_id DROP NOT NULL; + +CREATE TABLE user_merchants ( + id BIGSERIAL PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + merchant_id VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'admin', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE (user_id, merchant_id) +); + +CREATE INDEX idx_user_merchants_user_id ON user_merchants (user_id); +CREATE INDEX idx_user_merchants_merchant_id ON user_merchants (merchant_id); + +ALTER TABLE merchant_account ADD COLUMN merchant_name VARCHAR(255); diff --git a/scripts/test_auth.sh b/scripts/test_auth.sh index 17f4d00a..4baa7f64 100755 --- a/scripts/test_auth.sh +++ b/scripts/test_auth.sh @@ -162,14 +162,16 @@ if [ -n "$NEW_KEY_ID" ]; then fi fi -# ── 8. User signup ───────────────────────────────────── +# ── 8. User signup (no merchant_id required) ─────────── echo "" echo "[ User signup ]" SIGNUP_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/signup" \ -H "Content-Type: application/json" \ - -d "{\"email\": \"$TEST_EMAIL\", \"password\": \"$TEST_PASSWORD\", \"merchant_id\": \"$MERCHANT_ID\"}") + -d "{\"email\": \"$TEST_EMAIL\", \"password\": \"$TEST_PASSWORD\"}") JWT_TOKEN=$(echo "$SIGNUP_RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) +SIGNUP_MERCHANT_ID=$(echo "$SIGNUP_RESPONSE" | grep -o '"merchant_id":"[^"]*"' | cut -d'"' -f4) +SIGNUP_MERCHANTS=$(echo "$SIGNUP_RESPONSE" | grep -o '"merchants":\[\]') if [ -n "$JWT_TOKEN" ]; then echo " PASS POST /auth/signup returns JWT token" PASS=$((PASS + 1)) @@ -177,16 +179,130 @@ else echo " FAIL POST /auth/signup — unexpected response: $SIGNUP_RESPONSE" FAIL=$((FAIL + 1)) fi +if [ -z "$SIGNUP_MERCHANT_ID" ] || [ "$SIGNUP_MERCHANT_ID" = '""' ]; then + echo " PASS POST /auth/signup — merchant_id is empty (onboarding pending)" + PASS=$((PASS + 1)) +else + echo " INFO POST /auth/signup — merchant_id: $SIGNUP_MERCHANT_ID" +fi +if [ -n "$SIGNUP_MERCHANTS" ]; then + echo " PASS POST /auth/signup — merchants list is empty" + PASS=$((PASS + 1)) +else + echo " FAIL POST /auth/signup — expected empty merchants list, got: $SIGNUP_RESPONSE" + FAIL=$((FAIL + 1)) +fi # ── 9. Duplicate signup → 409 ───────────────────────── echo "" echo "[ Duplicate signup ]" STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/auth/signup" \ -H "Content-Type: application/json" \ - -d "{\"email\": \"$TEST_EMAIL\", \"password\": \"$TEST_PASSWORD\", \"merchant_id\": \"$MERCHANT_ID\"}") + -d "{\"email\": \"$TEST_EMAIL\", \"password\": \"$TEST_PASSWORD\"}") check "Duplicate email → 409" "409" "$STATUS" -# ── 10. Login ────────────────────────────────────────── +# ── 10. Onboarding: create first merchant ────────────── +echo "" +echo "[ Onboarding: create merchant ]" +ONBOARD_RESPONSE=$(curl -s -X POST "$BASE_URL/onboarding/merchant" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $JWT_TOKEN" \ + -d "{\"merchant_name\": \"Test Corp\"}") + +ONBOARD_TOKEN=$(echo "$ONBOARD_RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) +ONBOARD_MID=$(echo "$ONBOARD_RESPONSE" | grep -o '"merchant_id":"[^"]*"' | head -1 | cut -d'"' -f4) +ONBOARD_NAME=$(echo "$ONBOARD_RESPONSE" | grep -o '"merchant_name":"[^"]*"' | head -1 | cut -d'"' -f4) + +if [ -n "$ONBOARD_TOKEN" ] && [ -n "$ONBOARD_MID" ]; then + echo " PASS POST /onboarding/merchant returns token + merchant_id" + echo " merchant_id: $ONBOARD_MID" + PASS=$((PASS + 1)) +else + echo " FAIL POST /onboarding/merchant — unexpected response: $ONBOARD_RESPONSE" + FAIL=$((FAIL + 1)) +fi +check "Merchant name stored correctly" "Test Corp" "$ONBOARD_NAME" + +ONBOARD_COUNT=$(echo "$ONBOARD_RESPONSE" | grep -o '"merchant_id"' | wc -l | tr -d ' ') +if [ "$ONBOARD_COUNT" -ge "1" ]; then + echo " PASS POST /onboarding/merchant — merchants list has 1 entry" + PASS=$((PASS + 1)) +else + echo " FAIL POST /onboarding/merchant — merchants list empty: $ONBOARD_RESPONSE" + FAIL=$((FAIL + 1)) +fi + +# ── 11. Onboarding: create second merchant ───────────── +echo "" +echo "[ Onboarding: create second merchant ]" +ONBOARD2_RESPONSE=$(curl -s -X POST "$BASE_URL/onboarding/merchant" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ONBOARD_TOKEN" \ + -d "{\"merchant_name\": \"Beta Inc\"}") + +ONBOARD2_MID=$(echo "$ONBOARD2_RESPONSE" | grep -o '"merchant_id":"[^"]*"' | head -1 | cut -d'"' -f4) +ONBOARD2_COUNT=$(echo "$ONBOARD2_RESPONSE" | grep -o '"merchant_id"' | wc -l | tr -d ' ') +ONBOARD2_TOKEN=$(echo "$ONBOARD2_RESPONSE" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4) + +if [ -n "$ONBOARD2_MID" ]; then + echo " PASS POST /onboarding/merchant — second merchant created" + echo " merchant_id: $ONBOARD2_MID" + PASS=$((PASS + 1)) +else + echo " FAIL POST /onboarding/merchant (2nd) — unexpected: $ONBOARD2_RESPONSE" + FAIL=$((FAIL + 1)) +fi +if [ "$ONBOARD2_COUNT" -ge "2" ]; then + echo " PASS merchants list now has $ONBOARD2_COUNT entries" + PASS=$((PASS + 1)) +else + echo " FAIL Expected 2+ merchants in list, got $ONBOARD2_COUNT: $ONBOARD2_RESPONSE" + FAIL=$((FAIL + 1)) +fi + +# ── 12. List merchants ───────────────────────────────── +echo "" +echo "[ List merchants ]" +LIST_MERCHANTS=$(curl -s "$BASE_URL/auth/merchants" \ + -H "Authorization: Bearer $ONBOARD2_TOKEN") +LIST_COUNT=$(echo "$LIST_MERCHANTS" | grep -o '"merchant_id"' | wc -l | tr -d ' ') +if [ "$LIST_COUNT" -ge "2" ]; then + echo " PASS GET /auth/merchants returns $LIST_COUNT merchants" + PASS=$((PASS + 1)) +else + echo " FAIL GET /auth/merchants — got: $LIST_MERCHANTS" + FAIL=$((FAIL + 1)) +fi + +# ── 13. Switch merchant ──────────────────────────────── +echo "" +echo "[ Switch merchant ]" +SWITCH_RESPONSE=$(curl -s --max-time 10 -X POST "$BASE_URL/auth/switch-merchant" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ONBOARD2_TOKEN" \ + -d "{\"merchant_id\": \"$ONBOARD_MID\"}") + +SWITCH_TOKEN=$(echo "$SWITCH_RESPONSE" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4) +SWITCH_MID=$(echo "$SWITCH_RESPONSE" | grep -o '"merchant_id":"[^"]*"' | head -1 | cut -d'"' -f4) + +if [ -n "$SWITCH_TOKEN" ]; then + echo " PASS POST /auth/switch-merchant returns new token" + PASS=$((PASS + 1)) +else + echo " FAIL POST /auth/switch-merchant — unexpected: $SWITCH_RESPONSE" + FAIL=$((FAIL + 1)) +fi +check "Switch sets correct active merchant_id" "$ONBOARD_MID" "$SWITCH_MID" + +echo "" +echo "[ Switch to non-existent merchant → 404 ]" +STATUS=$(curl -s --max-time 10 -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/auth/switch-merchant" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $ONBOARD2_TOKEN" \ + -d '{"merchant_id": "merchant_doesnotexist"}') +check "Switch to unknown merchant → 404" "404" "$STATUS" + +# ── 14. Login returns merchants list ────────────────── echo "" echo "[ User login ]" LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/login" \ @@ -194,6 +310,7 @@ LOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/login" \ -d "{\"email\": \"$TEST_EMAIL\", \"password\": \"$TEST_PASSWORD\"}") JWT_TOKEN=$(echo "$LOGIN_RESPONSE" | grep -o '"token":"[^"]*"' | cut -d'"' -f4) +LOGIN_MERCHANT_COUNT=$(echo "$LOGIN_RESPONSE" | grep -o '"merchant_id"' | wc -l | tr -d ' ') if [ -n "$JWT_TOKEN" ]; then echo " PASS POST /auth/login returns JWT token" PASS=$((PASS + 1)) @@ -201,8 +318,15 @@ else echo " FAIL POST /auth/login — unexpected response: $LOGIN_RESPONSE" FAIL=$((FAIL + 1)) fi +if [ "$LOGIN_MERCHANT_COUNT" -ge "2" ]; then + echo " PASS POST /auth/login — merchants list populated ($LOGIN_MERCHANT_COUNT entries)" + PASS=$((PASS + 1)) +else + echo " FAIL POST /auth/login — merchants list missing or empty: $LOGIN_RESPONSE" + FAIL=$((FAIL + 1)) +fi -# ── 11. Wrong password → 401 ────────────────────────── +# ── 15. Wrong password → 401 ────────────────────────── echo "" echo "[ Wrong password ]" STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/auth/login" \ @@ -210,7 +334,7 @@ STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/auth/login" \ -d "{\"email\": \"$TEST_EMAIL\", \"password\": \"wrongpassword\"}") check "Wrong password → 401" "401" "$STATUS" -# ── 12. JWT accesses protected route ────────────────── +# ── 16. JWT accesses protected route ────────────────── if [ -n "$JWT_TOKEN" ]; then echo "" echo "[ JWT auth on protected routes ]" @@ -230,7 +354,7 @@ if [ -n "$JWT_TOKEN" ]; then echo " INFO Auth not enforced — skipping invalid JWT check" fi - # ── 13. /auth/me ────────────────────────────────── + # ── 17. /auth/me ────────────────────────────────── echo "" echo "[ /auth/me ]" ME_RESPONSE=$(curl -s "$BASE_URL/auth/me" \ @@ -242,8 +366,16 @@ if [ -n "$JWT_TOKEN" ]; then echo " FAIL GET /auth/me — got: $ME_RESPONSE" FAIL=$((FAIL + 1)) fi + ME_MERCHANT_COUNT=$(echo "$ME_RESPONSE" | grep -o '"merchant_id"' | wc -l | tr -d ' ') + if [ "$ME_MERCHANT_COUNT" -ge "2" ]; then + echo " PASS GET /auth/me — merchants list populated" + PASS=$((PASS + 1)) + else + echo " FAIL GET /auth/me — merchants list missing: $ME_RESPONSE" + FAIL=$((FAIL + 1)) + fi - # ── 14. Logout ──────────────────────────────────── + # ── 18. Logout ──────────────────────────────────── echo "" echo "[ Logout ]" LOGOUT_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/logout" \ @@ -256,7 +388,7 @@ if [ -n "$JWT_TOKEN" ]; then FAIL=$((FAIL + 1)) fi - # ── 15. Revoked JWT rejected ────────────────────── + # ── 19. Revoked JWT rejected ────────────────────── echo "" echo "[ Revoked JWT rejected ]" sleep 1 @@ -270,7 +402,7 @@ if [ -n "$JWT_TOKEN" ]; then echo " INFO Auth not enforced — skipping revoked JWT check" fi - # ── 16. Re-login after logout ───────────────────── + # ── 20. Re-login after logout ───────────────────── echo "" echo "[ Re-login after logout ]" RELOGIN_RESPONSE=$(curl -s -X POST "$BASE_URL/auth/login" \ @@ -286,7 +418,7 @@ if [ -n "$JWT_TOKEN" ]; then fi fi -# ── 17. Redis cache hit (API key) ────────────────────── +# ── 21. Redis cache hit (API key) ────────────────────── echo "" echo "[ Redis cache hit ]" for i in 1 2; do diff --git a/src/app.rs b/src/app.rs index f36a1fbc..6566534c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -281,7 +281,16 @@ where .route("/auth/signup", post(routes::user_auth::signup)) .route("/auth/login", post(routes::user_auth::login)) .route("/auth/logout", post(routes::user_auth::logout)) - .route("/auth/me", get(routes::user_auth::me)); + .route("/auth/me", get(routes::user_auth::me)) + .route("/auth/merchants", get(routes::user_auth::list_merchants)) + .route( + "/auth/switch-merchant", + post(routes::user_auth::switch_merchant), + ) + .route( + "/onboarding/merchant", + post(routes::user_auth::create_merchant), + ); let router = axum::Router::new() .merge(protected_router) diff --git a/src/routes/user_auth.rs b/src/routes/user_auth.rs index 5730b857..d095f32c 100644 --- a/src/routes/user_auth.rs +++ b/src/routes/user_auth.rs @@ -1,7 +1,9 @@ use crate::app::{get_tenant_app_state, APP_STATE}; use crate::auth; use crate::error::{self, UserAuthError}; -use crate::storage::types::{NewUser, User}; +use crate::storage::types::{ + MerchantAccountNew, NewUser, NewUserMerchant, User, UserMerchant, UserMerchantIdUpdate, +}; use crate::utils::date_time; use axum::http::HeaderMap; use axum::Json; @@ -14,13 +16,17 @@ use crate::storage::schema::users::dsl; #[cfg(feature = "postgres")] use crate::storage::schema_pg::users::dsl; +#[cfg(feature = "mysql")] +use crate::storage::schema::user_merchants::dsl as um_dsl; +#[cfg(feature = "postgres")] +use crate::storage::schema_pg::user_merchants::dsl as um_dsl; + const JWT_DENYLIST_PREFIX: &str = "jwt_revoked:"; #[derive(Debug, Deserialize)] pub struct SignupRequest { pub email: String, pub password: String, - pub merchant_id: String, } #[derive(Debug, Deserialize)] @@ -29,6 +35,23 @@ pub struct LoginRequest { pub password: String, } +#[derive(Debug, Deserialize)] +pub struct CreateMerchantRequest { + pub merchant_name: String, +} + +#[derive(Debug, Deserialize)] +pub struct SwitchMerchantRequest { + pub merchant_id: String, +} + +#[derive(Debug, Serialize, Clone)] +pub struct MerchantInfo { + pub merchant_id: String, + pub merchant_name: String, + pub role: String, +} + #[derive(Debug, Serialize)] pub struct AuthResponse { pub token: String, @@ -36,6 +59,7 @@ pub struct AuthResponse { pub email: String, pub merchant_id: String, pub role: String, + pub merchants: Vec, } #[derive(Debug, Serialize)] @@ -45,6 +69,15 @@ pub struct MeResponse { pub merchant_id: String, pub role: String, pub email_verified: bool, + pub merchants: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CreateMerchantResponse { + pub token: String, + pub merchant_id: String, + pub merchant_name: String, + pub merchants: Vec, } #[axum::debug_handler] @@ -57,30 +90,6 @@ pub async fn signup( .map(|s| s.global_config.clone()) .ok_or(UserAuthError::StorageError)?; - // Check merchant exists - { - #[cfg(feature = "mysql")] - use crate::storage::schema::merchant_account::dsl as ma_dsl; - #[cfg(feature = "postgres")] - use crate::storage::schema_pg::merchant_account::dsl as ma_dsl; - - let exists = crate::generics::generic_find_all::< - ::Table, - _, - crate::storage::types::MerchantAccount, - >( - &app_state.db, - ma_dsl::merchant_id.eq(payload.merchant_id.clone()), - ) - .await - .map_err(|_| UserAuthError::StorageError)?; - - if exists.is_empty() { - return Err(error::ContainerError::from(UserAuthError::MerchantNotFound)); - } - } - - // Check email uniqueness let existing = crate::generics::generic_find_all::<::Table, _, User>( &app_state.db, dsl::email.eq(payload.email.clone()), @@ -104,13 +113,12 @@ pub async fn signup( user_id: user_id.clone(), email: payload.email.clone(), password_hash, - merchant_id: payload.merchant_id.clone(), + merchant_id: None, role: "admin".to_string(), #[cfg(feature = "mysql")] is_active: 1, #[cfg(feature = "postgres")] is_active: true, - // Email verification skipped for local; in production set to 0/false and send email #[cfg(feature = "mysql")] email_verified: if global_config.user_auth.email_verification_enabled { 0 @@ -127,14 +135,13 @@ pub async fn signup( .map_err(|_| UserAuthError::StorageError)?; if global_config.user_auth.email_verification_enabled { - // TODO: send verification email via email provider return Err(error::ContainerError::from(UserAuthError::EmailNotVerified)); } let token = auth::generate_jwt( &user_id, &payload.email, - &payload.merchant_id, + "", "admin", &global_config.user_auth.jwt_secret, global_config.user_auth.jwt_expiry_seconds, @@ -145,8 +152,9 @@ pub async fn signup( token, user_id, email: payload.email, - merchant_id: payload.merchant_id, + merchant_id: String::new(), role: "admin".to_string(), + merchants: vec![], })) } @@ -197,16 +205,24 @@ pub async fn login( return Err(error::ContainerError::from(UserAuthError::EmailNotVerified)); } - let valid = auth::verify_password(&payload.password, &user.password_hash) - .map_err(|_| UserAuthError::StorageError)?; - if !valid { + if !auth::verify_password(&payload.password, &user.password_hash) + .map_err(|_| UserAuthError::StorageError)? + { return Err(error::ContainerError::from(UserAuthError::InvalidPassword)); } + let merchants = fetch_user_merchants(&app_state, &user.user_id).await?; + let active_merchant_id = user.merchant_id.clone().unwrap_or_else(|| { + merchants + .first() + .map(|m| m.merchant_id.clone()) + .unwrap_or_default() + }); + let token = auth::generate_jwt( &user.user_id, &user.email, - &user.merchant_id, + &active_merchant_id, &user.role, &global_config.user_auth.jwt_secret, global_config.user_auth.jwt_expiry_seconds, @@ -217,8 +233,158 @@ pub async fn login( token, user_id: user.user_id, email: user.email, - merchant_id: user.merchant_id, + merchant_id: active_merchant_id, role: user.role, + merchants, + })) +} + +#[axum::debug_handler] +pub async fn create_merchant( + headers: HeaderMap, + Json(payload): Json, +) -> Result, error::ContainerError> { + let token = extract_bearer_token(&headers)?; + let global_config = APP_STATE + .get() + .map(|s| s.global_config.clone()) + .ok_or(UserAuthError::StorageError)?; + + let claims = verify_jwt_not_revoked(token, &global_config.user_auth.jwt_secret).await?; + let app_state = get_tenant_app_state().await; + + let merchant_id = format!( + "merchant_{}", + &uuid::Uuid::new_v4().to_string().replace('-', "")[..12] + ); + let now = date_time::now(); + + let new_merchant = MerchantAccountNew { + merchant_id: Some(merchant_id.clone()), + merchant_name: Some(payload.merchant_name.clone()), + date_created: now, + use_code_for_gateway_priority: crate::storage::types::BitBoolWrite(false), + gateway_success_rate_based_decider_input: None, + internal_metadata: None, + enabled: crate::storage::types::BitBoolWrite(true), + }; + + crate::generics::generic_insert(&app_state.db, new_merchant) + .await + .map_err(|_| UserAuthError::StorageError)?; + + let new_user_merchant = NewUserMerchant { + user_id: claims.user_id.clone(), + merchant_id: merchant_id.clone(), + role: "admin".to_string(), + created_at: now, + }; + + crate::generics::generic_insert(&app_state.db, new_user_merchant) + .await + .map_err(|_| UserAuthError::StorageError)?; + + // Update users.merchant_id to the newly created merchant + { + #[cfg(feature = "mysql")] + use crate::storage::schema::users::dsl as u_dsl; + #[cfg(feature = "postgres")] + use crate::storage::schema_pg::users::dsl as u_dsl; + + let conn = &app_state + .db + .get_conn() + .await + .map_err(|_| UserAuthError::StorageError)?; + crate::generics::generic_update_if_present::< + ::Table, + UserMerchantIdUpdate, + _, + >( + conn, + u_dsl::user_id.eq(claims.user_id.clone()), + UserMerchantIdUpdate { + merchant_id: Some(merchant_id.clone()), + }, + ) + .await + .map_err(|_| UserAuthError::StorageError)?; + } + + let merchants = fetch_user_merchants(&app_state, &claims.user_id).await?; + + let new_token = auth::generate_jwt( + &claims.user_id, + &claims.email, + &merchant_id, + &claims.role, + &global_config.user_auth.jwt_secret, + global_config.user_auth.jwt_expiry_seconds, + ) + .map_err(|_| UserAuthError::TokenGenerationFailed)?; + + Ok(Json(CreateMerchantResponse { + token: new_token, + merchant_id, + merchant_name: payload.merchant_name, + merchants, + })) +} + +#[axum::debug_handler] +pub async fn list_merchants( + headers: HeaderMap, +) -> Result>, error::ContainerError> { + let token = extract_bearer_token(&headers)?; + let global_config = APP_STATE + .get() + .map(|s| s.global_config.clone()) + .ok_or(UserAuthError::StorageError)?; + + let claims = verify_jwt_not_revoked(token, &global_config.user_auth.jwt_secret).await?; + let app_state = get_tenant_app_state().await; + + let merchants = fetch_user_merchants(&app_state, &claims.user_id).await?; + Ok(Json(merchants)) +} + +#[axum::debug_handler] +pub async fn switch_merchant( + headers: HeaderMap, + Json(payload): Json, +) -> Result, error::ContainerError> { + let token = extract_bearer_token(&headers)?; + let global_config = APP_STATE + .get() + .map(|s| s.global_config.clone()) + .ok_or(UserAuthError::StorageError)?; + + let claims = verify_jwt_not_revoked(token, &global_config.user_auth.jwt_secret).await?; + let app_state = get_tenant_app_state().await; + + let merchants = fetch_user_merchants(&app_state, &claims.user_id).await?; + let target = merchants + .iter() + .find(|m| m.merchant_id == payload.merchant_id) + .ok_or_else(|| error::ContainerError::from(UserAuthError::MerchantNotFound))?; + + let new_token = auth::generate_jwt( + &claims.user_id, + &claims.email, + &target.merchant_id, + &target.role, + &global_config.user_auth.jwt_secret, + global_config.user_auth.jwt_expiry_seconds, + ) + .map_err(|_| UserAuthError::TokenGenerationFailed)?; + + Ok(Json(AuthResponse { + token: new_token, + user_id: claims.user_id, + email: claims.email, + merchant_id: target.merchant_id.clone(), + role: target.role.clone(), + merchants, })) } @@ -266,8 +432,8 @@ pub async fn me( .ok_or(UserAuthError::StorageError)?; let claims = verify_jwt_not_revoked(token, &global_config.user_auth.jwt_secret).await?; - let app_state = get_tenant_app_state().await; + let mut users = crate::generics::generic_find_all::<::Table, _, User>( &app_state.db, dsl::user_id.eq(claims.user_id.clone()), @@ -276,19 +442,65 @@ pub async fn me( .map_err(|_| UserAuthError::StorageError)?; let user = users.pop().ok_or(UserAuthError::UserNotFound)?; + let merchants = fetch_user_merchants(&app_state, &user.user_id).await?; Ok(Json(MeResponse { user_id: user.user_id, email: user.email, - merchant_id: user.merchant_id, + merchant_id: claims.merchant_id, role: user.role, #[cfg(feature = "mysql")] email_verified: user.email_verified != 0, #[cfg(feature = "postgres")] email_verified: user.email_verified, + merchants, })) } +async fn fetch_user_merchants( + app_state: &crate::app::TenantAppState, + user_id: &String, +) -> Result, UserAuthError> { + #[cfg(feature = "mysql")] + use crate::storage::schema::merchant_account::dsl as ma_dsl; + #[cfg(feature = "postgres")] + use crate::storage::schema_pg::merchant_account::dsl as ma_dsl; + + let user_merchant_rows = crate::generics::generic_find_all::< + ::Table, + _, + UserMerchant, + >(&app_state.db, um_dsl::user_id.eq(user_id.clone())) + .await + .map_err(|_| UserAuthError::StorageError)?; + + let mut result = Vec::new(); + for um in user_merchant_rows { + let mut accounts = crate::generics::generic_find_all::< + ::Table, + _, + crate::storage::types::MerchantAccount, + >( + &app_state.db, + ma_dsl::merchant_id.eq(Some(um.merchant_id.clone())), + ) + .await + .map_err(|_| UserAuthError::StorageError)?; + + let name = accounts + .pop() + .and_then(|a| a.merchant_name) + .unwrap_or_else(|| um.merchant_id.clone()); + + result.push(MerchantInfo { + merchant_id: um.merchant_id, + merchant_name: name, + role: um.role, + }); + } + Ok(result) +} + fn extract_bearer_token(headers: &HeaderMap) -> Result<&str, error::ContainerError> { headers .get(axum::http::header::AUTHORIZATION) diff --git a/src/storage/schema.rs b/src/storage/schema.rs index b5c325f2..6d78f01c 100644 --- a/src/storage/schema.rs +++ b/src/storage/schema.rs @@ -222,6 +222,7 @@ diesel::table! { tenant_account_id -> Nullable, priority_logic_config -> Nullable, merchant_category_code -> Nullable, + merchant_name -> Nullable, } } @@ -530,8 +531,7 @@ diesel::table! { email -> Varchar, #[max_length = 255] password_hash -> Varchar, - #[max_length = 255] - merchant_id -> Varchar, + merchant_id -> Nullable, #[max_length = 50] role -> Varchar, is_active -> TinyInt, @@ -540,6 +540,20 @@ diesel::table! { } } +diesel::table! { + use diesel::sql_types::*; + user_merchants (id) { + id -> Bigint, + #[max_length = 64] + user_id -> Varchar, + #[max_length = 255] + merchant_id -> Varchar, + #[max_length = 50] + role -> Varchar, + created_at -> Datetime, + } +} + diesel::table! { use diesel::sql_types::*; merchant_api_keys (id) { @@ -590,4 +604,5 @@ diesel::allow_tables_to_appear_in_same_query!( user_eligibility_info, merchant_api_keys, users, + user_merchants, ); diff --git a/src/storage/schema_pg.rs b/src/storage/schema_pg.rs index 6c020e80..7905e284 100644 --- a/src/storage/schema_pg.rs +++ b/src/storage/schema_pg.rs @@ -217,6 +217,26 @@ diesel::table! { tenant_account_id -> Nullable, priority_logic_config -> Nullable, merchant_category_code -> Nullable, + #[max_length = 255] + merchant_name -> Nullable, + } +} + +diesel::table! { + merchant_api_keys (id) { + id -> Int8, + #[max_length = 64] + key_id -> Varchar, + #[max_length = 255] + merchant_id -> Varchar, + #[max_length = 64] + key_hash -> Varchar, + #[max_length = 16] + key_prefix -> Varchar, + #[max_length = 255] + description -> Nullable, + is_active -> Bool, + created_at -> Timestamp, } } @@ -514,37 +534,33 @@ diesel::table! { } diesel::table! { - users (id) { + user_merchants (id) { id -> Int8, #[max_length = 64] user_id -> Varchar, #[max_length = 255] - email -> Varchar, - #[max_length = 255] - password_hash -> Varchar, - #[max_length = 255] merchant_id -> Varchar, #[max_length = 50] role -> Varchar, - is_active -> Bool, - email_verified -> Bool, created_at -> Timestamp, } } diesel::table! { - merchant_api_keys (id) { + users (id) { id -> Int8, #[max_length = 64] - key_id -> Varchar, + user_id -> Varchar, #[max_length = 255] - merchant_id -> Varchar, - #[max_length = 64] - key_hash -> Varchar, - #[max_length = 16] - key_prefix -> Varchar, - description -> Nullable, + email -> Varchar, + #[max_length = 255] + password_hash -> Varchar, + #[max_length = 255] + merchant_id -> Nullable, + #[max_length = 50] + role -> Varchar, is_active -> Bool, + email_verified -> Bool, created_at -> Timestamp, } } @@ -564,6 +580,7 @@ diesel::allow_tables_to_appear_in_same_query!( issuer_routes, juspay_bank_code, merchant_account, + merchant_api_keys, merchant_config, merchant_gateway_account, merchant_gateway_account_sub_info, @@ -583,6 +600,6 @@ diesel::allow_tables_to_appear_in_same_query!( txn_offer, txn_offer_detail, user_eligibility_info, - merchant_api_keys, + user_merchants, users, ); diff --git a/src/storage/types.rs b/src/storage/types.rs index 24c7aea8..25e3336c 100644 --- a/src/storage/types.rs +++ b/src/storage/types.rs @@ -285,6 +285,7 @@ pub struct MerchantAccount { pub tenant_account_id: Option, pub priority_logic_config: Option, pub merchant_category_code: Option, + pub merchant_name: Option, } #[derive(Debug, Clone, Insertable)] @@ -292,6 +293,7 @@ pub struct MerchantAccount { #[cfg_attr(feature = "postgres", diesel(table_name = schema_pg::merchant_account))] pub struct MerchantAccountNew { pub merchant_id: Option, + pub merchant_name: Option, pub date_created: PrimitiveDateTime, pub use_code_for_gateway_priority: BitBoolWrite, pub gateway_success_rate_based_decider_input: Option, @@ -743,7 +745,7 @@ pub struct User { pub user_id: String, pub email: String, pub password_hash: String, - pub merchant_id: String, + pub merchant_id: Option, pub role: String, #[cfg(feature = "mysql")] pub is_active: i8, @@ -763,7 +765,7 @@ pub struct NewUser { pub user_id: String, pub email: String, pub password_hash: String, - pub merchant_id: String, + pub merchant_id: Option, pub role: String, #[cfg(feature = "mysql")] pub is_active: i8, @@ -775,3 +777,31 @@ pub struct NewUser { pub email_verified: bool, pub created_at: PrimitiveDateTime, } + +#[derive(Debug, Clone, Identifiable, Queryable, Serialize, Deserialize)] +#[cfg_attr(feature = "mysql", diesel(table_name = schema::user_merchants))] +#[cfg_attr(feature = "postgres", diesel(table_name = schema_pg::user_merchants))] +pub struct UserMerchant { + pub id: i64, + pub user_id: String, + pub merchant_id: String, + pub role: String, + pub created_at: PrimitiveDateTime, +} + +#[derive(Debug, Clone, Insertable)] +#[cfg_attr(feature = "mysql", diesel(table_name = schema::user_merchants))] +#[cfg_attr(feature = "postgres", diesel(table_name = schema_pg::user_merchants))] +pub struct NewUserMerchant { + pub user_id: String, + pub merchant_id: String, + pub role: String, + pub created_at: PrimitiveDateTime, +} + +#[derive(AsChangeset, Debug)] +#[cfg_attr(feature = "mysql", diesel(table_name = schema::users))] +#[cfg_attr(feature = "postgres", diesel(table_name = schema_pg::users))] +pub struct UserMerchantIdUpdate { + pub merchant_id: Option, +} diff --git a/src/types/merchant/merchant_account.rs b/src/types/merchant/merchant_account.rs index 8208a9ad..930eda73 100644 --- a/src/types/merchant/merchant_account.rs +++ b/src/types/merchant/merchant_account.rs @@ -122,6 +122,7 @@ impl TryFrom for MerchantAccountNew { fn try_from(value: MerchantAccountCreateRequest) -> Result { Ok(Self { merchant_id: Some(value.merchant_id), + merchant_name: None, date_created: date_time::now(), use_code_for_gateway_priority: BitBoolWrite(true), gateway_success_rate_based_decider_input: value diff --git a/website/public/hyperswitch-icon.png b/website/public/hyperswitch-icon.png new file mode 100644 index 00000000..c9a0fc50 Binary files /dev/null and b/website/public/hyperswitch-icon.png differ diff --git a/website/src/App.tsx b/website/src/App.tsx index f6352c22..c3c835f6 100644 --- a/website/src/App.tsx +++ b/website/src/App.tsx @@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import { AppShell } from './components/layout/AppShell' import { AuthGuard } from './components/layout/AuthGuard' import { AuthPage } from './pages/AuthPage' +import { OnboardingPage } from './pages/OnboardingPage' import { OverviewPage } from './components/pages/OverviewPage' import { RoutingHubPage } from './components/pages/RoutingHubPage' import { SRRoutingPage } from './components/pages/SRRoutingPage' @@ -15,6 +16,7 @@ export default function App() { } /> }> + } /> }> } /> } /> diff --git a/website/src/components/layout/AppShell.tsx b/website/src/components/layout/AppShell.tsx index 7912a8f2..d8529294 100644 --- a/website/src/components/layout/AppShell.tsx +++ b/website/src/components/layout/AppShell.tsx @@ -4,12 +4,11 @@ import { TopBar } from './TopBar' export function AppShell() { return ( -
-
+
-
+
-
+
diff --git a/website/src/components/layout/Sidebar.tsx b/website/src/components/layout/Sidebar.tsx index 862d6e00..5a800aed 100644 --- a/website/src/components/layout/Sidebar.tsx +++ b/website/src/components/layout/Sidebar.tsx @@ -3,7 +3,6 @@ import { LayoutDashboard, GitBranch, Search, - Zap, TrendingUp, BookOpen, PieChart, @@ -12,81 +11,82 @@ import { export function Sidebar() { return ( -
+ ) +} diff --git a/website/src/store/authStore.ts b/website/src/store/authStore.ts index b4db2b72..3f153e65 100644 --- a/website/src/store/authStore.ts +++ b/website/src/store/authStore.ts @@ -2,6 +2,12 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import { tokenRef } from '../lib/tokenRef' +export interface MerchantInfo { + merchant_id: string + merchant_name: string + role: string +} + export interface AuthUser { userId: string email: string @@ -12,7 +18,9 @@ export interface AuthUser { interface AuthStore { token: string | null user: AuthUser | null - setAuth: (token: string, user: AuthUser) => void + merchants: MerchantInfo[] + setAuth: (token: string, user: AuthUser, merchants?: MerchantInfo[]) => void + updateMerchant: (token: string, merchantId: string, merchants: MerchantInfo[]) => void clearAuth: () => void } @@ -21,19 +29,27 @@ export const useAuthStore = create()( (set) => ({ token: null, user: null, - setAuth: (token, user) => { + merchants: [], + setAuth: (token, user, merchants = []) => { + tokenRef.set(token) + set({ token, user, merchants }) + }, + updateMerchant: (token, merchantId, merchants) => { tokenRef.set(token) - set({ token, user }) + set((state) => ({ + token, + merchants, + user: state.user ? { ...state.user, merchantId } : null, + })) }, clearAuth: () => { tokenRef.set(null) - set({ token: null, user: null }) + set({ token: null, user: null, merchants: [] }) }, }), { name: 'auth-store', onRehydrateStorage: () => (state) => { - // Restore token ref from persisted storage on page load if (state?.token) { tokenRef.set(state.token) } diff --git a/website/tailwind.config.ts b/website/tailwind.config.ts index a298f58b..17d09a35 100644 --- a/website/tailwind.config.ts +++ b/website/tailwind.config.ts @@ -7,16 +7,17 @@ export default { extend: { colors: { brand: { - DEFAULT: '#4f46e5', - 50: '#eef2ff', - 100: '#e0e7ff', - 500: '#6366f1', - 600: '#4f46e5', - 700: '#4338ca', + DEFAULT: '#006df9', + 50: '#eff6ff', + 100: '#dbeafe', + 300: '#7cb9fc', + 500: '#1272f9', + 600: '#006df9', + 700: '#0057cc', }, }, fontFamily: { - sans: ['Outfit', 'Inter', 'system-ui', '-apple-system', 'sans-serif'], + sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], mono: ['JetBrains Mono', 'Menlo', 'Monaco', 'monospace'], }, letterSpacing: { diff --git a/website/vite.config.ts b/website/vite.config.ts index 3a75f424..619d9487 100644 --- a/website/vite.config.ts +++ b/website/vite.config.ts @@ -176,6 +176,23 @@ export default defineConfig({ }) }, }, + '/onboarding': { + target: 'http://localhost:8080', + changeOrigin: true, + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq, req) => { + console.log(`\n[PROXY] ${new Date().toISOString()}`) + console.log(`Forwarding: ${req.method} ${req.url} -> http://localhost:8080${req.url}`) + }) + proxy.on('proxyRes', (proxyRes, req) => { + console.log(`[PROXY] Response: ${proxyRes.statusCode} ${proxyRes.statusMessage} for ${req.url}`) + }) + proxy.on('error', (err, req) => { + console.log(`\n[PROXY ERROR] ${new Date().toISOString()}`) + console.log(`Error forwarding ${req.url}:`, err.message) + }) + }, + }, }, fs: { strict: false,