setting, session and profile
This commit is contained in:
parent
48eea5d201
commit
4a563f4f34
31 changed files with 1797 additions and 159 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)],
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
173
backend/app/services/login_mfa.py
Normal file
173
backend/app/services/login_mfa.py
Normal 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
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
47
backend/app/services/session_policy.py
Normal file
47
backend/app/services/session_policy.py
Normal 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
|
||||||
236
backend/app/services/urmi_otp.py
Normal file
236
backend/app/services/urmi_otp.py
Normal 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}},
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
21
backend/tests/test_session_policy.py
Normal file
21
backend/tests/test_session_policy.py
Normal 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)
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
28
frontend/src/auth.js
Normal 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");
|
||||||
|
}
|
||||||
132
frontend/src/components/AccountProfile.jsx
Normal file
132
frontend/src/components/AccountProfile.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/components/AccountSettings.jsx
Normal file
139
frontend/src/components/AccountSettings.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
{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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +86,32 @@ 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">
|
||||||
|
<h2>{challenge ? "Verify it’s you" : "Log in to agmail"}</h2>
|
||||||
|
<p>
|
||||||
|
{challenge
|
||||||
|
? `Enter the 6-digit code sent to ${challenge.phone_hint}.`
|
||||||
|
: "Enter your account details below."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<form className="auth-form" onSubmit={handleSubmit}>
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@gentic.email" required autoComplete="email" /></div></label>
|
{!challenge && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Email address
|
||||||
|
<div className="input-wrap">
|
||||||
|
<Mail size={16} />
|
||||||
|
<input
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@gentic.email"
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span className="auth-label-row">
|
<span className="auth-label-row">
|
||||||
Password
|
Password
|
||||||
|
|
@ -44,14 +122,56 @@ export default function LoginPage() {
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>}
|
{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 ? "Signing in…" : "Log in"} <ArrowRight size={16} />
|
{status.loading
|
||||||
|
? challenge
|
||||||
|
? "Verifying…"
|
||||||
|
: "Signing in…"
|
||||||
|
: challenge
|
||||||
|
? "Verify and continue"
|
||||||
|
: "Log in"} <ArrowRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 } = {}) => {
|
||||||
|
if (!silent) {
|
||||||
setStatus({ loading: true, error: "" });
|
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) {
|
||||||
|
if (!silent) {
|
||||||
setStatus({ loading: false, error: error.message });
|
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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
10
frontend/src/components/SessionExpiredNotice.jsx
Normal file
10
frontend/src/components/SessionExpiredNotice.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
76
frontend/src/components/WorkspaceAccountMenu.jsx
Normal file
76
frontend/src/components/WorkspaceAccountMenu.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
22
frontend/src/utils/sessionTimeouts.js
Normal file
22
frontend/src/utils/sessionTimeouts.js
Normal 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`;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue