batch import csv

This commit is contained in:
debu-2 2026-10-05 04:53:45 +05:30
parent a679775f57
commit dd0ceef665
20 changed files with 1614 additions and 190 deletions

View file

@ -26,6 +26,24 @@ settings = get_settings()
inbound_server = InboundSMTPServer() 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: async def migrate_user_org_columns(connection) -> None:
await connection.execute( await connection.execute(
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_role VARCHAR(20)") 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 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) await migrate_user_org_columns(connection)
await migrate_mail_group_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()

View file

@ -198,11 +198,19 @@ class MailGroup(Base):
name: Mapped[str] = mapped_column(String(120)) name: Mapped[str] = mapped_column(String(120))
email: Mapped[str] = mapped_column(String(320), unique=True, index=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
group_type: Mapped[str] = mapped_column(String(20), default="distribution") 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( created_by_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), UUID(as_uuid=True),
ForeignKey("users.id"), ForeignKey("users.id"),
nullable=True, nullable=True,
) )
owner: Mapped["Account | None"] = relationship(foreign_keys=[owner_id])
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),

View file

@ -5,8 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager from app.deps.org_manager import require_org_manager
from app.models import Account from app.models import Account
from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead, MailGroupEmailCheck
from app.services.mail_aliases import create_mail_alias, list_mail_aliases from app.services.mail_aliases import (
check_alias_email_available,
create_mail_alias,
list_mail_aliases,
)
router = APIRouter(prefix="/aliases", tags=["aliases"]) router = APIRouter(prefix="/aliases", tags=["aliases"])
@ -20,6 +24,24 @@ async def get_aliases(
return MailAliasListResponse(items=items, counts=counts) 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) @router.post("", response_model=MailAliasRead, status_code=status.HTTP_201_CREATED)
async def add_alias( async def add_alias(
payload: MailAliasCreate, payload: MailAliasCreate,

View file

@ -57,14 +57,6 @@ async def verify_owned_domain_dns(
domain: MailDomain, domain: MailDomain,
) -> DnsVerificationResponse: ) -> DnsVerificationResponse:
settings = get_settings() 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) verification_token = await ensure_ownership_dns_record(session, domain)
checks = verify_company_dns( checks = verify_company_dns(
domain_name=domain.domain_name, domain_name=domain.domain_name,
@ -75,12 +67,15 @@ async def verify_owned_domain_dns(
verification_token=verification_token, verification_token=verification_token,
) )
verified = all(check["verified"] for check in checks) verified = all(check["verified"] for check in checks)
if verified: if domain.status != "active" and verified:
await activate_company_domain(session, domain) await activate_company_domain(session, domain)
await session.commit() await session.commit()
status = "active"
else:
status = domain.status
return DnsVerificationResponse( return DnsVerificationResponse(
domain=domain.domain_name, domain=domain.domain_name,
status=domain.status if not verified else "active", status=status,
verified=verified, verified=verified,
checks=checks, checks=checks,
) )

View file

@ -5,8 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager from app.deps.org_manager import require_org_manager
from app.models import Account from app.models import Account
from app.schemas import MailGroupCreate, MailGroupListResponse, MailGroupRead from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupListResponse, MailGroupRead
from app.services.mail_groups import create_mail_group, list_mail_groups from app.services.mail_groups import (
check_group_email_available,
create_mail_group,
list_mail_groups,
)
router = APIRouter(prefix="/groups", tags=["groups"]) router = APIRouter(prefix="/groups", tags=["groups"])
@ -20,6 +24,25 @@ async def get_groups(
return MailGroupListResponse(items=items, counts=counts) 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) @router.post("", response_model=MailGroupRead, status_code=status.HTTP_201_CREATED)
async def add_group( async def add_group(
payload: MailGroupCreate, payload: MailGroupCreate,

View file

@ -10,10 +10,13 @@ from app.models import Account
from app.routers.auth import validate_password from app.routers.auth import validate_password
from app.schemas import ( from app.schemas import (
OrgMemberCreate, OrgMemberCreate,
OrgMemberImportRequest,
OrgMemberImportResponse,
OrgMemberListResponse, OrgMemberListResponse,
OrgMemberRead, OrgMemberRead,
OrgMemberRoleUpdate, OrgMemberRoleUpdate,
) )
from app.services.org_member_import import import_org_members
from app.services.org_members import ( from app.services.org_members import (
create_org_member, create_org_member,
list_org_members, list_org_members,
@ -62,6 +65,23 @@ async def add_person(
) from error ) 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) @router.patch("/{member_id}/role", response_model=OrgMemberRead)
async def update_member_role( async def update_member_role(
member_id: UUID, member_id: UUID,

View file

@ -104,10 +104,48 @@ class OrgMemberListResponse(BaseModel):
counts: dict[str, int] 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): class MailGroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
email: EmailStr email: EmailStr
group_type: Literal["distribution", "dynamic"] = "distribution" 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) member_ids: list[UUID] = Field(default_factory=list)
@ -116,12 +154,20 @@ class MailGroupRead(BaseModel):
name: str name: str
email: EmailStr email: EmailStr
group_type: str group_type: str
owner_id: UUID | None = None
send_permission: str = "org_members"
member_count: int member_count: int
created_at: datetime created_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class MailGroupEmailCheck(BaseModel):
email: EmailStr
available: bool
conflict_type: str | None = None
class MailGroupListResponse(BaseModel): class MailGroupListResponse(BaseModel):
items: list[MailGroupRead] items: list[MailGroupRead]
counts: dict[str, int] counts: dict[str, int]

View file

@ -6,7 +6,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Account, MailAlias, MailGroup 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: def _alias_read(alias: MailAlias) -> MailAliasRead:
@ -39,6 +39,55 @@ async def list_mail_aliases(
return items, counts 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( async def _destination_exists(
session: AsyncSession, session: AsyncSession,
company_domain: str, company_domain: str,
@ -80,26 +129,14 @@ async def create_mail_alias(
if alias_address == forwards_to: if alias_address == forwards_to:
raise ValueError("Alias cannot forward to itself.") raise ValueError("Alias cannot forward to itself.")
existing = await session.scalar( availability = await check_alias_email_available(
select(MailAlias).where( session,
func.lower(MailAlias.alias_address) == alias_address company_domain,
alias_address,
) )
) if not availability.available:
if existing:
raise ValueError("That alias address is already in use.") 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): if not await _destination_exists(session, company_domain, forwards_to):
raise ValueError("Forwarding address must be an existing user or group.") raise ValueError("Forwarding address must be an existing user or group.")

View file

@ -6,8 +6,8 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.models import Account, MailGroup, MailGroupMember from app.models import Account, MailAlias, MailGroup, MailGroupMember
from app.schemas import MailGroupCreate, MailGroupRead from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupRead
def _group_read(group: MailGroup) -> MailGroupRead: def _group_read(group: MailGroup) -> MailGroupRead:
@ -16,11 +16,62 @@ def _group_read(group: MailGroup) -> MailGroupRead:
name=group.name, name=group.name,
email=group.email, email=group.email,
group_type=group.group_type, group_type=group.group_type,
owner_id=group.owner_id,
send_permission=group.send_permission,
member_count=len(group.members), member_count=len(group.members),
created_at=group.created_at, 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( async def list_mail_groups(
session: AsyncSession, session: AsyncSession,
company_domain: str, company_domain: str,
@ -81,6 +132,20 @@ async def create_mail_group(
if existing: if existing:
raise ValueError("That group email address is already in use.") 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( members = await _validate_member_ids(
session, session,
company_domain, company_domain,
@ -92,6 +157,9 @@ async def create_mail_group(
name=payload.name.strip(), name=payload.name.strip(),
email=email, email=email,
group_type=payload.group_type, 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, created_by_id=manager.id,
) )
session.add(group) session.add(group)

View 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,
)

View file

@ -52,6 +52,8 @@ export const listOrgMembers = (token) =>
request("/people", { token }); request("/people", { token });
export const createOrgMember = (payload, token) => export const createOrgMember = (payload, token) =>
request("/people", { method: "POST", payload, token }); request("/people", { method: "POST", payload, token });
export const importOrgMembers = (payload, token) =>
request("/people/import", { method: "POST", payload, token });
export const listOrgSections = (token) => export const listOrgSections = (token) =>
request("/sections", { token }); request("/sections", { token });
export const createOrgSection = (payload, token) => export const createOrgSection = (payload, token) =>
@ -60,10 +62,14 @@ export const listMailGroups = (token) =>
request("/groups", { token }); request("/groups", { token });
export const createMailGroup = (payload, token) => export const createMailGroup = (payload, token) =>
request("/groups", { method: "POST", 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) => export const listMailAliases = (token) =>
request("/aliases", { token }); request("/aliases", { token });
export const createMailAlias = (payload, token) => export const createMailAlias = (payload, token) =>
request("/aliases", { method: "POST", 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) => export const getOrgSecurity = (token) =>
request("/org/security", { token }); request("/org/security", { token });
export const updateOrgSecurity = (payload, token) => export const updateOrgSecurity = (payload, token) =>

View file

@ -1,4 +1,5 @@
import { import {
AlertCircle,
CheckCircle2, CheckCircle2,
Mail, Mail,
MoreHorizontal, MoreHorizontal,
@ -7,17 +8,54 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "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 = [ const TYPE_TABS = [
{ key: "all", label: "All aliases" }, { key: "all", label: "All aliases" },
{ key: "domain", label: "Domain aliases" }, { key: "domain", label: "Domain aliases" },
]; ];
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
function aliasTypeLabel(aliasType) { function aliasTypeLabel(aliasType) {
return aliasType === "group" ? "Group alias" : "User alias"; 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 }) { function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
const token = sessionStorage.getItem("agmail_access_token"); const token = sessionStorage.getItem("agmail_access_token");
const [form, setForm] = useState({ const [form, setForm] = useState({
@ -26,29 +64,107 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
alias_type: "user", alias_type: "user",
is_domain_alias: false, 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 [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) { function updateField(field, value) {
setForm((current) => ({ ...current, [field]: value })); setForm((current) => ({ ...current, [field]: value }));
} }
function handleDestinationChange(value) { function selectDestination(item) {
const selected = destinations.find((item) => item.email === value);
setForm((current) => ({ setForm((current) => ({
...current, ...current,
forwards_to: value, forwards_to: item.email,
alias_type: selected?.kind === "group" ? "group" : "user", 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) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
if (!canSubmit) return;
setStatus({ loading: true, error: "" }); setStatus({ loading: true, error: "" });
const aliasAddress = `${form.alias_local.trim().toLowerCase()}@${companyDomain}`;
try { try {
await createMailAlias( await createMailAlias(
{ {
alias_address: aliasAddress, alias_address: fullEmail,
forwards_to: form.forwards_to, forwards_to: form.forwards_to,
alias_type: form.alias_type, alias_type: form.alias_type,
is_domain_alias: form.is_domain_alias, is_domain_alias: form.is_domain_alias,
@ -65,79 +181,136 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
return ( return (
<div className="people-drawer-scrim" onClick={onClose}> <div className="people-drawer-scrim" onClick={onClose}>
<aside <aside
className="people-drawer" className="people-drawer people-drawer-wide group-drawer"
role="dialog" role="dialog"
aria-labelledby="create-alias-title" aria-labelledby="create-alias-title"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<header className="people-drawer-head"> <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> <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}> <button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
<X size={18} /> <X size={18} />
</button> </button>
</header> </header>
<form className="people-drawer-form" onSubmit={handleSubmit}> <form className="group-drawer-form" onSubmit={handleSubmit} noValidate>
<label> <div className="add-user-form-body">
Alias address <span aria-hidden="true">*</span> <h3 className="group-drawer-section-title">Alias details</h3>
<div className="people-email-field">
<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 <input
required required
value={form.alias_local} value={form.alias_local}
onChange={(event) => updateField("alias_local", event.target.value)} onChange={(event) => updateField("alias_local", event.target.value)}
placeholder="ceo" placeholder="ceo"
/> />
<span>@{companyDomain}</span> <span className="group-email-domain">@{companyDomain}</span>
</div> </div>
<small>Alternative address that forwards to an existing mailbox.</small> </div>
</label> </SetupField>
<label> <div className="add-user-section-divider" aria-hidden="true" />
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> <div className="group-members-head">
Type <h3 className="group-drawer-section-title">Destination</h3>
<select {selectedDestination && (
value={form.alias_type} <span className="group-members-count">
onChange={(event) => updateField("alias_type", event.target.value)} {selectedDestination.kind === "group" ? "Group" : "User"} selected
>
<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> </span>
)}
</div>
<label className="group-member-search">
<Search size={16} />
<input
value={destinationSearch}
onChange={(event) => setDestinationSearch(event.target.value)}
placeholder="Search by name or email"
/>
</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>
<div className="add-user-section-divider" aria-hidden="true" />
<h3 className="group-drawer-section-title">Options</h3>
<label className="add-user-checkbox">
<input <input
type="checkbox" type="checkbox"
checked={form.is_domain_alias} checked={form.is_domain_alias}
onChange={(event) => updateField("is_domain_alias", event.target.checked)} 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> </label>
{status.error && <p className="form-error" role="alert">{status.error}</p>} {form.forwards_to === fullEmail && fullEmail && (
<p className="add-user-error" role="alert">Alias cannot forward to itself.</p>
)}
<footer className="people-drawer-actions"> {status.error && <p className="form-error" role="alert">{status.error}</p>}
<button type="button" className="button button-outline" onClick={onClose}> </div>
<footer className="group-drawer-footer">
<button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
Cancel Cancel
</button> </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"} {status.loading ? "Creating…" : "Create alias"}
</button> </button>
</footer> </footer>

View file

@ -374,17 +374,8 @@ function DomainDetail({
}, [domain.id, onRefresh, token]); }, [domain.id, onRefresh, token]);
useEffect(() => { useEffect(() => {
if (domain.status === "active") {
setChecks([
{ purpose: "mx", verified: true },
{ purpose: "spf", verified: true },
{ purpose: "dkim", verified: true },
{ purpose: "dmarc", verified: true },
]);
return;
}
runVerify(); runVerify();
}, [domain.id, domain.status, runVerify]); }, [domain.id, runVerify]);
const checkMap = Object.fromEntries((checks || []).map((c) => [c.purpose, c])); const checkMap = Object.fromEntries((checks || []).map((c) => [c.purpose, c]));
const mailRecords = domain.dns_records?.filter((r) => r.purpose !== "ownership") || []; const mailRecords = domain.dns_records?.filter((r) => r.purpose !== "ownership") || [];

View file

@ -1,4 +1,7 @@
import { import {
AlertCircle,
CheckCircle2,
ChevronDown,
MoreHorizontal, MoreHorizontal,
Plus, Plus,
Search, Search,
@ -6,7 +9,12 @@ import {
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { createMailGroup, listMailGroups, listOrgMembers } from "../api"; import {
checkGroupEmail,
createMailGroup,
listMailGroups,
listOrgMembers,
} from "../api";
const TYPE_TABS = [ const TYPE_TABS = [
{ key: "all", label: "All groups" }, { key: "all", label: "All groups" },
@ -16,6 +24,31 @@ const TYPE_TABS = [
const GROUP_TONES = ["green", "violet", "blue", "amber", "coral", "teal"]; 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) { function formatRelativeTime(dateValue) {
const date = new Date(dateValue); const date = new Date(dateValue);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000); const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
@ -31,20 +64,128 @@ function groupInitial(name) {
return (name?.trim()?.[0] || "G").toUpperCase(); 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 token = sessionStorage.getItem("agmail_access_token");
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "", name: "",
email_local: "", email_local: "",
group_type: "distribution", owner_id: account.id,
send_permission: "org_members",
description: "",
member_ids: [], 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 [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) { function updateField(field, value) {
setForm((current) => ({ ...current, [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) { function toggleMember(memberId) {
setForm((current) => { setForm((current) => {
const exists = current.member_ids.includes(memberId); 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) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
if (!canSubmit) return;
setStatus({ loading: true, error: "" }); setStatus({ loading: true, error: "" });
const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`;
try { try {
await createMailGroup( await createMailGroup(
{ {
name: form.name.trim(), name: form.name.trim(),
email, email: fullEmail,
group_type: form.group_type, group_type: "distribution",
owner_id: form.owner_id,
send_permission: form.send_permission,
description: form.description.trim() || undefined,
member_ids: form.member_ids, member_ids: form.member_ids,
}, },
token, token,
@ -81,83 +271,182 @@ function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
return ( return (
<div className="people-drawer-scrim" onClick={onClose}> <div className="people-drawer-scrim" onClick={onClose}>
<aside <aside
className="people-drawer" className="people-drawer people-drawer-wide group-drawer"
role="dialog" role="dialog"
aria-labelledby="create-group-title" aria-labelledby="create-group-title"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<header className="people-drawer-head"> <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> <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}> <button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
<X size={18} /> <X size={18} />
</button> </button>
</header> </header>
<form className="people-drawer-form" onSubmit={handleSubmit}> <form className="group-drawer-form" onSubmit={handleSubmit} noValidate>
<label> <div className="add-user-form-body">
Group name <span aria-hidden="true">*</span> <h3 className="group-drawer-section-title">Group details</h3>
<SetupField label={<RequiredLabel>Group name</RequiredLabel>}>
<div className="add-user-input-wrap">
<input <input
required required
value={form.name} value={form.name}
onChange={(event) => updateField("name", event.target.value)} onChange={(event) => updateName(event.target.value)}
placeholder="Engineering" placeholder="Engineering"
/> />
</label> </div>
</SetupField>
<label> <SetupField
Email address <span aria-hidden="true">*</span> label={<RequiredLabel>Group email</RequiredLabel>}
<div className="people-email-field"> status={emailStatus}
hint="Messages sent to this address are delivered to all members."
>
<div className="add-user-email-field">
<div className="add-user-input-wrap group-email-input">
<input <input
required required
value={form.email_local} value={form.email_local}
onChange={(event) => updateField("email_local", event.target.value)} onChange={(event) => {
setEmailTouched(true);
updateField("email_local", event.target.value);
}}
placeholder="engineering" placeholder="engineering"
/> />
<span>@{companyDomain}</span> <span className="group-email-domain">@{companyDomain}</span>
</div> </div>
<small>Messages sent here are delivered to all group members.</small> </div>
</label> </SetupField>
<label> <SetupField label={<RequiredLabel>Group owner</RequiredLabel>}>
Type <div className="add-user-input-wrap">
<select <select
value={form.group_type} value={form.owner_id}
onChange={(event) => updateField("group_type", event.target.value)} onChange={(event) => updateField("owner_id", event.target.value)}
required
> >
<option value="distribution">Distribution</option> {ownerOptions.map((member) => (
<option value="dynamic">Dynamic</option> <option key={member.id} value={member.id}>
{member.first_name} {member.last_name}
</option>
))}
</select> </select>
<small>Distribution groups have a fixed member list. Dynamic groups update automatically.</small> </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> </label>
<fieldset className="people-groups-field"> <div className="group-member-picker">
<legend>Members</legend>
<div className="org-member-picker">
{members.length === 0 ? ( {members.length === 0 ? (
<p className="org-member-picker-empty">Add people first, then assign them to groups.</p> <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={allFilteredSelected}
onChange={toggleSelectAll}
disabled={filteredMembers.length === 0}
/>
<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 <input
type="checkbox" type="checkbox"
checked={form.member_ids.includes(member.id)} checked={form.member_ids.includes(member.id)}
onChange={() => toggleMember(member.id)} onChange={() => toggleMember(member.id)}
/> />
<span>{member.first_name} {member.last_name}</span> <span>
<strong>{member.first_name} {member.last_name}</strong>
<small>{member.email}</small> <small>{member.email}</small>
</span>
</label> </label>
)) ))
)} )}
</div> </div>
</>
)}
</div>
<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> </fieldset>
)}
</div>
{status.error && <p className="form-error" role="alert">{status.error}</p>} {status.error && <p className="form-error" role="alert">{status.error}</p>}
</div>
<footer className="people-drawer-actions"> <footer className="group-drawer-footer">
<button type="button" className="button button-outline" onClick={onClose}> <button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
Cancel Cancel
</button> </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"} {status.loading ? "Creating…" : "Create group"}
</button> </button>
</footer> </footer>
@ -320,6 +609,7 @@ export default function CompanyGroups({ account }) {
{drawerOpen && ( {drawerOpen && (
<CreateGroupPanel <CreateGroupPanel
account={account}
companyDomain={companyDomain} companyDomain={companyDomain}
members={members} members={members}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}

View file

@ -74,6 +74,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
const [dnsChecks, setDnsChecks] = useState([]); const [dnsChecks, setDnsChecks] = useState([]);
const [lastChecked, setLastChecked] = useState(null); const [lastChecked, setLastChecked] = useState(null);
const [dnsLoading, setDnsLoading] = useState(false); const [dnsLoading, setDnsLoading] = useState(false);
const [dnsError, setDnsError] = useState("");
const loadOverview = useCallback(async () => { const loadOverview = useCallback(async () => {
setLoading(true); setLoading(true);
@ -97,16 +98,8 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
setDnsChecks([]); setDnsChecks([]);
return; 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); setDnsLoading(true);
setDnsError("");
try { try {
const result = await verifyMyDomainDns(primary.id, token); const result = await verifyMyDomainDns(primary.id, token);
const checkMap = Object.fromEntries(result.checks.map((check) => [check.purpose, check])); 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, recommended: item.purpose === "dmarc" && !checkMap[item.purpose]?.verified,
}))); })));
setLastChecked(new Date()); setLastChecked(new Date());
} catch { if (result.status !== primary.status) {
setDnsChecks(HEALTH_ITEMS.map((item) => ({ setDomains((current) => current.map((domain) => (
...item, domain.id === primary.id ? { ...domain, status: result.status } : domain
verified: false, )));
recommended: item.purpose === "dmarc", }
}))); } catch (error) {
setDnsError(error.message || "Could not verify DNS records.");
} finally { } finally {
setDnsLoading(false); setDnsLoading(false);
} }
@ -245,9 +239,15 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
))} ))}
</ul> </ul>
<footer className="company-overview-panel-footer"> <footer className="company-overview-panel-footer">
<span>{lastChecked ? `Last checked ${formatRelativeTime(lastChecked)}` : "Not checked yet"}</span> <span>
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading}> {dnsError
<RefreshCw size={14} /> ? 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"} {dnsLoading ? "Checking…" : "Recheck"}
</button> </button>
</footer> </footer>
@ -294,7 +294,13 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
</button> </button>
</li> </li>
<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 <FileUp size={16} /> Invite users via CSV
</button> </button>
</li> </li>

View file

@ -1,6 +1,7 @@
import { ArrowRight, Check } from "lucide-react"; import { ArrowRight, Check, Upload } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import AddEmployeeForm from "./AddEmployeeForm"; import AddEmployeeForm from "./AddEmployeeForm";
import ImportUsersPanel from "./ImportUsersPanel";
const STEPS = [ const STEPS = [
{ id: 1, title: "Add domain", hint: "Use your company domain for email" }, { 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 [fieldErrors, setFieldErrors] = useState({});
const [submitAttempted, setSubmitAttempted] = useState(false); const [submitAttempted, setSubmitAttempted] = useState(false);
const [addedEmployees, setAddedEmployees] = useState([]); const [addedEmployees, setAddedEmployees] = useState([]);
const [importOpen, setImportOpen] = useState(false);
useEffect(() => { useEffect(() => {
localStorage.setItem(storageKey(account.id, "step"), String(step)); localStorage.setItem(storageKey(account.id, "step"), String(step));
@ -311,12 +313,21 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
{step === 5 && ( {step === 5 && (
<> <>
<div className="org-setup-panel-intro"> <div className="org-setup-panel-intro org-setup-panel-intro-row">
<div>
<h2>Add employees</h2> <h2>Add employees</h2>
<p className="org-setup-section-label"> <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. Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People.
</p> </p>
</div> </div>
<button
type="button"
className="button button-outline"
onClick={() => setImportOpen(true)}
>
<Upload size={16} /> Import CSV
</button>
</div>
{addedEmployees.length > 0 && ( {addedEmployees.length > 0 && (
<ul className="employee-added-list"> <ul className="employee-added-list">
@ -367,6 +378,17 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
)} )}
</div> </div>
</div> </div>
{importOpen && (
<ImportUsersPanel
companyDomain={companyDomainName}
onClose={() => setImportOpen(false)}
onImported={(result) => {
setAddedEmployees((current) => [...current, ...result.created]);
setImportOpen(false);
}}
/>
)}
</section> </section>
); );
} }

View file

@ -12,6 +12,7 @@ import {
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { listOrgMembers } from "../api"; import { listOrgMembers } from "../api";
import AddEmployeeForm from "./AddEmployeeForm"; import AddEmployeeForm from "./AddEmployeeForm";
import ImportUsersPanel from "./ImportUsersPanel";
const STATUS_TABS = [ const STATUS_TABS = [
{ key: "all", label: "All users" }, { key: "all", label: "All users" },
@ -101,6 +102,7 @@ export default function CompanyPeople({ account }) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState("all"); const [roleFilter, setRoleFilter] = useState("all");
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [notice, setNotice] = useState(""); const [notice, setNotice] = useState("");
const loadMembers = useCallback(async () => { const loadMembers = useCallback(async () => {
@ -121,6 +123,13 @@ export default function CompanyPeople({ account }) {
loadMembers(); loadMembers();
}, [loadMembers]); }, [loadMembers]);
useEffect(() => {
if (sessionStorage.getItem("agmail_open_people_import") === "1") {
sessionStorage.removeItem("agmail_open_people_import");
setImportOpen(true);
}
}, []);
const filteredMembers = useMemo(() => { const filteredMembers = useMemo(() => {
const query = search.trim().toLowerCase(); const query = search.trim().toLowerCase();
return members.filter((member) => { return members.filter((member) => {
@ -147,7 +156,11 @@ export default function CompanyPeople({ account }) {
<p>Manage your team members and their email accounts.</p> <p>Manage your team members and their email accounts.</p>
</div> </div>
<div className="company-people-actions"> <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 <Upload size={16} /> Import CSV
</button> </button>
<button <button
@ -250,6 +263,18 @@ export default function CompanyPeople({ account }) {
</table> </table>
</div> </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 && ( {drawerOpen && (
<AddUserPanel <AddUserPanel
companyDomain={companyDomain} companyDomain={companyDomain}

View 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>
);
}

View file

@ -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 { 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.ok { background:#e8f7ef; color:#2f8a57; }
.company-overview-health-icon.warn { background:#fff4df; color:#b7892d; } .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-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 { 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; } .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-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-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 { 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-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 { 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; } .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 { 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:hover:not(:disabled) { filter:brightness(1.04); }
.add-user-button-submit:active:not(:disabled) { transform:scale(0.99); } .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-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; } .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; } .people-drawer-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }

View 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(),
})),
};
}