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

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