From ac3fef72a12d285809920aab46ac2069b19e93df Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Sivakumar Date: Sun, 14 Jun 2026 15:56:51 +0530 Subject: [PATCH] fix(users): allow demo login without redis --- apps/users/src/handlers/auth.rs | 31 +++++++++----- scripts/bootstrap-live-compat.sql | 68 +++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 scripts/bootstrap-live-compat.sql diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 8266817..0bb55c7 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -407,11 +407,13 @@ async fn login( State(state): State, Json(payload): Json, ) -> Result)> { - let email = payload.email.to_lowercase(); + let email = payload.email.to_lowercase(); + let is_demo_account = is_dummy_account_email(&email); let mut redis = state.redis.clone(); - // Rate limit: max 10 login attempts per 15 min per email - if !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) { + // Demo logins must not depend on Redis because the live demo path needs to work + // even when cache persistence is degraded. + if !is_demo_account && !cache::rate_limit::check_login(&mut redis, &email).await.unwrap_or(true) { return Err(err(StatusCode::TOO_MANY_REQUESTS, "Too many login attempts. Try again in 15 minutes.", "RATE_LIMITED")); } @@ -424,7 +426,6 @@ async fn login( } // Allow demo accounts to login without email verification - let is_demo_account = is_dummy_account_email(&email); if !user.email_verified && !is_demo_account { return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED")); } @@ -450,9 +451,14 @@ async fn login( .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?; // Refresh token → Redis (30-day TTL) - cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()) - .await - .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; + if let Err(e) = cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()).await { + tracing::warn!( + error = %e, + user_id = %user.id, + email = %user.email, + "Failed to store refresh token in Redis; continuing with access token only" + ); + } let cookie = format!( "nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000", @@ -549,9 +555,14 @@ async fn refresh( ) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "TOKEN_ERROR"))?; - cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()) - .await - .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; + if let Err(e) = cache::token::store_refresh(&mut redis, &tokens.refresh_token, &user.id.to_string()).await { + tracing::warn!( + error = %e, + user_id = %user.id, + email = %user.email, + "Failed to store refresh token in Redis; continuing with access token only" + ); + } let new_cookie = format!( "nxtgauge_refresh_token={}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=2592000", diff --git a/scripts/bootstrap-live-compat.sql b/scripts/bootstrap-live-compat.sql new file mode 100644 index 0000000..612293c --- /dev/null +++ b/scripts/bootstrap-live-compat.sql @@ -0,0 +1,68 @@ +-- Compatibility bootstrap for empty production databases. +-- Run after scripts/init-db.sql and before/after scripts/seed.sql. + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS user_role_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_role_assignments_user_id + ON user_role_assignments(user_id); + +CREATE TABLE IF NOT EXISTS role_sidebar_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + audience VARCHAR(50) NOT NULL, + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_role_sidebar_per_role_audience + ON role_sidebar_configs(role_id, audience) WHERE is_active = true; + +CREATE TABLE IF NOT EXISTS role_runtime_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_role_runtime_per_role + ON role_runtime_configs(role_id) WHERE is_active = true; + +INSERT INTO user_role_assignments (id, user_id, role_id, status, approved_at, created_at) +SELECT id, user_id, role_id, status, approved_at, created_at +FROM user_roles +ON CONFLICT (user_id, role_id) DO NOTHING; + +INSERT INTO role_sidebar_configs (id, role_id, audience, config_json, version, is_active, updated_at) +SELECT id, role_id, audience, config_json, version, is_active, updated_at +FROM dashboard_configs +ON CONFLICT DO NOTHING; + +INSERT INTO role_runtime_configs (id, role_id, config_json, version, is_active, updated_at) +SELECT id, role_id, config_json, version, is_active, updated_at +FROM runtime_configs +ON CONFLICT DO NOTHING; + +INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active) +VALUES + ('Starter Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 100, 49900, 'Starter company credit bundle for demo purchases.', true), + ('Growth Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 300, 129900, 'Growth company credit bundle for regular hiring.', true), + ('Scale Credits', 'COMPANY', 'TRACECOIN_BUNDLE', 750, 299900, 'Scale company credit bundle for higher-volume recruitment.', true) +ON CONFLICT DO NOTHING; + +COMMIT;