diff --git a/backend/app/deps/org_manager.py b/backend/app/deps/org_manager.py new file mode 100644 index 0000000..6c91bc5 --- /dev/null +++ b/backend/app/deps/org_manager.py @@ -0,0 +1,19 @@ +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.org_members import can_manage_org_members + + +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 organization settings.", + ) + return account diff --git a/backend/app/main.py b/backend/app/main.py index 666a0e3..d653a6a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,16 @@ from sqlalchemy import text from app.config import get_settings from app.database import SessionFactory, engine, mongo_client, mongo_db from app.models import Base -from app.routers import auth, dns_setup, domains, emails, sticky_notes +from app.routers import ( + aliases, + auth, + dns_setup, + domains, + emails, + groups, + people, + sticky_notes, +) from app.services.domain_provisioning import ensure_platform_domain from app.services.inbound_smtp import InboundSMTPServer @@ -15,6 +24,29 @@ settings = get_settings() inbound_server = InboundSMTPServer() +async def migrate_user_org_columns(connection) -> None: + await connection.execute( + text("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_role VARCHAR(20)") + ) + await connection.execute( + text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS member_status VARCHAR(20)" + ) + ) + await connection.execute( + text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS created_by_id UUID " + "REFERENCES users(id)" + ) + ) + await connection.execute( + text("UPDATE users SET org_role = 'user' WHERE org_role IS NULL") + ) + await connection.execute( + text("UPDATE users SET member_status = 'active' WHERE member_status IS NULL") + ) + + async def migrate_legacy_user_tables(connection) -> None: """Promote the former auth table to users without discarding old profiles.""" tables = ( @@ -44,6 +76,7 @@ async def lifespan(_: FastAPI): async with engine.begin() as connection: await migrate_legacy_user_tables(connection) await connection.run_sync(Base.metadata.create_all) + await migrate_user_org_columns(connection) async with SessionFactory() as session: await ensure_platform_domain(session) await session.commit() @@ -124,6 +157,9 @@ app.include_router(emails.router, prefix=settings.api_prefix) app.include_router(sticky_notes.router, prefix=settings.api_prefix) app.include_router(auth.router, prefix=settings.api_prefix) 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(dns_setup.router, prefix=settings.api_prefix) diff --git a/backend/app/models.py b/backend/app/models.py index c5bf07c..601c592 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -41,6 +41,13 @@ class Account(Base): date_of_birth: Mapped[date] = mapped_column(Date) phone_number: Mapped[str] = mapped_column(String(24), unique=True) company_domain: Mapped[str | None] = mapped_column(String(253), nullable=True) + org_role: Mapped[str] = mapped_column(String(20), default="user") + member_status: Mapped[str] = mapped_column(String(20), default="active") + created_by_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True, + ) password_hash: Mapped[str] = mapped_column(String(255)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -127,3 +134,93 @@ class SmtpCredential(Base): server_default=func.now(), ) domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials") + + +class MailGroup(Base): + __tablename__ = "mail_groups" + __table_args__ = ( + CheckConstraint( + "group_type IN ('distribution', 'dynamic')", + name="ck_mail_groups_group_type", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + company_domain: Mapped[str] = mapped_column(String(253), index=True) + name: Mapped[str] = mapped_column(String(120)) + email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + group_type: Mapped[str] = mapped_column(String(20), default="distribution") + created_by_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + members: Mapped[list["MailGroupMember"]] = relationship( + back_populates="group", + cascade="all, delete-orphan", + ) + + +class MailGroupMember(Base): + __tablename__ = "mail_group_members" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + group_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("mail_groups.id"), + index=True, + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + index=True, + ) + group: Mapped[MailGroup] = relationship(back_populates="members") + account: Mapped[Account] = relationship() + + +class MailAlias(Base): + __tablename__ = "mail_aliases" + __table_args__ = ( + CheckConstraint( + "alias_type IN ('user', 'group')", + name="ck_mail_aliases_alias_type", + ), + CheckConstraint( + "status IN ('active', 'disabled')", + name="ck_mail_aliases_status", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + company_domain: Mapped[str] = mapped_column(String(253), index=True) + alias_address: Mapped[str] = mapped_column(String(320), unique=True, index=True) + forwards_to: Mapped[str] = mapped_column(String(320)) + alias_type: Mapped[str] = mapped_column(String(20), default="user") + is_domain_alias: Mapped[bool] = mapped_column(Boolean, default=False) + status: Mapped[str] = mapped_column(String(20), default="active") + created_by_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) diff --git a/backend/app/routers/aliases.py b/backend/app/routers/aliases.py new file mode 100644 index 0000000..260a8af --- /dev/null +++ b/backend/app/routers/aliases.py @@ -0,0 +1,43 @@ +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.org_manager import require_org_manager +from app.models import Account +from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead +from app.services.mail_aliases import create_mail_alias, list_mail_aliases + +router = APIRouter(prefix="/aliases", tags=["aliases"]) + + +@router.get("", response_model=MailAliasListResponse) +async def get_aliases( + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> MailAliasListResponse: + items, counts = await list_mail_aliases(session, manager.company_domain) + return MailAliasListResponse(items=items, counts=counts) + + +@router.post("", response_model=MailAliasRead, status_code=status.HTTP_201_CREATED) +async def add_alias( + payload: MailAliasCreate, + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> MailAliasRead: + try: + alias = await create_mail_alias(session, manager=manager, payload=payload) + await session.commit() + return alias + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(error), + ) from error + except IntegrityError as error: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="That alias could not be created.", + ) from error diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index cdc9416..22038cb 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -176,6 +176,8 @@ async def register( date_of_birth=payload.date_of_birth, phone_number=phone_number, company_domain=company_domain, + org_role="owner" if company_domain else "user", + member_status="active", password_hash=password_hash.hash(payload.password), ) session.add(account) diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py new file mode 100644 index 0000000..987239b --- /dev/null +++ b/backend/app/routers/groups.py @@ -0,0 +1,43 @@ +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.org_manager import require_org_manager +from app.models import Account +from app.schemas import MailGroupCreate, MailGroupListResponse, MailGroupRead +from app.services.mail_groups import create_mail_group, list_mail_groups + +router = APIRouter(prefix="/groups", tags=["groups"]) + + +@router.get("", response_model=MailGroupListResponse) +async def get_groups( + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> MailGroupListResponse: + items, counts = await list_mail_groups(session, manager.company_domain) + return MailGroupListResponse(items=items, counts=counts) + + +@router.post("", response_model=MailGroupRead, status_code=status.HTTP_201_CREATED) +async def add_group( + payload: MailGroupCreate, + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> MailGroupRead: + try: + group = await create_mail_group(session, manager=manager, payload=payload) + await session.commit() + return group + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(error), + ) from error + except IntegrityError as error: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="That group could not be created.", + ) from error diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py new file mode 100644 index 0000000..a984168 --- /dev/null +++ b/backend/app/routers/people.py @@ -0,0 +1,66 @@ +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.models import Account +from app.routers.auth import get_current_account, validate_password +from app.schemas import OrgMemberCreate, OrgMemberListResponse, OrgMemberRead +from app.services.org_members import ( + can_manage_org_members, + create_org_member, + list_org_members, +) + +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), + session: AsyncSession = Depends(get_postgres_session), +) -> OrgMemberListResponse: + items, counts = await list_org_members(session, manager.company_domain) + return OrgMemberListResponse(items=items, counts=counts) + + +@router.post("", response_model=OrgMemberRead, status_code=status.HTTP_201_CREATED) +async def add_person( + payload: OrgMemberCreate, + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> OrgMemberRead: + if not payload.send_invitation: + validate_password(payload.password or "") + + try: + member = await create_org_member( + session, + manager=manager, + payload=payload, + ) + await session.commit() + return member + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(error), + ) from error + except IntegrityError as error: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An account with those details already exists.", + ) from error diff --git a/backend/app/schemas.py b/backend/app/schemas.py index a173765..1ccb172 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -51,6 +51,81 @@ class AuthResponse(BaseModel): account: AccountRead +class OrgMemberCreate(BaseModel): + first_name: str = Field(min_length=1, max_length=80) + last_name: str = Field(min_length=1, max_length=80) + email: EmailStr + org_role: Literal["user", "admin"] = "user" + send_invitation: bool = True + password: str | None = Field(default=None, min_length=8, max_length=128) + + +class OrgMemberRead(BaseModel): + id: UUID + email: EmailStr + first_name: str + last_name: str + org_role: str + member_status: str + is_domain_owner: bool = False + groups: list[str] = Field(default_factory=list) + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class OrgMemberListResponse(BaseModel): + items: list[OrgMemberRead] + counts: dict[str, int] + + +class MailGroupCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + email: EmailStr + group_type: Literal["distribution", "dynamic"] = "distribution" + member_ids: list[UUID] = Field(default_factory=list) + + +class MailGroupRead(BaseModel): + id: UUID + name: str + email: EmailStr + group_type: str + member_count: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class MailGroupListResponse(BaseModel): + items: list[MailGroupRead] + counts: dict[str, int] + + +class MailAliasCreate(BaseModel): + alias_address: EmailStr + forwards_to: EmailStr + alias_type: Literal["user", "group"] = "user" + is_domain_alias: bool = False + + +class MailAliasRead(BaseModel): + id: UUID + alias_address: EmailStr + forwards_to: EmailStr + alias_type: str + is_domain_alias: bool + status: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class MailAliasListResponse(BaseModel): + items: list[MailAliasRead] + counts: dict[str, int] + + class EmailCreate(BaseModel): recipients: list[EmailStr] = Field(min_length=1) cc: list[EmailStr] = Field(default_factory=list) diff --git a/backend/app/services/mail_aliases.py b/backend/app/services/mail_aliases.py new file mode 100644 index 0000000..f7a26de --- /dev/null +++ b/backend/app/services/mail_aliases.py @@ -0,0 +1,117 @@ +"""Email alias management.""" + +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Account, MailAlias, MailGroup +from app.schemas import MailAliasCreate, MailAliasRead + + +def _alias_read(alias: MailAlias) -> MailAliasRead: + return MailAliasRead( + id=alias.id, + alias_address=alias.alias_address, + forwards_to=alias.forwards_to, + alias_type=alias.alias_type, + is_domain_alias=alias.is_domain_alias, + status=alias.status, + created_at=alias.created_at, + ) + + +async def list_mail_aliases( + session: AsyncSession, + company_domain: str, +) -> tuple[list[MailAliasRead], dict[str, int]]: + result = await session.scalars( + select(MailAlias) + .where(MailAlias.company_domain == company_domain) + .order_by(MailAlias.created_at.desc()) + ) + aliases = list(result.all()) + items = [_alias_read(alias) for alias in aliases] + counts = { + "all": len(items), + "domain": sum(1 for item in items if item.is_domain_alias), + } + return items, counts + + +async def _destination_exists( + session: AsyncSession, + company_domain: str, + address: str, +) -> bool: + normalized = address.lower() + if not normalized.endswith(f"@{company_domain}"): + return False + + account = await session.scalar( + select(Account).where(func.lower(Account.email) == normalized) + ) + if account: + return True + + group = await session.scalar( + select(MailGroup).where(func.lower(MailGroup.email) == normalized) + ) + return group is not None + + +async def create_mail_alias( + session: AsyncSession, + *, + manager: Account, + payload: MailAliasCreate, +) -> MailAliasRead: + company_domain = manager.company_domain + if company_domain is None: + raise ValueError("Company domain is required.") + + alias_address = str(payload.alias_address).lower() + forwards_to = str(payload.forwards_to).lower() + + if not alias_address.endswith(f"@{company_domain}"): + raise ValueError(f"Alias address must use @{company_domain}.") + if not forwards_to.endswith(f"@{company_domain}"): + raise ValueError(f"Forwarding address must use @{company_domain}.") + if alias_address == forwards_to: + raise ValueError("Alias cannot forward to itself.") + + existing = await session.scalar( + select(MailAlias).where( + func.lower(MailAlias.alias_address) == alias_address + ) + ) + if existing: + raise ValueError("That alias address is already in use.") + + account_conflict = await session.scalar( + select(Account).where(func.lower(Account.email) == alias_address) + ) + if account_conflict: + raise ValueError("That address is already a user mailbox.") + + group_conflict = await session.scalar( + select(MailGroup).where(func.lower(MailGroup.email) == alias_address) + ) + if group_conflict: + raise ValueError("That address is already a group mailbox.") + + if not await _destination_exists(session, company_domain, forwards_to): + raise ValueError("Forwarding address must be an existing user or group.") + + alias = MailAlias( + company_domain=company_domain, + alias_address=alias_address, + forwards_to=forwards_to, + alias_type=payload.alias_type, + is_domain_alias=payload.is_domain_alias, + status="active", + created_by_id=manager.id, + ) + session.add(alias) + await session.flush() + return _alias_read(alias) diff --git a/backend/app/services/mail_groups.py b/backend/app/services/mail_groups.py new file mode 100644 index 0000000..ad6e9da --- /dev/null +++ b/backend/app/services/mail_groups.py @@ -0,0 +1,109 @@ +"""Distribution group management.""" + +from __future__ import annotations + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models import Account, MailGroup, MailGroupMember +from app.schemas import MailGroupCreate, MailGroupRead + + +def _group_read(group: MailGroup) -> MailGroupRead: + return MailGroupRead( + id=group.id, + name=group.name, + email=group.email, + group_type=group.group_type, + member_count=len(group.members), + created_at=group.created_at, + ) + + +async def list_mail_groups( + session: AsyncSession, + company_domain: str, +) -> tuple[list[MailGroupRead], dict[str, int]]: + result = await session.scalars( + select(MailGroup) + .options(selectinload(MailGroup.members)) + .where(MailGroup.company_domain == company_domain) + .order_by(MailGroup.created_at.desc()) + ) + groups = list(result.all()) + items = [_group_read(group) for group in groups] + counts = { + "all": len(items), + "distribution": sum(1 for item in items if item.group_type == "distribution"), + "dynamic": sum(1 for item in items if item.group_type == "dynamic"), + } + return items, counts + + +async def _validate_member_ids( + session: AsyncSession, + company_domain: str, + member_ids: list, +) -> list[Account]: + if not member_ids: + return [] + result = await session.scalars( + select(Account).where( + Account.id.in_(member_ids), + Account.company_domain == company_domain, + Account.member_status != "suspended", + ) + ) + members = list(result.all()) + if len(members) != len(set(member_ids)): + raise ValueError("One or more selected members are invalid.") + return members + + +async def create_mail_group( + session: AsyncSession, + *, + manager: Account, + payload: MailGroupCreate, +) -> MailGroupRead: + company_domain = manager.company_domain + if company_domain is None: + raise ValueError("Company domain is required.") + + email = str(payload.email).lower() + if not email.endswith(f"@{company_domain}"): + raise ValueError(f"Group email must use @{company_domain}.") + + existing = await session.scalar( + select(MailGroup).where(func.lower(MailGroup.email) == email) + ) + if existing: + raise ValueError("That group email address is already in use.") + + members = await _validate_member_ids( + session, + company_domain, + payload.member_ids, + ) + + group = MailGroup( + company_domain=company_domain, + name=payload.name.strip(), + email=email, + group_type=payload.group_type, + created_by_id=manager.id, + ) + session.add(group) + await session.flush() + + for member in members: + session.add( + MailGroupMember( + group_id=group.id, + account_id=member.id, + ) + ) + await session.flush() + await session.refresh(group, attribute_names=["members"]) + return _group_read(group) diff --git a/backend/app/services/org_members.py b/backend/app/services/org_members.py new file mode 100644 index 0000000..9c4bc18 --- /dev/null +++ b/backend/app/services/org_members.py @@ -0,0 +1,143 @@ +"""Organization member provisioning for company admins.""" + +from __future__ import annotations + +import secrets +from datetime import date +from pwdlib import PasswordHash +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Account +from app.schemas import OrgMemberCreate, OrgMemberRead +from app.services.domain_provisioning import account_is_domain_owner + +password_hasher = PasswordHash.recommended() +PLACEHOLDER_BIRTHDATE = date(1990, 1, 1) + + +async def can_manage_org_members( + session: AsyncSession, + account: Account, +) -> bool: + if account.account_type != "company" or not account.company_domain: + return False + if account.member_status == "suspended": + return False + if account.org_role == "admin": + return True + return await account_is_domain_owner( + session, + account.id, + account.company_domain, + ) + + +async def member_read( + session: AsyncSession, + member: Account, +) -> OrgMemberRead: + is_owner = await account_is_domain_owner( + session, + member.id, + member.company_domain, + ) + role = "owner" if is_owner else member.org_role + return OrgMemberRead( + id=member.id, + email=member.email, + first_name=member.first_name, + last_name=member.last_name, + org_role=role, + member_status=member.member_status, + is_domain_owner=is_owner, + groups=[], + created_at=member.created_at, + ) + + +async def list_org_members( + session: AsyncSession, + company_domain: str, +) -> tuple[list[OrgMemberRead], dict[str, int]]: + result = await session.scalars( + select(Account) + .where( + Account.account_type == "company", + Account.company_domain == company_domain, + ) + .order_by(Account.created_at.asc()) + ) + members = list(result.all()) + items = [await member_read(session, member) for member in members] + counts = { + "all": len(items), + "active": sum(1 for item in items if item.member_status == "active"), + "invited": sum(1 for item in items if item.member_status == "invited"), + "suspended": sum(1 for item in items if item.member_status == "suspended"), + } + return items, counts + + +def _placeholder_phone() -> str: + digits = "".join(str(secrets.randbelow(10)) for _ in range(10)) + return f"+1{digits}" + + +def _email_local_part(email: str) -> str: + return email.split("@", 1)[0].lower() + + +async def create_org_member( + session: AsyncSession, + *, + manager: Account, + payload: OrgMemberCreate, +) -> OrgMemberRead: + company_domain = manager.company_domain + if company_domain is None: + raise ValueError("Company domain is required.") + + email = str(payload.email).lower() + if not email.endswith(f"@{company_domain}"): + raise ValueError(f"Email must use your company domain @{company_domain}.") + + if payload.send_invitation: + raw_password = secrets.token_urlsafe(24) + member_status = "invited" + else: + if not payload.password: + raise ValueError("Password is required when invitation email is disabled.") + raw_password = payload.password + member_status = "active" + + existing = await session.scalar( + select(Account).where( + func.lower(Account.email) == email, + ) + ) + if existing: + raise ValueError("That email address is already registered.") + + phone_number = _placeholder_phone() + while await session.scalar( + select(Account).where(Account.phone_number == phone_number) + ): + phone_number = _placeholder_phone() + + member = Account( + account_type="company", + email=email, + first_name=payload.first_name.strip(), + last_name=payload.last_name.strip(), + date_of_birth=PLACEHOLDER_BIRTHDATE, + phone_number=phone_number, + company_domain=company_domain, + org_role=payload.org_role, + member_status=member_status, + created_by_id=manager.id, + password_hash=password_hasher.hash(raw_password), + ) + session.add(member) + await session.flush() + return await member_read(session, member) diff --git a/frontend/src/api.js b/frontend/src/api.js index 88d4605..9340f96 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -48,6 +48,18 @@ export const getMyDomain = (domainId, token) => request(`/domains/me/domains/${domainId}`, { token }); export const verifyMyDomainDns = (domainId, token) => request(`/domains/me/domains/${domainId}/verify-dns`, { method: "POST", token }); +export const listOrgMembers = (token) => + request("/people", { token }); +export const createOrgMember = (payload, token) => + request("/people", { method: "POST", payload, token }); +export const listMailGroups = (token) => + request("/groups", { token }); +export const createMailGroup = (payload, token) => + request("/groups", { method: "POST", payload, token }); +export const listMailAliases = (token) => + request("/aliases", { token }); +export const createMailAlias = (payload, token) => + request("/aliases", { method: "POST", payload, token }); export const sendEmail = (payload, token) => request("/emails", { method: "POST", payload, token }); diff --git a/frontend/src/components/CompanyAliases.jsx b/frontend/src/components/CompanyAliases.jsx new file mode 100644 index 0000000..4e10613 --- /dev/null +++ b/frontend/src/components/CompanyAliases.jsx @@ -0,0 +1,315 @@ +import { + CheckCircle2, + Mail, + MoreHorizontal, + Plus, + Search, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createMailAlias, listMailAliases, listMailGroups, listOrgMembers } from "../api"; + +const TYPE_TABS = [ + { key: "all", label: "All aliases" }, + { key: "domain", label: "Domain aliases" }, +]; + +function aliasTypeLabel(aliasType) { + return aliasType === "group" ? "Group alias" : "User alias"; +} + +function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) { + const token = sessionStorage.getItem("agmail_access_token"); + const [form, setForm] = useState({ + alias_local: "", + forwards_to: "", + alias_type: "user", + is_domain_alias: false, + }); + const [status, setStatus] = useState({ loading: false, error: "" }); + + function updateField(field, value) { + setForm((current) => ({ ...current, [field]: value })); + } + + function handleDestinationChange(value) { + const selected = destinations.find((item) => item.email === value); + setForm((current) => ({ + ...current, + forwards_to: value, + alias_type: selected?.kind === "group" ? "group" : "user", + })); + } + + async function handleSubmit(event) { + event.preventDefault(); + setStatus({ loading: true, error: "" }); + const aliasAddress = `${form.alias_local.trim().toLowerCase()}@${companyDomain}`; + try { + await createMailAlias( + { + alias_address: aliasAddress, + forwards_to: form.forwards_to, + alias_type: form.alias_type, + is_domain_alias: form.is_domain_alias, + }, + token, + ); + onCreated?.(); + onClose(); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + } + + return ( +
+ +
+ ); +} + +export default function CompanyAliases({ account }) { + const token = sessionStorage.getItem("agmail_access_token"); + const companyDomain = account.company_domain; + const [aliases, setAliases] = useState([]); + const [destinations, setDestinations] = useState([]); + const [counts, setCounts] = useState({ all: 0, domain: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [typeTab, setTypeTab] = useState("all"); + const [search, setSearch] = useState(""); + const [drawerOpen, setDrawerOpen] = useState(false); + + const loadData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [aliasData, memberData, groupData] = await Promise.all([ + listMailAliases(token), + listOrgMembers(token), + listMailGroups(token), + ]); + setAliases(aliasData.items); + setCounts(aliasData.counts); + const activeMembers = memberData.items.filter((item) => item.member_status === "active"); + setDestinations([ + ...activeMembers.map((member) => ({ + kind: "user", + email: member.email, + label: `${member.first_name} ${member.last_name}`, + })), + ...groupData.items.map((group) => ({ + kind: "group", + email: group.email, + label: group.name, + })), + ]); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const filteredAliases = useMemo(() => { + const query = search.trim().toLowerCase(); + return aliases.filter((alias) => { + if (typeTab === "domain" && !alias.is_domain_alias) return false; + if (!query) return true; + const haystack = `${alias.alias_address} ${alias.forwards_to}`.toLowerCase(); + return haystack.includes(query); + }); + }, [aliases, search, typeTab]); + + return ( +
+
+
+

Aliases

+

Manage alternative email addresses for your users.

+
+ +
+ +
+ {TYPE_TABS.map((tab) => ( + + ))} +
+ +
+ +
+ + {error &&

{error}

} + +
+ + + + + + + + + + + {loading ? ( + + + + ) : filteredAliases.length === 0 ? ( + + + + ) : ( + filteredAliases.map((alias) => ( + + + + + + + + )) + )} + +
Alias addressForwards toTypeStatus +
Loading aliases…
+ {aliases.length === 0 + ? "No aliases yet. Add your first forwarding address." + : "No aliases match your search."} +
+
+ + {alias.alias_address} +
+
{alias.forwards_to} + {aliasTypeLabel(alias.alias_type)} + + + Active + + + +
+
+ + {drawerOpen && ( + setDrawerOpen(false)} + onCreated={loadData} + /> + )} +
+ ); +} diff --git a/frontend/src/components/CompanyGroups.jsx b/frontend/src/components/CompanyGroups.jsx new file mode 100644 index 0000000..de1dacc --- /dev/null +++ b/frontend/src/components/CompanyGroups.jsx @@ -0,0 +1,331 @@ +import { + MoreHorizontal, + Plus, + Search, + UsersRound, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createMailGroup, listMailGroups, listOrgMembers } from "../api"; + +const TYPE_TABS = [ + { key: "all", label: "All groups" }, + { key: "distribution", label: "Distribution" }, + { key: "dynamic", label: "Dynamic" }, +]; + +const GROUP_TONES = ["green", "violet", "blue", "amber", "coral", "teal"]; + +function formatRelativeTime(dateValue) { + const date = new Date(dateValue); + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + const days = Math.floor(seconds / 86400); + if (days < 7) return `${days}d ago`; + const weeks = Math.floor(days / 7); + return `${weeks}w ago`; +} + +function groupInitial(name) { + return (name?.trim()?.[0] || "G").toUpperCase(); +} + +function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) { + const token = sessionStorage.getItem("agmail_access_token"); + const [form, setForm] = useState({ + name: "", + email_local: "", + group_type: "distribution", + member_ids: [], + }); + const [status, setStatus] = useState({ loading: false, error: "" }); + + function updateField(field, value) { + setForm((current) => ({ ...current, [field]: value })); + } + + function toggleMember(memberId) { + setForm((current) => { + const exists = current.member_ids.includes(memberId); + return { + ...current, + member_ids: exists + ? current.member_ids.filter((id) => id !== memberId) + : [...current.member_ids, memberId], + }; + }); + } + + async function handleSubmit(event) { + event.preventDefault(); + setStatus({ loading: true, error: "" }); + const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`; + try { + await createMailGroup( + { + name: form.name.trim(), + email, + group_type: form.group_type, + member_ids: form.member_ids, + }, + token, + ); + onCreated?.(); + onClose(); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + } + + return ( +
+ +
+ ); +} + +export default function CompanyGroups({ account }) { + const token = sessionStorage.getItem("agmail_access_token"); + const companyDomain = account.company_domain; + const [groups, setGroups] = useState([]); + const [members, setMembers] = useState([]); + const [counts, setCounts] = useState({ all: 0, distribution: 0, dynamic: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [typeTab, setTypeTab] = useState("all"); + const [search, setSearch] = useState(""); + const [drawerOpen, setDrawerOpen] = useState(false); + + const loadData = useCallback(async () => { + setLoading(true); + setError(""); + try { + const [groupData, memberData] = await Promise.all([ + listMailGroups(token), + listOrgMembers(token), + ]); + setGroups(groupData.items); + setCounts(groupData.counts); + setMembers(memberData.items.filter((item) => item.member_status === "active")); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const filteredGroups = useMemo(() => { + const query = search.trim().toLowerCase(); + return groups.filter((group) => { + if (typeTab !== "all" && group.group_type !== typeTab) return false; + if (!query) return true; + const haystack = `${group.name} ${group.email}`.toLowerCase(); + return haystack.includes(query); + }); + }, [groups, search, typeTab]); + + return ( +
+
+
+

Groups

+

Create distribution groups for team communication.

+
+ +
+ +
+ {TYPE_TABS.map((tab) => ( + + ))} +
+ +
+ +
+ + {error &&

{error}

} + +
+ + + + + + + + + + + + {loading ? ( + + + + ) : filteredGroups.length === 0 ? ( + + + + ) : ( + filteredGroups.map((group, index) => ( + + + + + + + + + + )) + )} + +
+ NameEmail addressTypeMembersCreated +
Loading groups…
+ {groups.length === 0 + ? "No groups yet. Create your first distribution group." + : "No groups match your search."} +
+
+ + {groupInitial(group.name)} + + {group.name} +
+
{group.email} + + {group.group_type === "dynamic" ? "Dynamic" : "Distribution"} + + + + + {group.member_count} + + {formatRelativeTime(group.created_at)} + +
+
+ + {drawerOpen && ( + setDrawerOpen(false)} + onCreated={loadData} + /> + )} +
+ ); +} diff --git a/frontend/src/components/CompanyPeople.jsx b/frontend/src/components/CompanyPeople.jsx new file mode 100644 index 0000000..4c0e234 --- /dev/null +++ b/frontend/src/components/CompanyPeople.jsx @@ -0,0 +1,404 @@ +import { + CheckCircle2, + Clock3, + MoreHorizontal, + Plus, + Search, + Upload, + X, + XCircle, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createOrgMember, listOrgMembers } from "../api"; + +const STATUS_TABS = [ + { key: "all", label: "All users" }, + { key: "active", label: "Active" }, + { key: "invited", label: "Invited" }, + { key: "suspended", label: "Suspended" }, +]; + +const GROUP_OPTIONS = ["Admin", "HR", "Engineering", "Finance"]; + +function initials(firstName, lastName) { + return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase(); +} + +function roleLabel(member) { + if (member.is_domain_owner || member.org_role === "owner") return "Owner"; + if (member.org_role === "admin") return "User Admin"; + return "User"; +} + +function groupLabel(member) { + if (member.is_domain_owner || member.org_role === "owner" || member.org_role === "admin") { + return "Admin"; + } + return "—"; +} + +function StatusBadge({ status }) { + if (status === "active") { + return ( + + Active + + ); + } + if (status === "invited") { + return ( + + Invited + + ); + } + return ( + + Suspended + + ); +} + +function AddUserPanel({ companyDomain, onClose, onCreated }) { + const token = sessionStorage.getItem("agmail_access_token"); + const [form, setForm] = useState({ + first_name: "", + last_name: "", + email_local: "", + org_role: "user", + groups: [], + send_invitation: true, + password: "", + }); + const [status, setStatus] = useState({ loading: false, error: "" }); + + function updateField(field, value) { + setForm((current) => ({ ...current, [field]: value })); + } + + function toggleGroup(group) { + setForm((current) => { + const exists = current.groups.includes(group); + return { + ...current, + groups: exists + ? current.groups.filter((item) => item !== group) + : [...current.groups, group], + }; + }); + } + + async function handleSubmit(event) { + event.preventDefault(); + setStatus({ loading: true, error: "" }); + const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`; + try { + const member = await createOrgMember( + { + first_name: form.first_name.trim(), + last_name: form.last_name.trim(), + email, + org_role: form.org_role, + send_invitation: form.send_invitation, + password: form.send_invitation ? undefined : form.password, + }, + token, + ); + onCreated?.(member); + onClose(); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + } + + return ( +
+ +
+ ); +} + +export default function CompanyPeople({ account }) { + const token = sessionStorage.getItem("agmail_access_token"); + const companyDomain = account.company_domain; + const [members, setMembers] = useState([]); + const [counts, setCounts] = useState({ all: 0, active: 0, invited: 0, suspended: 0 }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [statusTab, setStatusTab] = useState("all"); + const [search, setSearch] = useState(""); + const [roleFilter, setRoleFilter] = useState("all"); + const [drawerOpen, setDrawerOpen] = useState(false); + + const loadMembers = useCallback(async () => { + setLoading(true); + setError(""); + try { + const data = await listOrgMembers(token); + setMembers(data.items); + setCounts(data.counts); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + loadMembers(); + }, [loadMembers]); + + const filteredMembers = useMemo(() => { + const query = search.trim().toLowerCase(); + return members.filter((member) => { + if (statusTab !== "all" && member.member_status !== statusTab) { + return false; + } + if (roleFilter === "admin" && member.org_role !== "admin" && !member.is_domain_owner) { + return false; + } + if (roleFilter === "user" && (member.org_role !== "user" || member.is_domain_owner)) { + return false; + } + if (!query) return true; + const haystack = `${member.first_name} ${member.last_name} ${member.email}`.toLowerCase(); + return haystack.includes(query); + }); + }, [members, roleFilter, search, statusTab]); + + return ( +
+
+
+

People

+

Manage your team members and their email accounts.

+
+
+ + +
+
+ +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ +
+ + +
+ + {error &&

{error}

} + +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : filteredMembers.length === 0 ? ( + + + + ) : ( + filteredMembers.map((member) => ( + + + + + + + + + + + + )) + )} + +
+ NameEmailStatusRoleGroupStorageLast active +
Loading people…
+ {members.length === 0 + ? "No team members yet. Add your first user to get started." + : "No people match your filters."} +
+
+ {initials(member.first_name, member.last_name)} + {member.first_name} {member.last_name} +
+
{member.email}{roleLabel(member)}{groupLabel(member)}{member.member_status === "invited" ? "—" : "—"}{member.member_status === "invited" ? "—" : "—"} + +
+
+ + {drawerOpen && ( + setDrawerOpen(false)} + onCreated={() => loadMembers()} + /> + )} +
+ ); +} diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx index 844c522..67e13c6 100644 --- a/frontend/src/components/DashboardPage.jsx +++ b/frontend/src/components/DashboardPage.jsx @@ -29,6 +29,9 @@ import { getCurrentAccount, getMyCompanyDomain } from "../api"; import Brand from "./Brand"; import CompanyDnsSetup from "./CompanyDnsSetup"; import CompanyDomains from "./CompanyDomains"; +import CompanyAliases from "./CompanyAliases"; +import CompanyGroups from "./CompanyGroups"; +import CompanyPeople from "./CompanyPeople"; import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup"; import ComposeModal from "./ComposeModal"; import MailboxView from "./MailboxView"; @@ -277,82 +280,84 @@ export default function DashboardPage() { - {isDomainOwner && ( - -