batch import csv
This commit is contained in:
parent
a679775f57
commit
dd0ceef665
20 changed files with 1614 additions and 190 deletions
|
|
@ -26,6 +26,24 @@ settings = get_settings()
|
|||
inbound_server = InboundSMTPServer()
|
||||
|
||||
|
||||
async def migrate_mail_group_columns(connection) -> None:
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS owner_id UUID "
|
||||
"REFERENCES users(id)"
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
text(
|
||||
"ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS send_permission "
|
||||
"VARCHAR(30) DEFAULT 'org_members'"
|
||||
)
|
||||
)
|
||||
await connection.execute(
|
||||
text("ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS description TEXT")
|
||||
)
|
||||
|
||||
|
||||
async def migrate_user_org_columns(connection) -> None:
|
||||
await connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_role VARCHAR(20)")
|
||||
|
|
@ -96,6 +114,7 @@ async def lifespan(_: FastAPI):
|
|||
await migrate_legacy_user_tables(connection)
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
await migrate_user_org_columns(connection)
|
||||
await migrate_mail_group_columns(connection)
|
||||
async with SessionFactory() as session:
|
||||
await ensure_platform_domain(session)
|
||||
await session.commit()
|
||||
|
|
|
|||
|
|
@ -198,11 +198,19 @@ class MailGroup(Base):
|
|||
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")
|
||||
owner_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
)
|
||||
send_permission: Mapped[str] = mapped_column(String(30), default="org_members")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
)
|
||||
owner: Mapped["Account | None"] = relationship(foreign_keys=[owner_id])
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ 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
|
||||
from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead, MailGroupEmailCheck
|
||||
from app.services.mail_aliases import (
|
||||
check_alias_email_available,
|
||||
create_mail_alias,
|
||||
list_mail_aliases,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/aliases", tags=["aliases"])
|
||||
|
||||
|
|
@ -20,6 +24,24 @@ async def get_aliases(
|
|||
return MailAliasListResponse(items=items, counts=counts)
|
||||
|
||||
|
||||
@router.get("/check-email", response_model=MailGroupEmailCheck)
|
||||
async def check_email(
|
||||
email: str,
|
||||
manager: Account = Depends(require_org_manager),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> MailGroupEmailCheck:
|
||||
if manager.company_domain is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Company domain is required.",
|
||||
)
|
||||
return await check_alias_email_available(
|
||||
session,
|
||||
manager.company_domain,
|
||||
email,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MailAliasRead, status_code=status.HTTP_201_CREATED)
|
||||
async def add_alias(
|
||||
payload: MailAliasCreate,
|
||||
|
|
|
|||
|
|
@ -57,14 +57,6 @@ async def verify_owned_domain_dns(
|
|||
domain: MailDomain,
|
||||
) -> DnsVerificationResponse:
|
||||
settings = get_settings()
|
||||
if domain.status == "active":
|
||||
return DnsVerificationResponse(
|
||||
domain=domain.domain_name,
|
||||
status=domain.status,
|
||||
verified=True,
|
||||
checks=[],
|
||||
)
|
||||
|
||||
verification_token = await ensure_ownership_dns_record(session, domain)
|
||||
checks = verify_company_dns(
|
||||
domain_name=domain.domain_name,
|
||||
|
|
@ -75,12 +67,15 @@ async def verify_owned_domain_dns(
|
|||
verification_token=verification_token,
|
||||
)
|
||||
verified = all(check["verified"] for check in checks)
|
||||
if verified:
|
||||
if domain.status != "active" and verified:
|
||||
await activate_company_domain(session, domain)
|
||||
await session.commit()
|
||||
status = "active"
|
||||
else:
|
||||
status = domain.status
|
||||
return DnsVerificationResponse(
|
||||
domain=domain.domain_name,
|
||||
status=domain.status if not verified else "active",
|
||||
status=status,
|
||||
verified=verified,
|
||||
checks=checks,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ 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
|
||||
from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupListResponse, MailGroupRead
|
||||
from app.services.mail_groups import (
|
||||
check_group_email_available,
|
||||
create_mail_group,
|
||||
list_mail_groups,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
|
||||
|
|
@ -20,6 +24,25 @@ async def get_groups(
|
|||
return MailGroupListResponse(items=items, counts=counts)
|
||||
|
||||
|
||||
@router.get("/check-email", response_model=MailGroupEmailCheck)
|
||||
async def check_email(
|
||||
email: str,
|
||||
manager: Account = Depends(require_org_manager),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> MailGroupEmailCheck:
|
||||
if manager.company_domain is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Company domain is required.",
|
||||
)
|
||||
result = await check_group_email_available(
|
||||
session,
|
||||
manager.company_domain,
|
||||
email,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=MailGroupRead, status_code=status.HTTP_201_CREATED)
|
||||
async def add_group(
|
||||
payload: MailGroupCreate,
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ from app.models import Account
|
|||
from app.routers.auth import validate_password
|
||||
from app.schemas import (
|
||||
OrgMemberCreate,
|
||||
OrgMemberImportRequest,
|
||||
OrgMemberImportResponse,
|
||||
OrgMemberListResponse,
|
||||
OrgMemberRead,
|
||||
OrgMemberRoleUpdate,
|
||||
)
|
||||
from app.services.org_member_import import import_org_members
|
||||
from app.services.org_members import (
|
||||
create_org_member,
|
||||
list_org_members,
|
||||
|
|
@ -62,6 +65,23 @@ async def add_person(
|
|||
) from error
|
||||
|
||||
|
||||
@router.post("/import", response_model=OrgMemberImportResponse)
|
||||
async def import_people(
|
||||
payload: OrgMemberImportRequest,
|
||||
manager: Account = Depends(require_org_manager),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> OrgMemberImportResponse:
|
||||
try:
|
||||
result = await import_org_members(session, manager=manager, payload=payload)
|
||||
await session.commit()
|
||||
return result
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
|
||||
@router.patch("/{member_id}/role", response_model=OrgMemberRead)
|
||||
async def update_member_role(
|
||||
member_id: UUID,
|
||||
|
|
|
|||
|
|
@ -104,10 +104,48 @@ class OrgMemberListResponse(BaseModel):
|
|||
counts: dict[str, int]
|
||||
|
||||
|
||||
class OrgMemberImportRow(BaseModel):
|
||||
first_name: str = Field(min_length=1, max_length=80)
|
||||
last_name: str = Field(min_length=1, max_length=80)
|
||||
work_email: str = Field(min_length=1, max_length=320)
|
||||
invitation_email: EmailStr | None = None
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class OrgMemberImportRequest(BaseModel):
|
||||
org_role: Literal["user", "admin"] = "user"
|
||||
send_invitation: bool = True
|
||||
force_password_change: bool = True
|
||||
rows: list[OrgMemberImportRow] = Field(min_length=1, max_length=300)
|
||||
|
||||
|
||||
class OrgMemberImportRowResult(BaseModel):
|
||||
row: int
|
||||
email: str
|
||||
success: bool
|
||||
message: str
|
||||
invitation_sent: bool = False
|
||||
|
||||
|
||||
class OrgMemberImportResponse(BaseModel):
|
||||
created: list[OrgMemberRead]
|
||||
errors: list[OrgMemberImportRowResult]
|
||||
created_count: int
|
||||
error_count: int
|
||||
|
||||
|
||||
class MailGroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
email: EmailStr
|
||||
group_type: Literal["distribution", "dynamic"] = "distribution"
|
||||
owner_id: UUID
|
||||
send_permission: Literal[
|
||||
"org_members",
|
||||
"group_members",
|
||||
"owners_managers",
|
||||
"anyone",
|
||||
] = "org_members"
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
member_ids: list[UUID] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
|
@ -116,12 +154,20 @@ class MailGroupRead(BaseModel):
|
|||
name: str
|
||||
email: EmailStr
|
||||
group_type: str
|
||||
owner_id: UUID | None = None
|
||||
send_permission: str = "org_members"
|
||||
member_count: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MailGroupEmailCheck(BaseModel):
|
||||
email: EmailStr
|
||||
available: bool
|
||||
conflict_type: str | None = None
|
||||
|
||||
|
||||
class MailGroupListResponse(BaseModel):
|
||||
items: list[MailGroupRead]
|
||||
counts: dict[str, int]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from sqlalchemy import func, select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Account, MailAlias, MailGroup
|
||||
from app.schemas import MailAliasCreate, MailAliasRead
|
||||
from app.schemas import MailAliasCreate, MailAliasRead, MailGroupEmailCheck
|
||||
|
||||
|
||||
def _alias_read(alias: MailAlias) -> MailAliasRead:
|
||||
|
|
@ -39,6 +39,55 @@ async def list_mail_aliases(
|
|||
return items, counts
|
||||
|
||||
|
||||
async def check_alias_email_available(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
email: str,
|
||||
) -> MailGroupEmailCheck:
|
||||
normalized = str(email).lower()
|
||||
if not normalized.endswith(f"@{company_domain}"):
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="invalid_domain",
|
||||
)
|
||||
|
||||
account = await session.scalar(
|
||||
select(Account).where(func.lower(Account.email) == normalized)
|
||||
)
|
||||
if account:
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="user",
|
||||
)
|
||||
|
||||
group = await session.scalar(
|
||||
select(MailGroup).where(func.lower(MailGroup.email) == normalized)
|
||||
)
|
||||
if group:
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="group",
|
||||
)
|
||||
|
||||
alias = await session.scalar(
|
||||
select(MailAlias).where(
|
||||
MailAlias.company_domain == company_domain,
|
||||
func.lower(MailAlias.alias_address) == normalized,
|
||||
)
|
||||
)
|
||||
if alias:
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="alias",
|
||||
)
|
||||
|
||||
return MailGroupEmailCheck(email=normalized, available=True)
|
||||
|
||||
|
||||
async def _destination_exists(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
|
|
@ -80,26 +129,14 @@ async def create_mail_alias(
|
|||
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
|
||||
)
|
||||
availability = await check_alias_email_available(
|
||||
session,
|
||||
company_domain,
|
||||
alias_address,
|
||||
)
|
||||
if existing:
|
||||
if not availability.available:
|
||||
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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ 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
|
||||
from app.models import Account, MailAlias, MailGroup, MailGroupMember
|
||||
from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupRead
|
||||
|
||||
|
||||
def _group_read(group: MailGroup) -> MailGroupRead:
|
||||
|
|
@ -16,11 +16,62 @@ def _group_read(group: MailGroup) -> MailGroupRead:
|
|||
name=group.name,
|
||||
email=group.email,
|
||||
group_type=group.group_type,
|
||||
owner_id=group.owner_id,
|
||||
send_permission=group.send_permission,
|
||||
member_count=len(group.members),
|
||||
created_at=group.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def check_group_email_available(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
email: str,
|
||||
) -> MailGroupEmailCheck:
|
||||
normalized = str(email).lower()
|
||||
if not normalized.endswith(f"@{company_domain}"):
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="invalid_domain",
|
||||
)
|
||||
|
||||
account = await session.scalar(
|
||||
select(Account).where(func.lower(Account.email) == normalized)
|
||||
)
|
||||
if account:
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="user",
|
||||
)
|
||||
|
||||
group = await session.scalar(
|
||||
select(MailGroup).where(func.lower(MailGroup.email) == normalized)
|
||||
)
|
||||
if group:
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="group",
|
||||
)
|
||||
|
||||
alias = await session.scalar(
|
||||
select(MailAlias).where(
|
||||
MailAlias.company_domain == company_domain,
|
||||
func.lower(MailAlias.alias_address) == normalized,
|
||||
)
|
||||
)
|
||||
if alias:
|
||||
return MailGroupEmailCheck(
|
||||
email=normalized,
|
||||
available=False,
|
||||
conflict_type="alias",
|
||||
)
|
||||
|
||||
return MailGroupEmailCheck(email=normalized, available=True)
|
||||
|
||||
|
||||
async def list_mail_groups(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
|
|
@ -81,6 +132,20 @@ async def create_mail_group(
|
|||
if existing:
|
||||
raise ValueError("That group email address is already in use.")
|
||||
|
||||
owner = await session.scalar(
|
||||
select(Account).where(
|
||||
Account.id == payload.owner_id,
|
||||
Account.company_domain == company_domain,
|
||||
Account.member_status != "suspended",
|
||||
)
|
||||
)
|
||||
if owner is None:
|
||||
raise ValueError("Group owner must be an active member of your organization.")
|
||||
|
||||
availability = await check_group_email_available(session, company_domain, email)
|
||||
if not availability.available:
|
||||
raise ValueError("That group email address is already in use.")
|
||||
|
||||
members = await _validate_member_ids(
|
||||
session,
|
||||
company_domain,
|
||||
|
|
@ -92,6 +157,9 @@ async def create_mail_group(
|
|||
name=payload.name.strip(),
|
||||
email=email,
|
||||
group_type=payload.group_type,
|
||||
owner_id=owner.id,
|
||||
send_permission=payload.send_permission,
|
||||
description=payload.description.strip() if payload.description else None,
|
||||
created_by_id=manager.id,
|
||||
)
|
||||
session.add(group)
|
||||
|
|
|
|||
208
backend/app/services/org_member_import.py
Normal file
208
backend/app/services/org_member_import.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""Bulk CSV import for organization members.
|
||||
|
||||
TODO: Move large imports to an async batch job with webhook completion callbacks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Account
|
||||
from app.schemas import (
|
||||
OrgMemberCreate,
|
||||
OrgMemberImportRequest,
|
||||
OrgMemberImportResponse,
|
||||
OrgMemberImportRowResult,
|
||||
OrgMemberRead,
|
||||
)
|
||||
from app.services.org_members import create_org_member
|
||||
|
||||
MAX_IMPORT_ROWS = 300
|
||||
EMAIL_LOCAL_PATTERN = re.compile(
|
||||
r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$"
|
||||
)
|
||||
|
||||
|
||||
def _password_error(value: str) -> str | None:
|
||||
if len(value) < 8:
|
||||
return "Password must be at least 8 characters."
|
||||
if not (
|
||||
re.search(r"[a-z]", value)
|
||||
and re.search(r"[A-Z]", value)
|
||||
and re.search(r"\d", value)
|
||||
):
|
||||
return "Password must include uppercase, lowercase, and a number."
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_work_email(raw: str, company_domain: str) -> str:
|
||||
value = raw.strip().lower()
|
||||
if "@" in value:
|
||||
return value
|
||||
return f"{value}@{company_domain}"
|
||||
|
||||
|
||||
async def import_org_members(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
manager: Account,
|
||||
payload: OrgMemberImportRequest,
|
||||
) -> OrgMemberImportResponse:
|
||||
company_domain = manager.company_domain
|
||||
if company_domain is None:
|
||||
raise ValueError("Company domain is required.")
|
||||
|
||||
rows = payload.rows
|
||||
if not rows:
|
||||
raise ValueError("Add at least one person to import.")
|
||||
if len(rows) > MAX_IMPORT_ROWS:
|
||||
raise ValueError(f"You can import up to {MAX_IMPORT_ROWS} people at a time.")
|
||||
|
||||
errors: list[OrgMemberImportRowResult] = []
|
||||
normalized_rows: list[tuple[int, OrgMemberCreate]] = []
|
||||
seen_emails: set[str] = set()
|
||||
seen_invites: set[str] = set()
|
||||
|
||||
for index, row in enumerate(rows, start=1):
|
||||
row_errors: list[str] = []
|
||||
first_name = row.first_name.strip()
|
||||
last_name = row.last_name.strip()
|
||||
if not first_name:
|
||||
row_errors.append("First name is required.")
|
||||
if not last_name:
|
||||
row_errors.append("Last name is required.")
|
||||
|
||||
work_email = _normalize_work_email(row.work_email, company_domain)
|
||||
local_part = work_email.split("@", 1)[0]
|
||||
if not work_email.endswith(f"@{company_domain}"):
|
||||
row_errors.append(f"Work email must use @{company_domain}.")
|
||||
elif not EMAIL_LOCAL_PATTERN.match(local_part):
|
||||
row_errors.append("Work email format is invalid.")
|
||||
|
||||
if work_email in seen_emails:
|
||||
row_errors.append("Duplicate work email in this file.")
|
||||
seen_emails.add(work_email)
|
||||
|
||||
password = row.password.strip()
|
||||
password_issue = _password_error(password)
|
||||
if password_issue:
|
||||
row_errors.append(password_issue)
|
||||
|
||||
invitation_email = (
|
||||
str(row.invitation_email).lower().strip()
|
||||
if row.invitation_email
|
||||
else None
|
||||
)
|
||||
if payload.send_invitation:
|
||||
if not invitation_email:
|
||||
row_errors.append(
|
||||
"Invitation email is required when sending invitations."
|
||||
)
|
||||
elif invitation_email.endswith(f"@{company_domain}"):
|
||||
row_errors.append(
|
||||
"Use a personal email for invitations, not your company domain."
|
||||
)
|
||||
elif invitation_email in seen_invites:
|
||||
row_errors.append("Duplicate invitation email in this file.")
|
||||
else:
|
||||
seen_invites.add(invitation_email)
|
||||
|
||||
if row_errors:
|
||||
errors.append(
|
||||
OrgMemberImportRowResult(
|
||||
row=index,
|
||||
email=work_email,
|
||||
success=False,
|
||||
message="; ".join(row_errors),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
normalized_rows.append(
|
||||
(
|
||||
index,
|
||||
OrgMemberCreate(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
email=work_email,
|
||||
invitation_email=invitation_email,
|
||||
org_role=payload.org_role,
|
||||
send_invitation=payload.send_invitation,
|
||||
force_password_change=payload.force_password_change,
|
||||
password=password,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if normalized_rows:
|
||||
existing = await session.scalars(
|
||||
select(Account.email).where(
|
||||
func.lower(Account.email).in_(
|
||||
[item[1].email.lower() for item in normalized_rows]
|
||||
)
|
||||
)
|
||||
)
|
||||
existing_emails = {email.lower() for email in existing.all()}
|
||||
if existing_emails:
|
||||
filtered: list[tuple[int, OrgMemberCreate]] = []
|
||||
for row_number, member_payload in normalized_rows:
|
||||
if member_payload.email.lower() in existing_emails:
|
||||
errors.append(
|
||||
OrgMemberImportRowResult(
|
||||
row=row_number,
|
||||
email=member_payload.email,
|
||||
success=False,
|
||||
message="That work email is already registered.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
filtered.append((row_number, member_payload))
|
||||
normalized_rows = filtered
|
||||
|
||||
if errors and not normalized_rows:
|
||||
return OrgMemberImportResponse(
|
||||
created=[],
|
||||
errors=errors,
|
||||
created_count=0,
|
||||
error_count=len(errors),
|
||||
)
|
||||
|
||||
created: list[OrgMemberRead] = []
|
||||
for row_number, member_payload in normalized_rows:
|
||||
try:
|
||||
member = await create_org_member(
|
||||
session,
|
||||
manager=manager,
|
||||
payload=member_payload,
|
||||
)
|
||||
created.append(member)
|
||||
errors.append(
|
||||
OrgMemberImportRowResult(
|
||||
row=row_number,
|
||||
email=member.email,
|
||||
success=True,
|
||||
message="Created",
|
||||
invitation_sent=member.invitation_sent,
|
||||
)
|
||||
)
|
||||
except ValueError as error:
|
||||
errors.append(
|
||||
OrgMemberImportRowResult(
|
||||
row=row_number,
|
||||
email=member_payload.email,
|
||||
success=False,
|
||||
message=str(error),
|
||||
)
|
||||
)
|
||||
|
||||
created_count = len(created)
|
||||
error_count = sum(1 for item in errors if not item.success)
|
||||
return OrgMemberImportResponse(
|
||||
created=created,
|
||||
errors=errors,
|
||||
created_count=created_count,
|
||||
error_count=error_count,
|
||||
)
|
||||
|
|
@ -52,6 +52,8 @@ export const listOrgMembers = (token) =>
|
|||
request("/people", { token });
|
||||
export const createOrgMember = (payload, token) =>
|
||||
request("/people", { method: "POST", payload, token });
|
||||
export const importOrgMembers = (payload, token) =>
|
||||
request("/people/import", { method: "POST", payload, token });
|
||||
export const listOrgSections = (token) =>
|
||||
request("/sections", { token });
|
||||
export const createOrgSection = (payload, token) =>
|
||||
|
|
@ -60,10 +62,14 @@ export const listMailGroups = (token) =>
|
|||
request("/groups", { token });
|
||||
export const createMailGroup = (payload, token) =>
|
||||
request("/groups", { method: "POST", payload, token });
|
||||
export const checkGroupEmail = (email, token) =>
|
||||
request(`/groups/check-email?email=${encodeURIComponent(email)}`, { token });
|
||||
export const listMailAliases = (token) =>
|
||||
request("/aliases", { token });
|
||||
export const createMailAlias = (payload, token) =>
|
||||
request("/aliases", { method: "POST", payload, token });
|
||||
export const checkAliasEmail = (email, token) =>
|
||||
request(`/aliases/check-email?email=${encodeURIComponent(email)}`, { token });
|
||||
export const getOrgSecurity = (token) =>
|
||||
request("/org/security", { token });
|
||||
export const updateOrgSecurity = (payload, token) =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
|
|
@ -7,17 +8,54 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createMailAlias, listMailAliases, listMailGroups, listOrgMembers } from "../api";
|
||||
import {
|
||||
checkAliasEmail,
|
||||
createMailAlias,
|
||||
listMailAliases,
|
||||
listMailGroups,
|
||||
listOrgMembers,
|
||||
} from "../api";
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ key: "all", label: "All aliases" },
|
||||
{ key: "domain", label: "Domain aliases" },
|
||||
];
|
||||
|
||||
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
function aliasTypeLabel(aliasType) {
|
||||
return aliasType === "group" ? "Group alias" : "User alias";
|
||||
}
|
||||
|
||||
function RequiredLabel({ children }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<span className="add-user-required" aria-hidden="true">*</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupField({ label, error, children, hint, status }) {
|
||||
return (
|
||||
<div className={`add-user-field${error ? " has-error" : ""}`}>
|
||||
{label && <span className="add-user-label">{label}</span>}
|
||||
{children}
|
||||
{status && (
|
||||
<p className={`group-email-status ${status.kind}`}>
|
||||
{status.kind === "ok" && <CheckCircle2 size={14} aria-hidden="true" />}
|
||||
{(status.kind === "warn" || status.kind === "checking") && (
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
)}
|
||||
{status.text}
|
||||
</p>
|
||||
)}
|
||||
{hint && <small className="add-user-hint">{hint}</small>}
|
||||
{error && <span className="add-user-error" role="alert">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [form, setForm] = useState({
|
||||
|
|
@ -26,29 +64,107 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
|
|||
alias_type: "user",
|
||||
is_domain_alias: false,
|
||||
});
|
||||
const [destinationSearch, setDestinationSearch] = useState("");
|
||||
const [emailCheck, setEmailCheck] = useState({ loading: false, available: null, conflict: null });
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
const fullEmail = useMemo(() => {
|
||||
const local = form.alias_local.trim().toLowerCase();
|
||||
return local ? `${local}@${companyDomain}` : "";
|
||||
}, [form.alias_local, companyDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullEmail || !EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
setEmailCheck((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await checkAliasEmail(fullEmail, token);
|
||||
if (!cancelled) {
|
||||
setEmailCheck({
|
||||
loading: false,
|
||||
available: result.available,
|
||||
conflict: result.conflict_type,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [fullEmail, form.alias_local, token]);
|
||||
|
||||
const filteredDestinations = useMemo(() => {
|
||||
const query = destinationSearch.trim().toLowerCase();
|
||||
if (!query) return destinations;
|
||||
return destinations.filter((item) => {
|
||||
const haystack = `${item.label} ${item.email} ${item.kind}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [destinationSearch, destinations]);
|
||||
|
||||
const selectedDestination = destinations.find((item) => item.email === form.forwards_to);
|
||||
|
||||
function updateField(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function handleDestinationChange(value) {
|
||||
const selected = destinations.find((item) => item.email === value);
|
||||
function selectDestination(item) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
forwards_to: value,
|
||||
alias_type: selected?.kind === "group" ? "group" : "user",
|
||||
forwards_to: item.email,
|
||||
alias_type: item.kind === "group" ? "group" : "user",
|
||||
}));
|
||||
}
|
||||
|
||||
const emailStatus = useMemo(() => {
|
||||
if (!form.alias_local.trim()) return null;
|
||||
if (!EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())) {
|
||||
return { kind: "warn", text: "Use lowercase letters, numbers, dots, or hyphens." };
|
||||
}
|
||||
if (emailCheck.loading) {
|
||||
return { kind: "checking", text: `Checking ${fullEmail}…` };
|
||||
}
|
||||
if (emailCheck.available === true) {
|
||||
return { kind: "ok", text: `${fullEmail} is available` };
|
||||
}
|
||||
if (emailCheck.available === false) {
|
||||
const conflictLabel = {
|
||||
user: "a user mailbox",
|
||||
group: "a group mailbox",
|
||||
alias: "another alias",
|
||||
invalid_domain: "an invalid domain",
|
||||
}[emailCheck.conflict] || "another resource";
|
||||
return { kind: "warn", text: `${fullEmail} is already in use by ${conflictLabel}` };
|
||||
}
|
||||
return null;
|
||||
}, [emailCheck, form.alias_local, fullEmail]);
|
||||
|
||||
const canSubmit = form.alias_local.trim()
|
||||
&& EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())
|
||||
&& form.forwards_to
|
||||
&& emailCheck.available === true
|
||||
&& form.forwards_to !== fullEmail;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
setStatus({ loading: true, error: "" });
|
||||
const aliasAddress = `${form.alias_local.trim().toLowerCase()}@${companyDomain}`;
|
||||
try {
|
||||
await createMailAlias(
|
||||
{
|
||||
alias_address: aliasAddress,
|
||||
alias_address: fullEmail,
|
||||
forwards_to: form.forwards_to,
|
||||
alias_type: form.alias_type,
|
||||
is_domain_alias: form.is_domain_alias,
|
||||
|
|
@ -65,79 +181,136 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
|
|||
return (
|
||||
<div className="people-drawer-scrim" onClick={onClose}>
|
||||
<aside
|
||||
className="people-drawer"
|
||||
className="people-drawer people-drawer-wide group-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>
|
||||
<header className="people-drawer-head group-drawer-head">
|
||||
<div>
|
||||
<div className="people-drawer-title">
|
||||
<span className="people-drawer-title-icon" aria-hidden="true">
|
||||
<Mail size={18} />
|
||||
</span>
|
||||
<h2 id="create-alias-title">Add alias</h2>
|
||||
</div>
|
||||
<p className="group-drawer-subtitle">
|
||||
Create an alternative address that forwards to an existing mailbox.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
<form className="group-drawer-form" onSubmit={handleSubmit} noValidate>
|
||||
<div className="add-user-form-body">
|
||||
<h3 className="group-drawer-section-title">Alias details</h3>
|
||||
|
||||
<SetupField
|
||||
label={<RequiredLabel>Alias address</RequiredLabel>}
|
||||
status={emailStatus}
|
||||
hint="Incoming mail to this address will be forwarded to your chosen destination."
|
||||
>
|
||||
<div className="add-user-email-field">
|
||||
<div className="add-user-input-wrap group-email-input">
|
||||
<input
|
||||
required
|
||||
value={form.alias_local}
|
||||
onChange={(event) => updateField("alias_local", event.target.value)}
|
||||
placeholder="ceo"
|
||||
/>
|
||||
<span className="group-email-domain">@{companyDomain}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
<div className="group-members-head">
|
||||
<h3 className="group-drawer-section-title">Destination</h3>
|
||||
{selectedDestination && (
|
||||
<span className="group-members-count">
|
||||
{selectedDestination.kind === "group" ? "Group" : "User"} selected
|
||||
</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 className="group-member-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={destinationSearch}
|
||||
onChange={(event) => setDestinationSearch(event.target.value)}
|
||||
placeholder="Search by name or email"
|
||||
/>
|
||||
</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>
|
||||
<div className="group-member-picker">
|
||||
{destinations.length === 0 ? (
|
||||
<p className="org-member-picker-empty">
|
||||
Add people or groups first, then create an alias.
|
||||
</p>
|
||||
) : (
|
||||
<div className="group-member-list">
|
||||
{filteredDestinations.length === 0 ? (
|
||||
<p className="org-member-picker-empty">No destinations match your search.</p>
|
||||
) : (
|
||||
filteredDestinations.map((item) => (
|
||||
<label key={`${item.kind}-${item.email}`} className="group-member-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="alias_destination"
|
||||
checked={form.forwards_to === item.email}
|
||||
onChange={() => selectDestination(item)}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{item.label}
|
||||
<em className="alias-destination-kind">
|
||||
{item.kind === "group" ? "Group" : "User"}
|
||||
</em>
|
||||
</strong>
|
||||
<small>{item.email}</small>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<h3 className="group-drawer-section-title">Options</h3>
|
||||
|
||||
<footer className="people-drawer-actions">
|
||||
<button type="button" className="button button-outline" onClick={onClose}>
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_domain_alias}
|
||||
onChange={(event) => updateField("is_domain_alias", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Domain alias</strong>
|
||||
<small>Use for shared addresses like info@ or support@ that represent your organization.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{form.forwards_to === fullEmail && fullEmail && (
|
||||
<p className="add-user-error" role="alert">Alias cannot forward to itself.</p>
|
||||
)}
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
</div>
|
||||
|
||||
<footer className="group-drawer-footer">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button button-purple" type="submit" disabled={status.loading}>
|
||||
<button
|
||||
className="add-user-button add-user-button-submit"
|
||||
type="submit"
|
||||
disabled={!canSubmit || status.loading}
|
||||
>
|
||||
{status.loading ? "Creating…" : "Create alias"}
|
||||
</button>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -374,17 +374,8 @@ function DomainDetail({
|
|||
}, [domain.id, onRefresh, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (domain.status === "active") {
|
||||
setChecks([
|
||||
{ purpose: "mx", verified: true },
|
||||
{ purpose: "spf", verified: true },
|
||||
{ purpose: "dkim", verified: true },
|
||||
{ purpose: "dmarc", verified: true },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
runVerify();
|
||||
}, [domain.id, domain.status, runVerify]);
|
||||
}, [domain.id, runVerify]);
|
||||
|
||||
const checkMap = Object.fromEntries((checks || []).map((c) => [c.purpose, c]));
|
||||
const mailRecords = domain.dns_records?.filter((r) => r.purpose !== "ownership") || [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
|
|
@ -6,7 +9,12 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createMailGroup, listMailGroups, listOrgMembers } from "../api";
|
||||
import {
|
||||
checkGroupEmail,
|
||||
createMailGroup,
|
||||
listMailGroups,
|
||||
listOrgMembers,
|
||||
} from "../api";
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ key: "all", label: "All groups" },
|
||||
|
|
@ -16,6 +24,31 @@ const TYPE_TABS = [
|
|||
|
||||
const GROUP_TONES = ["green", "violet", "blue", "amber", "coral", "teal"];
|
||||
|
||||
const SEND_PERMISSIONS = [
|
||||
{
|
||||
value: "org_members",
|
||||
label: "Anyone in organization",
|
||||
getHint: (domain) => `Anyone with an @${domain} address can send to this group.`,
|
||||
},
|
||||
{
|
||||
value: "group_members",
|
||||
label: "Group members only",
|
||||
getHint: () => "Only people added to this group can send messages to it.",
|
||||
},
|
||||
{
|
||||
value: "owners_managers",
|
||||
label: "Owners and managers only",
|
||||
getHint: () => "Only the group owner and organization admins can send.",
|
||||
},
|
||||
{
|
||||
value: "anyone",
|
||||
label: "Anyone, including external senders",
|
||||
getHint: () => "External addresses can email this group. Use with care.",
|
||||
},
|
||||
];
|
||||
|
||||
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
function formatRelativeTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
|
@ -31,20 +64,128 @@ function groupInitial(name) {
|
|||
return (name?.trim()?.[0] || "G").toUpperCase();
|
||||
}
|
||||
|
||||
function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
|
||||
function slugFromName(name) {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function RequiredLabel({ children }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<span className="add-user-required" aria-hidden="true">*</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupField({ label, error, children, hint, status }) {
|
||||
return (
|
||||
<div className={`add-user-field${error ? " has-error" : ""}`}>
|
||||
{label && <span className="add-user-label">{label}</span>}
|
||||
{children}
|
||||
{status && (
|
||||
<p className={`group-email-status ${status.kind}`}>
|
||||
{status.kind === "ok" && <CheckCircle2 size={14} aria-hidden="true" />}
|
||||
{(status.kind === "warn" || status.kind === "checking") && (
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
)}
|
||||
{status.text}
|
||||
</p>
|
||||
)}
|
||||
{hint && <small className="add-user-hint">{hint}</small>}
|
||||
{error && <span className="add-user-error" role="alert">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateGroupPanel({ account, companyDomain, members, onClose, onCreated }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email_local: "",
|
||||
group_type: "distribution",
|
||||
owner_id: account.id,
|
||||
send_permission: "org_members",
|
||||
description: "",
|
||||
member_ids: [],
|
||||
});
|
||||
const [emailTouched, setEmailTouched] = useState(false);
|
||||
const [memberSearch, setMemberSearch] = useState("");
|
||||
const [permissionsOpen, setPermissionsOpen] = useState(false);
|
||||
const [emailCheck, setEmailCheck] = useState({ loading: false, available: null, conflict: null });
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
const fullEmail = useMemo(() => {
|
||||
const local = form.email_local.trim().toLowerCase();
|
||||
return local ? `${local}@${companyDomain}` : "";
|
||||
}, [form.email_local, companyDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullEmail || !EMAIL_LOCAL_PATTERN.test(form.email_local.trim().toLowerCase())) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
setEmailCheck((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await checkGroupEmail(fullEmail, token);
|
||||
if (!cancelled) {
|
||||
setEmailCheck({
|
||||
loading: false,
|
||||
available: result.available,
|
||||
conflict: result.conflict_type,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [fullEmail, form.email_local, token]);
|
||||
|
||||
const ownerOptions = useMemo(() => {
|
||||
const byId = new Map(members.map((member) => [member.id, member]));
|
||||
if (!byId.has(account.id)) {
|
||||
byId.set(account.id, account);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}, [account, members]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const query = memberSearch.trim().toLowerCase();
|
||||
if (!query) return members;
|
||||
return members.filter((member) => {
|
||||
const haystack = `${member.first_name} ${member.last_name} ${member.email}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [memberSearch, members]);
|
||||
|
||||
const allFilteredSelected = filteredMembers.length > 0
|
||||
&& filteredMembers.every((member) => form.member_ids.includes(member.id));
|
||||
|
||||
function updateField(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateName(value) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
name: value,
|
||||
email_local: emailTouched ? current.email_local : slugFromName(value),
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleMember(memberId) {
|
||||
setForm((current) => {
|
||||
const exists = current.member_ids.includes(memberId);
|
||||
|
|
@ -57,16 +198,65 @@ function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
|
|||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const filteredIds = filteredMembers.map((member) => member.id);
|
||||
setForm((current) => {
|
||||
if (allFilteredSelected) {
|
||||
return {
|
||||
...current,
|
||||
member_ids: current.member_ids.filter((id) => !filteredIds.includes(id)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
member_ids: [...new Set([...current.member_ids, ...filteredIds])],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const emailStatus = useMemo(() => {
|
||||
if (!form.email_local.trim()) return null;
|
||||
if (!EMAIL_LOCAL_PATTERN.test(form.email_local.trim().toLowerCase())) {
|
||||
return { kind: "warn", text: "Use lowercase letters, numbers, dots, or hyphens." };
|
||||
}
|
||||
if (emailCheck.loading) {
|
||||
return { kind: "checking", text: `Checking ${fullEmail}…` };
|
||||
}
|
||||
if (emailCheck.available === true) {
|
||||
return { kind: "ok", text: `${fullEmail} is available` };
|
||||
}
|
||||
if (emailCheck.available === false) {
|
||||
const conflictLabel = {
|
||||
user: "a user mailbox",
|
||||
group: "another group",
|
||||
alias: "an alias",
|
||||
invalid_domain: "an invalid domain",
|
||||
}[emailCheck.conflict] || "another resource";
|
||||
return { kind: "warn", text: `${fullEmail} is already in use by ${conflictLabel}` };
|
||||
}
|
||||
return null;
|
||||
}, [emailCheck, form.email_local, fullEmail]);
|
||||
|
||||
const canSubmit = form.name.trim()
|
||||
&& form.email_local.trim()
|
||||
&& EMAIL_LOCAL_PATTERN.test(form.email_local.trim().toLowerCase())
|
||||
&& form.owner_id
|
||||
&& emailCheck.available === true;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
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,
|
||||
email: fullEmail,
|
||||
group_type: "distribution",
|
||||
owner_id: form.owner_id,
|
||||
send_permission: form.send_permission,
|
||||
description: form.description.trim() || undefined,
|
||||
member_ids: form.member_ids,
|
||||
},
|
||||
token,
|
||||
|
|
@ -81,83 +271,182 @@ function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
|
|||
return (
|
||||
<div className="people-drawer-scrim" onClick={onClose}>
|
||||
<aside
|
||||
className="people-drawer"
|
||||
className="people-drawer people-drawer-wide group-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>
|
||||
<header className="people-drawer-head group-drawer-head">
|
||||
<div>
|
||||
<div className="people-drawer-title">
|
||||
<span className="people-drawer-title-icon" aria-hidden="true">
|
||||
<UsersRound size={18} />
|
||||
</span>
|
||||
<h2 id="create-group-title">Create group</h2>
|
||||
</div>
|
||||
<p className="group-drawer-subtitle">Create an email group for your organization.</p>
|
||||
</div>
|
||||
<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>
|
||||
<form className="group-drawer-form" onSubmit={handleSubmit} noValidate>
|
||||
<div className="add-user-form-body">
|
||||
<h3 className="group-drawer-section-title">Group details</h3>
|
||||
|
||||
<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>
|
||||
<SetupField label={<RequiredLabel>Group name</RequiredLabel>}>
|
||||
<div className="add-user-input-wrap">
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => updateName(event.target.value)}
|
||||
placeholder="Engineering"
|
||||
/>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={form.group_type}
|
||||
onChange={(event) => updateField("group_type", event.target.value)}
|
||||
<SetupField
|
||||
label={<RequiredLabel>Group email</RequiredLabel>}
|
||||
status={emailStatus}
|
||||
hint="Messages sent to this address are delivered to all members."
|
||||
>
|
||||
<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>
|
||||
<div className="add-user-email-field">
|
||||
<div className="add-user-input-wrap group-email-input">
|
||||
<input
|
||||
required
|
||||
value={form.email_local}
|
||||
onChange={(event) => {
|
||||
setEmailTouched(true);
|
||||
updateField("email_local", event.target.value);
|
||||
}}
|
||||
placeholder="engineering"
|
||||
/>
|
||||
<span className="group-email-domain">@{companyDomain}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<fieldset className="people-groups-field">
|
||||
<legend>Members</legend>
|
||||
<div className="org-member-picker">
|
||||
<SetupField label={<RequiredLabel>Group owner</RequiredLabel>}>
|
||||
<div className="add-user-input-wrap">
|
||||
<select
|
||||
value={form.owner_id}
|
||||
onChange={(event) => updateField("owner_id", event.target.value)}
|
||||
required
|
||||
>
|
||||
{ownerOptions.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.first_name} {member.last_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<small className="add-user-hint">The owner can manage members without full admin access.</small>
|
||||
</SetupField>
|
||||
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
<div className="group-members-head">
|
||||
<h3 className="group-drawer-section-title">Members</h3>
|
||||
<span className="group-members-count">
|
||||
{form.member_ids.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="group-member-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={memberSearch}
|
||||
onChange={(event) => setMemberSearch(event.target.value)}
|
||||
placeholder="Search by name or email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="group-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">
|
||||
<>
|
||||
<label className="group-member-select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.member_ids.includes(member.id)}
|
||||
onChange={() => toggleMember(member.id)}
|
||||
checked={allFilteredSelected}
|
||||
onChange={toggleSelectAll}
|
||||
disabled={filteredMembers.length === 0}
|
||||
/>
|
||||
<span>{member.first_name} {member.last_name}</span>
|
||||
<small>{member.email}</small>
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
))
|
||||
<div className="group-member-list">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<p className="org-member-picker-empty">No people match your search.</p>
|
||||
) : (
|
||||
filteredMembers.map((member) => (
|
||||
<label key={member.id} className="group-member-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.member_ids.includes(member.id)}
|
||||
onChange={() => toggleMember(member.id)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{member.first_name} {member.last_name}</strong>
|
||||
<small>{member.email}</small>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<div className="group-permissions">
|
||||
<button
|
||||
type="button"
|
||||
className="group-permissions-toggle"
|
||||
onClick={() => setPermissionsOpen((open) => !open)}
|
||||
aria-expanded={permissionsOpen}
|
||||
>
|
||||
<ChevronDown size={16} className={permissionsOpen ? "open" : ""} />
|
||||
Permissions
|
||||
</button>
|
||||
{permissionsOpen && (
|
||||
<fieldset className="group-permissions-panel">
|
||||
<legend className="add-user-label">Who can send to this group?</legend>
|
||||
{SEND_PERMISSIONS.map((option) => (
|
||||
<label key={option.value} className="group-permission-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="send_permission"
|
||||
value={option.value}
|
||||
checked={form.send_permission === option.value}
|
||||
onChange={() => updateField("send_permission", option.value)}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{option.value === "org_members"
|
||||
? `Anyone in ${companyDomain}`
|
||||
: option.label}
|
||||
</strong>
|
||||
<small>{option.getHint(companyDomain)}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="people-drawer-actions">
|
||||
<button type="button" className="button button-outline" onClick={onClose}>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
</div>
|
||||
|
||||
<footer className="group-drawer-footer">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button button-purple" type="submit" disabled={status.loading}>
|
||||
<button
|
||||
className="add-user-button add-user-button-submit"
|
||||
type="submit"
|
||||
disabled={!canSubmit || status.loading}
|
||||
>
|
||||
{status.loading ? "Creating…" : "Create group"}
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -320,6 +609,7 @@ export default function CompanyGroups({ account }) {
|
|||
|
||||
{drawerOpen && (
|
||||
<CreateGroupPanel
|
||||
account={account}
|
||||
companyDomain={companyDomain}
|
||||
members={members}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
const [dnsChecks, setDnsChecks] = useState([]);
|
||||
const [lastChecked, setLastChecked] = useState(null);
|
||||
const [dnsLoading, setDnsLoading] = useState(false);
|
||||
const [dnsError, setDnsError] = useState("");
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -97,16 +98,8 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
setDnsChecks([]);
|
||||
return;
|
||||
}
|
||||
if (primary.status === "active") {
|
||||
setDnsChecks(HEALTH_ITEMS.map((item) => ({
|
||||
...item,
|
||||
verified: item.purpose !== "dmarc",
|
||||
recommended: item.purpose === "dmarc",
|
||||
})));
|
||||
setLastChecked(new Date());
|
||||
return;
|
||||
}
|
||||
setDnsLoading(true);
|
||||
setDnsError("");
|
||||
try {
|
||||
const result = await verifyMyDomainDns(primary.id, token);
|
||||
const checkMap = Object.fromEntries(result.checks.map((check) => [check.purpose, check]));
|
||||
|
|
@ -116,12 +109,13 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
recommended: item.purpose === "dmarc" && !checkMap[item.purpose]?.verified,
|
||||
})));
|
||||
setLastChecked(new Date());
|
||||
} catch {
|
||||
setDnsChecks(HEALTH_ITEMS.map((item) => ({
|
||||
...item,
|
||||
verified: false,
|
||||
recommended: item.purpose === "dmarc",
|
||||
})));
|
||||
if (result.status !== primary.status) {
|
||||
setDomains((current) => current.map((domain) => (
|
||||
domain.id === primary.id ? { ...domain, status: result.status } : domain
|
||||
)));
|
||||
}
|
||||
} catch (error) {
|
||||
setDnsError(error.message || "Could not verify DNS records.");
|
||||
} finally {
|
||||
setDnsLoading(false);
|
||||
}
|
||||
|
|
@ -245,9 +239,15 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
))}
|
||||
</ul>
|
||||
<footer className="company-overview-panel-footer">
|
||||
<span>{lastChecked ? `Last checked ${formatRelativeTime(lastChecked)}` : "Not checked yet"}</span>
|
||||
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading}>
|
||||
<RefreshCw size={14} />
|
||||
<span>
|
||||
{dnsError
|
||||
? dnsError
|
||||
: lastChecked
|
||||
? `Last checked ${formatRelativeTime(lastChecked)}`
|
||||
: "Not checked yet"}
|
||||
</span>
|
||||
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}>
|
||||
<RefreshCw size={14} className={dnsLoading ? "spinning" : ""} />
|
||||
{dnsLoading ? "Checking…" : "Recheck"}
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -294,7 +294,13 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" disabled title="Coming soon">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("agmail_open_people_import", "1");
|
||||
onNavigate?.("People");
|
||||
}}
|
||||
>
|
||||
<FileUp size={16} /> Invite users via CSV
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ArrowRight, Check } from "lucide-react";
|
||||
import { ArrowRight, Check, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddEmployeeForm from "./AddEmployeeForm";
|
||||
import ImportUsersPanel from "./ImportUsersPanel";
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: "Add domain", hint: "Use your company domain for email" },
|
||||
|
|
@ -140,6 +141,7 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [addedEmployees, setAddedEmployees] = useState([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey(account.id, "step"), String(step));
|
||||
|
|
@ -311,11 +313,20 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
|
||||
{step === 5 && (
|
||||
<>
|
||||
<div className="org-setup-panel-intro">
|
||||
<h2>Add employees</h2>
|
||||
<p className="org-setup-section-label">
|
||||
Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People.
|
||||
</p>
|
||||
<div className="org-setup-panel-intro org-setup-panel-intro-row">
|
||||
<div>
|
||||
<h2>Add employees</h2>
|
||||
<p className="org-setup-section-label">
|
||||
Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-outline"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<Upload size={16} /> Import CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addedEmployees.length > 0 && (
|
||||
|
|
@ -367,6 +378,17 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importOpen && (
|
||||
<ImportUsersPanel
|
||||
companyDomain={companyDomainName}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={(result) => {
|
||||
setAddedEmployees((current) => [...current, ...result.created]);
|
||||
setImportOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { listOrgMembers } from "../api";
|
||||
import AddEmployeeForm from "./AddEmployeeForm";
|
||||
import ImportUsersPanel from "./ImportUsersPanel";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All users" },
|
||||
|
|
@ -101,6 +102,7 @@ export default function CompanyPeople({ account }) {
|
|||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
|
|
@ -121,6 +123,13 @@ export default function CompanyPeople({ account }) {
|
|||
loadMembers();
|
||||
}, [loadMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem("agmail_open_people_import") === "1") {
|
||||
sessionStorage.removeItem("agmail_open_people_import");
|
||||
setImportOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return members.filter((member) => {
|
||||
|
|
@ -147,7 +156,11 @@ export default function CompanyPeople({ account }) {
|
|||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-outline people-import-btn"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<Upload size={16} /> Import CSV
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -250,6 +263,18 @@ export default function CompanyPeople({ account }) {
|
|||
</table>
|
||||
</div>
|
||||
|
||||
{importOpen && (
|
||||
<ImportUsersPanel
|
||||
companyDomain={companyDomain}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={(result) => {
|
||||
loadMembers();
|
||||
setNotice(`Imported ${result.created_count} users${result.error_count ? ` (${result.error_count} failed)` : ""}.`);
|
||||
window.setTimeout(() => setNotice(""), 6000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{drawerOpen && (
|
||||
<AddUserPanel
|
||||
companyDomain={companyDomain}
|
||||
|
|
|
|||
242
frontend/src/components/ImportUsersPanel.jsx
Normal file
242
frontend/src/components/ImportUsersPanel.jsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { Download, FileUp, Upload, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { importOrgMembers } from "../api";
|
||||
import {
|
||||
IMPORT_ROW_LIMIT,
|
||||
downloadSampleCsv,
|
||||
parseUsersCsv,
|
||||
rowsToImportPayload,
|
||||
validateImportRows,
|
||||
} from "../utils/importUsersCsv";
|
||||
|
||||
function SetupField({ label, children, hint }) {
|
||||
return (
|
||||
<div className="add-user-field">
|
||||
{label && <span className="add-user-label">{label}</span>}
|
||||
{children}
|
||||
{hint && <small className="add-user-hint">{hint}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportUsersPanel({ companyDomain, onClose, onImported }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [form, setForm] = useState({
|
||||
org_role: "user",
|
||||
send_invitation: true,
|
||||
force_password_change: true,
|
||||
});
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [rows, setRows] = useState([]);
|
||||
const [parseError, setParseError] = useState("");
|
||||
const [importResult, setImportResult] = useState(null);
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
const clientErrors = useMemo(() => {
|
||||
if (!rows.length) return [];
|
||||
return validateImportRows(rows, {
|
||||
companyDomain,
|
||||
sendInvitation: form.send_invitation,
|
||||
});
|
||||
}, [companyDomain, form.send_invitation, rows]);
|
||||
|
||||
const canImport = rows.length > 0
|
||||
&& rows.length <= IMPORT_ROW_LIMIT
|
||||
&& clientErrors.length === 0
|
||||
&& !status.loading;
|
||||
|
||||
function updateField(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
setImportResult(null);
|
||||
}
|
||||
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
setParseError("");
|
||||
setValidationErrors([]);
|
||||
setImportResult(null);
|
||||
if (!file) {
|
||||
setRows([]);
|
||||
setFileName("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedRows = parseUsersCsv(text);
|
||||
setRows(parsedRows);
|
||||
setFileName(file.name);
|
||||
} catch (error) {
|
||||
setRows([]);
|
||||
setFileName("");
|
||||
setParseError(error.message);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!canImport) return;
|
||||
setStatus({ loading: true, error: "" });
|
||||
setImportResult(null);
|
||||
try {
|
||||
const payload = rowsToImportPayload(rows, {
|
||||
orgRole: form.org_role,
|
||||
sendInvitation: form.send_invitation,
|
||||
forcePasswordChange: form.force_password_change,
|
||||
companyDomain,
|
||||
});
|
||||
const result = await importOrgMembers(payload, token);
|
||||
setImportResult(result);
|
||||
onImported?.(result);
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
setStatus({ loading: false, error: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="people-drawer-scrim" onClick={onClose}>
|
||||
<aside
|
||||
className="people-drawer people-drawer-wide group-drawer"
|
||||
role="dialog"
|
||||
aria-labelledby="import-users-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="people-drawer-head group-drawer-head">
|
||||
<div>
|
||||
<div className="people-drawer-title">
|
||||
<span className="people-drawer-title-icon" aria-hidden="true">
|
||||
<Upload size={18} />
|
||||
</span>
|
||||
<h2 id="import-users-title">Import users</h2>
|
||||
</div>
|
||||
<p className="group-drawer-subtitle">
|
||||
Upload a CSV to add up to {IMPORT_ROW_LIMIT} people at once.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="group-drawer-form">
|
||||
<div className="add-user-form-body">
|
||||
<h3 className="group-drawer-section-title">Import settings</h3>
|
||||
|
||||
<SetupField label="Role">
|
||||
<div className="add-user-input-wrap">
|
||||
<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>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<div className="add-user-checkboxes">
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.send_invitation}
|
||||
onChange={(event) => updateField("send_invitation", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Send invitation email</strong>
|
||||
<small>They will receive an activation link to set their password.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.force_password_change}
|
||||
onChange={(event) => updateField("force_password_change", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Require password change on first login</strong>
|
||||
<small>User must create a new password when they sign in for the first time.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
<h3 className="group-drawer-section-title">CSV file</h3>
|
||||
|
||||
<div className="import-users-actions">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={downloadSampleCsv}>
|
||||
<Download size={16} /> Download sample CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="import-users-dropzone">
|
||||
<input type="file" accept=".csv,text/csv" onChange={handleFileChange} />
|
||||
<FileUp size={22} />
|
||||
<strong>{fileName || "Choose a CSV file"}</strong>
|
||||
<small>
|
||||
Columns: First name*, Last name*, Work email*, Invitation email, Initial password
|
||||
</small>
|
||||
</label>
|
||||
|
||||
{parseError && <p className="form-error" role="alert">{parseError}</p>}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="import-users-summary">
|
||||
<p>
|
||||
<strong>{rows.length}</strong> people ready to import
|
||||
{clientErrors.length > 0 && (
|
||||
<> · <span className="import-users-summary-error">{clientErrors.length} rows need fixes</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clientErrors.length > 0 && (
|
||||
<ul className="import-users-errors">
|
||||
{clientErrors.slice(0, 8).map((item) => (
|
||||
<li key={`${item.row}-${item.email}`}>
|
||||
Row {item.row} ({item.email || "missing email"}): {item.messages.join(" ")}
|
||||
</li>
|
||||
))}
|
||||
{clientErrors.length > 8 && (
|
||||
<li>…and {clientErrors.length - 8} more rows with issues.</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className="import-users-result" role="status">
|
||||
<p>
|
||||
Created <strong>{importResult.created_count}</strong> users.
|
||||
{importResult.error_count > 0 && (
|
||||
<> {importResult.error_count} rows failed.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
</div>
|
||||
|
||||
<footer className="group-drawer-footer">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
|
||||
{importResult?.created_count ? "Close" : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="add-user-button add-user-button-submit"
|
||||
disabled={!canImport}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{status.loading ? "Importing…" : `Import ${rows.length || ""} users`.trim()}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -261,6 +261,9 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.company-overview-health-icon { width:24px; height:24px; display:grid; place-items:center; border-radius:50%; background:#f2f0f5; color:#b4afba; }
|
||||
.company-overview-health-icon.ok { background:#e8f7ef; color:#2f8a57; }
|
||||
.company-overview-health-icon.warn { background:#fff4df; color:#b7892d; }
|
||||
.company-overview-health-icon.fail { background:#fdeeed; color:#c44b38; }
|
||||
.company-overview-panel-footer button .spinning { animation:overview-spin .8s linear infinite; }
|
||||
@keyframes overview-spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
|
||||
.company-overview-health-badge { margin-left:auto; padding:3px 8px; border-radius:999px; background:#fff4df; color:#b7892d; font-size:10px; font-style:normal; font-weight:700; }
|
||||
.company-overview-panel-footer { margin-top:16px; padding-top:14px; border-top:1px solid #f0ebe7; display:flex; align-items:center; justify-content:space-between; gap:12px; color:#9b96a3; font-size:12px; }
|
||||
.company-overview-panel-footer button { display:inline-flex; align-items:center; gap:6px; border:0; background:transparent; color:#4d74c4; font-size:12px; font-weight:600; cursor:pointer; }
|
||||
|
|
@ -390,6 +393,38 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.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(500px,100%); height:100%; padding:24px; display:flex; flex-direction:column; background:#fff; box-shadow:-12px 0 40px rgba(20,18,30,0.12); overflow:auto; }
|
||||
.people-drawer-wide { width:min(580px,100%); }
|
||||
.group-drawer-head { align-items:flex-start; }
|
||||
.group-drawer-subtitle { margin:8px 0 0 42px; color:#6B7280; font-size:13px; line-height:1.45; }
|
||||
.group-drawer-form { flex:1; min-height:0; display:flex; flex-direction:column; }
|
||||
.group-drawer-section-title { margin:0; font-size:14px; font-weight:600; color:#1a1628; }
|
||||
.group-drawer-footer { margin-top:24px; padding-top:20px; border-top:1px solid #EEEEEE; display:flex; justify-content:flex-end; align-items:center; gap:10px; flex-shrink:0; }
|
||||
.group-email-input { padding-right:0; overflow:hidden; }
|
||||
.group-email-domain { min-height:42px; padding:0 14px; display:flex; align-items:center; border-left:1px solid #E1DFE7; background:#F7F7F8; color:#6B7280; font-size:13px; font-weight:600; white-space:nowrap; }
|
||||
.group-email-status { margin:6px 0 0; display:flex; align-items:center; gap:6px; font-size:12px; line-height:1.4; }
|
||||
.group-email-status.ok { color:#2f8a57; }
|
||||
.group-email-status.warn,.group-email-status.checking { color:#b7892d; }
|
||||
.group-members-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.group-members-count { color:#6B7280; font-size:12px; font-weight:600; }
|
||||
.group-member-search { min-height:44px; padding:0 14px; display:flex; align-items:center; gap:10px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; color:#A1A1AA; }
|
||||
.group-member-search input { width:100%; min-width:0; border:0; outline:0; background:transparent; font-size:13px; color:#1a1628; }
|
||||
.group-member-picker { border:1px solid #E1DFE7; border-radius:12px; background:#FAFAFC; overflow:hidden; }
|
||||
.group-member-select-all { padding:12px 14px; display:grid; grid-template-columns:18px 1fr; gap:12px; align-items:center; border-bottom:1px solid #ECE9F2; background:#fff; font-size:13px; font-weight:600; color:#4a4652; cursor:pointer; }
|
||||
.group-member-list { max-height:240px; overflow:auto; }
|
||||
.group-member-row { padding:12px 14px; display:grid; grid-template-columns:18px 1fr; gap:12px; align-items:start; border-bottom:1px solid #F0EEF5; background:#fff; cursor:pointer; }
|
||||
.group-member-row:last-child { border-bottom:0; }
|
||||
.group-member-row strong { display:block; font-size:14px; font-weight:500; color:#1a1628; line-height:1.3; }
|
||||
.group-member-row small { display:block; margin-top:2px; color:#6B7280; font-size:12px; line-height:1.35; }
|
||||
.group-permissions { margin-top:4px; }
|
||||
.group-permissions-toggle { width:100%; padding:0; display:flex; align-items:center; gap:8px; border:0; background:transparent; color:#4a4652; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.group-permissions-toggle svg { transition:transform .15s ease; }
|
||||
.group-permissions-toggle svg.open { transform:rotate(180deg); }
|
||||
.group-permissions-panel { margin-top:14px; padding:0; border:0; display:grid; gap:12px; }
|
||||
.group-permission-option { display:grid; grid-template-columns:18px 1fr; gap:12px; align-items:start; cursor:pointer; }
|
||||
.group-permission-option strong { display:block; font-size:13px; font-weight:600; color:#1a1628; line-height:1.35; }
|
||||
.group-permission-option small { display:block; margin-top:4px; color:#6B7280; font-size:12px; line-height:1.45; }
|
||||
.alias-destination-kind { margin-left:8px; padding:2px 8px; border-radius:999px; background:#EEF4FF; color:#4f6fd8; font-size:10px; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.3px; }
|
||||
.group-member-row strong { display:flex; align-items:center; flex-wrap:wrap; gap:4px; }
|
||||
.people-drawer-head { margin-bottom:24px; display:flex; align-items:center; justify-content:space-between; gap:12px; flex-shrink:0; }
|
||||
.people-drawer-title { display:flex; align-items:center; gap:10px; }
|
||||
.people-drawer-title-icon { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; background:#F6F2FF; color:#7357F6; }
|
||||
|
|
@ -430,6 +465,17 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.add-user-button-submit { border:0; background:#7357F6; color:#fff; box-shadow:0 4px 14px rgba(115,87,246,0.24); }
|
||||
.add-user-button-submit:hover:not(:disabled) { filter:brightness(1.04); }
|
||||
.add-user-button-submit:active:not(:disabled) { transform:scale(0.99); }
|
||||
.org-setup-panel-intro-row { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
||||
.org-setup-panel-intro-row .button { flex-shrink:0; }
|
||||
.import-users-actions { display:flex; gap:10px; }
|
||||
.import-users-dropzone { min-height:140px; padding:24px; display:grid; justify-items:center; gap:8px; border:1px dashed #D8D5E0; border-radius:12px; background:#FAFAFC; text-align:center; cursor:pointer; }
|
||||
.import-users-dropzone input { display:none; }
|
||||
.import-users-dropzone strong { font-size:14px; color:#1a1628; }
|
||||
.import-users-dropzone small { color:#6B7280; font-size:12px; line-height:1.45; max-width:360px; }
|
||||
.import-users-summary { padding:12px 14px; border-radius:10px; background:#F6F2FF; color:#4a4652; font-size:13px; }
|
||||
.import-users-summary-error { color:#c44b38; }
|
||||
.import-users-errors { margin:0; padding-left:18px; color:#c44b38; font-size:12px; line-height:1.5; }
|
||||
.import-users-result { padding:12px 14px; border-radius:10px; background:#eef8f0; color:#2f6b3a; font-size:13px; }
|
||||
.employee-add-form-inline .add-user-row,.employee-add-form-inline .add-user-form-body { max-width:720px; }
|
||||
.employee-add-form-inline .add-user-input-wrap input,.employee-add-form-inline .add-user-input-wrap select { height:40px; }
|
||||
.people-drawer-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
|
|
|
|||
177
frontend/src/utils/importUsersCsv.js
Normal file
177
frontend/src/utils/importUsersCsv.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
const SAMPLE_CSV = `First name*,Last name*,Work email*,Invitation email,Initial password
|
||||
Rahul,Sharma,rahul,rahul@gmail.com,Roadrentals123
|
||||
Anurag,Jangid,anurag.jangid,anurag.personal@gmail.com,Roadrentals123`;
|
||||
|
||||
const HEADER_ALIASES = {
|
||||
"first name": "first_name",
|
||||
"first name*": "first_name",
|
||||
"last name": "last_name",
|
||||
"last name*": "last_name",
|
||||
"work email": "work_email",
|
||||
"work email*": "work_email",
|
||||
"invitation email": "invitation_email",
|
||||
"initial password": "password",
|
||||
};
|
||||
|
||||
export const IMPORT_ROW_LIMIT = 300;
|
||||
|
||||
function normalizeHeader(value) {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const cells = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[index + 1] === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "," && !inQuotes) {
|
||||
cells.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function buildSampleCsvBlob() {
|
||||
return new Blob([SAMPLE_CSV], { type: "text/csv;charset=utf-8" });
|
||||
}
|
||||
|
||||
export function downloadSampleCsv() {
|
||||
const url = URL.createObjectURL(buildSampleCsvBlob());
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "agmail-people-import-sample.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function parseUsersCsv(text) {
|
||||
const lines = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error("CSV must include a header row and at least one person.");
|
||||
}
|
||||
|
||||
const headers = parseCsvLine(lines[0]).map(normalizeHeader);
|
||||
const columnMap = headers.map((header) => HEADER_ALIASES[header]);
|
||||
if (columnMap.some((column) => !column)) {
|
||||
throw new Error(
|
||||
"CSV headers must be: First name*, Last name*, Work email*, Invitation email, Initial password",
|
||||
);
|
||||
}
|
||||
|
||||
const rows = lines.slice(1).map((line, index) => {
|
||||
const cells = parseCsvLine(line);
|
||||
const row = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
work_email: "",
|
||||
invitation_email: "",
|
||||
password: "",
|
||||
};
|
||||
columnMap.forEach((field, columnIndex) => {
|
||||
row[field] = cells[columnIndex]?.trim() || "";
|
||||
});
|
||||
return { rowNumber: index + 2, ...row };
|
||||
});
|
||||
|
||||
if (rows.length > IMPORT_ROW_LIMIT) {
|
||||
throw new Error(`You can import up to ${IMPORT_ROW_LIMIT} people at a time.`);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;
|
||||
|
||||
export function validateImportRows(rows, { companyDomain, sendInvitation }) {
|
||||
const errors = [];
|
||||
const seenEmails = new Set();
|
||||
const seenInvites = new Set();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const rowErrors = [];
|
||||
if (!row.first_name.trim()) rowErrors.push("First name is required.");
|
||||
if (!row.last_name.trim()) rowErrors.push("Last name is required.");
|
||||
|
||||
const workEmailRaw = row.work_email.trim().toLowerCase();
|
||||
const workEmail = workEmailRaw.includes("@")
|
||||
? workEmailRaw
|
||||
: `${workEmailRaw}@${companyDomain}`;
|
||||
const localPart = workEmail.split("@")[0];
|
||||
|
||||
if (!workEmailRaw) {
|
||||
rowErrors.push("Work email is required.");
|
||||
} else if (!workEmail.endsWith(`@${companyDomain}`)) {
|
||||
rowErrors.push(`Work email must use @${companyDomain}.`);
|
||||
} else if (!/^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/.test(localPart)) {
|
||||
rowErrors.push("Work email format is invalid.");
|
||||
} else if (seenEmails.has(workEmail)) {
|
||||
rowErrors.push("Duplicate work email in this file.");
|
||||
}
|
||||
seenEmails.add(workEmail);
|
||||
|
||||
const password = row.password.trim();
|
||||
if (!password) {
|
||||
rowErrors.push("Initial password is required.");
|
||||
} else if (!PASSWORD_PATTERN.test(password)) {
|
||||
rowErrors.push("Password must be at least 8 characters with upper, lower, and a number.");
|
||||
}
|
||||
|
||||
const invitationEmail = row.invitation_email.trim().toLowerCase();
|
||||
if (sendInvitation) {
|
||||
if (!invitationEmail) {
|
||||
rowErrors.push("Invitation email is required when sending invitations.");
|
||||
} else if (invitationEmail.endsWith(`@${companyDomain}`)) {
|
||||
rowErrors.push("Use a personal email for invitations.");
|
||||
} else if (seenInvites.has(invitationEmail)) {
|
||||
rowErrors.push("Duplicate invitation email in this file.");
|
||||
} else {
|
||||
seenInvites.add(invitationEmail);
|
||||
}
|
||||
}
|
||||
|
||||
if (rowErrors.length > 0) {
|
||||
errors.push({ row: row.rowNumber, email: workEmail, messages: rowErrors });
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function rowsToImportPayload(rows, options) {
|
||||
const { orgRole, sendInvitation, forcePasswordChange, companyDomain } = options;
|
||||
return {
|
||||
org_role: orgRole,
|
||||
send_invitation: sendInvitation,
|
||||
force_password_change: forcePasswordChange,
|
||||
rows: rows.map((row) => ({
|
||||
first_name: row.first_name.trim(),
|
||||
last_name: row.last_name.trim(),
|
||||
work_email: row.work_email.trim().toLowerCase().includes("@")
|
||||
? row.work_email.trim().toLowerCase()
|
||||
: `${row.work_email.trim().toLowerCase()}@${companyDomain}`,
|
||||
invitation_email: row.invitation_email.trim() || undefined,
|
||||
password: row.password.trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue