diff --git a/backend/app/config.py b/backend/app/config.py index b5e5832..bb028f4 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -19,7 +19,7 @@ class Settings(BaseSettings): ) mongo_database: str = "agmail_messages" jwt_secret: str = "replace-this-development-secret" - access_token_minutes: int = 60 + access_token_minutes: int = 10080 platform_domain: str = "gentic.email" platform_spf_include: str = "_spf.gentic.email" @@ -55,6 +55,11 @@ class Settings(BaseSettings): dns_setup_username: str = "smtp-credentia" dns_setup_password: str = "Nishtha#01" + urmi_otp_api_url: str = "https://urmiai.com/api/v1/otp/generate" + urmi_otp_api_key: str = "" + urmi_otp_project_name: str = "gentic-email-login" + urmi_otp_template_type: str = "login_authentication" + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/backend/app/main.py b/backend/app/main.py index cb15c2c..3ec2bc3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -151,6 +151,30 @@ async def migrate_user_org_columns(connection) -> None: "REFERENCES org_sections(id)" ) ) + await connection.execute( + text("ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_picture_url TEXT") + ) + await connection.execute( + text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN " + "NOT NULL DEFAULT FALSE" + ) + ) + await connection.execute( + text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS session_timeout_minutes " + "INTEGER NOT NULL DEFAULT 10080" + ) + ) + await connection.execute( + text( + "ALTER TABLE org_security_settings " + "ALTER COLUMN session_timeout_minutes SET DEFAULT 10080" + ) + ) + await connection.execute(text("ALTER TABLE users DROP CONSTRAINT IF EXISTS users_phone_number_key")) + await connection.execute(text("DROP INDEX IF EXISTS users_phone_number_key")) + await connection.execute(text("DROP INDEX IF EXISTS ix_users_phone_number")) async def migrate_legacy_user_tables(connection) -> None: @@ -342,6 +366,31 @@ async def lifespan(_: FastAPI): mongo_db.mailbox_entries, [("account_id", 1), ("message_scope", 1), ("mailbox", 1), ("event_at", -1)], ) + await ensure_mongo_index( + mongo_db.signup_otps, + [("phone_key", 1), ("consumed", 1), ("created_at", -1)], + ) + await ensure_mongo_index( + mongo_db.signup_otps, + [("expires_at", 1)], + expireAfterSeconds=0, + name="signup_otps_expires_at_ttl", + ) + await ensure_mongo_index( + mongo_db.signup_otps, + [("phone_key", 1), ("purpose", 1), ("purpose_key", 1), ("consumed", 1), ("created_at", -1)], + ) + await ensure_mongo_index( + mongo_db.login_mfa_challenges, + [("challenge_token", 1)], + unique=True, + ) + await ensure_mongo_index( + mongo_db.login_mfa_challenges, + [("expires_at", 1)], + expireAfterSeconds=0, + name="login_mfa_challenges_expires_at_ttl", + ) await ensure_mongo_index( mongo_db.messages, [("message_id", 1), ("message_scope", 1)], diff --git a/backend/app/models.py b/backend/app/models.py index eee3e7c..d5b5b25 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -39,7 +39,8 @@ class Account(Base): first_name: Mapped[str] = mapped_column(String(80)) last_name: Mapped[str] = mapped_column(String(80)) date_of_birth: Mapped[date] = mapped_column(Date) - phone_number: Mapped[str] = mapped_column(String(24), unique=True) + phone_number: Mapped[str] = mapped_column(String(24)) + profile_picture_url: Mapped[str | None] = mapped_column(Text, nullable=True) company_domain: Mapped[str | None] = mapped_column(String(253), nullable=True) org_role: Mapped[str] = mapped_column(String(20), default="user") member_status: Mapped[str] = mapped_column(String(20), default="active") @@ -50,6 +51,8 @@ class Account(Base): ) invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True) must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) + mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False) + session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=10080) section_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("org_sections.id"), @@ -198,7 +201,7 @@ class OrgSecuritySettings(Base): require_strong_passwords: Mapped[bool] = mapped_column(Boolean, default=True) require_mfa_admins: Mapped[bool] = mapped_column(Boolean, default=True) require_mfa_all_users: Mapped[bool] = mapped_column(Boolean, default=False) - session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=60) + session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=10080) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 121df49..fb85ad4 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -8,25 +8,52 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pwdlib import PasswordHash from pydantic import EmailStr, TypeAdapter, ValidationError -from sqlalchemy import or_, select +from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings -from app.database import get_postgres_session +from app.database import get_mongo_database, get_postgres_session from app.models import Account from app.schemas import ( + AccountProfileUpdate, AccountRegister, AccountRead, + AccountSecurityRead, + AccountSecurityUpdate, AuthResponse, InviteAcceptRequest, InvitePreview, + LoginChallengeResponse, + LoginOtpResendRequest, + LoginOtpResendResponse, LoginRequest, PasswordChangeRequest, PasswordResetRequest, PasswordResetResponse, + SignupOtpSendRequest, + SignupOtpSendResponse, ) +from app.services.login_mfa import ( + CHALLENGE_TTL_MINUTES, + account_requires_mfa, + create_login_challenge, + mfa_phone_parts, + resend_login_challenge_otp, + verify_login_challenge, +) +from app.services.session_policy import ( + resolve_session_timeout_minutes, + validate_session_timeout_minutes, +) +from app.services.urmi_otp import parse_india_phone from app.services.member_invitations import get_valid_invitation +from app.services.urmi_otp import ( + INDIA_COUNTRY_CODE, + format_phone_e164, + send_signup_otp, + verify_signup_otp, +) from app.services.welcome_email import send_welcome_email logger = logging.getLogger(__name__) @@ -49,11 +76,6 @@ domain_pattern = re.compile( ) -def normalize_phone(value: str) -> str: - prefix = "+" if value.strip().startswith("+") else "" - return prefix + "".join(character for character in value if character.isdigit()) - - def validate_password(value: str) -> None: if not ( re.search(r"[a-z]", value) @@ -98,10 +120,12 @@ def build_email(payload: AccountRegister) -> tuple[str, str | None]: ) from error -def create_access_token(account: Account) -> str: - expires_at = datetime.now(timezone.utc) + timedelta( - minutes=settings.access_token_minutes - ) +async def create_access_token( + session: AsyncSession, + account: Account, +) -> str: + minutes = await resolve_session_timeout_minutes(session, account) + expires_at = datetime.now(timezone.utc) + timedelta(minutes=minutes) return jwt.encode( {"sub": str(account.id), "email": account.email, "exp": expires_at}, settings.jwt_secret, @@ -109,6 +133,42 @@ def create_access_token(account: Account) -> str: ) +async def build_auth_response( + session: AsyncSession, + account: Account, +) -> AuthResponse: + return AuthResponse( + access_token=await create_access_token(session, account), + account=await account_read(session, account), + ) + + +async def build_security_read( + session: AsyncSession, + account: Account, +) -> AccountSecurityRead: + mfa_available = False + try: + parse_india_phone(account.phone_number) + mfa_available = True + except ValueError: + mfa_available = False + org_timeout = None + if account.company_domain: + from app.services.org_security import get_or_create_security_settings + + org = await get_or_create_security_settings(session, account.company_domain) + org_timeout = org.session_timeout_minutes + effective = await resolve_session_timeout_minutes(session, account) + return AccountSecurityRead( + mfa_enabled=account.mfa_enabled, + mfa_available=mfa_available, + session_timeout_minutes=account.session_timeout_minutes, + effective_session_timeout_minutes=effective, + org_session_timeout_minutes=org_timeout, + ) + + async def account_read( session: AsyncSession, account: Account, @@ -152,6 +212,19 @@ async def get_current_account( return account +@router.post("/signup/otp/send", response_model=SignupOtpSendResponse) +async def send_signup_phone_otp( + payload: SignupOtpSendRequest, + database=Depends(get_mongo_database), +) -> SignupOtpSendResponse: + result = await send_signup_otp( + database, + payload.country_code.strip(), + payload.phone.strip(), + ) + return SignupOtpSendResponse(**result) + + @router.post( "/register", response_model=AuthResponse, @@ -160,6 +233,7 @@ async def get_current_account( async def register( payload: AccountRegister, session: AsyncSession = Depends(get_postgres_session), + database=Depends(get_mongo_database), ) -> AuthResponse: if payload.date_of_birth >= date.today(): raise HTTPException( @@ -168,20 +242,23 @@ async def register( ) validate_password(payload.password) email, company_domain = build_email(payload) - phone_number = normalize_phone(payload.phone_number) + country_code = payload.country_code.strip() + phone = payload.phone.strip() + if country_code == INDIA_COUNTRY_CODE: + if not payload.otp: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Verification code is required for Indian phone numbers.", + ) + await verify_signup_otp(database, country_code, phone, payload.otp) + phone_number = format_phone_e164(country_code, phone) - existing = await session.scalar( - select(Account).where( - or_(Account.email == email, Account.phone_number == phone_number) - ) - ) + existing = await session.scalar(select(Account).where(Account.email == email)) if existing: - detail = ( - "That email address is already registered." - if existing.email == email - else "That phone number is already registered." + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="That email address is already registered.", ) - raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail) account = Account( account_type=payload.account_type, @@ -215,17 +292,15 @@ async def register( detail="An account with those details already exists.", ) from error await session.refresh(account) - return AuthResponse( - access_token=create_access_token(account), - account=await account_read(session, account), - ) + return await build_auth_response(session, account) -@router.post("/login", response_model=AuthResponse) +@router.post("/login", response_model=AuthResponse | LoginChallengeResponse) async def login( payload: LoginRequest, session: AsyncSession = Depends(get_postgres_session), -) -> AuthResponse: + database=Depends(get_mongo_database), +) -> AuthResponse | LoginChallengeResponse: account = await session.scalar( select(Account).where(Account.email == str(payload.email).lower()) ) @@ -243,9 +318,49 @@ async def login( status_code=status.HTTP_403_FORBIDDEN, detail="Accept your invitation email to set your password before signing in.", ) - return AuthResponse( - access_token=create_access_token(account), - account=await account_read(session, account), + if not await account_requires_mfa(session, account): + return await build_auth_response(session, account) + + if payload.challenge_token and payload.otp: + await verify_login_challenge( + database, + payload.challenge_token, + account, + payload.otp, + ) + return await build_auth_response(session, account) + + if payload.challenge_token or payload.otp: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Enter the verification code sent to your phone.", + ) + + try: + parse_india_phone(account.phone_number) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Multi-factor authentication is required but your account does not have a supported phone number.", + ) from error + + challenge_token, hint = await create_login_challenge(database, account) + return LoginChallengeResponse( + challenge_token=challenge_token, + phone_hint=hint, + expires_in_minutes=CHALLENGE_TTL_MINUTES, + ) + + +@router.post("/login/otp/resend", response_model=LoginOtpResendResponse) +async def resend_login_otp( + payload: LoginOtpResendRequest, + database=Depends(get_mongo_database), +) -> LoginOtpResendResponse: + hint = await resend_login_challenge_otp(database, payload.challenge_token) + return LoginOtpResendResponse( + phone_hint=hint, + expires_in_minutes=CHALLENGE_TTL_MINUTES, ) @@ -302,10 +417,7 @@ async def accept_invite( await send_welcome_email(session, account) except Exception as error: logger.warning("Welcome email could not be sent: %s", error) - return AuthResponse( - access_token=create_access_token(account), - account=await account_read(session, account), - ) + return await build_auth_response(session, account) @router.post("/reset-password", response_model=PasswordResetResponse) @@ -363,3 +475,69 @@ async def get_me( session: AsyncSession = Depends(get_postgres_session), ) -> AccountRead: return await account_read(session, account) + + +def validate_profile_picture(value: str | None) -> str | None: + if value is None: + return None + trimmed = value.strip() + if not trimmed: + return None + if not trimmed.startswith("data:image/"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Profile picture must be a JPG, PNG, or WebP image.", + ) + if len(trimmed) > 700_000: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Profile picture must be 512 KB or smaller.", + ) + return trimmed + + +@router.patch("/me", response_model=AccountRead) +async def update_me( + payload: AccountProfileUpdate, + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> AccountRead: + if "profile_picture_url" in payload.model_fields_set: + account.profile_picture_url = validate_profile_picture(payload.profile_picture_url) + await session.commit() + await session.refresh(account) + return await account_read(session, account) + + +@router.get("/me/security", response_model=AccountSecurityRead) +async def get_my_security( + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> AccountSecurityRead: + return await build_security_read(session, account) + + +@router.patch("/me/security", response_model=AccountSecurityRead) +async def update_my_security( + payload: AccountSecurityUpdate, + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> AccountSecurityRead: + updates = payload.model_dump(exclude_unset=True) + if "session_timeout_minutes" in updates: + validate_session_timeout_minutes(updates["session_timeout_minutes"]) + account.session_timeout_minutes = updates["session_timeout_minutes"] + if "mfa_enabled" in updates: + enabled = updates["mfa_enabled"] + if enabled: + try: + parse_india_phone(account.phone_number) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(error), + ) from error + account.mfa_enabled = enabled + await session.commit() + await session.refresh(account) + return await build_security_read(session, account) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e8ac302..aa8238d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -16,14 +16,22 @@ class AccountRegister(BaseModel): first_name: str = Field(min_length=1, max_length=80) last_name: str = Field(min_length=1, max_length=80) date_of_birth: date - phone_number: str = Field( - min_length=7, - max_length=24, - pattern=r"^\+?[0-9][0-9 ()-]{5,22}[0-9]$", - ) + country_code: str = Field(min_length=1, max_length=4, pattern=r"^\d+$") + phone: str = Field(min_length=6, max_length=15, pattern=r"^\d+$") + otp: str | None = Field(default=None, min_length=4, max_length=8) password: str = Field(min_length=8, max_length=128) +class SignupOtpSendRequest(BaseModel): + country_code: str = Field(min_length=1, max_length=4, pattern=r"^\d+$") + phone: str = Field(min_length=6, max_length=15, pattern=r"^\d+$") + + +class SignupOtpSendResponse(BaseModel): + message: str + expires_in_minutes: int + + class AccountRead(BaseModel): id: UUID account_type: str @@ -32,6 +40,7 @@ class AccountRead(BaseModel): last_name: str date_of_birth: date phone_number: str + profile_picture_url: str | None = None company_domain: str | None is_domain_owner: bool = False must_change_password: bool = False @@ -40,9 +49,15 @@ class AccountRead(BaseModel): model_config = ConfigDict(from_attributes=True) +class AccountProfileUpdate(BaseModel): + profile_picture_url: str | None = None + + class LoginRequest(BaseModel): email: EmailStr password: str = Field(min_length=1, max_length=128) + otp: str | None = Field(default=None, min_length=4, max_length=8) + challenge_token: str | None = Field(default=None, min_length=8, max_length=128) class AuthResponse(BaseModel): @@ -51,6 +66,35 @@ class AuthResponse(BaseModel): account: AccountRead +class LoginChallengeResponse(BaseModel): + mfa_required: Literal[True] = True + challenge_token: str + phone_hint: str + expires_in_minutes: int + + +class LoginOtpResendRequest(BaseModel): + challenge_token: str = Field(min_length=8, max_length=128) + + +class LoginOtpResendResponse(BaseModel): + phone_hint: str + expires_in_minutes: int + + +class AccountSecurityRead(BaseModel): + mfa_enabled: bool + mfa_available: bool + session_timeout_minutes: int + effective_session_timeout_minutes: int + org_session_timeout_minutes: int | None = None + + +class AccountSecurityUpdate(BaseModel): + mfa_enabled: bool | None = None + session_timeout_minutes: int | None = None + + class OrgMemberCreate(BaseModel): first_name: str = Field(min_length=1, max_length=80) last_name: str = Field(min_length=1, max_length=80) @@ -367,7 +411,7 @@ class OrgSecuritySettingsUpdate(BaseModel): require_strong_passwords: bool | None = None require_mfa_admins: bool | None = None require_mfa_all_users: bool | None = None - session_timeout_minutes: int | None = Field(default=None, ge=15, le=1440) + session_timeout_minutes: int | None = Field(default=None, ge=60, le=129600) class OrgMemberRoleUpdate(BaseModel): diff --git a/backend/app/services/login_mfa.py b/backend/app/services/login_mfa.py new file mode 100644 index 0000000..797d8ef --- /dev/null +++ b/backend/app/services/login_mfa.py @@ -0,0 +1,173 @@ +"""Login-time MFA challenges and OTP verification.""" + +from __future__ import annotations + +import re +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Account +from app.services.org_security import get_or_create_security_settings +from app.services.urmi_otp import ( + INDIA_COUNTRY_CODE, + OTP_TTL_MINUTES, + _hash_otp, + parse_india_phone, + send_phone_otp, +) + +CHALLENGE_TTL_MINUTES = 10 +MAX_VERIFY_ATTEMPTS = 5 + + +def phone_hint(phone_number: str) -> str: + digits = re.sub(r"\D", "", phone_number) + if len(digits) >= 4: + return f"•••• {digits[-4:]}" + return "your phone" + + +async def account_requires_mfa(session: AsyncSession, account: Account) -> bool: + if account.mfa_enabled: + return True + if not account.company_domain: + return False + org = await get_or_create_security_settings(session, account.company_domain) + if org.require_mfa_all_users: + return True + if org.require_mfa_admins and account.org_role in {"admin", "owner"}: + return True + return False + + +def mfa_phone_parts(account: Account) -> tuple[str, str]: + try: + return parse_india_phone(account.phone_number) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(error), + ) from error + + +def assert_mfa_phone(account: Account) -> None: + try: + parse_india_phone(account.phone_number) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(error), + ) from error + + +async def create_login_challenge( + database, + account: Account, +) -> tuple[str, str]: + country_code, local_phone = mfa_phone_parts(account) + challenge_token = secrets.token_urlsafe(32) + otp, _ = await send_phone_otp( + database, + country_code, + local_phone, + purpose="login_mfa", + purpose_key=challenge_token, + ) + now = datetime.now(timezone.utc) + await database.login_mfa_challenges.update_many( + {"account_id": str(account.id), "consumed": False}, + {"$set": {"consumed": True}}, + ) + await database.login_mfa_challenges.insert_one( + { + "challenge_token": challenge_token, + "account_id": str(account.id), + "phone_key": f"{country_code}{local_phone}", + "otp_hash": _hash_otp(otp), + "attempts": 0, + "consumed": False, + "sent_at": now, + "created_at": now, + "expires_at": now + timedelta(minutes=CHALLENGE_TTL_MINUTES), + } + ) + return challenge_token, phone_hint(account.phone_number) + + +async def resend_login_challenge_otp(database, challenge_token: str) -> str: + record = await _active_challenge(database, challenge_token) + country_code, local_phone = record["phone_key"][:2], record["phone_key"][2:] + otp, _ = await send_phone_otp( + database, + country_code, + local_phone, + purpose="login_mfa", + purpose_key=challenge_token, + ) + now = datetime.now(timezone.utc) + await database.login_mfa_challenges.update_one( + {"_id": record["_id"]}, + { + "$set": { + "otp_hash": _hash_otp(otp), + "attempts": 0, + "sent_at": now, + "expires_at": now + timedelta(minutes=CHALLENGE_TTL_MINUTES), + } + }, + ) + return phone_hint(f"+{record['phone_key']}") + + +async def verify_login_challenge( + database, + challenge_token: str, + account: Account, + otp: str, +) -> None: + record = await _active_challenge(database, challenge_token) + if record["account_id"] != str(account.id): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Verification session is invalid. Sign in again.", + ) + code = otp.strip() + if not re.fullmatch(r"\d{6}", code): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Enter the 6-digit verification code.", + ) + if record.get("attempts", 0) >= MAX_VERIFY_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Too many incorrect attempts. Sign in again to receive a new code.", + ) + if _hash_otp(code) != record["otp_hash"]: + await database.login_mfa_challenges.update_one( + {"_id": record["_id"]}, + {"$inc": {"attempts": 1}}, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect verification code.", + ) + await database.login_mfa_challenges.update_one( + {"_id": record["_id"]}, + {"$set": {"consumed": True, "verified_at": datetime.now(timezone.utc)}}, + ) + + +async def _active_challenge(database, challenge_token: str) -> dict: + now = datetime.now(timezone.utc) + record = await database.login_mfa_challenges.find_one( + {"challenge_token": challenge_token, "consumed": False}, + ) + if record is None or record.get("expires_at") <= now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verification session expired. Sign in again.", + ) + return record diff --git a/backend/app/services/org_members.py b/backend/app/services/org_members.py index e5697dd..4834e26 100644 --- a/backend/app/services/org_members.py +++ b/backend/app/services/org_members.py @@ -138,10 +138,6 @@ async def create_org_member( raise ValueError("Selected section was not found.") phone_number = _placeholder_phone() - while await session.scalar( - select(Account).where(Account.phone_number == phone_number) - ): - phone_number = _placeholder_phone() member = Account( account_type="company", diff --git a/backend/app/services/org_security.py b/backend/app/services/org_security.py index 669980a..48387d7 100644 --- a/backend/app/services/org_security.py +++ b/backend/app/services/org_security.py @@ -7,8 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.models import OrgSecuritySettings from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate - -ALLOWED_SESSION_TIMEOUTS = {15, 30, 60, 120, 480, 1440} +from app.services.session_policy import validate_session_timeout_minutes async def get_or_create_security_settings( @@ -43,12 +42,7 @@ async def update_security_settings( settings = await get_or_create_security_settings(session, company_domain) updates = payload.model_dump(exclude_unset=True) if "session_timeout_minutes" in updates: - timeout = updates["session_timeout_minutes"] - if timeout not in ALLOWED_SESSION_TIMEOUTS: - raise ValueError( - "Session timeout must be one of: " - + ", ".join(str(value) for value in sorted(ALLOWED_SESSION_TIMEOUTS)) - ) + validate_session_timeout_minutes(updates["session_timeout_minutes"]) for field, value in updates.items(): setattr(settings, field, value) await session.flush() diff --git a/backend/app/services/session_policy.py b/backend/app/services/session_policy.py new file mode 100644 index 0000000..f2acd9e --- /dev/null +++ b/backend/app/services/session_policy.py @@ -0,0 +1,47 @@ +"""Session duration policy for accounts and organizations.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Account +from app.services.org_security import get_or_create_security_settings + +DEFAULT_SESSION_TIMEOUT_MINUTES = 10_080 # 1 week + +ALLOWED_SESSION_TIMEOUTS = ( + 60, # 1 hour + 120, # 2 hours + 240, # 4 hours + 480, # 8 hours + 720, # 12 hours + 1_440, # 1 day + 2_880, # 2 days + 4_320, # 3 days + 7_200, # 5 days + 10_080, # 1 week + 20_160, # 2 weeks + 43_200, # 30 days + 64_800, # 45 days + 86_400, # 60 days + 129_600, # 90 days +) + +ALLOWED_SESSION_TIMEOUT_SET = set(ALLOWED_SESSION_TIMEOUTS) + + +def validate_session_timeout_minutes(minutes: int) -> None: + if minutes not in ALLOWED_SESSION_TIMEOUT_SET: + allowed = ", ".join(str(value) for value in ALLOWED_SESSION_TIMEOUTS) + raise ValueError(f"Session timeout must be one of: {allowed}") + + +async def resolve_session_timeout_minutes( + session: AsyncSession, + account: Account, +) -> int: + timeout = account.session_timeout_minutes or DEFAULT_SESSION_TIMEOUT_MINUTES + if account.company_domain: + org = await get_or_create_security_settings(session, account.company_domain) + timeout = min(timeout, org.session_timeout_minutes) + return timeout diff --git a/backend/app/services/urmi_otp.py b/backend/app/services/urmi_otp.py new file mode 100644 index 0000000..77c2acc --- /dev/null +++ b/backend/app/services/urmi_otp.py @@ -0,0 +1,236 @@ +import hashlib +import logging +import re +import secrets +from datetime import datetime, timedelta, timezone + +import httpx +from fastapi import HTTPException, status + +from app.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + +INDIA_COUNTRY_CODE = "91" +OTP_LENGTH = 6 +OTP_TTL_MINUTES = 10 +OTP_RESEND_SECONDS = 60 +MAX_VERIFY_ATTEMPTS = 5 + +INDIAN_MOBILE_RE = re.compile(r"^[6-9]\d{9}$") + + +def phone_key(country_code: str, phone: str) -> str: + return f"{country_code.strip()}{phone.strip()}" + + +def format_phone_e164(country_code: str, phone: str) -> str: + return f"+{phone_key(country_code, phone)}" + + +def parse_india_phone(phone_number: str) -> tuple[str, str]: + digits = re.sub(r"\D", "", phone_number or "") + if digits.startswith("91") and len(digits) == 12: + local = digits[2:] + elif len(digits) == 10: + local = digits + else: + raise ValueError( + "Multi-factor authentication requires an Indian (+91) mobile number on your account." + ) + if not INDIAN_MOBILE_RE.fullmatch(local): + raise ValueError( + "Multi-factor authentication requires a valid Indian mobile number on your account." + ) + return INDIA_COUNTRY_CODE, local + + +def _hash_otp(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _generate_otp() -> str: + return f"{secrets.randbelow(10 ** OTP_LENGTH):0{OTP_LENGTH}d}" + + +async def send_phone_otp( + database, + country_code: str, + phone: str, + *, + purpose: str, + purpose_key: str, +) -> tuple[str, dict]: + if country_code != INDIA_COUNTRY_CODE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Phone verification is only available for India (+91).", + ) + local = phone.strip() + if not INDIAN_MOBILE_RE.fullmatch(local): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Enter a valid 10-digit Indian mobile number.", + ) + if not settings.urmi_otp_api_key: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Phone verification is temporarily unavailable.", + ) + + key = phone_key(country_code, local) + now = datetime.now(timezone.utc) + existing = await database.signup_otps.find_one( + { + "phone_key": key, + "purpose": purpose, + "purpose_key": purpose_key, + "consumed": False, + }, + sort=[("created_at", -1)], + ) + if existing and existing.get("sent_at"): + retry_at = existing["sent_at"] + timedelta(seconds=OTP_RESEND_SECONDS) + if retry_at > now: + wait = int((retry_at - now).total_seconds()) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Wait {wait}s before requesting another code.", + ) + + otp = _generate_otp() + payload = { + "project_name": settings.urmi_otp_project_name, + "template_type": settings.urmi_otp_template_type, + "to": key, + "variables": { + "otp": otp, + "validity_minutes": str(OTP_TTL_MINUTES), + }, + } + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "X-Api-Key": settings.urmi_otp_api_key, + } + try: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.post( + settings.urmi_otp_api_url, + json=payload, + headers=headers, + ) + except httpx.HTTPError as error: + logger.exception("Urmi OTP request failed for %s", key) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not send verification code. Try again shortly.", + ) from error + + if response.status_code >= 400: + logger.warning( + "Urmi OTP rejected request for %s: %s %s", + key, + response.status_code, + response.text[:300], + ) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="Could not send verification code. Try again shortly.", + ) + + expires_at = now + timedelta(minutes=OTP_TTL_MINUTES) + await database.signup_otps.update_many( + { + "phone_key": key, + "purpose": purpose, + "purpose_key": purpose_key, + "consumed": False, + }, + {"$set": {"consumed": True}}, + ) + await database.signup_otps.insert_one( + { + "phone_key": key, + "purpose": purpose, + "purpose_key": purpose_key, + "otp_hash": _hash_otp(otp), + "attempts": 0, + "consumed": False, + "sent_at": now, + "created_at": now, + "expires_at": expires_at, + } + ) + return otp, { + "message": "Verification code sent.", + "expires_in_minutes": OTP_TTL_MINUTES, + } + + +async def send_signup_otp(database, country_code: str, phone: str) -> dict: + key = phone_key(country_code, phone.strip()) + _, meta = await send_phone_otp( + database, + country_code, + phone, + purpose="signup", + purpose_key=key, + ) + return meta + + +async def verify_signup_otp(database, country_code: str, phone: str, otp: str) -> None: + if country_code != INDIA_COUNTRY_CODE: + return + + local = phone.strip() + code = otp.strip() + if not INDIAN_MOBILE_RE.fullmatch(local): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Enter a valid 10-digit Indian mobile number.", + ) + if not re.fullmatch(rf"\d{{{OTP_LENGTH}}}", code): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Enter the 6-digit verification code.", + ) + + key = phone_key(country_code, local) + now = datetime.now(timezone.utc) + record = await database.signup_otps.find_one( + { + "phone_key": key, + "purpose": "signup", + "purpose_key": key, + "consumed": False, + }, + sort=[("created_at", -1)], + ) + if record is None or record.get("expires_at") <= now: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verification code expired. Request a new one.", + ) + if record.get("attempts", 0) >= MAX_VERIFY_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Too many incorrect attempts. Request a new code.", + ) + + if _hash_otp(code) != record["otp_hash"]: + await database.signup_otps.update_one( + {"_id": record["_id"]}, + {"$inc": {"attempts": 1}}, + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Incorrect verification code.", + ) + + await database.signup_otps.update_one( + {"_id": record["_id"]}, + {"$set": {"consumed": True, "verified_at": now}}, + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index fd87616..7b7e561 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -15,5 +15,6 @@ aiosmtpd>=1.4.6 google-cloud-storage>=3.0 google-crc32c>=1.7 dnspython>=2.7 +httpx>=0.28 pytest>=8.0 pytest-asyncio>=0.25 diff --git a/backend/tests/test_session_policy.py b/backend/tests/test_session_policy.py new file mode 100644 index 0000000..3f5fbac --- /dev/null +++ b/backend/tests/test_session_policy.py @@ -0,0 +1,21 @@ +import pytest + +from app.services.session_policy import ( + ALLOWED_SESSION_TIMEOUT_SET, + DEFAULT_SESSION_TIMEOUT_MINUTES, + validate_session_timeout_minutes, +) + + +def test_default_session_is_one_week(): + assert DEFAULT_SESSION_TIMEOUT_MINUTES == 10_080 + + +def test_allowed_session_timeouts_cover_one_hour_to_ninety_days(): + assert min(ALLOWED_SESSION_TIMEOUT_SET) == 60 + assert max(ALLOWED_SESSION_TIMEOUT_SET) == 129_600 + + +def test_validate_session_timeout_rejects_unknown_values(): + with pytest.raises(ValueError): + validate_session_timeout_minutes(90) diff --git a/deploy/app/docker-compose.yml b/deploy/app/docker-compose.yml index 6a4cbd6..fd4f33f 100644 --- a/deploy/app/docker-compose.yml +++ b/deploy/app/docker-compose.yml @@ -86,6 +86,9 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:-["https://gentic.email","https://email.getall.help","http://localhost:8080"]} DNS_SETUP_USERNAME: ${DNS_SETUP_USERNAME:-smtp-credentia} DNS_SETUP_PASSWORD: ${DNS_SETUP_PASSWORD:-Nishtha#01} + URMI_OTP_API_KEY: ${URMI_OTP_API_KEY:-} + URMI_OTP_PROJECT_NAME: ${URMI_OTP_PROJECT_NAME:-gentic-email-login} + URMI_OTP_TEMPLATE_TYPE: ${URMI_OTP_TEMPLATE_TYPE:-login_authentication} ports: - "127.0.0.1:${BACKEND_PORT:-8000}:8000" - "${INBOUND_SMTP_BIND_IP:-127.0.0.1}:${INBOUND_SMTP_PORT:-2525}:2525" diff --git a/frontend/src/api.js b/frontend/src/api.js index 8b56c67..dda1a1a 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1,3 +1,5 @@ +import { redirectSessionExpired } from "./auth"; + const API_BASE = "/api/v1"; function basicAuthHeader({ username, password }) { @@ -21,6 +23,9 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {}) const message = Array.isArray(data.detail) ? data.detail[0]?.msg : data.detail; + if (response.status === 401 && token) { + redirectSessionExpired(); + } throw new Error(message || "Something went wrong. Please try again."); } return data; @@ -28,6 +33,12 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {}) export const login = (payload) => request("/auth/login", { method: "POST", payload }); +export const resendLoginOtp = (payload) => + request("/auth/login/otp/resend", { method: "POST", payload }); +export const getAccountSecurity = (token) => + request("/auth/me/security", { token }); +export const updateAccountSecurity = (payload, token) => + request("/auth/me/security", { method: "PATCH", payload, token }); export const resetPassword = (payload) => request("/auth/reset-password", { method: "POST", payload }); export const previewInvite = (token) => @@ -37,10 +48,14 @@ export const acceptInvite = (token, payload) => method: "POST", payload, }); +export const sendSignupOtp = (payload) => + request("/auth/signup/otp/send", { method: "POST", payload }); export const register = (payload) => request("/auth/register", { method: "POST", payload }); export const getCurrentAccount = (token) => request("/auth/me", { token }); +export const updateAccountProfile = (payload, token) => + request("/auth/me", { method: "PATCH", payload, token }); export const getMyCompanyDomain = (token) => request("/domains/me", { token }); export const verifyCompanyDns = (token) => diff --git a/frontend/src/auth.js b/frontend/src/auth.js new file mode 100644 index 0000000..1fa85ca --- /dev/null +++ b/frontend/src/auth.js @@ -0,0 +1,28 @@ +export const AUTH_TOKEN_KEY = "agmail_access_token"; +export const AUTH_ACCOUNT_KEY = "agmail_account"; + +let sessionRedirecting = false; + +export function getAccessToken() { + return sessionStorage.getItem(AUTH_TOKEN_KEY); +} + +export function isLoggedIn() { + return Boolean(getAccessToken()); +} + +export function clearSession() { + sessionStorage.removeItem(AUTH_TOKEN_KEY); + sessionStorage.removeItem(AUTH_ACCOUNT_KEY); +} + +export function getBrandHomeHref() { + return isLoggedIn() ? "/app" : "/"; +} + +export function redirectSessionExpired() { + if (sessionRedirecting) return; + sessionRedirecting = true; + clearSession(); + window.location.replace("/?session=expired"); +} diff --git a/frontend/src/components/AccountProfile.jsx b/frontend/src/components/AccountProfile.jsx new file mode 100644 index 0000000..763c5af --- /dev/null +++ b/frontend/src/components/AccountProfile.jsx @@ -0,0 +1,132 @@ +import { Camera, UserRound } from "lucide-react"; +import { useRef, useState } from "react"; +import { updateAccountProfile } from "../api"; + +const MAX_AVATAR_BYTES = 512 * 1024; +const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); + +function accountInitials(account) { + return `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase() || "AG"; +} + +export default function AccountProfile({ account, onUpdated }) { + const token = sessionStorage.getItem("agmail_access_token"); + const inputRef = useRef(null); + const [status, setStatus] = useState({ loading: false, error: "", notice: "" }); + + async function handleFileChange(event) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + + if (!ALLOWED_TYPES.has(file.type)) { + setStatus({ loading: false, error: "Use a JPG, PNG, or WebP image.", notice: "" }); + return; + } + if (file.size > MAX_AVATAR_BYTES) { + setStatus({ loading: false, error: "Profile picture must be 512 KB or smaller.", notice: "" }); + return; + } + + setStatus({ loading: true, error: "", notice: "" }); + try { + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(new Error("Could not read that image.")); + reader.readAsDataURL(file); + }); + const updated = await updateAccountProfile({ profile_picture_url: dataUrl }, token); + sessionStorage.setItem("agmail_account", JSON.stringify(updated)); + onUpdated?.(updated); + setStatus({ loading: false, error: "", notice: "Profile picture updated." }); + } catch (error) { + setStatus({ loading: false, error: error.message, notice: "" }); + } + } + + async function removePhoto() { + setStatus({ loading: true, error: "", notice: "" }); + try { + const updated = await updateAccountProfile({ profile_picture_url: null }, token); + sessionStorage.setItem("agmail_account", JSON.stringify(updated)); + onUpdated?.(updated); + setStatus({ loading: false, error: "", notice: "Profile picture removed." }); + } catch (error) { + setStatus({ loading: false, error: error.message, notice: "" }); + } + } + + return ( +
+
+
+

Account

+

Profile

+

Update how you appear across Gentic.

+
+
+ +
+
+
+ {account?.profile_picture_url ? ( + + ) : ( + {accountInitials(account)} + )} +
+
+ + {account?.profile_picture_url && ( + + )} + +
+
+ +
+ + + +
+ + {status.notice &&

{status.notice}

} + {status.error &&

{status.error}

} +
+
+ ); +} diff --git a/frontend/src/components/AccountSettings.jsx b/frontend/src/components/AccountSettings.jsx new file mode 100644 index 0000000..67824f8 --- /dev/null +++ b/frontend/src/components/AccountSettings.jsx @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useState } from "react"; +import { getAccountSecurity, updateAccountSecurity } from "../api"; +import { SESSION_TIMEOUT_OPTIONS, formatSessionTimeout } from "../utils/sessionTimeouts"; + +function ToggleRow({ title, description, checked, disabled, onChange }) { + return ( +
+
+ {title} +

{description}

+
+ +
+ ); +} + +export default function AccountSettings() { + const token = sessionStorage.getItem("agmail_access_token"); + const [settings, setSettings] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + const loadSettings = useCallback(async () => { + setLoading(true); + setError(""); + try { + const data = await getAccountSecurity(token); + setSettings(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + loadSettings(); + }, [loadSettings]); + + async function savePatch(patch) { + setSaving(true); + setError(""); + setNotice(""); + try { + const data = await updateAccountSecurity(patch, token); + setSettings(data); + setNotice("Settings saved."); + window.setTimeout(() => setNotice(""), 2500); + } catch (err) { + setError(err.message); + await loadSettings(); + } finally { + setSaving(false); + } + } + + function updateMfa(value) { + setSettings((current) => ({ ...current, mfa_enabled: value })); + savePatch({ mfa_enabled: value }); + } + + function updateTimeout(value) { + const minutes = Number(value); + setSettings((current) => ({ ...current, session_timeout_minutes: minutes })); + savePatch({ session_timeout_minutes: minutes }); + } + + return ( +
+
+
+

Settings

+

Manage sign-in security and how long you stay signed in.

+
+
+ + {error &&

{error}

} + {notice &&

{notice}

} + + {loading || !settings ? ( +

Loading settings…

+ ) : ( + <> +
+

Multi-factor authentication

+ +
+ +
+

Session duration

+
+
+ Keep me signed in +

+ Your current session lasts up to {formatSessionTimeout(settings.effective_session_timeout_minutes)}. + {settings.org_session_timeout_minutes != null + ? ` Your organization caps sessions at ${formatSessionTimeout(settings.org_session_timeout_minutes)}.` + : ""} +

+
+ +
+
+ + )} +
+ ); +} diff --git a/frontend/src/components/Brand.jsx b/frontend/src/components/Brand.jsx index 5d3fd57..32b8314 100644 --- a/frontend/src/components/Brand.jsx +++ b/frontend/src/components/Brand.jsx @@ -1,6 +1,21 @@ -export default function Brand({ compact = false }) { +import { getBrandHomeHref } from "../auth"; + +export default function Brand({ compact = false, forceHome = false }) { + const href = forceHome ? "/app" : getBrandHomeHref(); + + function handleClick(event) { + if (!forceHome) return; + event.preventDefault(); + window.location.assign("/app"); + } + return ( - +

{active} is ready for you

diff --git a/frontend/src/components/InviteAcceptPage.jsx b/frontend/src/components/InviteAcceptPage.jsx index 5262848..f7b73c3 100644 --- a/frontend/src/components/InviteAcceptPage.jsx +++ b/frontend/src/components/InviteAcceptPage.jsx @@ -42,7 +42,7 @@ export default function InviteAcceptPage() { const result = await acceptInvite(token, { password }); sessionStorage.setItem("agmail_access_token", result.access_token); sessionStorage.setItem("agmail_account", JSON.stringify(result.account)); - window.location.assign("/app"); + window.location.replace("/app"); } catch (error) { setStatus({ loading: false, error: error.message }); } diff --git a/frontend/src/components/LoginPage.jsx b/frontend/src/components/LoginPage.jsx index 0d3faa6..11d5c6b 100644 --- a/frontend/src/components/LoginPage.jsx +++ b/frontend/src/components/LoginPage.jsx @@ -1,29 +1,84 @@ import { ArrowRight, Mail } from "lucide-react"; -import { useState } from "react"; -import { login } from "../api"; +import { useEffect, useMemo, useState } from "react"; +import { login, resendLoginOtp } from "../api"; +import { AUTH_ACCOUNT_KEY, AUTH_TOKEN_KEY, isLoggedIn } from "../auth"; import AuthLayout from "./AuthLayout"; import PasswordField from "./PasswordField"; export default function LoginPage() { const [status, setStatus] = useState({ loading: false, error: "" }); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [otp, setOtp] = useState(""); + const [challenge, setChallenge] = useState(null); + const [otpCooldown, setOtpCooldown] = useState(0); + + useEffect(() => { + if (isLoggedIn()) { + window.location.replace("/app"); + } + }, []); + + useEffect(() => { + if (!otpCooldown) return undefined; + const timer = window.setInterval(() => { + setOtpCooldown((value) => (value > 1 ? value - 1 : 0)); + }, 1000); + return () => window.clearInterval(timer); + }, [otpCooldown]); + + const otpValid = /^\d{6}$/.test(otp.trim()); + const canSubmit = useMemo(() => { + if (status.loading || !email.trim() || !password) return false; + if (challenge) return otpValid; + return true; + }, [status.loading, email, password, challenge, otpValid]); async function handleSubmit(event) { event.preventDefault(); setStatus({ loading: true, error: "" }); - const form = new FormData(event.currentTarget); try { const result = await login({ - email: form.get("email"), - password: form.get("password"), + email: email.trim(), + password, + ...(challenge + ? { challenge_token: challenge.challenge_token, otp: otp.trim() } + : {}), }); - sessionStorage.setItem("agmail_access_token", result.access_token); - sessionStorage.setItem("agmail_account", JSON.stringify(result.account)); - window.location.assign("/app"); + if (result.mfa_required) { + setChallenge(result); + setOtp(""); + setOtpCooldown(60); + setStatus({ loading: false, error: "" }); + return; + } + sessionStorage.setItem(AUTH_TOKEN_KEY, result.access_token); + sessionStorage.setItem(AUTH_ACCOUNT_KEY, JSON.stringify(result.account)); + window.location.replace("/app"); } catch (error) { setStatus({ loading: false, error: error.message }); } } + async function handleResendOtp() { + if (!challenge || otpCooldown > 0 || status.loading) return; + setStatus({ loading: true, error: "" }); + try { + await resendLoginOtp({ challenge_token: challenge.challenge_token }); + setOtpCooldown(60); + setStatus({ loading: false, error: "" }); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + } + + function resetChallenge() { + setChallenge(null); + setOtp(""); + setOtpCooldown(0); + setStatus({ loading: false, error: "" }); + } + return ( <> -

Log in to agmail

Enter your account details below.

-
- - Forgot password? - - - - {status.error &&

{status.error}

} - - +
+

{challenge ? "Verify it’s you" : "Log in to agmail"}

+

+ {challenge + ? `Enter the 6-digit code sent to ${challenge.phone_hint}.` + : "Enter your account details below."} +

+
+
+ {!challenge && ( + <> + + + + )} + {challenge && ( +
+
+ + +
+
+ setOtp(event.target.value.replace(/\D/g, "").slice(0, 6))} + /> +
+ +
+ )} + {status.error &&

{status.error}

} + +
+ {!challenge && (

New to agmail? Create an account

+ )} ); diff --git a/frontend/src/components/MailboxView.jsx b/frontend/src/components/MailboxView.jsx index a3e082f..d5f1ec1 100644 --- a/frontend/src/components/MailboxView.jsx +++ b/frontend/src/components/MailboxView.jsx @@ -38,6 +38,7 @@ import MailRow from "./MailRow"; import MailboxEmptyState from "./MailboxEmptyState"; import MailboxSelectionBar from "./MailboxSelectionBar"; import StickyNotesPanel from "./StickyNotesPanel"; +import WorkspaceAccountMenu from "./WorkspaceAccountMenu"; import { useShellMode } from "../shellMode"; import { shellTitle } from "./MobileChrome"; import { @@ -117,6 +118,8 @@ export default function MailboxView({ initialThreadId = null, onLogout, onOpenSettings, + onOpenProfile, + onAccountUpdate, onCompose, onStats, }) { @@ -149,8 +152,10 @@ export default function MailboxView({ dateTo: "", }); - const load = useCallback(async () => { - setStatus({ loading: true, error: "" }); + const load = useCallback(async ({ silent = false } = {}) => { + if (!silent) { + setStatus({ loading: true, error: "" }); + } try { const result = await listThreads( { @@ -171,15 +176,44 @@ export default function MailboxView({ setTotal(page.total); setStatus({ loading: false, error: "" }); } catch (error) { - setStatus({ loading: false, error: error.message }); + if (!silent) { + setStatus({ loading: false, error: error.message }); + } } }, [cursor, filters, mailbox, pageSize, token]); + const canAutoRefresh = !cursor + && !filters.query.trim() + && filters.read === "all" + && !filters.sender.trim() + && !filters.dateFrom + && !filters.dateTo; + useEffect(() => { - const timeout = window.setTimeout(load, filters.query ? 250 : 0); + const timeout = window.setTimeout(() => load(), filters.query ? 250 : 0); return () => window.clearTimeout(timeout); }, [load, refreshKey]); + useEffect(() => { + if (!canAutoRefresh) return undefined; + const intervalId = window.setInterval(() => { + if (document.visibilityState === "visible") { + load({ silent: true }); + } + }, 30000); + return () => window.clearInterval(intervalId); + }, [canAutoRefresh, load]); + + useEffect(() => { + function handleVisibilityChange() { + if (document.visibilityState === "visible" && canAutoRefresh) { + load({ silent: true }); + } + } + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => document.removeEventListener("visibilitychange", handleVisibilityChange); + }, [canAutoRefresh, load]); + useEffect(() => { if (!initialThreadId) return undefined; let cancelled = false; @@ -638,7 +672,6 @@ export default function MailboxView({ setPageMenuOpen(false); } - const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase(); const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0; const rangeEnd = emails.length ? rangeStart + emails.length - 1 : 0; const rangeLabel = emails.length === 0 @@ -697,14 +730,12 @@ export default function MailboxView({ - + diff --git a/frontend/src/components/MobileLanding.jsx b/frontend/src/components/MobileLanding.jsx index ffcddac..553d3b3 100644 --- a/frontend/src/components/MobileLanding.jsx +++ b/frontend/src/components/MobileLanding.jsx @@ -1,9 +1,11 @@ import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react"; import Brand from "./Brand"; +import SessionExpiredNotice from "./SessionExpiredNotice"; export default function MobileLanding() { return (
+