company flows

This commit is contained in:
debu-2 2026-10-04 20:01:29 +05:30
parent 6541284a4c
commit cc3f3f3de5
17 changed files with 1960 additions and 66 deletions

View file

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

View file

@ -7,7 +7,16 @@ from sqlalchemy import text
from app.config import get_settings from app.config import get_settings
from app.database import SessionFactory, engine, mongo_client, mongo_db from app.database import SessionFactory, engine, mongo_client, mongo_db
from app.models import Base 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.domain_provisioning import ensure_platform_domain
from app.services.inbound_smtp import InboundSMTPServer from app.services.inbound_smtp import InboundSMTPServer
@ -15,6 +24,29 @@ settings = get_settings()
inbound_server = InboundSMTPServer() 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: async def migrate_legacy_user_tables(connection) -> None:
"""Promote the former auth table to users without discarding old profiles.""" """Promote the former auth table to users without discarding old profiles."""
tables = ( tables = (
@ -44,6 +76,7 @@ async def lifespan(_: FastAPI):
async with engine.begin() as connection: async with engine.begin() as connection:
await migrate_legacy_user_tables(connection) await migrate_legacy_user_tables(connection)
await connection.run_sync(Base.metadata.create_all) await connection.run_sync(Base.metadata.create_all)
await migrate_user_org_columns(connection)
async with SessionFactory() as session: async with SessionFactory() as session:
await ensure_platform_domain(session) await ensure_platform_domain(session)
await session.commit() 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(sticky_notes.router, prefix=settings.api_prefix)
app.include_router(auth.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(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) app.include_router(dns_setup.router, prefix=settings.api_prefix)

View file

@ -41,6 +41,13 @@ class Account(Base):
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), unique=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")
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)) password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
@ -127,3 +134,93 @@ class SmtpCredential(Base):
server_default=func.now(), server_default=func.now(),
) )
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials") 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(),
)

View file

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

View file

@ -176,6 +176,8 @@ async def register(
date_of_birth=payload.date_of_birth, date_of_birth=payload.date_of_birth,
phone_number=phone_number, phone_number=phone_number,
company_domain=company_domain, company_domain=company_domain,
org_role="owner" if company_domain else "user",
member_status="active",
password_hash=password_hash.hash(payload.password), password_hash=password_hash.hash(payload.password),
) )
session.add(account) session.add(account)

View file

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

View file

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

View file

@ -51,6 +51,81 @@ class AuthResponse(BaseModel):
account: AccountRead 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): class EmailCreate(BaseModel):
recipients: list[EmailStr] = Field(min_length=1) recipients: list[EmailStr] = Field(min_length=1)
cc: list[EmailStr] = Field(default_factory=list) cc: list[EmailStr] = Field(default_factory=list)

View file

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

View file

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

View file

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

View file

@ -48,6 +48,18 @@ export const getMyDomain = (domainId, token) =>
request(`/domains/me/domains/${domainId}`, { token }); request(`/domains/me/domains/${domainId}`, { token });
export const verifyMyDomainDns = (domainId, token) => export const verifyMyDomainDns = (domainId, token) =>
request(`/domains/me/domains/${domainId}/verify-dns`, { method: "POST", 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) => export const sendEmail = (payload, token) =>
request("/emails", { method: "POST", payload, token }); request("/emails", { method: "POST", payload, token });

View file

@ -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 (
<div className="people-drawer-scrim" onClick={onClose}>
<aside
className="people-drawer"
role="dialog"
aria-labelledby="create-alias-title"
onClick={(event) => event.stopPropagation()}
>
<header className="people-drawer-head">
<h2 id="create-alias-title">Add alias</h2>
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
<X size={18} />
</button>
</header>
<form className="people-drawer-form" onSubmit={handleSubmit}>
<label>
Alias address <span aria-hidden="true">*</span>
<div className="people-email-field">
<input
required
value={form.alias_local}
onChange={(event) => updateField("alias_local", event.target.value)}
placeholder="ceo"
/>
<span>@{companyDomain}</span>
</div>
<small>Alternative address that forwards to an existing mailbox.</small>
</label>
<label>
Forwards to <span aria-hidden="true">*</span>
<select
required
value={form.forwards_to}
onChange={(event) => handleDestinationChange(event.target.value)}
>
<option value="">Select destination…</option>
{destinations.map((item) => (
<option key={`${item.kind}-${item.email}`} value={item.email}>
{item.label} ({item.email})
</option>
))}
</select>
</label>
<label>
Type
<select
value={form.alias_type}
onChange={(event) => updateField("alias_type", event.target.value)}
>
<option value="user">User alias</option>
<option value="group">Group alias</option>
</select>
</label>
<label className="people-toggle-row">
<span>
<strong>Domain alias</strong>
<small>Use for shared addresses like info@ or support@.</small>
</span>
<input
type="checkbox"
checked={form.is_domain_alias}
onChange={(event) => updateField("is_domain_alias", event.target.checked)}
/>
</label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className="people-drawer-actions">
<button type="button" className="button button-outline" onClick={onClose}>
Cancel
</button>
<button className="button button-purple" type="submit" disabled={status.loading}>
{status.loading ? "Creating…" : "Create alias"}
</button>
</footer>
</form>
</aside>
</div>
);
}
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 (
<section className="company-people">
<header className="company-people-head">
<div>
<h1>Aliases</h1>
<p>Manage alternative email addresses for your users.</p>
</div>
<button
type="button"
className="button button-purple"
onClick={() => setDrawerOpen(true)}
>
<Plus size={16} /> Add alias
</button>
</header>
<div className="people-tabs" role="tablist" aria-label="Filter aliases">
{TYPE_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={typeTab === tab.key}
className={typeTab === tab.key ? "active" : ""}
onClick={() => setTypeTab(tab.key)}
>
{tab.label} <em>{counts[tab.key] ?? 0}</em>
</button>
))}
</div>
<div className="people-filters">
<label className="people-search">
<Search size={16} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search aliases…"
/>
</label>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="people-table-wrap">
<table className="people-table">
<thead>
<tr>
<th>Alias address</th>
<th>Forwards to</th>
<th>Type</th>
<th>Status</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="people-table-empty">Loading aliases…</td>
</tr>
) : filteredAliases.length === 0 ? (
<tr>
<td colSpan={5} className="people-table-empty">
{aliases.length === 0
? "No aliases yet. Add your first forwarding address."
: "No aliases match your search."}
</td>
</tr>
) : (
filteredAliases.map((alias) => (
<tr key={alias.id}>
<td>
<div className="alias-address-cell">
<Mail size={15} />
<span>{alias.alias_address}</span>
</div>
</td>
<td>{alias.forwards_to}</td>
<td>
<span className="alias-type-badge">{aliasTypeLabel(alias.alias_type)}</span>
</td>
<td>
<span className="people-status people-status-active">
<CheckCircle2 size={13} /> Active
</span>
</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{drawerOpen && (
<CreateAliasPanel
companyDomain={companyDomain}
destinations={destinations}
onClose={() => setDrawerOpen(false)}
onCreated={loadData}
/>
)}
</section>
);
}

View file

@ -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 (
<div className="people-drawer-scrim" onClick={onClose}>
<aside
className="people-drawer"
role="dialog"
aria-labelledby="create-group-title"
onClick={(event) => event.stopPropagation()}
>
<header className="people-drawer-head">
<h2 id="create-group-title">Create group</h2>
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
<X size={18} />
</button>
</header>
<form className="people-drawer-form" onSubmit={handleSubmit}>
<label>
Group name <span aria-hidden="true">*</span>
<input
required
value={form.name}
onChange={(event) => updateField("name", event.target.value)}
placeholder="Engineering"
/>
</label>
<label>
Email address <span aria-hidden="true">*</span>
<div className="people-email-field">
<input
required
value={form.email_local}
onChange={(event) => updateField("email_local", event.target.value)}
placeholder="engineering"
/>
<span>@{companyDomain}</span>
</div>
<small>Messages sent here are delivered to all group members.</small>
</label>
<label>
Type
<select
value={form.group_type}
onChange={(event) => updateField("group_type", event.target.value)}
>
<option value="distribution">Distribution</option>
<option value="dynamic">Dynamic</option>
</select>
<small>Distribution groups have a fixed member list. Dynamic groups update automatically.</small>
</label>
<fieldset className="people-groups-field">
<legend>Members</legend>
<div className="org-member-picker">
{members.length === 0 ? (
<p className="org-member-picker-empty">Add people first, then assign them to groups.</p>
) : (
members.map((member) => (
<label key={member.id} className="org-member-picker-row">
<input
type="checkbox"
checked={form.member_ids.includes(member.id)}
onChange={() => toggleMember(member.id)}
/>
<span>{member.first_name} {member.last_name}</span>
<small>{member.email}</small>
</label>
))
)}
</div>
</fieldset>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className="people-drawer-actions">
<button type="button" className="button button-outline" onClick={onClose}>
Cancel
</button>
<button className="button button-purple" type="submit" disabled={status.loading}>
{status.loading ? "Creating…" : "Create group"}
</button>
</footer>
</form>
</aside>
</div>
);
}
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 (
<section className="company-people">
<header className="company-people-head">
<div>
<h1>Groups</h1>
<p>Create distribution groups for team communication.</p>
</div>
<button
type="button"
className="button button-purple"
onClick={() => setDrawerOpen(true)}
>
<Plus size={16} /> Create group
</button>
</header>
<div className="people-tabs" role="tablist" aria-label="Filter by group type">
{TYPE_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={typeTab === tab.key}
className={typeTab === tab.key ? "active" : ""}
onClick={() => setTypeTab(tab.key)}
>
{tab.label} <em>{counts[tab.key] ?? 0}</em>
</button>
))}
</div>
<div className="people-filters">
<label className="people-search">
<Search size={16} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search groups…"
/>
</label>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="people-table-wrap">
<table className="people-table">
<thead>
<tr>
<th aria-label="Select" />
<th>Name</th>
<th>Email address</th>
<th>Type</th>
<th>Members</th>
<th>Created</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="people-table-empty">Loading groups…</td>
</tr>
) : filteredGroups.length === 0 ? (
<tr>
<td colSpan={7} className="people-table-empty">
{groups.length === 0
? "No groups yet. Create your first distribution group."
: "No groups match your search."}
</td>
</tr>
) : (
filteredGroups.map((group, index) => (
<tr key={group.id}>
<td><input type="checkbox" aria-label={`Select ${group.name}`} /></td>
<td>
<div className="people-name-cell">
<span className={`group-avatar tone-${GROUP_TONES[index % GROUP_TONES.length]}`}>
{groupInitial(group.name)}
</span>
<span>{group.name}</span>
</div>
</td>
<td>{group.email}</td>
<td>
<span className="group-type-badge">
{group.group_type === "dynamic" ? "Dynamic" : "Distribution"}
</span>
</td>
<td>
<span className="group-member-count">
<UsersRound size={14} />
{group.member_count}
</span>
</td>
<td>{formatRelativeTime(group.created_at)}</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{drawerOpen && (
<CreateGroupPanel
companyDomain={companyDomain}
members={members}
onClose={() => setDrawerOpen(false)}
onCreated={loadData}
/>
)}
</section>
);
}

View file

@ -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 (
<span className="people-status people-status-active">
<CheckCircle2 size={13} /> Active
</span>
);
}
if (status === "invited") {
return (
<span className="people-status people-status-invited">
<Clock3 size={13} /> Invited
</span>
);
}
return (
<span className="people-status people-status-suspended">
<XCircle size={13} /> Suspended
</span>
);
}
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 (
<div className="people-drawer-scrim" onClick={onClose}>
<aside
className="people-drawer"
role="dialog"
aria-labelledby="add-user-title"
onClick={(event) => event.stopPropagation()}
>
<header className="people-drawer-head">
<h2 id="add-user-title">Add user</h2>
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
<X size={18} />
</button>
</header>
<form className="people-drawer-form" onSubmit={handleSubmit}>
<div className="people-drawer-row">
<label>
First name <span aria-hidden="true">*</span>
<input
required
value={form.first_name}
onChange={(event) => updateField("first_name", event.target.value)}
placeholder="Rahul"
/>
</label>
<label>
Last name <span aria-hidden="true">*</span>
<input
required
value={form.last_name}
onChange={(event) => updateField("last_name", event.target.value)}
placeholder="Sharma"
/>
</label>
</div>
<label>
Email <span aria-hidden="true">*</span>
<div className="people-email-field">
<input
required
value={form.email_local}
onChange={(event) => updateField("email_local", event.target.value)}
placeholder="rahul"
/>
<span>@{companyDomain}</span>
</div>
<small>This will be their login email address</small>
</label>
<label>
Role
<select
value={form.org_role}
onChange={(event) => updateField("org_role", event.target.value)}
>
<option value="user">User</option>
<option value="admin">User Admin</option>
</select>
<small>Users can access email. User Admins can manage people.</small>
</label>
<fieldset className="people-groups-field">
<legend>Groups</legend>
<div className="people-groups-options">
{GROUP_OPTIONS.map((group) => (
<button
key={group}
type="button"
className={`people-group-chip${form.groups.includes(group) ? " active" : ""}`}
onClick={() => toggleGroup(group)}
>
{group}
</button>
))}
</div>
<small>Group assignment is saved locally for now.</small>
</fieldset>
<label className="people-toggle-row">
<span>
<strong>Send invitation email</strong>
<small>They will receive an activation link to set their password.</small>
</span>
<input
type="checkbox"
checked={form.send_invitation}
onChange={(event) => updateField("send_invitation", event.target.checked)}
/>
</label>
{!form.send_invitation && (
<label>
Password <span aria-hidden="true">*</span>
<input
required
type="password"
value={form.password}
onChange={(event) => updateField("password", event.target.value)}
placeholder="Set an initial password"
minLength={8}
/>
<small>Must include uppercase, lowercase, and a number.</small>
</label>
)}
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className="people-drawer-actions">
<button type="button" className="button button-outline" onClick={onClose}>
Cancel
</button>
<button className="button button-purple" type="submit" disabled={status.loading}>
{status.loading ? "Creating…" : "Create user"}
</button>
</footer>
</form>
</aside>
</div>
);
}
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 (
<section className="company-people">
<header className="company-people-head">
<div>
<h1>People</h1>
<p>Manage your team members and their email accounts.</p>
</div>
<div className="company-people-actions">
<button type="button" className="button button-outline people-import-btn" disabled title="Coming soon">
<Upload size={16} /> Import CSV
</button>
<button
type="button"
className="button button-purple"
onClick={() => setDrawerOpen(true)}
>
<Plus size={16} /> Add user
</button>
</div>
</header>
<div className="people-tabs" role="tablist" aria-label="Filter by status">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={statusTab === tab.key}
className={statusTab === tab.key ? "active" : ""}
onClick={() => setStatusTab(tab.key)}
>
{tab.label} <em>{counts[tab.key] ?? 0}</em>
</button>
))}
</div>
<div className="people-filters">
<label className="people-search">
<Search size={16} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search people…"
/>
</label>
<select value={roleFilter} onChange={(event) => setRoleFilter(event.target.value)}>
<option value="all">All roles</option>
<option value="admin">Admins</option>
<option value="user">Users</option>
</select>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="people-table-wrap">
<table className="people-table">
<thead>
<tr>
<th aria-label="Select" />
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Group</th>
<th>Storage</th>
<th>Last active</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={9} className="people-table-empty">Loading people…</td>
</tr>
) : filteredMembers.length === 0 ? (
<tr>
<td colSpan={9} className="people-table-empty">
{members.length === 0
? "No team members yet. Add your first user to get started."
: "No people match your filters."}
</td>
</tr>
) : (
filteredMembers.map((member) => (
<tr key={member.id}>
<td><input type="checkbox" aria-label={`Select ${member.first_name}`} /></td>
<td>
<div className="people-name-cell">
<span className="people-avatar">{initials(member.first_name, member.last_name)}</span>
<span>{member.first_name} {member.last_name}</span>
</div>
</td>
<td>{member.email}</td>
<td><StatusBadge status={member.member_status} /></td>
<td>{roleLabel(member)}</td>
<td>{groupLabel(member)}</td>
<td>{member.member_status === "invited" ? "—" : "—"}</td>
<td>{member.member_status === "invited" ? "—" : "—"}</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{drawerOpen && (
<AddUserPanel
companyDomain={companyDomain}
onClose={() => setDrawerOpen(false)}
onCreated={() => loadMembers()}
/>
)}
</section>
);
}

View file

@ -29,6 +29,9 @@ import { getCurrentAccount, getMyCompanyDomain } from "../api";
import Brand from "./Brand"; import Brand from "./Brand";
import CompanyDnsSetup from "./CompanyDnsSetup"; import CompanyDnsSetup from "./CompanyDnsSetup";
import CompanyDomains from "./CompanyDomains"; import CompanyDomains from "./CompanyDomains";
import CompanyAliases from "./CompanyAliases";
import CompanyGroups from "./CompanyGroups";
import CompanyPeople from "./CompanyPeople";
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup"; import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
import ComposeModal from "./ComposeModal"; import ComposeModal from "./ComposeModal";
import MailboxView from "./MailboxView"; import MailboxView from "./MailboxView";
@ -277,6 +280,7 @@ export default function DashboardPage() {
</button> </button>
</div> </div>
<div className="dashboard-sidebar-scroll">
{isDomainOwner && ( {isDomainOwner && (
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first"> <SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
<nav aria-label="Company"> <nav aria-label="Company">
@ -299,7 +303,7 @@ export default function DashboardPage() {
<nav aria-label="Mail"> <nav aria-label="Mail">
{mailItems.map(([ItemIcon, label, countKey]) => ( {mailItems.map(([ItemIcon, label, countKey]) => (
<button <button
className={`dashboard-nav-item${mailActive === label ? " active" : ""}`} className={`dashboard-nav-item${!companyActive && mailActive === label ? " active" : ""}`}
key={label} key={label}
type="button" type="button"
onClick={() => selectMailItem(label)} onClick={() => selectMailItem(label)}
@ -316,7 +320,7 @@ export default function DashboardPage() {
<nav aria-label="Labels"> <nav aria-label="Labels">
{labels.map(([name, tone, countKey]) => ( {labels.map(([name, tone, countKey]) => (
<button <button
className={`dashboard-nav-item dashboard-label-item${lens === name ? " active" : ""}`} className={`dashboard-nav-item dashboard-label-item${!companyActive && lens === name ? " active" : ""}`}
key={name} key={name}
type="button" type="button"
onClick={() => selectLabel(name)} onClick={() => selectLabel(name)}
@ -341,7 +345,7 @@ export default function DashboardPage() {
<nav aria-label="Agent"> <nav aria-label="Agent">
{agentItems.map(([ItemIcon, label, actionKey]) => ( {agentItems.map(([ItemIcon, label, actionKey]) => (
<button <button
className={`dashboard-nav-item dashboard-agent-item${agentNav === actionKey ? " active" : ""}`} className={`dashboard-nav-item dashboard-agent-item${!companyActive && agentNav === actionKey ? " active" : ""}`}
key={label} key={label}
type="button" type="button"
onClick={() => selectAgentItem(label, actionKey)} onClick={() => selectAgentItem(label, actionKey)}
@ -353,6 +357,7 @@ export default function DashboardPage() {
))} ))}
</nav> </nav>
</SidebarSection> </SidebarSection>
</div>
<div className="dashboard-profile"> <div className="dashboard-profile">
<span>{account.first_name[0]}{account.last_name[0]}</span> <span>{account.first_name[0]}{account.last_name[0]}</span>
@ -362,7 +367,7 @@ export default function DashboardPage() {
</aside> </aside>
<main className="dashboard-content"> <main className="dashboard-content">
{companyActive && !["Overview", "Domains"].includes(companyActive) && ( {companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
<div className="dashboard-content-heading"> <div className="dashboard-content-heading">
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div> <div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
<span className="dashboard-account-pill">{workspaceLabel}</span> <span className="dashboard-account-pill">{workspaceLabel}</span>
@ -423,7 +428,16 @@ export default function DashboardPage() {
primaryDomainName={account.company_domain} primaryDomainName={account.company_domain}
/> />
)} )}
{companyActive && !["Overview", "Domains"].includes(companyActive) && ( {companyActive === "People" && isDomainOwner && (
<CompanyPeople account={account} />
)}
{companyActive === "Groups" && isDomainOwner && (
<CompanyGroups account={account} />
)}
{companyActive === "Aliases" && isDomainOwner && (
<CompanyAliases account={account} />
)}
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
<section className="dashboard-empty dashboard-company-view"> <section className="dashboard-empty dashboard-company-view">
<span><Sparkles size={24} /></span> <span><Sparkles size={24} /></span>
<h2>{companyActive}</h2> <h2>{companyActive}</h2>

View file

@ -282,6 +282,73 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.company-domain-recheck { padding:0; display:inline-flex; align-items:center; gap:6px; border:0; background:transparent; color:#7357F6; font-size:12px; font-weight:600; cursor:pointer; } .company-domain-recheck { padding:0; display:inline-flex; align-items:center; gap:6px; border:0; background:transparent; color:#7357F6; font-size:12px; font-weight:600; cursor:pointer; }
.company-domain-placeholder { margin-top:16px; padding:32px; display:grid; justify-items:center; gap:8px; border:1px dashed #E4E1E8; border-radius:12px; background:#FAFAFC; color:#8d8792; font-size:13px; text-align:center; } .company-domain-placeholder { margin-top:16px; padding:32px; display:grid; justify-items:center; gap:8px; border:1px dashed #E4E1E8; border-radius:12px; background:#FAFAFC; color:#8d8792; font-size:13px; text-align:center; }
.company-people { padding:8px 0 24px; }
.company-people-head { margin-bottom:20px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
.company-people-head h1 { margin:0 0 8px; font:700 clamp(28px,3vw,36px)/1.15 "DM Sans"; letter-spacing:-.6px; color:#1a1628; }
.company-people-head p { margin:0; color:#77758A; font-size:14px; }
.company-people-actions { display:flex; align-items:center; gap:10px; flex-shrink:0; }
.people-import-btn { min-height:40px; opacity:.55; cursor:not-allowed; }
.people-tabs { margin-bottom:14px; display:flex; flex-wrap:wrap; gap:18px; border-bottom:1px solid #ECE9F2; }
.people-tabs button { padding:10px 2px 12px; border:0; border-bottom:2px solid transparent; background:transparent; color:#8d8792; font-size:13px; font-weight:600; cursor:pointer; }
.people-tabs button em { margin-left:6px; color:#b4afba; font-style:normal; font-weight:500; }
.people-tabs button.active { color:#e15b45; border-bottom-color:#e15b45; }
.people-tabs button.active em { color:#e15b45; }
.people-filters { margin-bottom:14px; display:flex; gap:10px; }
.people-search { flex:1; min-width:0; height:40px; padding:0 12px; display:flex; align-items:center; gap:8px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; color:#8d8792; }
.people-search input { width:100%; border:0; outline:0; background:transparent; font-size:13px; color:#1a1628; }
.people-filters select { height:40px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#4a4652; }
.people-table-wrap { border:1px solid #ECE9F2; border-radius:16px; background:#fff; overflow:auto; box-shadow:0 8px 28px rgba(20,18,30,0.04); }
.people-table { width:100%; border-collapse:collapse; font-size:13px; }
.people-table th { padding:12px 14px; text-align:left; color:#8d8792; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; border-bottom:1px solid #F0EEF5; background:#FAFAFC; }
.people-table td { padding:12px 14px; border-bottom:1px solid #F5F3F8; color:#3d3948; vertical-align:middle; }
.people-table tr:last-child td { border-bottom:0; }
.people-table-empty { padding:28px 14px !important; text-align:center; color:#8d8792; }
.people-name-cell { display:flex; align-items:center; gap:10px; font-weight:600; color:#1a1628; }
.people-avatar { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; background:#F0EBFF; color:#7357F6; font-size:11px; font-weight:700; flex-shrink:0; }
.people-status { display:inline-flex; align-items:center; gap:5px; padding:4px 10px; border-radius:999px; font-size:11px; font-weight:600; white-space:nowrap; }
.people-status-active { background:#EEF8F1; color:#2f6b43; }
.people-status-invited { background:#FFF4E5; color:#c4842d; }
.people-status-suspended { background:#FFF0EB; color:#b04a38; }
.people-row-menu { width:30px; height:30px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#8d8792; cursor:pointer; }
.people-row-menu:hover { background:#F6F2FF; color:#7357F6; }
.people-drawer-scrim { position:fixed; inset:0; z-index:40; display:flex; justify-content:flex-end; background:rgba(20,18,30,0.28); backdrop-filter:blur(2px); }
.people-drawer { width:min(440px,100%); height:100%; padding:24px 24px 28px; display:flex; flex-direction:column; background:#fff; box-shadow:-12px 0 40px rgba(20,18,30,0.12); overflow:auto; }
.people-drawer-head { margin-bottom:20px; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.people-drawer-head h2 { margin:0; font:700 22px/1.2 "DM Sans"; color:#1a1628; }
.people-drawer-close { width:34px; height:34px; display:grid; place-items:center; border:0; border-radius:8px; background:#F5F3F8; color:#6f6c7b; cursor:pointer; }
.people-drawer-form { display:grid; gap:16px; }
.people-drawer-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.people-drawer-form label,.people-groups-field { display:grid; gap:6px; font-size:12px; font-weight:600; color:#4a4652; }
.people-drawer-form label span,.people-groups-field legend span { color:#e15b45; }
.people-drawer-form input,.people-drawer-form select { height:42px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#1a1628; }
.people-drawer-form small { color:#8d8792; font-weight:500; line-height:1.4; }
.people-email-field { display:grid; grid-template-columns:1fr auto; gap:8px; align-items:center; }
.people-email-field span { color:#8d8792; font-size:13px; font-weight:600; }
.people-groups-field { margin:0; padding:0; border:0; }
.people-groups-field legend { margin-bottom:6px; font-size:12px; font-weight:600; color:#4a4652; }
.people-groups-options { display:flex; flex-wrap:wrap; gap:8px; }
.people-group-chip { padding:6px 12px; border:1px solid #E1DFE7; border-radius:999px; background:#fff; color:#6f6c7b; font-size:12px; font-weight:600; cursor:pointer; }
.people-group-chip.active { border-color:#C8B8FF; background:#F6F2FF; color:#5a4fcf; }
.people-toggle-row { display:flex !important; align-items:flex-start; justify-content:space-between; gap:12px; }
.people-toggle-row strong { display:block; margin-bottom:2px; font-size:13px; color:#1a1628; }
.people-toggle-row input { width:18px; height:18px; margin-top:4px; accent-color:#7357F6; }
.people-drawer-actions { margin-top:8px; display:flex; justify-content:flex-end; gap:10px; }
.group-avatar { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; font-size:12px; font-weight:700; flex-shrink:0; }
.group-avatar.tone-green { background:#E8F7F3; color:#2d8a72; }
.group-avatar.tone-violet { background:#F0EBFF; color:#7357F6; }
.group-avatar.tone-blue { background:#E8F0FF; color:#4f6fd8; }
.group-avatar.tone-amber { background:#FFF4E5; color:#c4842d; }
.group-avatar.tone-coral { background:#FFF0EB; color:#e15b45; }
.group-avatar.tone-teal { background:#E8F7F3; color:#2d8a72; }
.group-type-badge,.alias-type-badge { display:inline-flex; padding:4px 10px; border-radius:999px; background:#EEF4FF; color:#4f6fd8; font-size:11px; font-weight:600; }
.group-member-count { display:inline-flex; align-items:center; gap:5px; color:#6f6c7b; font-size:12px; font-weight:600; }
.alias-address-cell { display:flex; align-items:center; gap:8px; font-weight:600; color:#1a1628; }
.alias-address-cell svg { color:#8d8792; }
.org-member-picker { max-height:220px; overflow:auto; display:grid; gap:6px; padding:10px; border:1px solid #ECE9F2; border-radius:10px; background:#FAFAFC; }
.org-member-picker-empty { margin:0; color:#8d8792; font-size:12px; }
.org-member-picker-row { display:grid; grid-template-columns:auto 1fr; gap:4px 10px; align-items:center; font-size:13px; color:#3d3948; cursor:pointer; }
.org-member-picker-row small { grid-column:2; color:#8d8792; font-size:11px; }
.dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; } .dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; }
.dns-copy:hover { background:#fff0eb; color:var(--coral); } .dns-copy:hover { background:#fff0eb; color:var(--coral); }
.dashboard-scrim { display:none; } .dashboard-scrim { display:none; }
@ -541,14 +608,15 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.dns-copy { width:28px; height:28px; flex:0 0 auto; display:grid; place-items:center; border:1px solid #ece7e3; border-radius:8px; background:white; color:#67677a; } .dns-copy { width:28px; height:28px; flex:0 0 auto; display:grid; place-items:center; border:1px solid #ece7e3; border-radius:8px; background:white; color:#67677a; }
.link-button { border:0; background:transparent; color:var(--coral); font:inherit; font-weight:600; cursor:pointer; padding:0; } .link-button { border:0; background:transparent; color:var(--coral); font:inherit; font-weight:600; cursor:pointer; padding:0; }
.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:auto; 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; } .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; }
.dashboard-brand-row { 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-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; }
.dashboard-sidebar .robot-eyes i { background:#1c1730; } .dashboard-sidebar .robot-eyes i { background:#1c1730; }
.dashboard-sidebar .robot-smile { border-color:#1c1730; } .dashboard-sidebar .robot-smile { border-color:#1c1730; }
.dashboard-compose-wrap { margin:var(--space-4) var(--space-1) 0; display:flex; border-radius:var(--radius-md); overflow:hidden; box-shadow:var(--shadow-accent); } .dashboard-compose-wrap { flex-shrink:0; margin:var(--space-4) var(--space-1) 0; display:flex; border-radius:var(--radius-md); overflow:hidden; box-shadow:var(--shadow-accent); }
.dashboard-compose { flex:1; min-height:40px; padding:0 var(--space-3); display:flex; align-items:center; border:0; color:#fff; background:linear-gradient(180deg,#ff806d 0%,#ff5c55 100%); font-size:var(--text-meta); font-weight:700; transition:filter var(--motion-fast) var(--ease-standard),transform var(--motion-fast) var(--ease-standard); } .dashboard-compose { flex:1; min-height:40px; padding:0 var(--space-3); display:flex; align-items:center; border:0; color:#fff; background:linear-gradient(180deg,#ff806d 0%,#ff5c55 100%); font-size:var(--text-meta); font-weight:700; transition:filter var(--motion-fast) var(--ease-standard),transform var(--motion-fast) var(--ease-standard); }
.dashboard-compose-main { display:flex; align-items:center; gap:var(--space-2); } .dashboard-compose-main { display:flex; align-items:center; gap:var(--space-2); }
.dashboard-compose-menu { width:38px; min-height:40px; display:grid; place-items:center; border:0; border-left:1px solid rgba(255,255,255,0.22); background:linear-gradient(180deg,#ff7860 0%,#ff554e 100%); color:#fff; } .dashboard-compose-menu { width:38px; min-height:40px; display:grid; place-items:center; border:0; border-left:1px solid rgba(255,255,255,0.22); background:linear-gradient(180deg,#ff7860 0%,#ff554e 100%); color:#fff; }