From 4a563f4f34074cee69ab7956c36dd539f6a3a918 Mon Sep 17 00:00:00 2001
From: debu-2
Date: Wed, 7 Oct 2026 03:19:09 +0530
Subject: [PATCH] setting, session and profile
---
backend/app/config.py | 7 +-
backend/app/main.py | 49 ++++
backend/app/models.py | 7 +-
backend/app/routers/auth.py | 248 +++++++++++++++---
backend/app/schemas.py | 56 +++-
backend/app/services/login_mfa.py | 173 ++++++++++++
backend/app/services/org_members.py | 4 -
backend/app/services/org_security.py | 10 +-
backend/app/services/session_policy.py | 47 ++++
backend/app/services/urmi_otp.py | 236 +++++++++++++++++
backend/requirements.txt | 1 +
backend/tests/test_session_policy.py | 21 ++
deploy/app/docker-compose.yml | 3 +
frontend/src/api.js | 15 ++
frontend/src/auth.js | 28 ++
frontend/src/components/AccountProfile.jsx | 132 ++++++++++
frontend/src/components/AccountSettings.jsx | 139 ++++++++++
frontend/src/components/Brand.jsx | 19 +-
frontend/src/components/CompanySecurity.jsx | 12 +-
frontend/src/components/DashboardPage.jsx | 93 +++++--
frontend/src/components/InviteAcceptPage.jsx | 2 +-
frontend/src/components/LoginPage.jsx | 176 +++++++++++--
frontend/src/components/MailboxView.jsx | 57 +++-
frontend/src/components/MobileLanding.jsx | 2 +
.../src/components/SessionExpiredNotice.jsx | 10 +
frontend/src/components/SignupPage.jsx | 152 ++++++++++-
frontend/src/components/WebLanding.jsx | 2 +
.../src/components/WorkspaceAccountMenu.jsx | 76 ++++++
frontend/src/responsive.css | 95 +++++--
frontend/src/styles.css | 62 ++++-
frontend/src/utils/sessionTimeouts.js | 22 ++
31 files changed, 1797 insertions(+), 159 deletions(-)
create mode 100644 backend/app/services/login_mfa.py
create mode 100644 backend/app/services/session_policy.py
create mode 100644 backend/app/services/urmi_otp.py
create mode 100644 backend/tests/test_session_policy.py
create mode 100644 frontend/src/auth.js
create mode 100644 frontend/src/components/AccountProfile.jsx
create mode 100644 frontend/src/components/AccountSettings.jsx
create mode 100644 frontend/src/components/SessionExpiredNotice.jsx
create mode 100644 frontend/src/components/WorkspaceAccountMenu.jsx
create mode 100644 frontend/src/utils/sessionTimeouts.js
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_picture_url ? (
+
+ ) : (
+
{accountInitials(account)}
+ )}
+
+
+ inputRef.current?.click()}
+ >
+
+ {account?.profile_picture_url ? "Change photo" : "Upload photo"}
+
+ {account?.profile_picture_url && (
+
+ Remove
+
+ )}
+
+
+
+
+
+
+ Name
+
+
+ {account?.first_name} {account?.last_name}
+
+
+
+ Email
+ {account?.email}
+
+
+ Phone
+ {account?.phone_number || "Not set"}
+
+
+
+ {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}
+
+
+ onChange(event.target.checked)}
+ />
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {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)}.`
+ : ""}
+
+
+
updateTimeout(event.target.value)}
+ >
+ {SESSION_TIMEOUT_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ >
+ )}
+
+ );
+}
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 (
-
+
diff --git a/frontend/src/components/CompanySecurity.jsx b/frontend/src/components/CompanySecurity.jsx
index 55c4a5a..db9300a 100644
--- a/frontend/src/components/CompanySecurity.jsx
+++ b/frontend/src/components/CompanySecurity.jsx
@@ -1,14 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { getOrgSecurity, updateOrgSecurity } from "../api";
-
-const SESSION_TIMEOUTS = [
- { value: 15, label: "15 minutes" },
- { value: 30, label: "30 minutes" },
- { value: 60, label: "1 hour" },
- { value: 120, label: "2 hours" },
- { value: 480, label: "8 hours" },
- { value: 1440, label: "24 hours" },
-];
+import { SESSION_TIMEOUT_OPTIONS } from "../utils/sessionTimeouts";
function ToggleRow({ title, description, checked, disabled, onChange }) {
return (
@@ -136,7 +128,7 @@ export default function CompanySecurity() {
disabled={saving}
onChange={(event) => updateTimeout(event.target.value)}
>
- {SESSION_TIMEOUTS.map((option) => (
+ {SESSION_TIMEOUT_OPTIONS.map((option) => (
{option.label}
diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx
index 46f4d7e..765344a 100644
--- a/frontend/src/components/DashboardPage.jsx
+++ b/frontend/src/components/DashboardPage.jsx
@@ -12,7 +12,6 @@ import {
LogOut,
Mail,
Menu,
- PanelLeftOpen,
HandHelping,
Plug,
Send,
@@ -24,10 +23,17 @@ import {
Trash2,
Users,
UsersRound,
+ UserRound,
X,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { getCurrentAccount, getMyCompanyDomain } from "../api";
+import {
+ AUTH_ACCOUNT_KEY,
+ AUTH_TOKEN_KEY,
+ clearSession,
+ getAccessToken,
+} from "../auth";
import Brand from "./Brand";
import MobileChrome from "./MobileChrome";
import { ShellModeContext } from "../shellMode";
@@ -43,6 +49,8 @@ import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup
import ChangePasswordModal from "./ChangePasswordModal";
import ComposeDock, { triggerOpenCompose } from "./ComposeDock";
import HumanHandoff from "./HumanHandoff";
+import AccountProfile from "./AccountProfile";
+import AccountSettings from "./AccountSettings";
import MailboxView from "./MailboxView";
const mailItems = [
@@ -87,11 +95,6 @@ const manageNavItems = [
[Settings, "Settings"],
];
-function clearSession() {
- sessionStorage.removeItem("agmail_access_token");
- sessionStorage.removeItem("agmail_account");
-}
-
function SidebarSection({ title, children, className = "" }) {
return (
@@ -112,7 +115,10 @@ export default function DashboardPage() {
const [needsDnsSetup, setNeedsDnsSetup] = useState(false);
const [domainError, setDomainError] = useState("");
const [active, setActive] = useState("Inbox");
- const [menuOpen, setMenuOpen] = useState(false);
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [sidebarExpanded, setSidebarExpanded] = useState(
+ () => typeof window !== "undefined" && window.matchMedia("(min-width: 1200px)").matches,
+ );
const [sheet, setSheet] = useState(null);
const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(true);
@@ -134,7 +140,7 @@ export default function DashboardPage() {
const handleStats = useCallback((stats) => setMailStats(stats), []);
useEffect(() => {
- const token = sessionStorage.getItem("agmail_access_token");
+ const token = getAccessToken();
if (!token) {
window.location.replace("/login");
return;
@@ -142,7 +148,7 @@ export default function DashboardPage() {
getCurrentAccount(token)
.then(async (currentAccount) => {
- sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
+ sessionStorage.setItem(AUTH_ACCOUNT_KEY, JSON.stringify(currentAccount));
setAccount(currentAccount);
setMustChangePassword(Boolean(currentAccount.must_change_password));
if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) {
@@ -157,6 +163,7 @@ export default function DashboardPage() {
setLoading(false);
})
.catch(() => {
+ if (!getAccessToken()) return;
clearSession();
window.location.replace("/login");
});
@@ -176,10 +183,24 @@ export default function DashboardPage() {
function closeMobileMenu() {
if (window.matchMedia("(max-width: 767.98px)").matches) {
- setMenuOpen(false);
+ setMobileMenuOpen(false);
}
}
+ function toggleSidebar() {
+ if (window.matchMedia("(max-width: 767.98px)").matches) {
+ setMobileMenuOpen(false);
+ return;
+ }
+ setSidebarExpanded((open) => !open);
+ }
+
+ const sidebarClasses = [
+ "dashboard-sidebar",
+ mobileMenuOpen ? "open" : "",
+ sidebarExpanded ? "expanded" : "",
+ ].filter(Boolean).join(" ");
+
function selectCompanyItem(label) {
setCompanyActive(label);
setAgentNav(null);
@@ -251,6 +272,19 @@ export default function DashboardPage() {
triggerOpenCompose(context);
}
+ function openProfile() {
+ setCompanyActive(null);
+ setActive("Profile");
+ setAgentNav(null);
+ closeMobileMenu();
+ setSheet(null);
+ }
+
+ function updateAccount(next) {
+ setAccount(next);
+ sessionStorage.setItem("agmail_account", JSON.stringify(next));
+ }
+
function openExternalThread(threadId) {
setCompanyActive(null);
setActive("Inbox");
@@ -323,22 +357,24 @@ export default function DashboardPage() {
data-onboarding={isOrgOnboarding ? "true" : undefined}
>
-
- { setSheet(null); setMenuOpen(true); }}>
+
+ { setSheet(null); setMobileMenuOpen(true); }}>
- {menuOpen && setMenuOpen(false)} />}
-
+ {mobileMenuOpen && (
+ setMobileMenuOpen(false)} />
+ )}
+
@@ -485,6 +526,8 @@ export default function DashboardPage() {
initialThreadId={openThreadId}
onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")}
+ onOpenProfile={openProfile}
+ onAccountUpdate={updateAccount}
onCompose={openCompose}
onStats={handleStats}
/>
@@ -497,6 +540,8 @@ export default function DashboardPage() {
account={account}
onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")}
+ onOpenProfile={openProfile}
+ onAccountUpdate={updateAccount}
onCompose={openCompose}
/>
)}
@@ -508,9 +553,14 @@ export default function DashboardPage() {
account={account}
onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")}
+ onOpenProfile={openProfile}
+ onAccountUpdate={updateAccount}
onCompose={openCompose}
/>
)}
+ {!companyActive && active === "Profile" && (
+
+ )}
{companyActive === "Overview" && isDomainOwner && !isOrgSetupComplete(account.id) && (
Company {companyActive.toLowerCase()} controls will appear here.
)}
- {!companyActive && !["Inbox", "Outbox", "Trash"].includes(active) && (
+ {!companyActive && active === "Settings" && (
+
+ )}
+ {!companyActive && !["Inbox", "Outbox", "Trash", "Settings"].includes(active) && (
{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.
-
+
+
{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 && (
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({
-
- {initials || "AG"}
-
- {account?.first_name} {account?.last_name}
- {account?.email}
-
-
-
+
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 (
+
diff --git a/frontend/src/components/SessionExpiredNotice.jsx b/frontend/src/components/SessionExpiredNotice.jsx
new file mode 100644
index 0000000..d5a27d4
--- /dev/null
+++ b/frontend/src/components/SessionExpiredNotice.jsx
@@ -0,0 +1,10 @@
+export default function SessionExpiredNotice() {
+ const expired = new URLSearchParams(window.location.search).get("session") === "expired";
+ if (!expired) return null;
+
+ return (
+
+ Your session expired.
Log in to continue where you left off.
+
+ );
+}
diff --git a/frontend/src/components/SignupPage.jsx b/frontend/src/components/SignupPage.jsx
index 8f86473..4c81d4d 100644
--- a/frontend/src/components/SignupPage.jsx
+++ b/frontend/src/components/SignupPage.jsx
@@ -1,17 +1,85 @@
import {
ArrowRight, Building2, CalendarDays, Mail, Phone, UserRound,
} from "lucide-react";
-import { useState } from "react";
-import { register } from "../api";
+import { useEffect, useMemo, useState } from "react";
+import { register, sendSignupOtp } from "../api";
import AuthLayout from "./AuthLayout";
import PasswordField from "./PasswordField";
+const COUNTRY_CODES = [
+ { code: "91", label: "India (+91)" },
+ { code: "1", label: "United States (+1)" },
+ { code: "44", label: "United Kingdom (+44)" },
+ { code: "61", label: "Australia (+61)" },
+ { code: "971", label: "UAE (+971)" },
+];
+
+const INDIA_CODE = "91";
+const INDIAN_PHONE_RE = /^[6-9]\d{9}$/;
+
export default function SignupPage() {
const [accountType, setAccountType] = useState("individual");
+ const [countryCode, setCountryCode] = useState(INDIA_CODE);
+ const [phone, setPhone] = useState("");
+ const [otp, setOtp] = useState("");
+ const [otpSent, setOtpSent] = useState(false);
+ const [otpCooldown, setOtpCooldown] = useState(0);
const [status, setStatus] = useState({ loading: false, error: "" });
+ const [otpStatus, setOtpStatus] = useState({ loading: false, error: "", notice: "" });
+
+ const isIndia = countryCode === INDIA_CODE;
+ const phoneDigits = phone.replace(/\D/g, "");
+ const indianPhoneValid = INDIAN_PHONE_RE.test(phoneDigits);
+ const otpValid = /^\d{6}$/.test(otp.trim());
+
+ useEffect(() => {
+ if (!otpCooldown) return undefined;
+ const timer = window.setInterval(() => {
+ setOtpCooldown((value) => (value > 1 ? value - 1 : 0));
+ }, 1000);
+ return () => window.clearInterval(timer);
+ }, [otpCooldown]);
+
+ useEffect(() => {
+ setOtp("");
+ setOtpSent(false);
+ setOtpStatus({ loading: false, error: "", notice: "" });
+ }, [countryCode, phoneDigits]);
+
+ const canSendOtp = useMemo(
+ () => isIndia && indianPhoneValid && !otpStatus.loading && otpCooldown === 0,
+ [isIndia, indianPhoneValid, otpStatus.loading, otpCooldown],
+ );
+
+ const canSubmit = useMemo(() => {
+ if (status.loading) return false;
+ if (!phoneDigits) return false;
+ if (isIndia) return otpSent && otpValid;
+ return true;
+ }, [status.loading, phoneDigits, isIndia, otpSent, otpValid]);
+
+ async function handleSendOtp() {
+ setOtpStatus({ loading: true, error: "", notice: "" });
+ try {
+ const result = await sendSignupOtp({
+ country_code: countryCode,
+ phone: phoneDigits,
+ });
+ setOtpSent(true);
+ setOtpCooldown(60);
+ setOtpStatus({
+ loading: false,
+ error: "",
+ notice: result.message || "Verification code sent.",
+ });
+ } catch (error) {
+ setOtpStatus({ loading: false, error: error.message, notice: "" });
+ }
+ }
async function handleSubmit(event) {
event.preventDefault();
+ if (!canSubmit) return;
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
@@ -22,12 +90,14 @@ export default function SignupPage() {
first_name: form.get("first_name"),
last_name: form.get("last_name"),
date_of_birth: form.get("date_of_birth"),
- phone_number: form.get("phone_number"),
+ country_code: countryCode,
+ phone: phoneDigits,
+ otp: isIndia ? otp.trim() : null,
password: form.get("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 });
}
@@ -67,10 +137,80 @@ export default function SignupPage() {
Last name
Date of birth
- Phone number
+
+ Phone number
+
+
+ setCountryCode(event.target.value)}
+ >
+ {COUNTRY_CODES.map((item) => (
+ {item.label}
+ ))}
+
+
+
+
+
setPhone(event.target.value.replace(/\D/g, ""))}
+ required
+ />
+
+
+ {isIndia && (
+ Indian numbers require SMS verification.
+ )}
+
+
+ {isIndia && (
+
+
+ Verification code
+
+ {otpStatus.loading
+ ? "Sending…"
+ : otpCooldown > 0
+ ? `Resend in ${otpCooldown}s`
+ : otpSent
+ ? "Resend code"
+ : "Send code"}
+
+
+
+ setOtp(event.target.value.replace(/\D/g, "").slice(0, 6))}
+ required
+ />
+
+ {otpStatus.notice &&
{otpStatus.notice}
}
+ {otpStatus.error &&
{otpStatus.error}
}
+
+ )}
+
PasswordUse uppercase, lowercase, and a number.
{status.error && {status.error}
}
-
+
{status.loading ? "Creating account…" : "Create account"}
diff --git a/frontend/src/components/WebLanding.jsx b/frontend/src/components/WebLanding.jsx
index d4b097a..33891ef 100644
--- a/frontend/src/components/WebLanding.jsx
+++ b/frontend/src/components/WebLanding.jsx
@@ -3,6 +3,7 @@ import {
Paperclip, Search, Send, ShieldCheck, Sparkles,
} from "lucide-react";
import Brand from "./Brand";
+import SessionExpiredNotice from "./SessionExpiredNotice";
const mail = [
{ sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" },
@@ -13,6 +14,7 @@ const mail = [
export default function WebLanding() {
return (
+
diff --git a/frontend/src/components/WorkspaceAccountMenu.jsx b/frontend/src/components/WorkspaceAccountMenu.jsx
new file mode 100644
index 0000000..97b8407
--- /dev/null
+++ b/frontend/src/components/WorkspaceAccountMenu.jsx
@@ -0,0 +1,76 @@
+import { ChevronDown, LogOut, Settings, UserRound } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+function accountInitials(account) {
+ return `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase() || "AG";
+}
+
+export default function WorkspaceAccountMenu({
+ account,
+ onOpenProfile,
+ onOpenSettings,
+ onLogout,
+}) {
+ const [open, setOpen] = useState(false);
+ const wrapRef = useRef(null);
+
+ useEffect(() => {
+ if (!open) return undefined;
+ function handlePointerDown(event) {
+ if (!wrapRef.current?.contains(event.target)) setOpen(false);
+ }
+ function handleKeyDown(event) {
+ if (event.key === "Escape") setOpen(false);
+ }
+ document.addEventListener("mousedown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [open]);
+
+ function choose(action) {
+ setOpen(false);
+ action?.();
+ }
+
+ return (
+
+
setOpen((value) => !value)}
+ >
+ {account?.profile_picture_url ? (
+
+ ) : (
+ {accountInitials(account)}
+ )}
+
+ {account?.first_name} {account?.last_name}
+ {account?.email}
+
+
+
+ {open && (
+
+ choose(onOpenProfile)}>
+
+ Profile
+
+ choose(onOpenSettings)}>
+
+ Settings
+
+ choose(onLogout)}>
+
+ Log out
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/responsive.css b/frontend/src/responsive.css
index fb8cfe0..c0c7a16 100644
--- a/frontend/src/responsive.css
+++ b/frontend/src/responsive.css
@@ -112,17 +112,17 @@
height:min(78vh,calc(100vh - 32px));
}
- .dashboard:has(.dashboard-sidebar.open) { grid-template-columns:228px minmax(0,1fr); }
- .dashboard-sidebar.open {
+ .dashboard:has(.dashboard-sidebar.expanded) { grid-template-columns:228px minmax(0,1fr); }
+ .dashboard-sidebar.expanded {
width:228px;
padding:var(--space-4) var(--space-3) var(--space-3);
}
- .dashboard-sidebar.open .dashboard-brand-row { flex-direction:row; justify-content:space-between; padding:0 6px 2px; }
- .dashboard-sidebar.open .dashboard-brand-row .brand > span:not(.brand-mark),
- .dashboard-sidebar.open .dashboard-compose span,
- .dashboard-sidebar.open .dashboard-sidebar-section h2,
- .dashboard-sidebar.open .dashboard-nav-item span,
- .dashboard-sidebar.open .dashboard-profile > div {
+ .dashboard-sidebar.expanded .dashboard-brand-row { flex-direction:row; justify-content:space-between; padding:0 6px 2px; }
+ .dashboard-sidebar.expanded .dashboard-brand-row .brand > span:not(.brand-mark),
+ .dashboard-sidebar.expanded .dashboard-compose span,
+ .dashboard-sidebar.expanded .dashboard-sidebar-section h2,
+ .dashboard-sidebar.expanded .dashboard-nav-item span,
+ .dashboard-sidebar.expanded .dashboard-profile > div {
position:static;
width:auto;
height:auto;
@@ -131,7 +131,7 @@
margin:0;
white-space:nowrap;
}
- .dashboard-sidebar.open .dashboard-nav-item em {
+ .dashboard-sidebar.expanded .dashboard-nav-item em {
position:static;
width:auto;
min-width:18px;
@@ -141,20 +141,83 @@
margin-left:auto;
padding:0 6px;
}
- .dashboard-sidebar.open .dashboard-sidebar-section h2 { margin:0 0 var(--space-2); padding:0 var(--space-2); }
- .dashboard-sidebar.open .dashboard-compose {
+ .dashboard-sidebar.expanded .dashboard-sidebar-section h2 { margin:0 0 var(--space-2); padding:0 var(--space-2); }
+ .dashboard-sidebar.expanded .dashboard-compose {
width:calc(100% - 8px);
margin:var(--space-4) var(--space-1) 0;
padding:0 var(--space-4);
}
- .dashboard-sidebar.open .dashboard-nav-item { justify-content:flex-start; padding:0 var(--space-3); gap:var(--space-3); }
- .dashboard-sidebar.open .dashboard-nav-item span { position:static; width:auto; height:auto; clip:auto; overflow:visible; flex:1; min-width:0; }
- .dashboard-sidebar.open .dashboard-profile {
- grid-template-columns:34px minmax(0,1fr) 31px;
+ .dashboard-sidebar.expanded .dashboard-nav-item { justify-content:flex-start; padding:0 var(--space-3); gap:var(--space-3); }
+ .dashboard-sidebar.expanded .dashboard-nav-item span { position:static; width:auto; height:auto; clip:auto; overflow:visible; flex:1; min-width:0; }
+ .dashboard-sidebar.expanded .dashboard-profile {
+ grid-template-columns:34px minmax(0,1fr) 31px 31px;
padding-left:5px;
padding-right:5px;
}
- .dashboard-sidebar.open .dashboard-profile > button { display:grid; }
+ .dashboard-sidebar.expanded .dashboard-profile > button { display:grid; }
+}
+
+@media (min-width:1200px) {
+ .dashboard { grid-template-columns:72px minmax(0,1fr); }
+ .dashboard:has(.dashboard-sidebar.expanded) { grid-template-columns:228px minmax(0,1fr); }
+ .dashboard-sidebar {
+ width:72px;
+ padding:16px 8px 12px;
+ }
+ .dashboard-brand-row { flex-direction:column; justify-content:center; padding:0; gap:6px; }
+ .dashboard-brand-row .brand > span:not(.brand-mark),
+ .dashboard-compose span,
+ .dashboard-sidebar-section h2,
+ .dashboard-nav-item em,
+ .dashboard-profile > div { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
+ .dashboard-brand-row > button { display:grid; width:32px; height:32px; color:#c8c3d4; }
+ .dashboard-profile > button { display:none; }
+ .dashboard-compose { width:44px; min-height:40px; margin:16px auto 0; padding:0; }
+ .dashboard-nav-item { justify-content:center; padding:0; gap:0; }
+ .dashboard-nav-item span { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0,0,0,0); }
+ .dashboard-profile { grid-template-columns:34px; justify-content:center; padding-left:0; padding-right:0; }
+ .dashboard-sidebar.expanded {
+ width:228px;
+ padding:var(--space-4) var(--space-3) var(--space-3);
+ }
+ .dashboard-sidebar.expanded .dashboard-brand-row { flex-direction:row; justify-content:space-between; padding:0 6px 2px; }
+ .dashboard-sidebar.expanded .dashboard-brand-row .brand > span:not(.brand-mark),
+ .dashboard-sidebar.expanded .dashboard-compose span,
+ .dashboard-sidebar.expanded .dashboard-sidebar-section h2,
+ .dashboard-sidebar.expanded .dashboard-nav-item span,
+ .dashboard-sidebar.expanded .dashboard-profile > div {
+ position:static;
+ width:auto;
+ height:auto;
+ clip:auto;
+ overflow:visible;
+ margin:0;
+ white-space:nowrap;
+ }
+ .dashboard-sidebar.expanded .dashboard-nav-item em {
+ position:static;
+ width:auto;
+ min-width:18px;
+ height:18px;
+ clip:auto;
+ overflow:visible;
+ margin-left:auto;
+ padding:0 6px;
+ }
+ .dashboard-sidebar.expanded .dashboard-sidebar-section h2 { margin:0 0 var(--space-2); padding:0 var(--space-2); }
+ .dashboard-sidebar.expanded .dashboard-compose {
+ width:calc(100% - 8px);
+ margin:var(--space-4) var(--space-1) 0;
+ padding:0 var(--space-4);
+ }
+ .dashboard-sidebar.expanded .dashboard-nav-item { justify-content:flex-start; padding:0 var(--space-3); gap:var(--space-3); }
+ .dashboard-sidebar.expanded .dashboard-nav-item span { position:static; width:auto; height:auto; clip:auto; overflow:visible; flex:1; min-width:0; }
+ .dashboard-sidebar.expanded .dashboard-profile {
+ grid-template-columns:34px minmax(0,1fr) 31px 31px;
+ padding-left:5px;
+ padding-right:5px;
+ }
+ .dashboard-sidebar.expanded .dashboard-profile > button { display:grid; }
}
@media (max-width:767.98px) {
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index dc78f91..4491b80 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -16,6 +16,9 @@ a { color:inherit; text-decoration:none; }
.robot-smile { width:14px; height:7px; position:absolute; left:9px; bottom:6px; border:2px solid white; border-top:0; border-radius:0 0 10px 10px; transform:rotate(4deg); }
.brand-compact { line-height:0; }
.nav { height:88px; display:flex; align-items:center; justify-content:space-between; }
+.session-expired-banner { margin:0; padding:10px 16px; text-align:center; background:#fff1ed; border-bottom:1px solid #ffd3ca; color:#8f4a3d; font-size:12px; font-weight:500; }
+.session-expired-banner a { color:#c95945; font-weight:700; text-decoration:none; }
+.session-expired-banner a:hover { text-decoration:underline; }
.nav-links,.nav-actions,.hero-actions,.trust-line { display:flex; align-items:center; }
.nav-links { gap:38px; margin-left:80px; color:#4f4f62; font-size:14px; }
.nav-actions,.hero-actions { gap:10px; }
@@ -116,6 +119,16 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.type-card>span { display:grid; gap:3px; }.type-card strong { font-size:11px; }.type-card small { color:#97919a; font-size:8px; font-weight:500; line-height:1.3; }
.type-card svg { color:#77727c; }.type-card.selected svg { color:var(--coral); }
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
+.phone-row { display:grid; grid-template-columns:132px minmax(0,1fr); gap:10px; margin-top:7px; }
+.phone-country-wrap { padding:0 10px; }
+.phone-country-wrap select { width:100%; min-width:0; border:0; outline:0; background:transparent; color:var(--ink); font-size:12px; font-weight:600; cursor:pointer; }
+.auth-otp-block { display:grid; gap:8px; }
+.auth-otp-head { display:flex; align-items:center; justify-content:space-between; gap:10px; }
+.auth-otp-head label { margin:0; font-size:12px; font-weight:600; color:#5a5562; }
+.auth-otp-send { padding:0; border:0; background:transparent; color:#7357f6; font-size:11px; font-weight:700; white-space:nowrap; }
+.auth-otp-send:hover:not(:disabled) { color:#5a3fe0; }
+.auth-otp-send:disabled { color:#b7b3be; cursor:not-allowed; }
+.auth-otp-notice { margin:0; color:#3d8a5d; }
.email-builder>span { padding-left:10px; border-left:1px solid #eae5e1; color:#7e7982; font-size:11px; white-space:nowrap; }
.field-hint { display:block; margin-top:6px; color:#99939b; font-size:9px; font-weight:500; }
.auth-submit { width:100%; margin-top:3px; }
@@ -142,7 +155,8 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.dashboard-profile>div { min-width:0; display:grid; gap:2px; }
.dashboard-profile strong,.dashboard-profile small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dashboard-profile strong { font-size:11px; }.dashboard-profile small { color:#99949d; font-size:8px; }
-.dashboard-profile button,.dashboard-brand-row>button,.dashboard-mobile-header>button { width:31px; height:31px; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#8c8790; }
+.dashboard-profile button,.dashboard-mobile-header>button { width:31px; height:31px; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#8c8790; }
+.dashboard-brand-row>button { width:31px; height:31px; display:none; place-items:center; border:0; border-radius:9px; background:transparent; color:#8c8790; }
.dashboard-profile button:hover { color:var(--ink); background:#f5f1ee; }
.dashboard-content { min-width:0; padding:28px clamp(28px,4vw,60px); background:#f5f4f2; }
.dashboard-content-heading { display:flex; align-items:end; justify-content:space-between; gap:20px; }
@@ -1269,7 +1283,7 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.compose-window.expanded { width:calc(100vw - 24px); min-width:0; height:min(78vh,calc(100vh - 24px)); }
}
@media(max-width:480px) {
- .account-types,.field-row { grid-template-columns:1fr; }.auth-story h1 br { display:none; }
+ .account-types,.field-row,.phone-row { grid-template-columns:1fr; }.auth-story h1 br { display:none; }
.compose-dock { right:0 !important; bottom:0; width:100%; }
.compose-dock-expanded { width:100%; }
.compose-window { width:100vw; height:min(88vh,100vh); border-radius:14px 14px 0 0; }
@@ -1308,7 +1322,11 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.dashboard { grid-template-columns:228px minmax(0,1fr); background:var(--canvas); }
.dashboard-sidebar { width:228px; min-height:100vh; max-height:100vh; padding:var(--space-4) var(--space-3) var(--space-3); display:flex; flex-direction:column; overflow:hidden; border-right:0; color:#d7d3e2; background:linear-gradient(180deg,#171329 0%,#120f24 55%,#100d20 100%); box-shadow:inset -1px 0 0 #ffffff08; position:sticky; top:0; transform:none; }
.dashboard-brand-row { flex-shrink:0; padding:0 6px 2px; }
-.dashboard-sidebar-scroll { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; }
+.dashboard-sidebar-scroll { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain; scrollbar-width:thin; scrollbar-color:#ffffff28 transparent; }
+.dashboard-sidebar-scroll::-webkit-scrollbar { width:6px; }
+.dashboard-sidebar-scroll::-webkit-scrollbar-track { background:transparent; }
+.dashboard-sidebar-scroll::-webkit-scrollbar-thumb { border-radius:999px; background:#ffffff28; }
+.dashboard-sidebar-scroll::-webkit-scrollbar-thumb:hover { background:#ffffff40; }
.dashboard-brand-row>button { color:#c8c3d4; }
.dashboard-sidebar .brand { color:#fff; font-size:17px; }
.dashboard-sidebar .brand-mark { background:#fff; }
@@ -1343,8 +1361,10 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
.dashboard-sidebar-mail,.dashboard-sidebar-labels,.dashboard-sidebar-agent,.dashboard-sidebar-company { flex:0 0 auto; }
.dashboard-sidebar-company.dashboard-sidebar-first { margin-top:var(--space-4); padding-top:0; border-top:0; }
.dashboard-company-item.active { background:rgba(255,255,255,0.09); box-shadow:inset 2px 0 0 #a78bfa; }
-.dashboard-profile { margin-top:auto; padding-top:var(--space-3); border-top:1px solid #ffffff10; flex:0 0 auto; }
-.dashboard-profile>span { background:linear-gradient(180deg,#ff9a82,#ff6f57); color:#2a2038; }
+.dashboard-profile { margin-top:auto; padding-top:var(--space-3); border-top:1px solid #ffffff10; flex:0 0 auto; grid-template-columns:34px minmax(0,1fr) 31px 31px; }
+.dashboard-profile>span,.dashboard-profile-photo { width:34px; height:34px; border-radius:11px; }
+.dashboard-profile-photo { object-fit:cover; display:block; }
+.dashboard-profile>span { background:linear-gradient(180deg,#ff9a82,#ff6f57); color:#2a2038; display:grid; place-items:center; font-size:10px; font-weight:700; text-transform:uppercase; }
.dashboard-profile strong { color:#fff; }
.dashboard-profile small,.dashboard-profile button { color:#9a95a8; }
.dashboard-profile button:hover { background:#ffffff12; color:#fff; }
@@ -1388,9 +1408,37 @@ body.compose-blank-open .mailbox-empty-cta { opacity:.42; filter:saturate(.55);
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
@keyframes scaleIn { from { opacity:0; transform:translateY(8px) scale(.98); } to { opacity:1; transform:translateY(0) scale(1); } }
@keyframes slideInDetail { from { opacity:0; transform:translateX(12px); } to { opacity:1; transform:translateX(0); } }
-.workspace-account { margin-left:2px; padding:var(--space-1) var(--space-2) var(--space-1) var(--space-1); display:flex; align-items:center; gap:var(--space-2); border:0; border-radius:var(--radius-lg); background:transparent; color:#6d6872; transition:background var(--motion-fast) var(--ease-standard); }
+.workspace-account-wrap { position:relative; margin-left:2px; }
+.workspace-account { padding:var(--space-1) var(--space-2) var(--space-1) var(--space-1); display:flex; align-items:center; gap:var(--space-2); border:0; border-radius:var(--radius-lg); background:transparent; color:#6d6872; transition:background var(--motion-fast) var(--ease-standard); }
.workspace-account:hover { background:#f8f6f3; }
-.workspace-account>span { width:34px; height:34px; display:grid; place-items:center; border-radius:50%; background:#6d4aff; color:#fff; font-size:11px; font-weight:700; }
+.workspace-account>span,.workspace-account-photo { width:34px; height:34px; flex-shrink:0; border-radius:50%; }
+.workspace-account-photo { object-fit:cover; display:block; }
+.workspace-account>span { display:grid; place-items:center; background:#6d4aff; color:#fff; font-size:11px; font-weight:700; }
+.workspace-account svg { color:#9a95a3; transition:transform var(--motion-fast) var(--ease-standard); }
+.workspace-account svg.open { transform:rotate(180deg); }
+.workspace-account-menu { position:absolute; top:calc(100% + 6px); right:0; z-index:30; min-width:196px; padding:6px; display:grid; gap:2px; border:1px solid var(--border-normal); border-radius:var(--radius-md); background:#fff; box-shadow:var(--shadow-float); }
+.workspace-account-menu button { min-height:36px; padding:0 10px; display:flex; align-items:center; gap:10px; border:0; border-radius:8px; background:transparent; color:#3d3948; font-size:12px; font-weight:600; text-align:left; }
+.workspace-account-menu button:hover { background:#f7f5fa; }
+.workspace-account-menu button.danger { color:#b54f3d; }
+.workspace-account-menu button.danger:hover { background:#fdeeed; }
+.account-profile-page { max-width:720px; }
+.account-profile-head h1 { margin:4px 0 0; font:700 clamp(28px,3vw,34px)/1.15 "Manrope"; letter-spacing:-.04em; }
+.account-profile-head p { margin:8px 0 0; color:#77758a; font-size:13px; }
+.account-profile-eyebrow { margin:0; color:#7357f6; font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; }
+.account-profile-card { margin-top:18px; padding:20px; border:1px solid #ece9ee; border-radius:16px; background:#fff; box-shadow:0 1px 2px rgba(20,18,30,.04); }
+.account-profile-avatar-block { display:flex; align-items:center; gap:16px; padding-bottom:18px; border-bottom:1px solid #f0eef2; }
+.account-profile-avatar { width:72px; height:72px; display:grid; place-items:center; border-radius:50%; overflow:hidden; background:linear-gradient(180deg,#ff9a82,#ff6f57); color:#2a2038; font-size:22px; font-weight:700; }
+.account-profile-avatar img { width:100%; height:100%; object-fit:cover; }
+.account-profile-avatar-actions { display:flex; flex-wrap:wrap; gap:8px; }
+.account-profile-btn { height:36px; padding:0 12px; display:inline-flex; align-items:center; gap:8px; border:1px solid #e4e1e8; border-radius:10px; background:#fff; color:#3d3948; font-size:12px; font-weight:600; }
+.account-profile-btn.subtle { color:#77758a; }
+.account-profile-btn:hover:not(:disabled) { background:#f7f5fa; }
+.account-profile-btn:disabled { opacity:.55; cursor:wait; }
+.account-profile-fields { margin-top:18px; display:grid; gap:14px; }
+.account-profile-fields label { display:grid; gap:6px; }
+.account-profile-fields label>span { color:#77758a; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; }
+.account-profile-readonly { min-height:42px; padding:0 12px; display:flex; align-items:center; gap:8px; border:1px solid #ece9ee; border-radius:10px; background:#faf9fc; color:#3d3948; font-size:13px; }
+.account-profile-notice { margin:14px 0 0; color:#3d8a5d; font-size:12px; }
.workspace-account div { min-width:0; display:grid; text-align:left; }
.workspace-account strong,.workspace-account small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.workspace-account strong { color:#241f2b; font-size:12px; font-weight:600; }
diff --git a/frontend/src/utils/sessionTimeouts.js b/frontend/src/utils/sessionTimeouts.js
new file mode 100644
index 0000000..abb8f78
--- /dev/null
+++ b/frontend/src/utils/sessionTimeouts.js
@@ -0,0 +1,22 @@
+export const SESSION_TIMEOUT_OPTIONS = [
+ { value: 60, label: "1 hour" },
+ { value: 120, label: "2 hours" },
+ { value: 240, label: "4 hours" },
+ { value: 480, label: "8 hours" },
+ { value: 720, label: "12 hours" },
+ { value: 1440, label: "1 day" },
+ { value: 2880, label: "2 days" },
+ { value: 4320, label: "3 days" },
+ { value: 7200, label: "5 days" },
+ { value: 10080, label: "1 week" },
+ { value: 20160, label: "2 weeks" },
+ { value: 43200, label: "30 days" },
+ { value: 64800, label: "45 days" },
+ { value: 86400, label: "60 days" },
+ { value: 129600, label: "90 days" },
+];
+
+export function formatSessionTimeout(minutes) {
+ const match = SESSION_TIMEOUT_OPTIONS.find((option) => option.value === minutes);
+ return match?.label || `${minutes} minutes`;
+}