ui enhancement company

This commit is contained in:
debu-2 2026-10-04 20:32:18 +05:30
parent cc3f3f3de5
commit c01914736b
14 changed files with 876 additions and 159 deletions

View file

@ -0,0 +1,24 @@
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.models import Account
from app.routers.auth import get_current_account
from app.services.domain_provisioning import account_is_domain_owner
async def require_domain_owner(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if account.account_type != "company" or not account.company_domain:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Company accounts only.",
)
if not await account_is_domain_owner(session, account.id, account.company_domain):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the domain owner can manage administrators.",
)
return account

View file

@ -14,6 +14,7 @@ from app.routers import (
domains,
emails,
groups,
org,
people,
sticky_notes,
)
@ -160,6 +161,7 @@ app.include_router(domains.router, prefix=settings.api_prefix)
app.include_router(people.router, prefix=settings.api_prefix)
app.include_router(groups.router, prefix=settings.api_prefix)
app.include_router(aliases.router, prefix=settings.api_prefix)
app.include_router(org.router, prefix=settings.api_prefix)
app.include_router(dns_setup.router, prefix=settings.api_prefix)

View file

@ -136,6 +136,26 @@ class SmtpCredential(Base):
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
class OrgSecuritySettings(Base):
__tablename__ = "org_security_settings"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
company_domain: Mapped[str] = mapped_column(String(253), unique=True, index=True)
require_strong_passwords: Mapped[bool] = mapped_column(Boolean, default=True)
require_mfa_admins: Mapped[bool] = mapped_column(Boolean, default=True)
require_mfa_all_users: Mapped[bool] = mapped_column(Boolean, default=False)
session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=60)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class MailGroup(Base):
__tablename__ = "mail_groups"
__table_args__ = (

View file

@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager
from app.models import Account
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
from app.services.org_security import read_security_settings, update_security_settings
router = APIRouter(prefix="/org", tags=["organization"])
@router.get("/security", response_model=OrgSecuritySettingsRead)
async def get_org_security(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgSecuritySettingsRead:
return await read_security_settings(session, manager.company_domain)
@router.patch("/security", response_model=OrgSecuritySettingsRead)
async def patch_org_security(
payload: OrgSecuritySettingsUpdate,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgSecuritySettingsRead:
try:
settings = await update_security_settings(
session,
manager.company_domain,
payload,
)
await session.commit()
return settings
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error

View file

@ -1,32 +1,29 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.domain_owner import require_domain_owner
from app.models import Account
from app.routers.auth import get_current_account, validate_password
from app.schemas import OrgMemberCreate, OrgMemberListResponse, OrgMemberRead
from app.routers.auth import validate_password
from app.schemas import (
OrgMemberCreate,
OrgMemberListResponse,
OrgMemberRead,
OrgMemberRoleUpdate,
)
from app.services.org_members import (
can_manage_org_members,
create_org_member,
list_org_members,
update_org_member_role,
)
from app.deps.org_manager import require_org_manager
router = APIRouter(prefix="/people", tags=["people"])
async def require_org_manager(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if not await can_manage_org_members(session, account):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only company admins can manage people.",
)
return account
@router.get("", response_model=OrgMemberListResponse)
async def get_people(
manager: Account = Depends(require_org_manager),
@ -64,3 +61,26 @@ async def add_person(
status_code=status.HTTP_409_CONFLICT,
detail="An account with those details already exists.",
) from error
@router.patch("/{member_id}/role", response_model=OrgMemberRead)
async def update_member_role(
member_id: UUID,
payload: OrgMemberRoleUpdate,
owner: Account = Depends(require_domain_owner),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberRead:
try:
member = await update_org_member_role(
session,
manager=owner,
member_id=member_id,
org_role=payload.org_role,
)
await session.commit()
return member
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error

View file

@ -126,6 +126,26 @@ class MailAliasListResponse(BaseModel):
counts: dict[str, int]
class OrgSecuritySettingsRead(BaseModel):
require_strong_passwords: bool
require_mfa_admins: bool
require_mfa_all_users: bool
session_timeout_minutes: int
model_config = ConfigDict(from_attributes=True)
class OrgSecuritySettingsUpdate(BaseModel):
require_strong_passwords: bool | None = None
require_mfa_admins: bool | None = None
require_mfa_all_users: bool | None = None
session_timeout_minutes: int | None = Field(default=None, ge=15, le=1440)
class OrgMemberRoleUpdate(BaseModel):
org_role: Literal["user", "admin"]
class EmailCreate(BaseModel):
recipients: list[EmailStr] = Field(min_length=1)
cc: list[EmailStr] = Field(default_factory=list)

View file

@ -141,3 +141,41 @@ async def create_org_member(
session.add(member)
await session.flush()
return await member_read(session, member)
async def update_org_member_role(
session: AsyncSession,
*,
manager: Account,
member_id,
org_role: str,
) -> OrgMemberRead:
company_domain = manager.company_domain
if company_domain is None:
raise ValueError("Company domain is required.")
if not await account_is_domain_owner(
session,
manager.id,
company_domain,
):
raise ValueError("Only the domain owner can assign roles.")
member = await session.scalar(
select(Account).where(
Account.id == member_id,
Account.company_domain == company_domain,
)
)
if member is None:
raise ValueError("User not found in your organization.")
if await account_is_domain_owner(session, member.id, company_domain):
raise ValueError("The domain owner role cannot be changed.")
if member.id == manager.id:
raise ValueError("You cannot change your own role.")
member.org_role = org_role
await session.flush()
return await member_read(session, member)

View file

@ -0,0 +1,55 @@
"""Organization security settings."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import OrgSecuritySettings
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
ALLOWED_SESSION_TIMEOUTS = {15, 30, 60, 120, 480, 1440}
async def get_or_create_security_settings(
session: AsyncSession,
company_domain: str,
) -> OrgSecuritySettings:
settings = await session.scalar(
select(OrgSecuritySettings).where(
OrgSecuritySettings.company_domain == company_domain
)
)
if settings is None:
settings = OrgSecuritySettings(company_domain=company_domain)
session.add(settings)
await session.flush()
return settings
async def read_security_settings(
session: AsyncSession,
company_domain: str,
) -> OrgSecuritySettingsRead:
settings = await get_or_create_security_settings(session, company_domain)
return OrgSecuritySettingsRead.model_validate(settings)
async def update_security_settings(
session: AsyncSession,
company_domain: str,
payload: OrgSecuritySettingsUpdate,
) -> OrgSecuritySettingsRead:
settings = await get_or_create_security_settings(session, company_domain)
updates = payload.model_dump(exclude_unset=True)
if "session_timeout_minutes" in updates:
timeout = updates["session_timeout_minutes"]
if timeout not in ALLOWED_SESSION_TIMEOUTS:
raise ValueError(
"Session timeout must be one of: "
+ ", ".join(str(value) for value in sorted(ALLOWED_SESSION_TIMEOUTS))
)
for field, value in updates.items():
setattr(settings, field, value)
await session.flush()
return OrgSecuritySettingsRead.model_validate(settings)