company flows
This commit is contained in:
parent
6541284a4c
commit
cc3f3f3de5
17 changed files with 1960 additions and 66 deletions
19
backend/app/deps/org_manager.py
Normal file
19
backend/app/deps/org_manager.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
|
||||||
43
backend/app/routers/aliases.py
Normal file
43
backend/app/routers/aliases.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
43
backend/app/routers/groups.py
Normal file
43
backend/app/routers/groups.py
Normal 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
|
||||||
66
backend/app/routers/people.py
Normal file
66
backend/app/routers/people.py
Normal 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
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
117
backend/app/services/mail_aliases.py
Normal file
117
backend/app/services/mail_aliases.py
Normal 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)
|
||||||
109
backend/app/services/mail_groups.py
Normal file
109
backend/app/services/mail_groups.py
Normal 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)
|
||||||
143
backend/app/services/org_members.py
Normal file
143
backend/app/services/org_members.py
Normal 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)
|
||||||
|
|
@ -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 });
|
||||||
|
|
||||||
|
|
|
||||||
315
frontend/src/components/CompanyAliases.jsx
Normal file
315
frontend/src/components/CompanyAliases.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
frontend/src/components/CompanyGroups.jsx
Normal file
331
frontend/src/components/CompanyGroups.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
404
frontend/src/components/CompanyPeople.jsx
Normal file
404
frontend/src/components/CompanyPeople.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,82 +280,84 @@ export default function DashboardPage() {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isDomainOwner && (
|
<div className="dashboard-sidebar-scroll">
|
||||||
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
|
{isDomainOwner && (
|
||||||
<nav aria-label="Company">
|
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
|
||||||
{companyItems.map(([ItemIcon, label]) => (
|
<nav aria-label="Company">
|
||||||
|
{companyItems.map(([ItemIcon, label]) => (
|
||||||
|
<button
|
||||||
|
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectCompanyItem(label)}
|
||||||
|
>
|
||||||
|
<ItemIcon size={16} strokeWidth={1.75} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</SidebarSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SidebarSection title="Mail" className="dashboard-sidebar-mail">
|
||||||
|
<nav aria-label="Mail">
|
||||||
|
{mailItems.map(([ItemIcon, label, countKey]) => (
|
||||||
<button
|
<button
|
||||||
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
|
className={`dashboard-nav-item${!companyActive && mailActive === label ? " active" : ""}`}
|
||||||
key={label}
|
key={label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectCompanyItem(label)}
|
onClick={() => selectMailItem(label)}
|
||||||
>
|
>
|
||||||
<ItemIcon size={16} strokeWidth={1.75} />
|
<ItemIcon size={16} strokeWidth={1.75} />
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
<NavBadge value={countKey ? mailStats[countKey] : 0} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
)}
|
|
||||||
|
|
||||||
<SidebarSection title="Mail" className="dashboard-sidebar-mail">
|
<SidebarSection title="Labels" className="dashboard-sidebar-labels">
|
||||||
<nav aria-label="Mail">
|
<nav aria-label="Labels">
|
||||||
{mailItems.map(([ItemIcon, label, countKey]) => (
|
{labels.map(([name, tone, countKey]) => (
|
||||||
|
<button
|
||||||
|
className={`dashboard-nav-item dashboard-label-item${!companyActive && lens === name ? " active" : ""}`}
|
||||||
|
key={name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectLabel(name)}
|
||||||
|
>
|
||||||
|
<i className={`label-dot label-dot-${tone}`} />
|
||||||
|
<span>{name}</span>
|
||||||
|
<NavBadge variant="neutral" value={mailStats.labels?.[countKey]} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<button
|
<button
|
||||||
className={`dashboard-nav-item${mailActive === label ? " active" : ""}`}
|
|
||||||
key={label}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectMailItem(label)}
|
className="dashboard-nav-item dashboard-label-more"
|
||||||
|
onClick={() => setLabelsExpanded((open) => !open)}
|
||||||
>
|
>
|
||||||
<ItemIcon size={16} strokeWidth={1.75} />
|
<ChevronDown size={14} strokeWidth={1.75} className={labelsExpanded ? "open" : ""} />
|
||||||
<span>{label}</span>
|
<span>{labelsExpanded ? "Less" : "More"}</span>
|
||||||
<NavBadge value={countKey ? mailStats[countKey] : 0} />
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
</nav>
|
||||||
</nav>
|
</SidebarSection>
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<SidebarSection title="Labels" className="dashboard-sidebar-labels">
|
<SidebarSection title="Agent" className="dashboard-sidebar-agent">
|
||||||
<nav aria-label="Labels">
|
<nav aria-label="Agent">
|
||||||
{labels.map(([name, tone, countKey]) => (
|
{agentItems.map(([ItemIcon, label, actionKey]) => (
|
||||||
<button
|
<button
|
||||||
className={`dashboard-nav-item dashboard-label-item${lens === name ? " active" : ""}`}
|
className={`dashboard-nav-item dashboard-agent-item${!companyActive && agentNav === actionKey ? " active" : ""}`}
|
||||||
key={name}
|
key={label}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectLabel(name)}
|
onClick={() => selectAgentItem(label, actionKey)}
|
||||||
>
|
>
|
||||||
<i className={`label-dot label-dot-${tone}`} />
|
<ItemIcon size={16} strokeWidth={1.75} />
|
||||||
<span>{name}</span>
|
<span>{label}</span>
|
||||||
<NavBadge variant="neutral" value={mailStats.labels?.[countKey]} />
|
<NavBadge value={actionKey === "actions" ? mailStats.actionItems : 0} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
</nav>
|
||||||
type="button"
|
</SidebarSection>
|
||||||
className="dashboard-nav-item dashboard-label-more"
|
</div>
|
||||||
onClick={() => setLabelsExpanded((open) => !open)}
|
|
||||||
>
|
|
||||||
<ChevronDown size={14} strokeWidth={1.75} className={labelsExpanded ? "open" : ""} />
|
|
||||||
<span>{labelsExpanded ? "Less" : "More"}</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<SidebarSection title="Agent" className="dashboard-sidebar-agent">
|
|
||||||
<nav aria-label="Agent">
|
|
||||||
{agentItems.map(([ItemIcon, label, actionKey]) => (
|
|
||||||
<button
|
|
||||||
className={`dashboard-nav-item dashboard-agent-item${agentNav === actionKey ? " active" : ""}`}
|
|
||||||
key={label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectAgentItem(label, actionKey)}
|
|
||||||
>
|
|
||||||
<ItemIcon size={16} strokeWidth={1.75} />
|
|
||||||
<span>{label}</span>
|
|
||||||
<NavBadge value={actionKey === "actions" ? mailStats.actionItems : 0} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
</SidebarSection>
|
|
||||||
|
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue