agentic email invite html
This commit is contained in:
parent
65bb73f30b
commit
04a033e099
4 changed files with 403 additions and 23 deletions
321
frontend/src/components/CompanyOverview.jsx
Normal file
321
frontend/src/components/CompanyOverview.jsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
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";
|
||||
|
||||
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 formatRelativeTime(dateValue) {
|
||||
const date = new Date(dateValue);
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
const days = Math.floor(seconds / 86400);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return `${Math.floor(days / 7)}w ago`;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
if (primary.status === "active") {
|
||||
setDnsChecks(HEALTH_ITEMS.map((item) => ({
|
||||
...item,
|
||||
verified: item.purpose !== "dmarc",
|
||||
recommended: item.purpose === "dmarc",
|
||||
})));
|
||||
setLastChecked(new Date());
|
||||
return;
|
||||
}
|
||||
setDnsLoading(true);
|
||||
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());
|
||||
} catch {
|
||||
setDnsChecks(HEALTH_ITEMS.map((item) => ({
|
||||
...item,
|
||||
verified: false,
|
||||
recommended: item.purpose === "dmarc",
|
||||
})));
|
||||
} 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) => new Date(right.time) - new Date(left.time))
|
||||
.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>{lastChecked ? `Last checked ${formatRelativeTime(lastChecked)}` : "Not checked yet"}</span>
|
||||
<button type="button" onClick={loadDnsHealth} disabled={dnsLoading}>
|
||||
<RefreshCw size={14} />
|
||||
{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)}</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" disabled title="Coming soon">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ const STEPS = [
|
|||
{ id: 3, title: "Configure email DNS", hint: "Set up MX, SPF, DKIM" },
|
||||
{ id: 4, title: "Company details", hint: "Tell us about your organization" },
|
||||
{ id: 5, title: "Add employees", hint: "Create and invite your team" },
|
||||
{ id: 6, title: "Finish setup", hint: "Start using AGMail" },
|
||||
{ id: 6, title: "Finish setup", hint: "Start using aGentic Email" },
|
||||
];
|
||||
|
||||
const ORG_SIZES = [
|
||||
|
|
@ -98,6 +98,18 @@ export function isOrgSetupComplete(accountId) {
|
|||
return localStorage.getItem(storageKey(accountId, "complete")) === "1";
|
||||
}
|
||||
|
||||
export function getOrgDetails(accountId) {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(accountId, "details"));
|
||||
if (raw) {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function RequiredLabel({ children }) {
|
||||
return (
|
||||
<span className="org-setup-label-text">
|
||||
|
|
@ -339,7 +351,7 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
<div className="org-setup-form-shell">
|
||||
<h2>Finish setup</h2>
|
||||
<p className="org-setup-lead">
|
||||
Domain <strong>{companyDomainName}</strong> is connected. You're ready to use AGMail.
|
||||
Domain <strong>{companyDomainName}</strong> is connected. You're ready to use aGentic Email.
|
||||
</p>
|
||||
<ul className="org-setup-summary">
|
||||
<li><Check size={14} /> Domain added and verified</li>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import CompanyAliases from "./CompanyAliases";
|
|||
import CompanyGroups from "./CompanyGroups";
|
||||
import CompanyPeople from "./CompanyPeople";
|
||||
import CompanySecurity from "./CompanySecurity";
|
||||
import CompanyOverview from "./CompanyOverview";
|
||||
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||
import ChangePasswordModal from "./ChangePasswordModal";
|
||||
import ComposeModal from "./ComposeModal";
|
||||
|
|
@ -278,17 +279,10 @@ export default function DashboardPage() {
|
|||
</div>
|
||||
|
||||
{!isOrgOnboarding && (
|
||||
<div className="dashboard-compose-wrap">
|
||||
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
|
||||
<span className="dashboard-compose-main">
|
||||
<Mail size={16} strokeWidth={1.75} />
|
||||
<span>Compose</span>
|
||||
</span>
|
||||
</button>
|
||||
<button className="dashboard-compose-menu" type="button" aria-label="Compose options">
|
||||
<ChevronDown size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
|
||||
<Mail size={16} strokeWidth={1.75} />
|
||||
<span>Compose</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="dashboard-sidebar-scroll">
|
||||
|
|
@ -446,7 +440,7 @@ export default function DashboardPage() {
|
|||
onOpenSettings={() => selectMailItem("Settings")}
|
||||
/>
|
||||
)}
|
||||
{companyActive === "Overview" && isDomainOwner && (
|
||||
{companyActive === "Overview" && isDomainOwner && !isOrgSetupComplete(account.id) && (
|
||||
<CompanyOverviewSetup
|
||||
account={account}
|
||||
companyDomain={companyDomain}
|
||||
|
|
@ -456,6 +450,13 @@ export default function DashboardPage() {
|
|||
}}
|
||||
/>
|
||||
)}
|
||||
{companyActive === "Overview" && isDomainOwner && isOrgSetupComplete(account.id) && (
|
||||
<CompanyOverview
|
||||
account={account}
|
||||
companyDomain={companyDomain}
|
||||
onNavigate={selectCompanyItem}
|
||||
/>
|
||||
)}
|
||||
{companyActive === "Domains" && isDomainOwner && (
|
||||
<CompanyDomains
|
||||
account={account}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue