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

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

View file

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

View file

@ -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") || [];

View file

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

View file

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

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

View file

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

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.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; }

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