setting, session and profile

This commit is contained in:
debu-2 2026-10-07 03:19:09 +05:30
parent 48eea5d201
commit 4a563f4f34
31 changed files with 1797 additions and 159 deletions

View file

@ -19,7 +19,7 @@ class Settings(BaseSettings):
) )
mongo_database: str = "agmail_messages" mongo_database: str = "agmail_messages"
jwt_secret: str = "replace-this-development-secret" jwt_secret: str = "replace-this-development-secret"
access_token_minutes: int = 60 access_token_minutes: int = 10080
platform_domain: str = "gentic.email" platform_domain: str = "gentic.email"
platform_spf_include: str = "_spf.gentic.email" platform_spf_include: str = "_spf.gentic.email"
@ -55,6 +55,11 @@ class Settings(BaseSettings):
dns_setup_username: str = "smtp-credentia" dns_setup_username: str = "smtp-credentia"
dns_setup_password: str = "Nishtha#01" 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
env_file_encoding="utf-8", env_file_encoding="utf-8",

View file

@ -151,6 +151,30 @@ async def migrate_user_org_columns(connection) -> None:
"REFERENCES org_sections(id)" "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: async def migrate_legacy_user_tables(connection) -> None:
@ -342,6 +366,31 @@ async def lifespan(_: FastAPI):
mongo_db.mailbox_entries, mongo_db.mailbox_entries,
[("account_id", 1), ("message_scope", 1), ("mailbox", 1), ("event_at", -1)], [("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( await ensure_mongo_index(
mongo_db.messages, mongo_db.messages,
[("message_id", 1), ("message_scope", 1)], [("message_id", 1), ("message_scope", 1)],

View file

@ -39,7 +39,8 @@ class Account(Base):
first_name: Mapped[str] = mapped_column(String(80)) first_name: Mapped[str] = mapped_column(String(80))
last_name: Mapped[str] = mapped_column(String(80)) last_name: Mapped[str] = mapped_column(String(80))
date_of_birth: Mapped[date] = mapped_column(Date) 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) company_domain: Mapped[str | None] = mapped_column(String(253), nullable=True)
org_role: Mapped[str] = mapped_column(String(20), default="user") org_role: Mapped[str] = mapped_column(String(20), default="user")
member_status: Mapped[str] = mapped_column(String(20), default="active") 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) invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) 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( section_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("org_sections.id"), ForeignKey("org_sections.id"),
@ -198,7 +201,7 @@ class OrgSecuritySettings(Base):
require_strong_passwords: Mapped[bool] = mapped_column(Boolean, default=True) require_strong_passwords: Mapped[bool] = mapped_column(Boolean, default=True)
require_mfa_admins: 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) 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( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),

View file

@ -8,25 +8,52 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pwdlib import PasswordHash from pwdlib import PasswordHash
from pydantic import EmailStr, TypeAdapter, ValidationError from pydantic import EmailStr, TypeAdapter, ValidationError
from sqlalchemy import or_, select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings 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.models import Account
from app.schemas import ( from app.schemas import (
AccountProfileUpdate,
AccountRegister, AccountRegister,
AccountRead, AccountRead,
AccountSecurityRead,
AccountSecurityUpdate,
AuthResponse, AuthResponse,
InviteAcceptRequest, InviteAcceptRequest,
InvitePreview, InvitePreview,
LoginChallengeResponse,
LoginOtpResendRequest,
LoginOtpResendResponse,
LoginRequest, LoginRequest,
PasswordChangeRequest, PasswordChangeRequest,
PasswordResetRequest, PasswordResetRequest,
PasswordResetResponse, 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.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 from app.services.welcome_email import send_welcome_email
logger = logging.getLogger(__name__) 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: def validate_password(value: str) -> None:
if not ( if not (
re.search(r"[a-z]", value) re.search(r"[a-z]", value)
@ -98,10 +120,12 @@ def build_email(payload: AccountRegister) -> tuple[str, str | None]:
) from error ) from error
def create_access_token(account: Account) -> str: async def create_access_token(
expires_at = datetime.now(timezone.utc) + timedelta( session: AsyncSession,
minutes=settings.access_token_minutes account: Account,
) ) -> str:
minutes = await resolve_session_timeout_minutes(session, account)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return jwt.encode( return jwt.encode(
{"sub": str(account.id), "email": account.email, "exp": expires_at}, {"sub": str(account.id), "email": account.email, "exp": expires_at},
settings.jwt_secret, 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( async def account_read(
session: AsyncSession, session: AsyncSession,
account: Account, account: Account,
@ -152,6 +212,19 @@ async def get_current_account(
return 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( @router.post(
"/register", "/register",
response_model=AuthResponse, response_model=AuthResponse,
@ -160,6 +233,7 @@ async def get_current_account(
async def register( async def register(
payload: AccountRegister, payload: AccountRegister,
session: AsyncSession = Depends(get_postgres_session), session: AsyncSession = Depends(get_postgres_session),
database=Depends(get_mongo_database),
) -> AuthResponse: ) -> AuthResponse:
if payload.date_of_birth >= date.today(): if payload.date_of_birth >= date.today():
raise HTTPException( raise HTTPException(
@ -168,20 +242,23 @@ async def register(
) )
validate_password(payload.password) validate_password(payload.password)
email, company_domain = build_email(payload) 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( existing = await session.scalar(select(Account).where(Account.email == email))
select(Account).where(
or_(Account.email == email, Account.phone_number == phone_number)
)
)
if existing: if existing:
detail = ( raise HTTPException(
"That email address is already registered." status_code=status.HTTP_409_CONFLICT,
if existing.email == email detail="That email address is already registered.",
else "That phone number is already registered."
) )
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
account = Account( account = Account(
account_type=payload.account_type, account_type=payload.account_type,
@ -215,17 +292,15 @@ async def register(
detail="An account with those details already exists.", detail="An account with those details already exists.",
) from error ) from error
await session.refresh(account) await session.refresh(account)
return AuthResponse( return await build_auth_response(session, account)
access_token=create_access_token(account),
account=await account_read(session, account),
)
@router.post("/login", response_model=AuthResponse) @router.post("/login", response_model=AuthResponse | LoginChallengeResponse)
async def login( async def login(
payload: LoginRequest, payload: LoginRequest,
session: AsyncSession = Depends(get_postgres_session), session: AsyncSession = Depends(get_postgres_session),
) -> AuthResponse: database=Depends(get_mongo_database),
) -> AuthResponse | LoginChallengeResponse:
account = await session.scalar( account = await session.scalar(
select(Account).where(Account.email == str(payload.email).lower()) select(Account).where(Account.email == str(payload.email).lower())
) )
@ -243,9 +318,49 @@ async def login(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Accept your invitation email to set your password before signing in.", detail="Accept your invitation email to set your password before signing in.",
) )
return AuthResponse( if not await account_requires_mfa(session, account):
access_token=create_access_token(account), return await build_auth_response(session, account)
account=await account_read(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) await send_welcome_email(session, account)
except Exception as error: except Exception as error:
logger.warning("Welcome email could not be sent: %s", error) logger.warning("Welcome email could not be sent: %s", error)
return AuthResponse( return await build_auth_response(session, account)
access_token=create_access_token(account),
account=await account_read(session, account),
)
@router.post("/reset-password", response_model=PasswordResetResponse) @router.post("/reset-password", response_model=PasswordResetResponse)
@ -363,3 +475,69 @@ async def get_me(
session: AsyncSession = Depends(get_postgres_session), session: AsyncSession = Depends(get_postgres_session),
) -> AccountRead: ) -> AccountRead:
return await account_read(session, account) 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)

View file

@ -16,14 +16,22 @@ class AccountRegister(BaseModel):
first_name: str = Field(min_length=1, max_length=80) first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80) last_name: str = Field(min_length=1, max_length=80)
date_of_birth: date date_of_birth: date
phone_number: str = Field( country_code: str = Field(min_length=1, max_length=4, pattern=r"^\d+$")
min_length=7, phone: str = Field(min_length=6, max_length=15, pattern=r"^\d+$")
max_length=24, otp: str | None = Field(default=None, min_length=4, max_length=8)
pattern=r"^\+?[0-9][0-9 ()-]{5,22}[0-9]$",
)
password: str = Field(min_length=8, max_length=128) 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): class AccountRead(BaseModel):
id: UUID id: UUID
account_type: str account_type: str
@ -32,6 +40,7 @@ class AccountRead(BaseModel):
last_name: str last_name: str
date_of_birth: date date_of_birth: date
phone_number: str phone_number: str
profile_picture_url: str | None = None
company_domain: str | None company_domain: str | None
is_domain_owner: bool = False is_domain_owner: bool = False
must_change_password: bool = False must_change_password: bool = False
@ -40,9 +49,15 @@ class AccountRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class AccountProfileUpdate(BaseModel):
profile_picture_url: str | None = None
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str = Field(min_length=1, max_length=128) 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): class AuthResponse(BaseModel):
@ -51,6 +66,35 @@ class AuthResponse(BaseModel):
account: AccountRead 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): class OrgMemberCreate(BaseModel):
first_name: str = Field(min_length=1, max_length=80) first_name: str = Field(min_length=1, max_length=80)
last_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_strong_passwords: bool | None = None
require_mfa_admins: bool | None = None require_mfa_admins: bool | None = None
require_mfa_all_users: 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): class OrgMemberRoleUpdate(BaseModel):

View file

@ -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

View file

@ -138,10 +138,6 @@ async def create_org_member(
raise ValueError("Selected section was not found.") raise ValueError("Selected section was not found.")
phone_number = _placeholder_phone() phone_number = _placeholder_phone()
while await session.scalar(
select(Account).where(Account.phone_number == phone_number)
):
phone_number = _placeholder_phone()
member = Account( member = Account(
account_type="company", account_type="company",

View file

@ -7,8 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.models import OrgSecuritySettings from app.models import OrgSecuritySettings
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
from app.services.session_policy import validate_session_timeout_minutes
ALLOWED_SESSION_TIMEOUTS = {15, 30, 60, 120, 480, 1440}
async def get_or_create_security_settings( 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) settings = await get_or_create_security_settings(session, company_domain)
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
if "session_timeout_minutes" in updates: if "session_timeout_minutes" in updates:
timeout = updates["session_timeout_minutes"] validate_session_timeout_minutes(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))
)
for field, value in updates.items(): for field, value in updates.items():
setattr(settings, field, value) setattr(settings, field, value)
await session.flush() await session.flush()

View file

@ -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

View file

@ -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}},
)

View file

@ -15,5 +15,6 @@ aiosmtpd>=1.4.6
google-cloud-storage>=3.0 google-cloud-storage>=3.0
google-crc32c>=1.7 google-crc32c>=1.7
dnspython>=2.7 dnspython>=2.7
httpx>=0.28
pytest>=8.0 pytest>=8.0
pytest-asyncio>=0.25 pytest-asyncio>=0.25

View file

@ -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)

View file

@ -86,6 +86,9 @@ services:
CORS_ORIGINS: ${CORS_ORIGINS:-["https://gentic.email","https://email.getall.help","http://localhost:8080"]} CORS_ORIGINS: ${CORS_ORIGINS:-["https://gentic.email","https://email.getall.help","http://localhost:8080"]}
DNS_SETUP_USERNAME: ${DNS_SETUP_USERNAME:-smtp-credentia} DNS_SETUP_USERNAME: ${DNS_SETUP_USERNAME:-smtp-credentia}
DNS_SETUP_PASSWORD: ${DNS_SETUP_PASSWORD:-Nishtha#01} 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: ports:
- "127.0.0.1:${BACKEND_PORT:-8000}:8000" - "127.0.0.1:${BACKEND_PORT:-8000}:8000"
- "${INBOUND_SMTP_BIND_IP:-127.0.0.1}:${INBOUND_SMTP_PORT:-2525}:2525" - "${INBOUND_SMTP_BIND_IP:-127.0.0.1}:${INBOUND_SMTP_PORT:-2525}:2525"

View file

@ -1,3 +1,5 @@
import { redirectSessionExpired } from "./auth";
const API_BASE = "/api/v1"; const API_BASE = "/api/v1";
function basicAuthHeader({ username, password }) { function basicAuthHeader({ username, password }) {
@ -21,6 +23,9 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {})
const message = Array.isArray(data.detail) const message = Array.isArray(data.detail)
? data.detail[0]?.msg ? data.detail[0]?.msg
: data.detail; : data.detail;
if (response.status === 401 && token) {
redirectSessionExpired();
}
throw new Error(message || "Something went wrong. Please try again."); throw new Error(message || "Something went wrong. Please try again.");
} }
return data; return data;
@ -28,6 +33,12 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {})
export const login = (payload) => export const login = (payload) =>
request("/auth/login", { method: "POST", 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) => export const resetPassword = (payload) =>
request("/auth/reset-password", { method: "POST", payload }); request("/auth/reset-password", { method: "POST", payload });
export const previewInvite = (token) => export const previewInvite = (token) =>
@ -37,10 +48,14 @@ export const acceptInvite = (token, payload) =>
method: "POST", method: "POST",
payload, payload,
}); });
export const sendSignupOtp = (payload) =>
request("/auth/signup/otp/send", { method: "POST", payload });
export const register = (payload) => export const register = (payload) =>
request("/auth/register", { method: "POST", payload }); request("/auth/register", { method: "POST", payload });
export const getCurrentAccount = (token) => export const getCurrentAccount = (token) =>
request("/auth/me", { token }); request("/auth/me", { token });
export const updateAccountProfile = (payload, token) =>
request("/auth/me", { method: "PATCH", payload, token });
export const getMyCompanyDomain = (token) => export const getMyCompanyDomain = (token) =>
request("/domains/me", { token }); request("/domains/me", { token });
export const verifyCompanyDns = (token) => export const verifyCompanyDns = (token) =>

28
frontend/src/auth.js Normal file
View file

@ -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");
}

View file

@ -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 (
<section className="account-profile-page">
<header className="account-profile-head">
<div>
<p className="account-profile-eyebrow">Account</p>
<h1>Profile</h1>
<p>Update how you appear across Gentic.</p>
</div>
</header>
<div className="account-profile-card">
<div className="account-profile-avatar-block">
<div className="account-profile-avatar">
{account?.profile_picture_url ? (
<img src={account.profile_picture_url} alt="" />
) : (
<span>{accountInitials(account)}</span>
)}
</div>
<div className="account-profile-avatar-actions">
<button
type="button"
className="account-profile-btn"
disabled={status.loading}
onClick={() => inputRef.current?.click()}
>
<Camera size={16} />
{account?.profile_picture_url ? "Change photo" : "Upload photo"}
</button>
{account?.profile_picture_url && (
<button
type="button"
className="account-profile-btn subtle"
disabled={status.loading}
onClick={removePhoto}
>
Remove
</button>
)}
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
hidden
onChange={handleFileChange}
/>
</div>
</div>
<div className="account-profile-fields">
<label>
<span>Name</span>
<div className="account-profile-readonly">
<UserRound size={16} />
{account?.first_name} {account?.last_name}
</div>
</label>
<label>
<span>Email</span>
<div className="account-profile-readonly">{account?.email}</div>
</label>
<label>
<span>Phone</span>
<div className="account-profile-readonly">{account?.phone_number || "Not set"}</div>
</label>
</div>
{status.notice && <p className="account-profile-notice" role="status">{status.notice}</p>}
{status.error && <p className="form-error" role="alert">{status.error}</p>}
</div>
</section>
);
}

View file

@ -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 (
<div className="security-setting-row">
<div>
<strong>{title}</strong>
<p>{description}</p>
</div>
<label className="security-toggle">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span aria-hidden="true" />
</label>
</div>
);
}
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 (
<section className="company-people company-security account-settings">
<header className="company-people-head">
<div>
<h1>Settings</h1>
<p>Manage sign-in security and how long you stay signed in.</p>
</div>
</header>
{error && <p className="form-error" role="alert">{error}</p>}
{notice && <p className="security-notice" role="status">{notice}</p>}
{loading || !settings ? (
<p className="people-table-empty">Loading settings…</p>
) : (
<>
<div className="security-panel">
<h2>Multi-factor authentication</h2>
<ToggleRow
title="Require OTP at sign-in"
description={
settings.mfa_available
? "When enabled, you’ll enter a one-time code sent to your phone after your password."
: "Add an Indian (+91) mobile number to your account to use OTP sign-in."
}
checked={settings.mfa_enabled}
disabled={saving || !settings.mfa_available}
onChange={updateMfa}
/>
</div>
<div className="security-panel">
<h2>Session duration</h2>
<div className="security-setting-row">
<div>
<strong>Keep me signed in</strong>
<p>
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)}.`
: ""}
</p>
</div>
<select
className="security-timeout-select"
value={settings.session_timeout_minutes}
disabled={saving}
onChange={(event) => updateTimeout(event.target.value)}
>
{SESSION_TIMEOUT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
</>
)}
</section>
);
}

View file

@ -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 ( return (
<a className={`brand${compact ? " brand-compact" : ""}`} href="/" aria-label="Agmail home"> <a
className={`brand${compact ? " brand-compact" : ""}`}
href={href}
aria-label={forceHome || getBrandHomeHref() === "/app" ? "Go to inbox" : "Agmail home"}
onClick={handleClick}
>
<span className="brand-mark" aria-hidden="true"> <span className="brand-mark" aria-hidden="true">
<span className="robot-eyes"><i /><i /></span> <span className="robot-eyes"><i /><i /></span>
<span className="robot-smile" /> <span className="robot-smile" />

View file

@ -1,14 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { getOrgSecurity, updateOrgSecurity } from "../api"; import { getOrgSecurity, updateOrgSecurity } from "../api";
import { SESSION_TIMEOUT_OPTIONS } from "../utils/sessionTimeouts";
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" },
];
function ToggleRow({ title, description, checked, disabled, onChange }) { function ToggleRow({ title, description, checked, disabled, onChange }) {
return ( return (
@ -136,7 +128,7 @@ export default function CompanySecurity() {
disabled={saving} disabled={saving}
onChange={(event) => updateTimeout(event.target.value)} onChange={(event) => updateTimeout(event.target.value)}
> >
{SESSION_TIMEOUTS.map((option) => ( {SESSION_TIMEOUT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
{option.label} {option.label}
</option> </option>

View file

@ -12,7 +12,6 @@ import {
LogOut, LogOut,
Mail, Mail,
Menu, Menu,
PanelLeftOpen,
HandHelping, HandHelping,
Plug, Plug,
Send, Send,
@ -24,10 +23,17 @@ import {
Trash2, Trash2,
Users, Users,
UsersRound, UsersRound,
UserRound,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { getCurrentAccount, getMyCompanyDomain } from "../api"; import { getCurrentAccount, getMyCompanyDomain } from "../api";
import {
AUTH_ACCOUNT_KEY,
AUTH_TOKEN_KEY,
clearSession,
getAccessToken,
} from "../auth";
import Brand from "./Brand"; import Brand from "./Brand";
import MobileChrome from "./MobileChrome"; import MobileChrome from "./MobileChrome";
import { ShellModeContext } from "../shellMode"; import { ShellModeContext } from "../shellMode";
@ -43,6 +49,8 @@ import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup
import ChangePasswordModal from "./ChangePasswordModal"; import ChangePasswordModal from "./ChangePasswordModal";
import ComposeDock, { triggerOpenCompose } from "./ComposeDock"; import ComposeDock, { triggerOpenCompose } from "./ComposeDock";
import HumanHandoff from "./HumanHandoff"; import HumanHandoff from "./HumanHandoff";
import AccountProfile from "./AccountProfile";
import AccountSettings from "./AccountSettings";
import MailboxView from "./MailboxView"; import MailboxView from "./MailboxView";
const mailItems = [ const mailItems = [
@ -87,11 +95,6 @@ const manageNavItems = [
[Settings, "Settings"], [Settings, "Settings"],
]; ];
function clearSession() {
sessionStorage.removeItem("agmail_access_token");
sessionStorage.removeItem("agmail_account");
}
function SidebarSection({ title, children, className = "" }) { function SidebarSection({ title, children, className = "" }) {
return ( return (
<section className={`dashboard-sidebar-section ${className}`}> <section className={`dashboard-sidebar-section ${className}`}>
@ -112,7 +115,10 @@ export default function DashboardPage() {
const [needsDnsSetup, setNeedsDnsSetup] = useState(false); const [needsDnsSetup, setNeedsDnsSetup] = useState(false);
const [domainError, setDomainError] = useState(""); const [domainError, setDomainError] = useState("");
const [active, setActive] = useState("Inbox"); 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 [sheet, setSheet] = useState(null);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -134,7 +140,7 @@ export default function DashboardPage() {
const handleStats = useCallback((stats) => setMailStats(stats), []); const handleStats = useCallback((stats) => setMailStats(stats), []);
useEffect(() => { useEffect(() => {
const token = sessionStorage.getItem("agmail_access_token"); const token = getAccessToken();
if (!token) { if (!token) {
window.location.replace("/login"); window.location.replace("/login");
return; return;
@ -142,7 +148,7 @@ export default function DashboardPage() {
getCurrentAccount(token) getCurrentAccount(token)
.then(async (currentAccount) => { .then(async (currentAccount) => {
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount)); sessionStorage.setItem(AUTH_ACCOUNT_KEY, JSON.stringify(currentAccount));
setAccount(currentAccount); setAccount(currentAccount);
setMustChangePassword(Boolean(currentAccount.must_change_password)); setMustChangePassword(Boolean(currentAccount.must_change_password));
if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) { if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) {
@ -157,6 +163,7 @@ export default function DashboardPage() {
setLoading(false); setLoading(false);
}) })
.catch(() => { .catch(() => {
if (!getAccessToken()) return;
clearSession(); clearSession();
window.location.replace("/login"); window.location.replace("/login");
}); });
@ -176,10 +183,24 @@ export default function DashboardPage() {
function closeMobileMenu() { function closeMobileMenu() {
if (window.matchMedia("(max-width: 767.98px)").matches) { 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) { function selectCompanyItem(label) {
setCompanyActive(label); setCompanyActive(label);
setAgentNav(null); setAgentNav(null);
@ -251,6 +272,19 @@ export default function DashboardPage() {
triggerOpenCompose(context); 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) { function openExternalThread(threadId) {
setCompanyActive(null); setCompanyActive(null);
setActive("Inbox"); setActive("Inbox");
@ -323,22 +357,24 @@ export default function DashboardPage() {
data-onboarding={isOrgOnboarding ? "true" : undefined} data-onboarding={isOrgOnboarding ? "true" : undefined}
> >
<header className="dashboard-mobile-header"> <header className="dashboard-mobile-header">
<Brand /> <Brand forceHome />
<button aria-label="Open navigation" type="button" onClick={() => { setSheet(null); setMenuOpen(true); }}> <button aria-label="Open navigation" type="button" onClick={() => { setSheet(null); setMobileMenuOpen(true); }}>
<Menu size={21} /> <Menu size={21} />
</button> </button>
</header> </header>
{menuOpen && <button className="dashboard-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />} {mobileMenuOpen && (
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}> <button className="dashboard-scrim" aria-label="Close navigation" onClick={() => setMobileMenuOpen(false)} />
)}
<aside className={sidebarClasses}>
<div className="dashboard-brand-row"> <div className="dashboard-brand-row">
<Brand /> <Brand forceHome />
<button <button
aria-label={menuOpen ? "Collapse navigation" : "Expand navigation"} aria-label={(mobileMenuOpen || sidebarExpanded) ? "Collapse navigation" : "Expand navigation"}
type="button" type="button"
onClick={() => setMenuOpen((open) => !open)} onClick={toggleSidebar}
> >
{menuOpen ? <X size={19} /> : <PanelLeftOpen size={19} />} {(mobileMenuOpen || sidebarExpanded) ? <X size={19} /> : <Menu size={19} />}
</button> </button>
</div> </div>
@ -452,8 +488,13 @@ export default function DashboardPage() {
</div> </div>
<div className="dashboard-profile"> <div className="dashboard-profile">
<span>{account.first_name[0]}{account.last_name[0]}</span> {account.profile_picture_url ? (
<img src={account.profile_picture_url} alt="" className="dashboard-profile-photo" />
) : (
<span>{account.first_name[0]}{account.last_name[0]}</span>
)}
<div><strong>{account.first_name} {account.last_name}</strong><small>{account.email}</small></div> <div><strong>{account.first_name} {account.last_name}</strong><small>{account.email}</small></div>
<button aria-label="Open profile" title="Profile" type="button" onClick={openProfile}><UserRound size={17} /></button>
<button aria-label="Log out" title="Log out" type="button" onClick={logout}><LogOut size={17} /></button> <button aria-label="Log out" title="Log out" type="button" onClick={logout}><LogOut size={17} /></button>
</div> </div>
</aside> </aside>
@ -485,6 +526,8 @@ export default function DashboardPage() {
initialThreadId={openThreadId} initialThreadId={openThreadId}
onLogout={logout} onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")} onOpenSettings={() => selectMailItem("Settings")}
onOpenProfile={openProfile}
onAccountUpdate={updateAccount}
onCompose={openCompose} onCompose={openCompose}
onStats={handleStats} onStats={handleStats}
/> />
@ -497,6 +540,8 @@ export default function DashboardPage() {
account={account} account={account}
onLogout={logout} onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")} onOpenSettings={() => selectMailItem("Settings")}
onOpenProfile={openProfile}
onAccountUpdate={updateAccount}
onCompose={openCompose} onCompose={openCompose}
/> />
)} )}
@ -508,9 +553,14 @@ export default function DashboardPage() {
account={account} account={account}
onLogout={logout} onLogout={logout}
onOpenSettings={() => selectMailItem("Settings")} onOpenSettings={() => selectMailItem("Settings")}
onOpenProfile={openProfile}
onAccountUpdate={updateAccount}
onCompose={openCompose} onCompose={openCompose}
/> />
)} )}
{!companyActive && active === "Profile" && (
<AccountProfile account={account} onUpdated={updateAccount} />
)}
{companyActive === "Overview" && isDomainOwner && !isOrgSetupComplete(account.id) && ( {companyActive === "Overview" && isDomainOwner && !isOrgSetupComplete(account.id) && (
<CompanyOverviewSetup <CompanyOverviewSetup
account={account} account={account}
@ -556,7 +606,10 @@ export default function DashboardPage() {
<p>Company {companyActive.toLowerCase()} controls will appear here.</p> <p>Company {companyActive.toLowerCase()} controls will appear here.</p>
</section> </section>
)} )}
{!companyActive && !["Inbox", "Outbox", "Trash"].includes(active) && ( {!companyActive && active === "Settings" && (
<AccountSettings />
)}
{!companyActive && !["Inbox", "Outbox", "Trash", "Settings"].includes(active) && (
<section className="dashboard-empty"> <section className="dashboard-empty">
<span><Sparkles size={24} /></span> <span><Sparkles size={24} /></span>
<h2>{active} is ready for you</h2> <h2>{active} is ready for you</h2>

View file

@ -42,7 +42,7 @@ export default function InviteAcceptPage() {
const result = await acceptInvite(token, { password }); const result = await acceptInvite(token, { password });
sessionStorage.setItem("agmail_access_token", result.access_token); sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account)); sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app"); window.location.replace("/app");
} catch (error) { } catch (error) {
setStatus({ loading: false, error: error.message }); setStatus({ loading: false, error: error.message });
} }

View file

@ -1,29 +1,84 @@
import { ArrowRight, Mail } from "lucide-react"; import { ArrowRight, Mail } from "lucide-react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { login } from "../api"; import { login, resendLoginOtp } from "../api";
import { AUTH_ACCOUNT_KEY, AUTH_TOKEN_KEY, isLoggedIn } from "../auth";
import AuthLayout from "./AuthLayout"; import AuthLayout from "./AuthLayout";
import PasswordField from "./PasswordField"; import PasswordField from "./PasswordField";
export default function LoginPage() { export default function LoginPage() {
const [status, setStatus] = useState({ loading: false, error: "" }); 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) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
setStatus({ loading: true, error: "" }); setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try { try {
const result = await login({ const result = await login({
email: form.get("email"), email: email.trim(),
password: form.get("password"), password,
...(challenge
? { challenge_token: challenge.challenge_token, otp: otp.trim() }
: {}),
}); });
sessionStorage.setItem("agmail_access_token", result.access_token); if (result.mfa_required) {
sessionStorage.setItem("agmail_account", JSON.stringify(result.account)); setChallenge(result);
window.location.assign("/app"); 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) { } catch (error) {
setStatus({ loading: false, error: error.message }); 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 ( return (
<AuthLayout <AuthLayout
eyebrow="Welcome back" eyebrow="Welcome back"
@ -31,27 +86,92 @@ export default function LoginPage() {
description="Sign in to continue working alongside your agents." description="Sign in to continue working alongside your agents."
> >
<> <>
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div> <div className="card-heading">
<form className="auth-form" onSubmit={handleSubmit}> <h2>{challenge ? "Verify it’s you" : "Log in to agmail"}</h2>
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@gentic.email" required autoComplete="email" /></div></label> <p>
<label> {challenge
<span className="auth-label-row"> ? `Enter the 6-digit code sent to ${challenge.phone_hint}.`
Password : "Enter your account details below."}
<a className="auth-forgot" href="/forgot-password">Forgot password?</a> </p>
</span> </div>
<PasswordField <form className="auth-form" onSubmit={handleSubmit}>
name="password" {!challenge && (
placeholder="Your password" <>
required <label>
autoComplete="current-password" Email address
/> <div className="input-wrap">
</label> <Mail size={16} />
{status.error && <p className="form-error" role="alert">{status.error}</p>} <input
<button className="button button-coral auth-submit" disabled={status.loading}> name="email"
{status.loading ? "Signing in…" : "Log in"} <ArrowRight size={16} /> type="email"
</button> placeholder="you@gentic.email"
</form> required
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</div>
</label>
<label>
<span className="auth-label-row">
Password
<a className="auth-forgot" href="/forgot-password">Forgot password?</a>
</span>
<PasswordField
name="password"
placeholder="Your password"
required
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
</>
)}
{challenge && (
<div className="auth-otp-block">
<div className="auth-otp-head">
<label htmlFor="login-otp">Verification code</label>
<button
type="button"
className="auth-otp-send"
disabled={status.loading || otpCooldown > 0}
onClick={handleResendOtp}
>
{otpCooldown > 0 ? `Resend in ${otpCooldown}s` : "Resend code"}
</button>
</div>
<div className="input-wrap">
<input
id="login-otp"
name="otp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code"
value={otp}
onChange={(event) => setOtp(event.target.value.replace(/\D/g, "").slice(0, 6))}
/>
</div>
<button type="button" className="auth-otp-send" onClick={resetChallenge}>
Use a different account
</button>
</div>
)}
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={!canSubmit}>
{status.loading
? challenge
? "Verifying…"
: "Signing in…"
: challenge
? "Verify and continue"
: "Log in"} <ArrowRight size={16} />
</button>
</form>
{!challenge && (
<p className="auth-switch">New to agmail? <a href="/signup">Create an account</a></p> <p className="auth-switch">New to agmail? <a href="/signup">Create an account</a></p>
)}
</> </>
</AuthLayout> </AuthLayout>
); );

View file

@ -38,6 +38,7 @@ import MailRow from "./MailRow";
import MailboxEmptyState from "./MailboxEmptyState"; import MailboxEmptyState from "./MailboxEmptyState";
import MailboxSelectionBar from "./MailboxSelectionBar"; import MailboxSelectionBar from "./MailboxSelectionBar";
import StickyNotesPanel from "./StickyNotesPanel"; import StickyNotesPanel from "./StickyNotesPanel";
import WorkspaceAccountMenu from "./WorkspaceAccountMenu";
import { useShellMode } from "../shellMode"; import { useShellMode } from "../shellMode";
import { shellTitle } from "./MobileChrome"; import { shellTitle } from "./MobileChrome";
import { import {
@ -117,6 +118,8 @@ export default function MailboxView({
initialThreadId = null, initialThreadId = null,
onLogout, onLogout,
onOpenSettings, onOpenSettings,
onOpenProfile,
onAccountUpdate,
onCompose, onCompose,
onStats, onStats,
}) { }) {
@ -149,8 +152,10 @@ export default function MailboxView({
dateTo: "", dateTo: "",
}); });
const load = useCallback(async () => { const load = useCallback(async ({ silent = false } = {}) => {
setStatus({ loading: true, error: "" }); if (!silent) {
setStatus({ loading: true, error: "" });
}
try { try {
const result = await listThreads( const result = await listThreads(
{ {
@ -171,15 +176,44 @@ export default function MailboxView({
setTotal(page.total); setTotal(page.total);
setStatus({ loading: false, error: "" }); setStatus({ loading: false, error: "" });
} catch (error) { } catch (error) {
setStatus({ loading: false, error: error.message }); if (!silent) {
setStatus({ loading: false, error: error.message });
}
} }
}, [cursor, filters, mailbox, pageSize, token]); }, [cursor, filters, mailbox, pageSize, token]);
const canAutoRefresh = !cursor
&& !filters.query.trim()
&& filters.read === "all"
&& !filters.sender.trim()
&& !filters.dateFrom
&& !filters.dateTo;
useEffect(() => { useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0); const timeout = window.setTimeout(() => load(), filters.query ? 250 : 0);
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [load, refreshKey]); }, [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(() => { useEffect(() => {
if (!initialThreadId) return undefined; if (!initialThreadId) return undefined;
let cancelled = false; let cancelled = false;
@ -638,7 +672,6 @@ export default function MailboxView({
setPageMenuOpen(false); setPageMenuOpen(false);
} }
const initials = `${account?.first_name?.[0] || ""}${account?.last_name?.[0] || ""}`.toUpperCase();
const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0; const rangeStart = emails.length ? cursorHistory.length * pageSize + 1 : 0;
const rangeEnd = emails.length ? rangeStart + emails.length - 1 : 0; const rangeEnd = emails.length ? rangeStart + emails.length - 1 : 0;
const rangeLabel = emails.length === 0 const rangeLabel = emails.length === 0
@ -697,14 +730,12 @@ export default function MailboxView({
<button type="button" className="workspace-icon" aria-label="Theme"><Sun size={16} /></button> <button type="button" className="workspace-icon" aria-label="Theme"><Sun size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Help"><CircleHelp size={16} /></button> <button type="button" className="workspace-icon" aria-label="Help"><CircleHelp size={16} /></button>
<button type="button" className="workspace-icon" aria-label="Settings" onClick={onOpenSettings}><Settings size={16} /></button> <button type="button" className="workspace-icon" aria-label="Settings" onClick={onOpenSettings}><Settings size={16} /></button>
<button type="button" className="workspace-account" onClick={onLogout} title="Log out"> <WorkspaceAccountMenu
<span>{initials || "AG"}</span> account={account}
<div> onOpenProfile={onOpenProfile}
<strong>{account?.first_name} {account?.last_name}</strong> onOpenSettings={onOpenSettings}
<small>{account?.email}</small> onLogout={onLogout}
</div> />
<ChevronDown size={14} />
</button>
</div> </div>
</header> </header>

View file

@ -1,9 +1,11 @@
import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react"; import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react";
import Brand from "./Brand"; import Brand from "./Brand";
import SessionExpiredNotice from "./SessionExpiredNotice";
export default function MobileLanding() { export default function MobileLanding() {
return ( return (
<main className="mobile-landing"> <main className="mobile-landing">
<SessionExpiredNotice />
<nav className="mobile-nav"> <nav className="mobile-nav">
<Brand /> <Brand />
<button className="icon-button" aria-label="Open menu"><Menu size={21} /></button> <button className="icon-button" aria-label="Open menu"><Menu size={21} /></button>

View file

@ -0,0 +1,10 @@
export default function SessionExpiredNotice() {
const expired = new URLSearchParams(window.location.search).get("session") === "expired";
if (!expired) return null;
return (
<div className="session-expired-banner" role="status">
Your session expired. <a href="/login">Log in</a> to continue where you left off.
</div>
);
}

View file

@ -1,17 +1,85 @@
import { import {
ArrowRight, Building2, CalendarDays, Mail, Phone, UserRound, ArrowRight, Building2, CalendarDays, Mail, Phone, UserRound,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { register } from "../api"; import { register, sendSignupOtp } from "../api";
import AuthLayout from "./AuthLayout"; import AuthLayout from "./AuthLayout";
import PasswordField from "./PasswordField"; 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() { export default function SignupPage() {
const [accountType, setAccountType] = useState("individual"); 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 [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) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
if (!canSubmit) return;
setStatus({ loading: true, error: "" }); setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget); const form = new FormData(event.currentTarget);
try { try {
@ -22,12 +90,14 @@ export default function SignupPage() {
first_name: form.get("first_name"), first_name: form.get("first_name"),
last_name: form.get("last_name"), last_name: form.get("last_name"),
date_of_birth: form.get("date_of_birth"), 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"), password: form.get("password"),
}); });
sessionStorage.setItem("agmail_access_token", result.access_token); sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account)); sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app"); window.location.replace("/app");
} catch (error) { } catch (error) {
setStatus({ loading: false, error: error.message }); setStatus({ loading: false, error: error.message });
} }
@ -67,10 +137,80 @@ export default function SignupPage() {
<label>Last name<div className="input-wrap"><UserRound size={16} /><input name="last_name" placeholder="Morgan" required /></div></label> <label>Last name<div className="input-wrap"><UserRound size={16} /><input name="last_name" placeholder="Morgan" required /></div></label>
</div> </div>
<label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label> <label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label>
<label>Phone number<div className="input-wrap"><Phone size={16} /><input name="phone_number" type="tel" placeholder="+1 555 123 4567" required /></div></label> <label>
Phone number
<div className="phone-row">
<div className="input-wrap phone-country-wrap">
<select
aria-label="Country code"
value={countryCode}
onChange={(event) => setCountryCode(event.target.value)}
>
{COUNTRY_CODES.map((item) => (
<option key={item.code} value={item.code}>{item.label}</option>
))}
</select>
</div>
<div className="input-wrap">
<Phone size={16} />
<input
name="phone"
type="tel"
inputMode="numeric"
autoComplete="tel-national"
placeholder={isIndia ? "9149154337" : "555 123 4567"}
value={phone}
onChange={(event) => setPhone(event.target.value.replace(/\D/g, ""))}
required
/>
</div>
</div>
{isIndia && (
<small className="field-hint">Indian numbers require SMS verification.</small>
)}
</label>
{isIndia && (
<div className="auth-otp-block">
<div className="auth-otp-head">
<label htmlFor="signup-otp">Verification code</label>
<button
type="button"
className="auth-otp-send"
disabled={!canSendOtp}
onClick={handleSendOtp}
>
{otpStatus.loading
? "Sending…"
: otpCooldown > 0
? `Resend in ${otpCooldown}s`
: otpSent
? "Resend code"
: "Send code"}
</button>
</div>
<div className="input-wrap">
<input
id="signup-otp"
name="otp"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6-digit code"
maxLength={6}
value={otp}
onChange={(event) => setOtp(event.target.value.replace(/\D/g, "").slice(0, 6))}
required
/>
</div>
{otpStatus.notice && <p className="field-hint auth-otp-notice">{otpStatus.notice}</p>}
{otpStatus.error && <p className="form-error" role="alert">{otpStatus.error}</p>}
</div>
)}
<label>Password<PasswordField name="password" placeholder="8+ characters" minLength={8} required autoComplete="new-password" /><small className="field-hint">Use uppercase, lowercase, and a number.</small></label> <label>Password<PasswordField name="password" placeholder="8+ characters" minLength={8} required autoComplete="new-password" /><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
{status.error && <p className="form-error" role="alert">{status.error}</p>} {status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={status.loading}> <button className="button button-coral auth-submit" disabled={!canSubmit}>
{status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} /> {status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} />
</button> </button>
</form> </form>

View file

@ -3,6 +3,7 @@ import {
Paperclip, Search, Send, ShieldCheck, Sparkles, Paperclip, Search, Send, ShieldCheck, Sparkles,
} from "lucide-react"; } from "lucide-react";
import Brand from "./Brand"; import Brand from "./Brand";
import SessionExpiredNotice from "./SessionExpiredNotice";
const mail = [ const mail = [
{ sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" }, { sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" },
@ -13,6 +14,7 @@ const mail = [
export default function WebLanding() { export default function WebLanding() {
return ( return (
<main className="landing"> <main className="landing">
<SessionExpiredNotice />
<nav className="nav shell"> <nav className="nav shell">
<Brand /> <Brand />
<div className="nav-links"><a href="#product">Product</a><a href="#security">Security</a><a href="#about">About</a></div> <div className="nav-links"><a href="#product">Product</a><a href="#security">Security</a><a href="#about">About</a></div>

View file

@ -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 (
<div className="workspace-account-wrap" ref={wrapRef}>
<button
type="button"
className="workspace-account"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
{account?.profile_picture_url ? (
<img src={account.profile_picture_url} alt="" className="workspace-account-photo" />
) : (
<span>{accountInitials(account)}</span>
)}
<div>
<strong>{account?.first_name} {account?.last_name}</strong>
<small>{account?.email}</small>
</div>
<ChevronDown size={14} className={open ? "open" : ""} />
</button>
{open && (
<div className="workspace-account-menu" role="menu">
<button type="button" role="menuitem" onClick={() => choose(onOpenProfile)}>
<UserRound size={16} />
Profile
</button>
<button type="button" role="menuitem" onClick={() => choose(onOpenSettings)}>
<Settings size={16} />
Settings
</button>
<button type="button" role="menuitem" className="danger" onClick={() => choose(onLogout)}>
<LogOut size={16} />
Log out
</button>
</div>
)}
</div>
);
}

View file

@ -112,17 +112,17 @@
height:min(78vh,calc(100vh - 32px)); height:min(78vh,calc(100vh - 32px));
} }
.dashboard:has(.dashboard-sidebar.open) { grid-template-columns:228px minmax(0,1fr); } .dashboard:has(.dashboard-sidebar.expanded) { grid-template-columns:228px minmax(0,1fr); }
.dashboard-sidebar.open { .dashboard-sidebar.expanded {
width:228px; width:228px;
padding:var(--space-4) var(--space-3) var(--space-3); 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.expanded .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.expanded .dashboard-brand-row .brand > span:not(.brand-mark),
.dashboard-sidebar.open .dashboard-compose span, .dashboard-sidebar.expanded .dashboard-compose span,
.dashboard-sidebar.open .dashboard-sidebar-section h2, .dashboard-sidebar.expanded .dashboard-sidebar-section h2,
.dashboard-sidebar.open .dashboard-nav-item span, .dashboard-sidebar.expanded .dashboard-nav-item span,
.dashboard-sidebar.open .dashboard-profile > div { .dashboard-sidebar.expanded .dashboard-profile > div {
position:static; position:static;
width:auto; width:auto;
height:auto; height:auto;
@ -131,7 +131,7 @@
margin:0; margin:0;
white-space:nowrap; white-space:nowrap;
} }
.dashboard-sidebar.open .dashboard-nav-item em { .dashboard-sidebar.expanded .dashboard-nav-item em {
position:static; position:static;
width:auto; width:auto;
min-width:18px; min-width:18px;
@ -141,20 +141,83 @@
margin-left:auto; margin-left:auto;
padding:0 6px; padding:0 6px;
} }
.dashboard-sidebar.open .dashboard-sidebar-section h2 { margin:0 0 var(--space-2); padding:0 var(--space-2); } .dashboard-sidebar.expanded .dashboard-sidebar-section h2 { margin:0 0 var(--space-2); padding:0 var(--space-2); }
.dashboard-sidebar.open .dashboard-compose { .dashboard-sidebar.expanded .dashboard-compose {
width:calc(100% - 8px); width:calc(100% - 8px);
margin:var(--space-4) var(--space-1) 0; margin:var(--space-4) var(--space-1) 0;
padding:0 var(--space-4); 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.expanded .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.expanded .dashboard-nav-item span { position:static; width:auto; height:auto; clip:auto; overflow:visible; flex:1; min-width:0; }
.dashboard-sidebar.open .dashboard-profile { .dashboard-sidebar.expanded .dashboard-profile {
grid-template-columns:34px minmax(0,1fr) 31px; grid-template-columns:34px minmax(0,1fr) 31px 31px;
padding-left:5px; padding-left:5px;
padding-right: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) { @media (max-width:767.98px) {

View file

@ -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); } .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; } .brand-compact { line-height:0; }
.nav { height:88px; display:flex; align-items:center; justify-content:space-between; } .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,.nav-actions,.hero-actions,.trust-line { display:flex; align-items:center; }
.nav-links { gap:38px; margin-left:80px; color:#4f4f62; font-size:14px; } .nav-links { gap:38px; margin-left:80px; color:#4f4f62; font-size:14px; }
.nav-actions,.hero-actions { gap:10px; } .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>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); } .type-card svg { color:#77727c; }.type-card.selected svg { color:var(--coral); }
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; } .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; } .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; } .field-hint { display:block; margin-top:6px; color:#99939b; font-size:9px; font-weight:500; }
.auth-submit { width:100%; margin-top:3px; } .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>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,.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 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-profile button:hover { color:var(--ink); background:#f5f1ee; }
.dashboard-content { min-width:0; padding:28px clamp(28px,4vw,60px); background:#f5f4f2; } .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; } .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)); } .compose-window.expanded { width:calc(100vw - 24px); min-width:0; height:min(78vh,calc(100vh - 24px)); }
} }
@media(max-width:480px) { @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 { right:0 !important; bottom:0; width:100%; }
.compose-dock-expanded { width:100%; } .compose-dock-expanded { width:100%; }
.compose-window { width:100vw; height:min(88vh,100vh); border-radius:14px 14px 0 0; } .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 { 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-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-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-brand-row>button { color:#c8c3d4; }
.dashboard-sidebar .brand { color:#fff; font-size:17px; } .dashboard-sidebar .brand { color:#fff; font-size:17px; }
.dashboard-sidebar .brand-mark { background:#fff; } .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-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-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-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 { 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 { background:linear-gradient(180deg,#ff9a82,#ff6f57); color:#2a2038; } .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 strong { color:#fff; }
.dashboard-profile small,.dashboard-profile button { color:#9a95a8; } .dashboard-profile small,.dashboard-profile button { color:#9a95a8; }
.dashboard-profile button:hover { background:#ffffff12; color:#fff; } .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 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 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); } } @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: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 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,.workspace-account small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.workspace-account strong { color:#241f2b; font-size:12px; font-weight:600; } .workspace-account strong { color:#241f2b; font-size:12px; font-weight:600; }

View file

@ -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`;
}