318 lines
11 KiB
JavaScript
318 lines
11 KiB
JavaScript
import {
|
|
ArrowRight,
|
|
CheckCircle2,
|
|
ChevronRight,
|
|
CreditCard,
|
|
Database,
|
|
FileUp,
|
|
Globe,
|
|
RefreshCw,
|
|
Shield,
|
|
UserPlus,
|
|
Users,
|
|
UsersRound,
|
|
} from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import {
|
|
listMailGroups,
|
|
listMyDomains,
|
|
listOrgMembers,
|
|
verifyMyDomainDns,
|
|
} from "../api";
|
|
import { getOrgDetails } from "./CompanyOverviewSetup";
|
|
import { formatRelativeTime, parseApiDate } from "../utils/datetime";
|
|
|
|
const HEALTH_ITEMS = [
|
|
{ purpose: "ownership", label: "Domain verified" },
|
|
{ purpose: "mx", label: "MX records" },
|
|
{ purpose: "spf", label: "SPF" },
|
|
{ purpose: "dkim", label: "DKIM" },
|
|
{ purpose: "dmarc", label: "DMARC" },
|
|
];
|
|
|
|
function companyTitle(account, details, domainName) {
|
|
if (details?.companyName?.trim()) return details.companyName.trim();
|
|
if (domainName) {
|
|
const base = domainName.split(".")[0];
|
|
return base.charAt(0).toUpperCase() + base.slice(1);
|
|
}
|
|
return `${account.first_name} ${account.last_name}`.trim();
|
|
}
|
|
|
|
function StatCard({ icon: Icon, tone, label, value, detail }) {
|
|
return (
|
|
<article className={`company-overview-stat tone-${tone}`}>
|
|
<span className="company-overview-stat-icon" aria-hidden="true">
|
|
<Icon size={18} />
|
|
</span>
|
|
<div>
|
|
<p>{label}</p>
|
|
<strong>{value}</strong>
|
|
<small>{detail}</small>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export default function CompanyOverview({ account, companyDomain, onNavigate }) {
|
|
const token = sessionStorage.getItem("agmail_access_token");
|
|
const domainName = companyDomain?.domain_name || account.company_domain || "";
|
|
const details = getOrgDetails(account.id);
|
|
const [loading, setLoading] = useState(true);
|
|
const [members, setMembers] = useState({ items: [], counts: {} });
|
|
const [groups, setGroups] = useState({ items: [], counts: {} });
|
|
const [domains, setDomains] = useState([]);
|
|
const [dnsChecks, setDnsChecks] = useState([]);
|
|
const [lastChecked, setLastChecked] = useState(null);
|
|
const [dnsLoading, setDnsLoading] = useState(false);
|
|
const [dnsError, setDnsError] = useState("");
|
|
|
|
const loadOverview = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [memberData, groupData, domainData] = await Promise.all([
|
|
listOrgMembers(token),
|
|
listMailGroups(token),
|
|
listMyDomains(token),
|
|
]);
|
|
setMembers(memberData);
|
|
setGroups(groupData);
|
|
setDomains(domainData);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
const loadDnsHealth = useCallback(async () => {
|
|
const primary = domains.find((domain) => domain.domain_name === domainName) || domains[0];
|
|
if (!primary) {
|
|
setDnsChecks([]);
|
|
return;
|
|
}
|
|
setDnsLoading(true);
|
|
setDnsError("");
|
|
try {
|
|
const result = await verifyMyDomainDns(primary.id, token);
|
|
const checkMap = Object.fromEntries(result.checks.map((check) => [check.purpose, check]));
|
|
setDnsChecks(HEALTH_ITEMS.map((item) => ({
|
|
...item,
|
|
verified: checkMap[item.purpose]?.verified ?? false,
|
|
recommended: item.purpose === "dmarc" && !checkMap[item.purpose]?.verified,
|
|
})));
|
|
setLastChecked(new Date());
|
|
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);
|
|
}
|
|
}, [domainName, domains, token]);
|
|
|
|
useEffect(() => {
|
|
loadOverview();
|
|
}, [loadOverview]);
|
|
|
|
useEffect(() => {
|
|
if (!loading) {
|
|
loadDnsHealth();
|
|
}
|
|
}, [loadDnsHealth, loading]);
|
|
|
|
const activity = useMemo(() => {
|
|
const events = [
|
|
...members.items.map((member) => ({
|
|
id: `member-${member.id}`,
|
|
icon: Users,
|
|
text: `${member.first_name} ${member.last_name} joined the organization`,
|
|
time: member.created_at,
|
|
})),
|
|
...groups.items.map((group) => ({
|
|
id: `group-${group.id}`,
|
|
icon: UsersRound,
|
|
text: `${group.name} group created`,
|
|
time: group.created_at,
|
|
})),
|
|
];
|
|
return events
|
|
.sort((left, right) => (parseApiDate(right.time)?.getTime() || 0) - (parseApiDate(left.time)?.getTime() || 0))
|
|
.slice(0, 5);
|
|
}, [groups.items, members.items]);
|
|
|
|
const primaryDomain = domains.find((domain) => domain.domain_name === domainName) || domains[0];
|
|
const secondaryCount = Math.max(0, domains.length - 1);
|
|
const distributionCount = groups.items.filter((group) => group.group_type === "distribution").length;
|
|
const dynamicCount = groups.items.filter((group) => group.group_type === "dynamic").length;
|
|
|
|
return (
|
|
<section className="company-overview">
|
|
<header className="company-overview-head">
|
|
<div className="company-overview-title">
|
|
<span className="company-overview-logo" aria-hidden="true">
|
|
{companyTitle(account, details, domainName).charAt(0).toUpperCase()}
|
|
</span>
|
|
<div>
|
|
<div className="company-overview-name-row">
|
|
<h1>{companyTitle(account, details, domainName)}</h1>
|
|
<span className="company-overview-badge">Business</span>
|
|
</div>
|
|
<p>Manage your organisation, people and email settings.</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" className="company-overview-subscription" onClick={() => onNavigate?.("Billing")}>
|
|
Manage subscription <ArrowRight size={15} />
|
|
</button>
|
|
</header>
|
|
|
|
<div className="company-overview-stats">
|
|
<StatCard
|
|
icon={Users}
|
|
tone="coral"
|
|
label="Total users"
|
|
value={loading ? "—" : members.counts.all ?? members.items.length}
|
|
detail={loading ? "Loading…" : `${members.counts.active ?? 0} active • ${members.counts.invited ?? 0} pending`}
|
|
/>
|
|
<StatCard
|
|
icon={UsersRound}
|
|
tone="violet"
|
|
label="Groups"
|
|
value={loading ? "—" : groups.items.length}
|
|
detail={loading ? "Loading…" : `${distributionCount} distribution • ${dynamicCount} dynamic`}
|
|
/>
|
|
<StatCard
|
|
icon={Globe}
|
|
tone="green"
|
|
label="Domains"
|
|
value={loading ? "—" : domains.length}
|
|
detail={loading ? "Loading…" : `1 primary • ${secondaryCount} secondary`}
|
|
/>
|
|
<StatCard
|
|
icon={Database}
|
|
tone="amber"
|
|
label="Total storage"
|
|
value="—"
|
|
detail="of 100 GB"
|
|
/>
|
|
</div>
|
|
|
|
<div className="company-overview-grid">
|
|
<section className="company-overview-panel">
|
|
<header className="company-overview-panel-head">
|
|
<h2>Domain health</h2>
|
|
</header>
|
|
{primaryDomain ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="company-overview-domain-row"
|
|
onClick={() => onNavigate?.("Domains")}
|
|
>
|
|
<span>
|
|
<strong>{primaryDomain.domain_name}</strong>
|
|
<em>Primary</em>
|
|
</span>
|
|
<ChevronRight size={16} />
|
|
</button>
|
|
<ul className="company-overview-health-list">
|
|
{dnsChecks.map((item) => (
|
|
<li key={item.purpose}>
|
|
<span className={`company-overview-health-icon${item.verified ? " ok" : item.recommended ? " warn" : ""}`}>
|
|
<CheckCircle2 size={15} />
|
|
</span>
|
|
<span>{item.label}</span>
|
|
{item.recommended && !item.verified && (
|
|
<em className="company-overview-health-badge">Recommended</em>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
<footer className="company-overview-panel-footer">
|
|
<span>
|
|
{dnsError
|
|
? dnsError
|
|
: lastChecked
|
|
? `Last checked ${formatRelativeTime(lastChecked, { compact: true })}`
|
|
: "Not checked yet"}
|
|
</span>
|
|
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading || !primaryDomain}>
|
|
<RefreshCw size={14} className={dnsLoading ? "spinning" : ""} />
|
|
{dnsLoading ? "Checking…" : "Recheck"}
|
|
</button>
|
|
</footer>
|
|
</>
|
|
) : (
|
|
<p className="company-overview-empty">No domains configured yet.</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="company-overview-panel">
|
|
<header className="company-overview-panel-head">
|
|
<h2>Recent activity</h2>
|
|
</header>
|
|
{activity.length === 0 ? (
|
|
<p className="company-overview-empty">No recent activity yet.</p>
|
|
) : (
|
|
<ul className="company-overview-activity">
|
|
{activity.map((item) => {
|
|
const Icon = item.icon;
|
|
return (
|
|
<li key={item.id}>
|
|
<span className="company-overview-activity-icon" aria-hidden="true">
|
|
<Icon size={15} />
|
|
</span>
|
|
<div>
|
|
<p>{item.text}</p>
|
|
<time>{formatRelativeTime(item.time, { compact: true })}</time>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="company-overview-panel">
|
|
<header className="company-overview-panel-head">
|
|
<h2>Admin shortcuts</h2>
|
|
</header>
|
|
<ul className="company-overview-shortcuts">
|
|
<li>
|
|
<button type="button" onClick={() => onNavigate?.("People")}>
|
|
<UserPlus size={16} /> Add user
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
sessionStorage.setItem("agmail_open_people_import", "1");
|
|
onNavigate?.("People");
|
|
}}
|
|
>
|
|
<FileUp size={16} /> Invite users via CSV
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button type="button" onClick={() => onNavigate?.("Groups")}>
|
|
<UsersRound size={16} /> Create group
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button type="button" onClick={() => onNavigate?.("Security")}>
|
|
<Shield size={16} /> Security settings
|
|
</button>
|
|
</li>
|
|
<li>
|
|
<button type="button" onClick={() => onNavigate?.("Billing")}>
|
|
<CreditCard size={16} /> Manage billing
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|