batch import csv
This commit is contained in:
parent
a679775f57
commit
dd0ceef665
20 changed files with 1614 additions and 190 deletions
|
|
@ -52,6 +52,8 @@ export const listOrgMembers = (token) =>
|
|||
request("/people", { token });
|
||||
export const createOrgMember = (payload, token) =>
|
||||
request("/people", { method: "POST", payload, token });
|
||||
export const importOrgMembers = (payload, token) =>
|
||||
request("/people/import", { method: "POST", payload, token });
|
||||
export const listOrgSections = (token) =>
|
||||
request("/sections", { token });
|
||||
export const createOrgSection = (payload, token) =>
|
||||
|
|
@ -60,10 +62,14 @@ export const listMailGroups = (token) =>
|
|||
request("/groups", { token });
|
||||
export const createMailGroup = (payload, token) =>
|
||||
request("/groups", { method: "POST", payload, token });
|
||||
export const checkGroupEmail = (email, token) =>
|
||||
request(`/groups/check-email?email=${encodeURIComponent(email)}`, { token });
|
||||
export const listMailAliases = (token) =>
|
||||
request("/aliases", { token });
|
||||
export const createMailAlias = (payload, token) =>
|
||||
request("/aliases", { method: "POST", payload, token });
|
||||
export const checkAliasEmail = (email, token) =>
|
||||
request(`/aliases/check-email?email=${encodeURIComponent(email)}`, { token });
|
||||
export const getOrgSecurity = (token) =>
|
||||
request("/org/security", { token });
|
||||
export const updateOrgSecurity = (payload, token) =>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Mail,
|
||||
MoreHorizontal,
|
||||
|
|
@ -7,17 +8,54 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createMailAlias, listMailAliases, listMailGroups, listOrgMembers } from "../api";
|
||||
import {
|
||||
checkAliasEmail,
|
||||
createMailAlias,
|
||||
listMailAliases,
|
||||
listMailGroups,
|
||||
listOrgMembers,
|
||||
} from "../api";
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ key: "all", label: "All aliases" },
|
||||
{ key: "domain", label: "Domain aliases" },
|
||||
];
|
||||
|
||||
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
function aliasTypeLabel(aliasType) {
|
||||
return aliasType === "group" ? "Group alias" : "User alias";
|
||||
}
|
||||
|
||||
function RequiredLabel({ children }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<span className="add-user-required" aria-hidden="true">*</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupField({ label, error, children, hint, status }) {
|
||||
return (
|
||||
<div className={`add-user-field${error ? " has-error" : ""}`}>
|
||||
{label && <span className="add-user-label">{label}</span>}
|
||||
{children}
|
||||
{status && (
|
||||
<p className={`group-email-status ${status.kind}`}>
|
||||
{status.kind === "ok" && <CheckCircle2 size={14} aria-hidden="true" />}
|
||||
{(status.kind === "warn" || status.kind === "checking") && (
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
)}
|
||||
{status.text}
|
||||
</p>
|
||||
)}
|
||||
{hint && <small className="add-user-hint">{hint}</small>}
|
||||
{error && <span className="add-user-error" role="alert">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [form, setForm] = useState({
|
||||
|
|
@ -26,29 +64,107 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
|
|||
alias_type: "user",
|
||||
is_domain_alias: false,
|
||||
});
|
||||
const [destinationSearch, setDestinationSearch] = useState("");
|
||||
const [emailCheck, setEmailCheck] = useState({ loading: false, available: null, conflict: null });
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
const fullEmail = useMemo(() => {
|
||||
const local = form.alias_local.trim().toLowerCase();
|
||||
return local ? `${local}@${companyDomain}` : "";
|
||||
}, [form.alias_local, companyDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullEmail || !EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
setEmailCheck((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await checkAliasEmail(fullEmail, token);
|
||||
if (!cancelled) {
|
||||
setEmailCheck({
|
||||
loading: false,
|
||||
available: result.available,
|
||||
conflict: result.conflict_type,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [fullEmail, form.alias_local, token]);
|
||||
|
||||
const filteredDestinations = useMemo(() => {
|
||||
const query = destinationSearch.trim().toLowerCase();
|
||||
if (!query) return destinations;
|
||||
return destinations.filter((item) => {
|
||||
const haystack = `${item.label} ${item.email} ${item.kind}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [destinationSearch, destinations]);
|
||||
|
||||
const selectedDestination = destinations.find((item) => item.email === form.forwards_to);
|
||||
|
||||
function updateField(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function handleDestinationChange(value) {
|
||||
const selected = destinations.find((item) => item.email === value);
|
||||
function selectDestination(item) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
forwards_to: value,
|
||||
alias_type: selected?.kind === "group" ? "group" : "user",
|
||||
forwards_to: item.email,
|
||||
alias_type: item.kind === "group" ? "group" : "user",
|
||||
}));
|
||||
}
|
||||
|
||||
const emailStatus = useMemo(() => {
|
||||
if (!form.alias_local.trim()) return null;
|
||||
if (!EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())) {
|
||||
return { kind: "warn", text: "Use lowercase letters, numbers, dots, or hyphens." };
|
||||
}
|
||||
if (emailCheck.loading) {
|
||||
return { kind: "checking", text: `Checking ${fullEmail}…` };
|
||||
}
|
||||
if (emailCheck.available === true) {
|
||||
return { kind: "ok", text: `${fullEmail} is available` };
|
||||
}
|
||||
if (emailCheck.available === false) {
|
||||
const conflictLabel = {
|
||||
user: "a user mailbox",
|
||||
group: "a group mailbox",
|
||||
alias: "another alias",
|
||||
invalid_domain: "an invalid domain",
|
||||
}[emailCheck.conflict] || "another resource";
|
||||
return { kind: "warn", text: `${fullEmail} is already in use by ${conflictLabel}` };
|
||||
}
|
||||
return null;
|
||||
}, [emailCheck, form.alias_local, fullEmail]);
|
||||
|
||||
const canSubmit = form.alias_local.trim()
|
||||
&& EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())
|
||||
&& form.forwards_to
|
||||
&& emailCheck.available === true
|
||||
&& form.forwards_to !== fullEmail;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
setStatus({ loading: true, error: "" });
|
||||
const aliasAddress = `${form.alias_local.trim().toLowerCase()}@${companyDomain}`;
|
||||
try {
|
||||
await createMailAlias(
|
||||
{
|
||||
alias_address: aliasAddress,
|
||||
alias_address: fullEmail,
|
||||
forwards_to: form.forwards_to,
|
||||
alias_type: form.alias_type,
|
||||
is_domain_alias: form.is_domain_alias,
|
||||
|
|
@ -65,79 +181,136 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
|
|||
return (
|
||||
<div className="people-drawer-scrim" onClick={onClose}>
|
||||
<aside
|
||||
className="people-drawer"
|
||||
className="people-drawer people-drawer-wide group-drawer"
|
||||
role="dialog"
|
||||
aria-labelledby="create-alias-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="people-drawer-head">
|
||||
<h2 id="create-alias-title">Add alias</h2>
|
||||
<header className="people-drawer-head group-drawer-head">
|
||||
<div>
|
||||
<div className="people-drawer-title">
|
||||
<span className="people-drawer-title-icon" aria-hidden="true">
|
||||
<Mail size={18} />
|
||||
</span>
|
||||
<h2 id="create-alias-title">Add alias</h2>
|
||||
</div>
|
||||
<p className="group-drawer-subtitle">
|
||||
Create an alternative address that forwards to an existing mailbox.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="people-drawer-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Alias address <span aria-hidden="true">*</span>
|
||||
<div className="people-email-field">
|
||||
<input
|
||||
required
|
||||
value={form.alias_local}
|
||||
onChange={(event) => updateField("alias_local", event.target.value)}
|
||||
placeholder="ceo"
|
||||
/>
|
||||
<span>@{companyDomain}</span>
|
||||
<form className="group-drawer-form" onSubmit={handleSubmit} noValidate>
|
||||
<div className="add-user-form-body">
|
||||
<h3 className="group-drawer-section-title">Alias details</h3>
|
||||
|
||||
<SetupField
|
||||
label={<RequiredLabel>Alias address</RequiredLabel>}
|
||||
status={emailStatus}
|
||||
hint="Incoming mail to this address will be forwarded to your chosen destination."
|
||||
>
|
||||
<div className="add-user-email-field">
|
||||
<div className="add-user-input-wrap group-email-input">
|
||||
<input
|
||||
required
|
||||
value={form.alias_local}
|
||||
onChange={(event) => updateField("alias_local", event.target.value)}
|
||||
placeholder="ceo"
|
||||
/>
|
||||
<span className="group-email-domain">@{companyDomain}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
<div className="group-members-head">
|
||||
<h3 className="group-drawer-section-title">Destination</h3>
|
||||
{selectedDestination && (
|
||||
<span className="group-members-count">
|
||||
{selectedDestination.kind === "group" ? "Group" : "User"} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<small>Alternative address that forwards to an existing mailbox.</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Forwards to <span aria-hidden="true">*</span>
|
||||
<select
|
||||
required
|
||||
value={form.forwards_to}
|
||||
onChange={(event) => handleDestinationChange(event.target.value)}
|
||||
>
|
||||
<option value="">Select destination…</option>
|
||||
{destinations.map((item) => (
|
||||
<option key={`${item.kind}-${item.email}`} value={item.email}>
|
||||
{item.label} ({item.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="group-member-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={destinationSearch}
|
||||
onChange={(event) => setDestinationSearch(event.target.value)}
|
||||
placeholder="Search by name or email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={form.alias_type}
|
||||
onChange={(event) => updateField("alias_type", event.target.value)}
|
||||
>
|
||||
<option value="user">User alias</option>
|
||||
<option value="group">Group alias</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="group-member-picker">
|
||||
{destinations.length === 0 ? (
|
||||
<p className="org-member-picker-empty">
|
||||
Add people or groups first, then create an alias.
|
||||
</p>
|
||||
) : (
|
||||
<div className="group-member-list">
|
||||
{filteredDestinations.length === 0 ? (
|
||||
<p className="org-member-picker-empty">No destinations match your search.</p>
|
||||
) : (
|
||||
filteredDestinations.map((item) => (
|
||||
<label key={`${item.kind}-${item.email}`} className="group-member-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="alias_destination"
|
||||
checked={form.forwards_to === item.email}
|
||||
onChange={() => selectDestination(item)}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{item.label}
|
||||
<em className="alias-destination-kind">
|
||||
{item.kind === "group" ? "Group" : "User"}
|
||||
</em>
|
||||
</strong>
|
||||
<small>{item.email}</small>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="people-toggle-row">
|
||||
<span>
|
||||
<strong>Domain alias</strong>
|
||||
<small>Use for shared addresses like info@ or support@.</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_domain_alias}
|
||||
onChange={(event) => updateField("is_domain_alias", event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<h3 className="group-drawer-section-title">Options</h3>
|
||||
|
||||
<footer className="people-drawer-actions">
|
||||
<button type="button" className="button button-outline" onClick={onClose}>
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_domain_alias}
|
||||
onChange={(event) => updateField("is_domain_alias", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Domain alias</strong>
|
||||
<small>Use for shared addresses like info@ or support@ that represent your organization.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{form.forwards_to === fullEmail && fullEmail && (
|
||||
<p className="add-user-error" role="alert">Alias cannot forward to itself.</p>
|
||||
)}
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
</div>
|
||||
|
||||
<footer className="group-drawer-footer">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button button-purple" type="submit" disabled={status.loading}>
|
||||
<button
|
||||
className="add-user-button add-user-button-submit"
|
||||
type="submit"
|
||||
disabled={!canSubmit || status.loading}
|
||||
>
|
||||
{status.loading ? "Creating…" : "Create alias"}
|
||||
</button>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -374,17 +374,8 @@ function DomainDetail({
|
|||
}, [domain.id, onRefresh, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (domain.status === "active") {
|
||||
setChecks([
|
||||
{ purpose: "mx", verified: true },
|
||||
{ purpose: "spf", verified: true },
|
||||
{ purpose: "dkim", verified: true },
|
||||
{ purpose: "dmarc", verified: true },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
runVerify();
|
||||
}, [domain.id, domain.status, runVerify]);
|
||||
}, [domain.id, runVerify]);
|
||||
|
||||
const checkMap = Object.fromEntries((checks || []).map((c) => [c.purpose, c]));
|
||||
const mailRecords = domain.dns_records?.filter((r) => r.purpose !== "ownership") || [];
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
|
|
@ -6,7 +9,12 @@ import {
|
|||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createMailGroup, listMailGroups, listOrgMembers } from "../api";
|
||||
import {
|
||||
checkGroupEmail,
|
||||
createMailGroup,
|
||||
listMailGroups,
|
||||
listOrgMembers,
|
||||
} from "../api";
|
||||
|
||||
const TYPE_TABS = [
|
||||
{ key: "all", label: "All groups" },
|
||||
|
|
@ -16,6 +24,31 @@ const TYPE_TABS = [
|
|||
|
||||
const GROUP_TONES = ["green", "violet", "blue", "amber", "coral", "teal"];
|
||||
|
||||
const SEND_PERMISSIONS = [
|
||||
{
|
||||
value: "org_members",
|
||||
label: "Anyone in organization",
|
||||
getHint: (domain) => `Anyone with an @${domain} address can send to this group.`,
|
||||
},
|
||||
{
|
||||
value: "group_members",
|
||||
label: "Group members only",
|
||||
getHint: () => "Only people added to this group can send messages to it.",
|
||||
},
|
||||
{
|
||||
value: "owners_managers",
|
||||
label: "Owners and managers only",
|
||||
getHint: () => "Only the group owner and organization admins can send.",
|
||||
},
|
||||
{
|
||||
value: "anyone",
|
||||
label: "Anyone, including external senders",
|
||||
getHint: () => "External addresses can email this group. Use with care.",
|
||||
},
|
||||
];
|
||||
|
||||
const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||||
|
||||
function formatRelativeTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
|
|
@ -31,20 +64,128 @@ function groupInitial(name) {
|
|||
return (name?.trim()?.[0] || "G").toUpperCase();
|
||||
}
|
||||
|
||||
function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
|
||||
function slugFromName(name) {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
function RequiredLabel({ children }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<span className="add-user-required" aria-hidden="true">*</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupField({ label, error, children, hint, status }) {
|
||||
return (
|
||||
<div className={`add-user-field${error ? " has-error" : ""}`}>
|
||||
{label && <span className="add-user-label">{label}</span>}
|
||||
{children}
|
||||
{status && (
|
||||
<p className={`group-email-status ${status.kind}`}>
|
||||
{status.kind === "ok" && <CheckCircle2 size={14} aria-hidden="true" />}
|
||||
{(status.kind === "warn" || status.kind === "checking") && (
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
)}
|
||||
{status.text}
|
||||
</p>
|
||||
)}
|
||||
{hint && <small className="add-user-hint">{hint}</small>}
|
||||
{error && <span className="add-user-error" role="alert">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateGroupPanel({ account, companyDomain, members, onClose, onCreated }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [form, setForm] = useState({
|
||||
name: "",
|
||||
email_local: "",
|
||||
group_type: "distribution",
|
||||
owner_id: account.id,
|
||||
send_permission: "org_members",
|
||||
description: "",
|
||||
member_ids: [],
|
||||
});
|
||||
const [emailTouched, setEmailTouched] = useState(false);
|
||||
const [memberSearch, setMemberSearch] = useState("");
|
||||
const [permissionsOpen, setPermissionsOpen] = useState(false);
|
||||
const [emailCheck, setEmailCheck] = useState({ loading: false, available: null, conflict: null });
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
const fullEmail = useMemo(() => {
|
||||
const local = form.email_local.trim().toLowerCase();
|
||||
return local ? `${local}@${companyDomain}` : "";
|
||||
}, [form.email_local, companyDomain]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fullEmail || !EMAIL_LOCAL_PATTERN.test(form.email_local.trim().toLowerCase())) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(async () => {
|
||||
setEmailCheck((current) => ({ ...current, loading: true }));
|
||||
try {
|
||||
const result = await checkGroupEmail(fullEmail, token);
|
||||
if (!cancelled) {
|
||||
setEmailCheck({
|
||||
loading: false,
|
||||
available: result.available,
|
||||
conflict: result.conflict_type,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEmailCheck({ loading: false, available: null, conflict: null });
|
||||
}
|
||||
}
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [fullEmail, form.email_local, token]);
|
||||
|
||||
const ownerOptions = useMemo(() => {
|
||||
const byId = new Map(members.map((member) => [member.id, member]));
|
||||
if (!byId.has(account.id)) {
|
||||
byId.set(account.id, account);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}, [account, members]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const query = memberSearch.trim().toLowerCase();
|
||||
if (!query) return members;
|
||||
return members.filter((member) => {
|
||||
const haystack = `${member.first_name} ${member.last_name} ${member.email}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [memberSearch, members]);
|
||||
|
||||
const allFilteredSelected = filteredMembers.length > 0
|
||||
&& filteredMembers.every((member) => form.member_ids.includes(member.id));
|
||||
|
||||
function updateField(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
}
|
||||
|
||||
function updateName(value) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
name: value,
|
||||
email_local: emailTouched ? current.email_local : slugFromName(value),
|
||||
}));
|
||||
}
|
||||
|
||||
function toggleMember(memberId) {
|
||||
setForm((current) => {
|
||||
const exists = current.member_ids.includes(memberId);
|
||||
|
|
@ -57,16 +198,65 @@ function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
|
|||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const filteredIds = filteredMembers.map((member) => member.id);
|
||||
setForm((current) => {
|
||||
if (allFilteredSelected) {
|
||||
return {
|
||||
...current,
|
||||
member_ids: current.member_ids.filter((id) => !filteredIds.includes(id)),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
member_ids: [...new Set([...current.member_ids, ...filteredIds])],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const emailStatus = useMemo(() => {
|
||||
if (!form.email_local.trim()) return null;
|
||||
if (!EMAIL_LOCAL_PATTERN.test(form.email_local.trim().toLowerCase())) {
|
||||
return { kind: "warn", text: "Use lowercase letters, numbers, dots, or hyphens." };
|
||||
}
|
||||
if (emailCheck.loading) {
|
||||
return { kind: "checking", text: `Checking ${fullEmail}…` };
|
||||
}
|
||||
if (emailCheck.available === true) {
|
||||
return { kind: "ok", text: `${fullEmail} is available` };
|
||||
}
|
||||
if (emailCheck.available === false) {
|
||||
const conflictLabel = {
|
||||
user: "a user mailbox",
|
||||
group: "another group",
|
||||
alias: "an alias",
|
||||
invalid_domain: "an invalid domain",
|
||||
}[emailCheck.conflict] || "another resource";
|
||||
return { kind: "warn", text: `${fullEmail} is already in use by ${conflictLabel}` };
|
||||
}
|
||||
return null;
|
||||
}, [emailCheck, form.email_local, fullEmail]);
|
||||
|
||||
const canSubmit = form.name.trim()
|
||||
&& form.email_local.trim()
|
||||
&& EMAIL_LOCAL_PATTERN.test(form.email_local.trim().toLowerCase())
|
||||
&& form.owner_id
|
||||
&& emailCheck.available === true;
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
|
||||
setStatus({ loading: true, error: "" });
|
||||
const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`;
|
||||
try {
|
||||
await createMailGroup(
|
||||
{
|
||||
name: form.name.trim(),
|
||||
email,
|
||||
group_type: form.group_type,
|
||||
email: fullEmail,
|
||||
group_type: "distribution",
|
||||
owner_id: form.owner_id,
|
||||
send_permission: form.send_permission,
|
||||
description: form.description.trim() || undefined,
|
||||
member_ids: form.member_ids,
|
||||
},
|
||||
token,
|
||||
|
|
@ -81,83 +271,182 @@ function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
|
|||
return (
|
||||
<div className="people-drawer-scrim" onClick={onClose}>
|
||||
<aside
|
||||
className="people-drawer"
|
||||
className="people-drawer people-drawer-wide group-drawer"
|
||||
role="dialog"
|
||||
aria-labelledby="create-group-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="people-drawer-head">
|
||||
<h2 id="create-group-title">Create group</h2>
|
||||
<header className="people-drawer-head group-drawer-head">
|
||||
<div>
|
||||
<div className="people-drawer-title">
|
||||
<span className="people-drawer-title-icon" aria-hidden="true">
|
||||
<UsersRound size={18} />
|
||||
</span>
|
||||
<h2 id="create-group-title">Create group</h2>
|
||||
</div>
|
||||
<p className="group-drawer-subtitle">Create an email group for your organization.</p>
|
||||
</div>
|
||||
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="people-drawer-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Group name <span aria-hidden="true">*</span>
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => updateField("name", event.target.value)}
|
||||
placeholder="Engineering"
|
||||
/>
|
||||
</label>
|
||||
<form className="group-drawer-form" onSubmit={handleSubmit} noValidate>
|
||||
<div className="add-user-form-body">
|
||||
<h3 className="group-drawer-section-title">Group details</h3>
|
||||
|
||||
<label>
|
||||
Email address <span aria-hidden="true">*</span>
|
||||
<div className="people-email-field">
|
||||
<input
|
||||
required
|
||||
value={form.email_local}
|
||||
onChange={(event) => updateField("email_local", event.target.value)}
|
||||
placeholder="engineering"
|
||||
/>
|
||||
<span>@{companyDomain}</span>
|
||||
</div>
|
||||
<small>Messages sent here are delivered to all group members.</small>
|
||||
</label>
|
||||
<SetupField label={<RequiredLabel>Group name</RequiredLabel>}>
|
||||
<div className="add-user-input-wrap">
|
||||
<input
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(event) => updateName(event.target.value)}
|
||||
placeholder="Engineering"
|
||||
/>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<label>
|
||||
Type
|
||||
<select
|
||||
value={form.group_type}
|
||||
onChange={(event) => updateField("group_type", event.target.value)}
|
||||
<SetupField
|
||||
label={<RequiredLabel>Group email</RequiredLabel>}
|
||||
status={emailStatus}
|
||||
hint="Messages sent to this address are delivered to all members."
|
||||
>
|
||||
<option value="distribution">Distribution</option>
|
||||
<option value="dynamic">Dynamic</option>
|
||||
</select>
|
||||
<small>Distribution groups have a fixed member list. Dynamic groups update automatically.</small>
|
||||
</label>
|
||||
<div className="add-user-email-field">
|
||||
<div className="add-user-input-wrap group-email-input">
|
||||
<input
|
||||
required
|
||||
value={form.email_local}
|
||||
onChange={(event) => {
|
||||
setEmailTouched(true);
|
||||
updateField("email_local", event.target.value);
|
||||
}}
|
||||
placeholder="engineering"
|
||||
/>
|
||||
<span className="group-email-domain">@{companyDomain}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<fieldset className="people-groups-field">
|
||||
<legend>Members</legend>
|
||||
<div className="org-member-picker">
|
||||
<SetupField label={<RequiredLabel>Group owner</RequiredLabel>}>
|
||||
<div className="add-user-input-wrap">
|
||||
<select
|
||||
value={form.owner_id}
|
||||
onChange={(event) => updateField("owner_id", event.target.value)}
|
||||
required
|
||||
>
|
||||
{ownerOptions.map((member) => (
|
||||
<option key={member.id} value={member.id}>
|
||||
{member.first_name} {member.last_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<small className="add-user-hint">The owner can manage members without full admin access.</small>
|
||||
</SetupField>
|
||||
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
<div className="group-members-head">
|
||||
<h3 className="group-drawer-section-title">Members</h3>
|
||||
<span className="group-members-count">
|
||||
{form.member_ids.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="group-member-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={memberSearch}
|
||||
onChange={(event) => setMemberSearch(event.target.value)}
|
||||
placeholder="Search by name or email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="group-member-picker">
|
||||
{members.length === 0 ? (
|
||||
<p className="org-member-picker-empty">Add people first, then assign them to groups.</p>
|
||||
) : (
|
||||
members.map((member) => (
|
||||
<label key={member.id} className="org-member-picker-row">
|
||||
<>
|
||||
<label className="group-member-select-all">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.member_ids.includes(member.id)}
|
||||
onChange={() => toggleMember(member.id)}
|
||||
checked={allFilteredSelected}
|
||||
onChange={toggleSelectAll}
|
||||
disabled={filteredMembers.length === 0}
|
||||
/>
|
||||
<span>{member.first_name} {member.last_name}</span>
|
||||
<small>{member.email}</small>
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
))
|
||||
<div className="group-member-list">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<p className="org-member-picker-empty">No people match your search.</p>
|
||||
) : (
|
||||
filteredMembers.map((member) => (
|
||||
<label key={member.id} className="group-member-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.member_ids.includes(member.id)}
|
||||
onChange={() => toggleMember(member.id)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{member.first_name} {member.last_name}</strong>
|
||||
<small>{member.email}</small>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<div className="group-permissions">
|
||||
<button
|
||||
type="button"
|
||||
className="group-permissions-toggle"
|
||||
onClick={() => setPermissionsOpen((open) => !open)}
|
||||
aria-expanded={permissionsOpen}
|
||||
>
|
||||
<ChevronDown size={16} className={permissionsOpen ? "open" : ""} />
|
||||
Permissions
|
||||
</button>
|
||||
{permissionsOpen && (
|
||||
<fieldset className="group-permissions-panel">
|
||||
<legend className="add-user-label">Who can send to this group?</legend>
|
||||
{SEND_PERMISSIONS.map((option) => (
|
||||
<label key={option.value} className="group-permission-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="send_permission"
|
||||
value={option.value}
|
||||
checked={form.send_permission === option.value}
|
||||
onChange={() => updateField("send_permission", option.value)}
|
||||
/>
|
||||
<span>
|
||||
<strong>
|
||||
{option.value === "org_members"
|
||||
? `Anyone in ${companyDomain}`
|
||||
: option.label}
|
||||
</strong>
|
||||
<small>{option.getHint(companyDomain)}</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="people-drawer-actions">
|
||||
<button type="button" className="button button-outline" onClick={onClose}>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
</div>
|
||||
|
||||
<footer className="group-drawer-footer">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="button button-purple" type="submit" disabled={status.loading}>
|
||||
<button
|
||||
className="add-user-button add-user-button-submit"
|
||||
type="submit"
|
||||
disabled={!canSubmit || status.loading}
|
||||
>
|
||||
{status.loading ? "Creating…" : "Create group"}
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -320,6 +609,7 @@ export default function CompanyGroups({ account }) {
|
|||
|
||||
{drawerOpen && (
|
||||
<CreateGroupPanel
|
||||
account={account}
|
||||
companyDomain={companyDomain}
|
||||
members={members}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
const [dnsChecks, setDnsChecks] = useState([]);
|
||||
const [lastChecked, setLastChecked] = useState(null);
|
||||
const [dnsLoading, setDnsLoading] = useState(false);
|
||||
const [dnsError, setDnsError] = useState("");
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
|
@ -97,16 +98,8 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
setDnsChecks([]);
|
||||
return;
|
||||
}
|
||||
if (primary.status === "active") {
|
||||
setDnsChecks(HEALTH_ITEMS.map((item) => ({
|
||||
...item,
|
||||
verified: item.purpose !== "dmarc",
|
||||
recommended: item.purpose === "dmarc",
|
||||
})));
|
||||
setLastChecked(new Date());
|
||||
return;
|
||||
}
|
||||
setDnsLoading(true);
|
||||
setDnsError("");
|
||||
try {
|
||||
const result = await verifyMyDomainDns(primary.id, token);
|
||||
const checkMap = Object.fromEntries(result.checks.map((check) => [check.purpose, check]));
|
||||
|
|
@ -116,12 +109,13 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
recommended: item.purpose === "dmarc" && !checkMap[item.purpose]?.verified,
|
||||
})));
|
||||
setLastChecked(new Date());
|
||||
} catch {
|
||||
setDnsChecks(HEALTH_ITEMS.map((item) => ({
|
||||
...item,
|
||||
verified: false,
|
||||
recommended: item.purpose === "dmarc",
|
||||
})));
|
||||
if (result.status !== primary.status) {
|
||||
setDomains((current) => current.map((domain) => (
|
||||
domain.id === primary.id ? { ...domain, status: result.status } : domain
|
||||
)));
|
||||
}
|
||||
} catch (error) {
|
||||
setDnsError(error.message || "Could not verify DNS records.");
|
||||
} finally {
|
||||
setDnsLoading(false);
|
||||
}
|
||||
|
|
@ -245,9 +239,15 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
))}
|
||||
</ul>
|
||||
<footer className="company-overview-panel-footer">
|
||||
<span>{lastChecked ? `Last checked ${formatRelativeTime(lastChecked)}` : "Not checked yet"}</span>
|
||||
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading}>
|
||||
<RefreshCw size={14} />
|
||||
<span>
|
||||
{dnsError
|
||||
? dnsError
|
||||
: lastChecked
|
||||
? `Last checked ${formatRelativeTime(lastChecked)}`
|
||||
: "Not checked yet"}
|
||||
</span>
|
||||
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}>
|
||||
<RefreshCw size={14} className={dnsLoading ? "spinning" : ""} />
|
||||
{dnsLoading ? "Checking…" : "Recheck"}
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -294,7 +294,13 @@ export default function CompanyOverview({ account, companyDomain, onNavigate })
|
|||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" disabled title="Coming soon">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
sessionStorage.setItem("agmail_open_people_import", "1");
|
||||
onNavigate?.("People");
|
||||
}}
|
||||
>
|
||||
<FileUp size={16} /> Invite users via CSV
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ArrowRight, Check } from "lucide-react";
|
||||
import { ArrowRight, Check, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import AddEmployeeForm from "./AddEmployeeForm";
|
||||
import ImportUsersPanel from "./ImportUsersPanel";
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: "Add domain", hint: "Use your company domain for email" },
|
||||
|
|
@ -140,6 +141,7 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [addedEmployees, setAddedEmployees] = useState([]);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey(account.id, "step"), String(step));
|
||||
|
|
@ -311,11 +313,20 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
|
||||
{step === 5 && (
|
||||
<>
|
||||
<div className="org-setup-panel-intro">
|
||||
<h2>Add employees</h2>
|
||||
<p className="org-setup-section-label">
|
||||
Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People.
|
||||
</p>
|
||||
<div className="org-setup-panel-intro org-setup-panel-intro-row">
|
||||
<div>
|
||||
<h2>Add employees</h2>
|
||||
<p className="org-setup-section-label">
|
||||
Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="button button-outline"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<Upload size={16} /> Import CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{addedEmployees.length > 0 && (
|
||||
|
|
@ -367,6 +378,17 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{importOpen && (
|
||||
<ImportUsersPanel
|
||||
companyDomain={companyDomainName}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={(result) => {
|
||||
setAddedEmployees((current) => [...current, ...result.created]);
|
||||
setImportOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { listOrgMembers } from "../api";
|
||||
import AddEmployeeForm from "./AddEmployeeForm";
|
||||
import ImportUsersPanel from "./ImportUsersPanel";
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: "all", label: "All users" },
|
||||
|
|
@ -101,6 +102,7 @@ export default function CompanyPeople({ account }) {
|
|||
const [search, setSearch] = useState("");
|
||||
const [roleFilter, setRoleFilter] = useState("all");
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
|
|
@ -121,6 +123,13 @@ export default function CompanyPeople({ account }) {
|
|||
loadMembers();
|
||||
}, [loadMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStorage.getItem("agmail_open_people_import") === "1") {
|
||||
sessionStorage.removeItem("agmail_open_people_import");
|
||||
setImportOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return members.filter((member) => {
|
||||
|
|
@ -147,7 +156,11 @@ export default function CompanyPeople({ account }) {
|
|||
<p>Manage your team members and their email accounts.</p>
|
||||
</div>
|
||||
<div className="company-people-actions">
|
||||
<button type="button" className="button button-outline people-import-btn" disabled title="Coming soon">
|
||||
<button
|
||||
type="button"
|
||||
className="button button-outline people-import-btn"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<Upload size={16} /> Import CSV
|
||||
</button>
|
||||
<button
|
||||
|
|
@ -250,6 +263,18 @@ export default function CompanyPeople({ account }) {
|
|||
</table>
|
||||
</div>
|
||||
|
||||
{importOpen && (
|
||||
<ImportUsersPanel
|
||||
companyDomain={companyDomain}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onImported={(result) => {
|
||||
loadMembers();
|
||||
setNotice(`Imported ${result.created_count} users${result.error_count ? ` (${result.error_count} failed)` : ""}.`);
|
||||
window.setTimeout(() => setNotice(""), 6000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{drawerOpen && (
|
||||
<AddUserPanel
|
||||
companyDomain={companyDomain}
|
||||
|
|
|
|||
242
frontend/src/components/ImportUsersPanel.jsx
Normal file
242
frontend/src/components/ImportUsersPanel.jsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import { Download, FileUp, Upload, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { importOrgMembers } from "../api";
|
||||
import {
|
||||
IMPORT_ROW_LIMIT,
|
||||
downloadSampleCsv,
|
||||
parseUsersCsv,
|
||||
rowsToImportPayload,
|
||||
validateImportRows,
|
||||
} from "../utils/importUsersCsv";
|
||||
|
||||
function SetupField({ label, children, hint }) {
|
||||
return (
|
||||
<div className="add-user-field">
|
||||
{label && <span className="add-user-label">{label}</span>}
|
||||
{children}
|
||||
{hint && <small className="add-user-hint">{hint}</small>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImportUsersPanel({ companyDomain, onClose, onImported }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [form, setForm] = useState({
|
||||
org_role: "user",
|
||||
send_invitation: true,
|
||||
force_password_change: true,
|
||||
});
|
||||
const [fileName, setFileName] = useState("");
|
||||
const [rows, setRows] = useState([]);
|
||||
const [parseError, setParseError] = useState("");
|
||||
const [importResult, setImportResult] = useState(null);
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
const clientErrors = useMemo(() => {
|
||||
if (!rows.length) return [];
|
||||
return validateImportRows(rows, {
|
||||
companyDomain,
|
||||
sendInvitation: form.send_invitation,
|
||||
});
|
||||
}, [companyDomain, form.send_invitation, rows]);
|
||||
|
||||
const canImport = rows.length > 0
|
||||
&& rows.length <= IMPORT_ROW_LIMIT
|
||||
&& clientErrors.length === 0
|
||||
&& !status.loading;
|
||||
|
||||
function updateField(field, value) {
|
||||
setForm((current) => ({ ...current, [field]: value }));
|
||||
setImportResult(null);
|
||||
}
|
||||
|
||||
async function handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
setParseError("");
|
||||
setValidationErrors([]);
|
||||
setImportResult(null);
|
||||
if (!file) {
|
||||
setRows([]);
|
||||
setFileName("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsedRows = parseUsersCsv(text);
|
||||
setRows(parsedRows);
|
||||
setFileName(file.name);
|
||||
} catch (error) {
|
||||
setRows([]);
|
||||
setFileName("");
|
||||
setParseError(error.message);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!canImport) return;
|
||||
setStatus({ loading: true, error: "" });
|
||||
setImportResult(null);
|
||||
try {
|
||||
const payload = rowsToImportPayload(rows, {
|
||||
orgRole: form.org_role,
|
||||
sendInvitation: form.send_invitation,
|
||||
forcePasswordChange: form.force_password_change,
|
||||
companyDomain,
|
||||
});
|
||||
const result = await importOrgMembers(payload, token);
|
||||
setImportResult(result);
|
||||
onImported?.(result);
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
setStatus({ loading: false, error: "" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="people-drawer-scrim" onClick={onClose}>
|
||||
<aside
|
||||
className="people-drawer people-drawer-wide group-drawer"
|
||||
role="dialog"
|
||||
aria-labelledby="import-users-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="people-drawer-head group-drawer-head">
|
||||
<div>
|
||||
<div className="people-drawer-title">
|
||||
<span className="people-drawer-title-icon" aria-hidden="true">
|
||||
<Upload size={18} />
|
||||
</span>
|
||||
<h2 id="import-users-title">Import users</h2>
|
||||
</div>
|
||||
<p className="group-drawer-subtitle">
|
||||
Upload a CSV to add up to {IMPORT_ROW_LIMIT} people at once.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="group-drawer-form">
|
||||
<div className="add-user-form-body">
|
||||
<h3 className="group-drawer-section-title">Import settings</h3>
|
||||
|
||||
<SetupField label="Role">
|
||||
<div className="add-user-input-wrap">
|
||||
<select
|
||||
value={form.org_role}
|
||||
onChange={(event) => updateField("org_role", event.target.value)}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">User Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</SetupField>
|
||||
|
||||
<div className="add-user-checkboxes">
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.send_invitation}
|
||||
onChange={(event) => updateField("send_invitation", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Send invitation email</strong>
|
||||
<small>They will receive an activation link to set their password.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="add-user-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.force_password_change}
|
||||
onChange={(event) => updateField("force_password_change", event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Require password change on first login</strong>
|
||||
<small>User must create a new password when they sign in for the first time.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="add-user-section-divider" aria-hidden="true" />
|
||||
|
||||
<h3 className="group-drawer-section-title">CSV file</h3>
|
||||
|
||||
<div className="import-users-actions">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={downloadSampleCsv}>
|
||||
<Download size={16} /> Download sample CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="import-users-dropzone">
|
||||
<input type="file" accept=".csv,text/csv" onChange={handleFileChange} />
|
||||
<FileUp size={22} />
|
||||
<strong>{fileName || "Choose a CSV file"}</strong>
|
||||
<small>
|
||||
Columns: First name*, Last name*, Work email*, Invitation email, Initial password
|
||||
</small>
|
||||
</label>
|
||||
|
||||
{parseError && <p className="form-error" role="alert">{parseError}</p>}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<div className="import-users-summary">
|
||||
<p>
|
||||
<strong>{rows.length}</strong> people ready to import
|
||||
{clientErrors.length > 0 && (
|
||||
<> · <span className="import-users-summary-error">{clientErrors.length} rows need fixes</span></>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clientErrors.length > 0 && (
|
||||
<ul className="import-users-errors">
|
||||
{clientErrors.slice(0, 8).map((item) => (
|
||||
<li key={`${item.row}-${item.email}`}>
|
||||
Row {item.row} ({item.email || "missing email"}): {item.messages.join(" ")}
|
||||
</li>
|
||||
))}
|
||||
{clientErrors.length > 8 && (
|
||||
<li>…and {clientErrors.length - 8} more rows with issues.</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{importResult && (
|
||||
<div className="import-users-result" role="status">
|
||||
<p>
|
||||
Created <strong>{importResult.created_count}</strong> users.
|
||||
{importResult.error_count > 0 && (
|
||||
<> {importResult.error_count} rows failed.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
</div>
|
||||
|
||||
<footer className="group-drawer-footer">
|
||||
<button type="button" className="add-user-button add-user-button-cancel" onClick={onClose}>
|
||||
{importResult?.created_count ? "Close" : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="add-user-button add-user-button-submit"
|
||||
disabled={!canImport}
|
||||
onClick={handleImport}
|
||||
>
|
||||
{status.loading ? "Importing…" : `Import ${rows.length || ""} users`.trim()}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -261,6 +261,9 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.company-overview-health-icon { width:24px; height:24px; display:grid; place-items:center; border-radius:50%; background:#f2f0f5; color:#b4afba; }
|
||||
.company-overview-health-icon.ok { background:#e8f7ef; color:#2f8a57; }
|
||||
.company-overview-health-icon.warn { background:#fff4df; color:#b7892d; }
|
||||
.company-overview-health-icon.fail { background:#fdeeed; color:#c44b38; }
|
||||
.company-overview-panel-footer button .spinning { animation:overview-spin .8s linear infinite; }
|
||||
@keyframes overview-spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
|
||||
.company-overview-health-badge { margin-left:auto; padding:3px 8px; border-radius:999px; background:#fff4df; color:#b7892d; font-size:10px; font-style:normal; font-weight:700; }
|
||||
.company-overview-panel-footer { margin-top:16px; padding-top:14px; border-top:1px solid #f0ebe7; display:flex; align-items:center; justify-content:space-between; gap:12px; color:#9b96a3; font-size:12px; }
|
||||
.company-overview-panel-footer button { display:inline-flex; align-items:center; gap:6px; border:0; background:transparent; color:#4d74c4; font-size:12px; font-weight:600; cursor:pointer; }
|
||||
|
|
@ -390,6 +393,38 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.people-row-menu:hover { background:#F6F2FF; color:#7357F6; }
|
||||
.people-drawer-scrim { position:fixed; inset:0; z-index:40; display:flex; justify-content:flex-end; background:rgba(20,18,30,0.28); backdrop-filter:blur(2px); }
|
||||
.people-drawer { width:min(500px,100%); height:100%; padding:24px; display:flex; flex-direction:column; background:#fff; box-shadow:-12px 0 40px rgba(20,18,30,0.12); overflow:auto; }
|
||||
.people-drawer-wide { width:min(580px,100%); }
|
||||
.group-drawer-head { align-items:flex-start; }
|
||||
.group-drawer-subtitle { margin:8px 0 0 42px; color:#6B7280; font-size:13px; line-height:1.45; }
|
||||
.group-drawer-form { flex:1; min-height:0; display:flex; flex-direction:column; }
|
||||
.group-drawer-section-title { margin:0; font-size:14px; font-weight:600; color:#1a1628; }
|
||||
.group-drawer-footer { margin-top:24px; padding-top:20px; border-top:1px solid #EEEEEE; display:flex; justify-content:flex-end; align-items:center; gap:10px; flex-shrink:0; }
|
||||
.group-email-input { padding-right:0; overflow:hidden; }
|
||||
.group-email-domain { min-height:42px; padding:0 14px; display:flex; align-items:center; border-left:1px solid #E1DFE7; background:#F7F7F8; color:#6B7280; font-size:13px; font-weight:600; white-space:nowrap; }
|
||||
.group-email-status { margin:6px 0 0; display:flex; align-items:center; gap:6px; font-size:12px; line-height:1.4; }
|
||||
.group-email-status.ok { color:#2f8a57; }
|
||||
.group-email-status.warn,.group-email-status.checking { color:#b7892d; }
|
||||
.group-members-head { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.group-members-count { color:#6B7280; font-size:12px; font-weight:600; }
|
||||
.group-member-search { min-height:44px; padding:0 14px; display:flex; align-items:center; gap:10px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; color:#A1A1AA; }
|
||||
.group-member-search input { width:100%; min-width:0; border:0; outline:0; background:transparent; font-size:13px; color:#1a1628; }
|
||||
.group-member-picker { border:1px solid #E1DFE7; border-radius:12px; background:#FAFAFC; overflow:hidden; }
|
||||
.group-member-select-all { padding:12px 14px; display:grid; grid-template-columns:18px 1fr; gap:12px; align-items:center; border-bottom:1px solid #ECE9F2; background:#fff; font-size:13px; font-weight:600; color:#4a4652; cursor:pointer; }
|
||||
.group-member-list { max-height:240px; overflow:auto; }
|
||||
.group-member-row { padding:12px 14px; display:grid; grid-template-columns:18px 1fr; gap:12px; align-items:start; border-bottom:1px solid #F0EEF5; background:#fff; cursor:pointer; }
|
||||
.group-member-row:last-child { border-bottom:0; }
|
||||
.group-member-row strong { display:block; font-size:14px; font-weight:500; color:#1a1628; line-height:1.3; }
|
||||
.group-member-row small { display:block; margin-top:2px; color:#6B7280; font-size:12px; line-height:1.35; }
|
||||
.group-permissions { margin-top:4px; }
|
||||
.group-permissions-toggle { width:100%; padding:0; display:flex; align-items:center; gap:8px; border:0; background:transparent; color:#4a4652; font-size:13px; font-weight:600; cursor:pointer; }
|
||||
.group-permissions-toggle svg { transition:transform .15s ease; }
|
||||
.group-permissions-toggle svg.open { transform:rotate(180deg); }
|
||||
.group-permissions-panel { margin-top:14px; padding:0; border:0; display:grid; gap:12px; }
|
||||
.group-permission-option { display:grid; grid-template-columns:18px 1fr; gap:12px; align-items:start; cursor:pointer; }
|
||||
.group-permission-option strong { display:block; font-size:13px; font-weight:600; color:#1a1628; line-height:1.35; }
|
||||
.group-permission-option small { display:block; margin-top:4px; color:#6B7280; font-size:12px; line-height:1.45; }
|
||||
.alias-destination-kind { margin-left:8px; padding:2px 8px; border-radius:999px; background:#EEF4FF; color:#4f6fd8; font-size:10px; font-style:normal; font-weight:700; text-transform:uppercase; letter-spacing:.3px; }
|
||||
.group-member-row strong { display:flex; align-items:center; flex-wrap:wrap; gap:4px; }
|
||||
.people-drawer-head { margin-bottom:24px; display:flex; align-items:center; justify-content:space-between; gap:12px; flex-shrink:0; }
|
||||
.people-drawer-title { display:flex; align-items:center; gap:10px; }
|
||||
.people-drawer-title-icon { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; background:#F6F2FF; color:#7357F6; }
|
||||
|
|
@ -430,6 +465,17 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
|||
.add-user-button-submit { border:0; background:#7357F6; color:#fff; box-shadow:0 4px 14px rgba(115,87,246,0.24); }
|
||||
.add-user-button-submit:hover:not(:disabled) { filter:brightness(1.04); }
|
||||
.add-user-button-submit:active:not(:disabled) { transform:scale(0.99); }
|
||||
.org-setup-panel-intro-row { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; }
|
||||
.org-setup-panel-intro-row .button { flex-shrink:0; }
|
||||
.import-users-actions { display:flex; gap:10px; }
|
||||
.import-users-dropzone { min-height:140px; padding:24px; display:grid; justify-items:center; gap:8px; border:1px dashed #D8D5E0; border-radius:12px; background:#FAFAFC; text-align:center; cursor:pointer; }
|
||||
.import-users-dropzone input { display:none; }
|
||||
.import-users-dropzone strong { font-size:14px; color:#1a1628; }
|
||||
.import-users-dropzone small { color:#6B7280; font-size:12px; line-height:1.45; max-width:360px; }
|
||||
.import-users-summary { padding:12px 14px; border-radius:10px; background:#F6F2FF; color:#4a4652; font-size:13px; }
|
||||
.import-users-summary-error { color:#c44b38; }
|
||||
.import-users-errors { margin:0; padding-left:18px; color:#c44b38; font-size:12px; line-height:1.5; }
|
||||
.import-users-result { padding:12px 14px; border-radius:10px; background:#eef8f0; color:#2f6b3a; font-size:13px; }
|
||||
.employee-add-form-inline .add-user-row,.employee-add-form-inline .add-user-form-body { max-width:720px; }
|
||||
.employee-add-form-inline .add-user-input-wrap input,.employee-add-form-inline .add-user-input-wrap select { height:40px; }
|
||||
.people-drawer-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
|
|
|
|||
177
frontend/src/utils/importUsersCsv.js
Normal file
177
frontend/src/utils/importUsersCsv.js
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
const SAMPLE_CSV = `First name*,Last name*,Work email*,Invitation email,Initial password
|
||||
Rahul,Sharma,rahul,rahul@gmail.com,Roadrentals123
|
||||
Anurag,Jangid,anurag.jangid,anurag.personal@gmail.com,Roadrentals123`;
|
||||
|
||||
const HEADER_ALIASES = {
|
||||
"first name": "first_name",
|
||||
"first name*": "first_name",
|
||||
"last name": "last_name",
|
||||
"last name*": "last_name",
|
||||
"work email": "work_email",
|
||||
"work email*": "work_email",
|
||||
"invitation email": "invitation_email",
|
||||
"initial password": "password",
|
||||
};
|
||||
|
||||
export const IMPORT_ROW_LIMIT = 300;
|
||||
|
||||
function normalizeHeader(value) {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function parseCsvLine(line) {
|
||||
const cells = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
if (char === '"') {
|
||||
if (inQuotes && line[index + 1] === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "," && !inQuotes) {
|
||||
cells.push(current.trim());
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function buildSampleCsvBlob() {
|
||||
return new Blob([SAMPLE_CSV], { type: "text/csv;charset=utf-8" });
|
||||
}
|
||||
|
||||
export function downloadSampleCsv() {
|
||||
const url = URL.createObjectURL(buildSampleCsvBlob());
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "agmail-people-import-sample.csv";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function parseUsersCsv(text) {
|
||||
const lines = text
|
||||
.replace(/\r\n/g, "\n")
|
||||
.replace(/\r/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length < 2) {
|
||||
throw new Error("CSV must include a header row and at least one person.");
|
||||
}
|
||||
|
||||
const headers = parseCsvLine(lines[0]).map(normalizeHeader);
|
||||
const columnMap = headers.map((header) => HEADER_ALIASES[header]);
|
||||
if (columnMap.some((column) => !column)) {
|
||||
throw new Error(
|
||||
"CSV headers must be: First name*, Last name*, Work email*, Invitation email, Initial password",
|
||||
);
|
||||
}
|
||||
|
||||
const rows = lines.slice(1).map((line, index) => {
|
||||
const cells = parseCsvLine(line);
|
||||
const row = {
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
work_email: "",
|
||||
invitation_email: "",
|
||||
password: "",
|
||||
};
|
||||
columnMap.forEach((field, columnIndex) => {
|
||||
row[field] = cells[columnIndex]?.trim() || "";
|
||||
});
|
||||
return { rowNumber: index + 2, ...row };
|
||||
});
|
||||
|
||||
if (rows.length > IMPORT_ROW_LIMIT) {
|
||||
throw new Error(`You can import up to ${IMPORT_ROW_LIMIT} people at a time.`);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
const PASSWORD_PATTERN = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/;
|
||||
|
||||
export function validateImportRows(rows, { companyDomain, sendInvitation }) {
|
||||
const errors = [];
|
||||
const seenEmails = new Set();
|
||||
const seenInvites = new Set();
|
||||
|
||||
rows.forEach((row) => {
|
||||
const rowErrors = [];
|
||||
if (!row.first_name.trim()) rowErrors.push("First name is required.");
|
||||
if (!row.last_name.trim()) rowErrors.push("Last name is required.");
|
||||
|
||||
const workEmailRaw = row.work_email.trim().toLowerCase();
|
||||
const workEmail = workEmailRaw.includes("@")
|
||||
? workEmailRaw
|
||||
: `${workEmailRaw}@${companyDomain}`;
|
||||
const localPart = workEmail.split("@")[0];
|
||||
|
||||
if (!workEmailRaw) {
|
||||
rowErrors.push("Work email is required.");
|
||||
} else if (!workEmail.endsWith(`@${companyDomain}`)) {
|
||||
rowErrors.push(`Work email must use @${companyDomain}.`);
|
||||
} else if (!/^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/.test(localPart)) {
|
||||
rowErrors.push("Work email format is invalid.");
|
||||
} else if (seenEmails.has(workEmail)) {
|
||||
rowErrors.push("Duplicate work email in this file.");
|
||||
}
|
||||
seenEmails.add(workEmail);
|
||||
|
||||
const password = row.password.trim();
|
||||
if (!password) {
|
||||
rowErrors.push("Initial password is required.");
|
||||
} else if (!PASSWORD_PATTERN.test(password)) {
|
||||
rowErrors.push("Password must be at least 8 characters with upper, lower, and a number.");
|
||||
}
|
||||
|
||||
const invitationEmail = row.invitation_email.trim().toLowerCase();
|
||||
if (sendInvitation) {
|
||||
if (!invitationEmail) {
|
||||
rowErrors.push("Invitation email is required when sending invitations.");
|
||||
} else if (invitationEmail.endsWith(`@${companyDomain}`)) {
|
||||
rowErrors.push("Use a personal email for invitations.");
|
||||
} else if (seenInvites.has(invitationEmail)) {
|
||||
rowErrors.push("Duplicate invitation email in this file.");
|
||||
} else {
|
||||
seenInvites.add(invitationEmail);
|
||||
}
|
||||
}
|
||||
|
||||
if (rowErrors.length > 0) {
|
||||
errors.push({ row: row.rowNumber, email: workEmail, messages: rowErrors });
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function rowsToImportPayload(rows, options) {
|
||||
const { orgRole, sendInvitation, forcePasswordChange, companyDomain } = options;
|
||||
return {
|
||||
org_role: orgRole,
|
||||
send_invitation: sendInvitation,
|
||||
force_password_change: forcePasswordChange,
|
||||
rows: rows.map((row) => ({
|
||||
first_name: row.first_name.trim(),
|
||||
last_name: row.last_name.trim(),
|
||||
work_email: row.work_email.trim().toLowerCase().includes("@")
|
||||
? row.work_email.trim().toLowerCase()
|
||||
: `${row.work_email.trim().toLowerCase()}@${companyDomain}`,
|
||||
invitation_email: row.invitation_email.trim() || undefined,
|
||||
password: row.password.trim(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue