ui enhancement company
This commit is contained in:
parent
cc3f3f3de5
commit
c01914736b
14 changed files with 876 additions and 159 deletions
24
backend/app/deps/domain_owner.py
Normal file
24
backend/app/deps/domain_owner.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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__ = (
|
||||
|
|
|
|||
39
backend/app/routers/org.py
Normal file
39
backend/app/routers/org.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
55
backend/app/services/org_security.py
Normal file
55
backend/app/services/org_security.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue