company flows

This commit is contained in:
debu-2 2026-10-04 20:01:29 +05:30
parent 6541284a4c
commit cc3f3f3de5
17 changed files with 1960 additions and 66 deletions

View file

@ -0,0 +1,315 @@
import {
CheckCircle2,
Mail,
MoreHorizontal,
Plus,
Search,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createMailAlias, listMailAliases, listMailGroups, listOrgMembers } from "../api";
const TYPE_TABS = [
{ key: "all", label: "All aliases" },
{ key: "domain", label: "Domain aliases" },
];
function aliasTypeLabel(aliasType) {
return aliasType === "group" ? "Group alias" : "User alias";
}
function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) {
const token = sessionStorage.getItem("agmail_access_token");
const [form, setForm] = useState({
alias_local: "",
forwards_to: "",
alias_type: "user",
is_domain_alias: false,
});
const [status, setStatus] = useState({ loading: false, error: "" });
function updateField(field, value) {
setForm((current) => ({ ...current, [field]: value }));
}
function handleDestinationChange(value) {
const selected = destinations.find((item) => item.email === value);
setForm((current) => ({
...current,
forwards_to: value,
alias_type: selected?.kind === "group" ? "group" : "user",
}));
}
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const aliasAddress = `${form.alias_local.trim().toLowerCase()}@${companyDomain}`;
try {
await createMailAlias(
{
alias_address: aliasAddress,
forwards_to: form.forwards_to,
alias_type: form.alias_type,
is_domain_alias: form.is_domain_alias,
},
token,
);
onCreated?.();
onClose();
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<div className="people-drawer-scrim" onClick={onClose}>
<aside
className="people-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>
<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>
</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>
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>
<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>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className="people-drawer-actions">
<button type="button" className="button button-outline" onClick={onClose}>
Cancel
</button>
<button className="button button-purple" type="submit" disabled={status.loading}>
{status.loading ? "Creating…" : "Create alias"}
</button>
</footer>
</form>
</aside>
</div>
);
}
export default function CompanyAliases({ account }) {
const token = sessionStorage.getItem("agmail_access_token");
const companyDomain = account.company_domain;
const [aliases, setAliases] = useState([]);
const [destinations, setDestinations] = useState([]);
const [counts, setCounts] = useState({ all: 0, domain: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [typeTab, setTypeTab] = useState("all");
const [search, setSearch] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [aliasData, memberData, groupData] = await Promise.all([
listMailAliases(token),
listOrgMembers(token),
listMailGroups(token),
]);
setAliases(aliasData.items);
setCounts(aliasData.counts);
const activeMembers = memberData.items.filter((item) => item.member_status === "active");
setDestinations([
...activeMembers.map((member) => ({
kind: "user",
email: member.email,
label: `${member.first_name} ${member.last_name}`,
})),
...groupData.items.map((group) => ({
kind: "group",
email: group.email,
label: group.name,
})),
]);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
loadData();
}, [loadData]);
const filteredAliases = useMemo(() => {
const query = search.trim().toLowerCase();
return aliases.filter((alias) => {
if (typeTab === "domain" && !alias.is_domain_alias) return false;
if (!query) return true;
const haystack = `${alias.alias_address} ${alias.forwards_to}`.toLowerCase();
return haystack.includes(query);
});
}, [aliases, search, typeTab]);
return (
<section className="company-people">
<header className="company-people-head">
<div>
<h1>Aliases</h1>
<p>Manage alternative email addresses for your users.</p>
</div>
<button
type="button"
className="button button-purple"
onClick={() => setDrawerOpen(true)}
>
<Plus size={16} /> Add alias
</button>
</header>
<div className="people-tabs" role="tablist" aria-label="Filter aliases">
{TYPE_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={typeTab === tab.key}
className={typeTab === tab.key ? "active" : ""}
onClick={() => setTypeTab(tab.key)}
>
{tab.label} <em>{counts[tab.key] ?? 0}</em>
</button>
))}
</div>
<div className="people-filters">
<label className="people-search">
<Search size={16} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search aliases…"
/>
</label>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="people-table-wrap">
<table className="people-table">
<thead>
<tr>
<th>Alias address</th>
<th>Forwards to</th>
<th>Type</th>
<th>Status</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="people-table-empty">Loading aliases…</td>
</tr>
) : filteredAliases.length === 0 ? (
<tr>
<td colSpan={5} className="people-table-empty">
{aliases.length === 0
? "No aliases yet. Add your first forwarding address."
: "No aliases match your search."}
</td>
</tr>
) : (
filteredAliases.map((alias) => (
<tr key={alias.id}>
<td>
<div className="alias-address-cell">
<Mail size={15} />
<span>{alias.alias_address}</span>
</div>
</td>
<td>{alias.forwards_to}</td>
<td>
<span className="alias-type-badge">{aliasTypeLabel(alias.alias_type)}</span>
</td>
<td>
<span className="people-status people-status-active">
<CheckCircle2 size={13} /> Active
</span>
</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{drawerOpen && (
<CreateAliasPanel
companyDomain={companyDomain}
destinations={destinations}
onClose={() => setDrawerOpen(false)}
onCreated={loadData}
/>
)}
</section>
);
}

View file

@ -0,0 +1,331 @@
import {
MoreHorizontal,
Plus,
Search,
UsersRound,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createMailGroup, listMailGroups, listOrgMembers } from "../api";
const TYPE_TABS = [
{ key: "all", label: "All groups" },
{ key: "distribution", label: "Distribution" },
{ key: "dynamic", label: "Dynamic" },
];
const GROUP_TONES = ["green", "violet", "blue", "amber", "coral", "teal"];
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`;
const weeks = Math.floor(days / 7);
return `${weeks}w ago`;
}
function groupInitial(name) {
return (name?.trim()?.[0] || "G").toUpperCase();
}
function CreateGroupPanel({ companyDomain, members, onClose, onCreated }) {
const token = sessionStorage.getItem("agmail_access_token");
const [form, setForm] = useState({
name: "",
email_local: "",
group_type: "distribution",
member_ids: [],
});
const [status, setStatus] = useState({ loading: false, error: "" });
function updateField(field, value) {
setForm((current) => ({ ...current, [field]: value }));
}
function toggleMember(memberId) {
setForm((current) => {
const exists = current.member_ids.includes(memberId);
return {
...current,
member_ids: exists
? current.member_ids.filter((id) => id !== memberId)
: [...current.member_ids, memberId],
};
});
}
async function handleSubmit(event) {
event.preventDefault();
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,
member_ids: form.member_ids,
},
token,
);
onCreated?.();
onClose();
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<div className="people-drawer-scrim" onClick={onClose}>
<aside
className="people-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>
<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>
<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>
<label>
Type
<select
value={form.group_type}
onChange={(event) => updateField("group_type", event.target.value)}
>
<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>
<fieldset className="people-groups-field">
<legend>Members</legend>
<div className="org-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">
<input
type="checkbox"
checked={form.member_ids.includes(member.id)}
onChange={() => toggleMember(member.id)}
/>
<span>{member.first_name} {member.last_name}</span>
<small>{member.email}</small>
</label>
))
)}
</div>
</fieldset>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className="people-drawer-actions">
<button type="button" className="button button-outline" onClick={onClose}>
Cancel
</button>
<button className="button button-purple" type="submit" disabled={status.loading}>
{status.loading ? "Creating…" : "Create group"}
</button>
</footer>
</form>
</aside>
</div>
);
}
export default function CompanyGroups({ account }) {
const token = sessionStorage.getItem("agmail_access_token");
const companyDomain = account.company_domain;
const [groups, setGroups] = useState([]);
const [members, setMembers] = useState([]);
const [counts, setCounts] = useState({ all: 0, distribution: 0, dynamic: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [typeTab, setTypeTab] = useState("all");
const [search, setSearch] = useState("");
const [drawerOpen, setDrawerOpen] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
setError("");
try {
const [groupData, memberData] = await Promise.all([
listMailGroups(token),
listOrgMembers(token),
]);
setGroups(groupData.items);
setCounts(groupData.counts);
setMembers(memberData.items.filter((item) => item.member_status === "active"));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
loadData();
}, [loadData]);
const filteredGroups = useMemo(() => {
const query = search.trim().toLowerCase();
return groups.filter((group) => {
if (typeTab !== "all" && group.group_type !== typeTab) return false;
if (!query) return true;
const haystack = `${group.name} ${group.email}`.toLowerCase();
return haystack.includes(query);
});
}, [groups, search, typeTab]);
return (
<section className="company-people">
<header className="company-people-head">
<div>
<h1>Groups</h1>
<p>Create distribution groups for team communication.</p>
</div>
<button
type="button"
className="button button-purple"
onClick={() => setDrawerOpen(true)}
>
<Plus size={16} /> Create group
</button>
</header>
<div className="people-tabs" role="tablist" aria-label="Filter by group type">
{TYPE_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={typeTab === tab.key}
className={typeTab === tab.key ? "active" : ""}
onClick={() => setTypeTab(tab.key)}
>
{tab.label} <em>{counts[tab.key] ?? 0}</em>
</button>
))}
</div>
<div className="people-filters">
<label className="people-search">
<Search size={16} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search groups…"
/>
</label>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="people-table-wrap">
<table className="people-table">
<thead>
<tr>
<th aria-label="Select" />
<th>Name</th>
<th>Email address</th>
<th>Type</th>
<th>Members</th>
<th>Created</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="people-table-empty">Loading groups…</td>
</tr>
) : filteredGroups.length === 0 ? (
<tr>
<td colSpan={7} className="people-table-empty">
{groups.length === 0
? "No groups yet. Create your first distribution group."
: "No groups match your search."}
</td>
</tr>
) : (
filteredGroups.map((group, index) => (
<tr key={group.id}>
<td><input type="checkbox" aria-label={`Select ${group.name}`} /></td>
<td>
<div className="people-name-cell">
<span className={`group-avatar tone-${GROUP_TONES[index % GROUP_TONES.length]}`}>
{groupInitial(group.name)}
</span>
<span>{group.name}</span>
</div>
</td>
<td>{group.email}</td>
<td>
<span className="group-type-badge">
{group.group_type === "dynamic" ? "Dynamic" : "Distribution"}
</span>
</td>
<td>
<span className="group-member-count">
<UsersRound size={14} />
{group.member_count}
</span>
</td>
<td>{formatRelativeTime(group.created_at)}</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{drawerOpen && (
<CreateGroupPanel
companyDomain={companyDomain}
members={members}
onClose={() => setDrawerOpen(false)}
onCreated={loadData}
/>
)}
</section>
);
}

View file

@ -0,0 +1,404 @@
import {
CheckCircle2,
Clock3,
MoreHorizontal,
Plus,
Search,
Upload,
X,
XCircle,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createOrgMember, listOrgMembers } from "../api";
const STATUS_TABS = [
{ key: "all", label: "All users" },
{ key: "active", label: "Active" },
{ key: "invited", label: "Invited" },
{ key: "suspended", label: "Suspended" },
];
const GROUP_OPTIONS = ["Admin", "HR", "Engineering", "Finance"];
function initials(firstName, lastName) {
return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase();
}
function roleLabel(member) {
if (member.is_domain_owner || member.org_role === "owner") return "Owner";
if (member.org_role === "admin") return "User Admin";
return "User";
}
function groupLabel(member) {
if (member.is_domain_owner || member.org_role === "owner" || member.org_role === "admin") {
return "Admin";
}
return "—";
}
function StatusBadge({ status }) {
if (status === "active") {
return (
<span className="people-status people-status-active">
<CheckCircle2 size={13} /> Active
</span>
);
}
if (status === "invited") {
return (
<span className="people-status people-status-invited">
<Clock3 size={13} /> Invited
</span>
);
}
return (
<span className="people-status people-status-suspended">
<XCircle size={13} /> Suspended
</span>
);
}
function AddUserPanel({ companyDomain, onClose, onCreated }) {
const token = sessionStorage.getItem("agmail_access_token");
const [form, setForm] = useState({
first_name: "",
last_name: "",
email_local: "",
org_role: "user",
groups: [],
send_invitation: true,
password: "",
});
const [status, setStatus] = useState({ loading: false, error: "" });
function updateField(field, value) {
setForm((current) => ({ ...current, [field]: value }));
}
function toggleGroup(group) {
setForm((current) => {
const exists = current.groups.includes(group);
return {
...current,
groups: exists
? current.groups.filter((item) => item !== group)
: [...current.groups, group],
};
});
}
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`;
try {
const member = await createOrgMember(
{
first_name: form.first_name.trim(),
last_name: form.last_name.trim(),
email,
org_role: form.org_role,
send_invitation: form.send_invitation,
password: form.send_invitation ? undefined : form.password,
},
token,
);
onCreated?.(member);
onClose();
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<div className="people-drawer-scrim" onClick={onClose}>
<aside
className="people-drawer"
role="dialog"
aria-labelledby="add-user-title"
onClick={(event) => event.stopPropagation()}
>
<header className="people-drawer-head">
<h2 id="add-user-title">Add user</h2>
<button type="button" className="people-drawer-close" aria-label="Close" onClick={onClose}>
<X size={18} />
</button>
</header>
<form className="people-drawer-form" onSubmit={handleSubmit}>
<div className="people-drawer-row">
<label>
First name <span aria-hidden="true">*</span>
<input
required
value={form.first_name}
onChange={(event) => updateField("first_name", event.target.value)}
placeholder="Rahul"
/>
</label>
<label>
Last name <span aria-hidden="true">*</span>
<input
required
value={form.last_name}
onChange={(event) => updateField("last_name", event.target.value)}
placeholder="Sharma"
/>
</label>
</div>
<label>
Email <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="rahul"
/>
<span>@{companyDomain}</span>
</div>
<small>This will be their login email address</small>
</label>
<label>
Role
<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>
<small>Users can access email. User Admins can manage people.</small>
</label>
<fieldset className="people-groups-field">
<legend>Groups</legend>
<div className="people-groups-options">
{GROUP_OPTIONS.map((group) => (
<button
key={group}
type="button"
className={`people-group-chip${form.groups.includes(group) ? " active" : ""}`}
onClick={() => toggleGroup(group)}
>
{group}
</button>
))}
</div>
<small>Group assignment is saved locally for now.</small>
</fieldset>
<label className="people-toggle-row">
<span>
<strong>Send invitation email</strong>
<small>They will receive an activation link to set their password.</small>
</span>
<input
type="checkbox"
checked={form.send_invitation}
onChange={(event) => updateField("send_invitation", event.target.checked)}
/>
</label>
{!form.send_invitation && (
<label>
Password <span aria-hidden="true">*</span>
<input
required
type="password"
value={form.password}
onChange={(event) => updateField("password", event.target.value)}
placeholder="Set an initial password"
minLength={8}
/>
<small>Must include uppercase, lowercase, and a number.</small>
</label>
)}
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<footer className="people-drawer-actions">
<button type="button" className="button button-outline" onClick={onClose}>
Cancel
</button>
<button className="button button-purple" type="submit" disabled={status.loading}>
{status.loading ? "Creating…" : "Create user"}
</button>
</footer>
</form>
</aside>
</div>
);
}
export default function CompanyPeople({ account }) {
const token = sessionStorage.getItem("agmail_access_token");
const companyDomain = account.company_domain;
const [members, setMembers] = useState([]);
const [counts, setCounts] = useState({ all: 0, active: 0, invited: 0, suspended: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [statusTab, setStatusTab] = useState("all");
const [search, setSearch] = useState("");
const [roleFilter, setRoleFilter] = useState("all");
const [drawerOpen, setDrawerOpen] = useState(false);
const loadMembers = useCallback(async () => {
setLoading(true);
setError("");
try {
const data = await listOrgMembers(token);
setMembers(data.items);
setCounts(data.counts);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
loadMembers();
}, [loadMembers]);
const filteredMembers = useMemo(() => {
const query = search.trim().toLowerCase();
return members.filter((member) => {
if (statusTab !== "all" && member.member_status !== statusTab) {
return false;
}
if (roleFilter === "admin" && member.org_role !== "admin" && !member.is_domain_owner) {
return false;
}
if (roleFilter === "user" && (member.org_role !== "user" || member.is_domain_owner)) {
return false;
}
if (!query) return true;
const haystack = `${member.first_name} ${member.last_name} ${member.email}`.toLowerCase();
return haystack.includes(query);
});
}, [members, roleFilter, search, statusTab]);
return (
<section className="company-people">
<header className="company-people-head">
<div>
<h1>People</h1>
<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">
<Upload size={16} /> Import CSV
</button>
<button
type="button"
className="button button-purple"
onClick={() => setDrawerOpen(true)}
>
<Plus size={16} /> Add user
</button>
</div>
</header>
<div className="people-tabs" role="tablist" aria-label="Filter by status">
{STATUS_TABS.map((tab) => (
<button
key={tab.key}
type="button"
role="tab"
aria-selected={statusTab === tab.key}
className={statusTab === tab.key ? "active" : ""}
onClick={() => setStatusTab(tab.key)}
>
{tab.label} <em>{counts[tab.key] ?? 0}</em>
</button>
))}
</div>
<div className="people-filters">
<label className="people-search">
<Search size={16} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search people…"
/>
</label>
<select value={roleFilter} onChange={(event) => setRoleFilter(event.target.value)}>
<option value="all">All roles</option>
<option value="admin">Admins</option>
<option value="user">Users</option>
</select>
</div>
{error && <p className="form-error" role="alert">{error}</p>}
<div className="people-table-wrap">
<table className="people-table">
<thead>
<tr>
<th aria-label="Select" />
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Role</th>
<th>Group</th>
<th>Storage</th>
<th>Last active</th>
<th aria-label="Actions" />
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={9} className="people-table-empty">Loading people…</td>
</tr>
) : filteredMembers.length === 0 ? (
<tr>
<td colSpan={9} className="people-table-empty">
{members.length === 0
? "No team members yet. Add your first user to get started."
: "No people match your filters."}
</td>
</tr>
) : (
filteredMembers.map((member) => (
<tr key={member.id}>
<td><input type="checkbox" aria-label={`Select ${member.first_name}`} /></td>
<td>
<div className="people-name-cell">
<span className="people-avatar">{initials(member.first_name, member.last_name)}</span>
<span>{member.first_name} {member.last_name}</span>
</div>
</td>
<td>{member.email}</td>
<td><StatusBadge status={member.member_status} /></td>
<td>{roleLabel(member)}</td>
<td>{groupLabel(member)}</td>
<td>{member.member_status === "invited" ? "—" : "—"}</td>
<td>{member.member_status === "invited" ? "—" : "—"}</td>
<td>
<button type="button" className="people-row-menu" aria-label="More actions">
<MoreHorizontal size={16} />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{drawerOpen && (
<AddUserPanel
companyDomain={companyDomain}
onClose={() => setDrawerOpen(false)}
onCreated={() => loadMembers()}
/>
)}
</section>
);
}

View file

@ -29,6 +29,9 @@ import { getCurrentAccount, getMyCompanyDomain } from "../api";
import Brand from "./Brand";
import CompanyDnsSetup from "./CompanyDnsSetup";
import CompanyDomains from "./CompanyDomains";
import CompanyAliases from "./CompanyAliases";
import CompanyGroups from "./CompanyGroups";
import CompanyPeople from "./CompanyPeople";
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
import ComposeModal from "./ComposeModal";
import MailboxView from "./MailboxView";
@ -277,82 +280,84 @@ export default function DashboardPage() {
</button>
</div>
{isDomainOwner && (
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
<nav aria-label="Company">
{companyItems.map(([ItemIcon, label]) => (
<div className="dashboard-sidebar-scroll">
{isDomainOwner && (
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
<nav aria-label="Company">
{companyItems.map(([ItemIcon, label]) => (
<button
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectCompanyItem(label)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
</button>
))}
</nav>
</SidebarSection>
)}
<SidebarSection title="Mail" className="dashboard-sidebar-mail">
<nav aria-label="Mail">
{mailItems.map(([ItemIcon, label, countKey]) => (
<button
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
className={`dashboard-nav-item${!companyActive && mailActive === label ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectCompanyItem(label)}
onClick={() => selectMailItem(label)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
<NavBadge value={countKey ? mailStats[countKey] : 0} />
</button>
))}
</nav>
</SidebarSection>
)}
<SidebarSection title="Mail" className="dashboard-sidebar-mail">
<nav aria-label="Mail">
{mailItems.map(([ItemIcon, label, countKey]) => (
<SidebarSection title="Labels" className="dashboard-sidebar-labels">
<nav aria-label="Labels">
{labels.map(([name, tone, countKey]) => (
<button
className={`dashboard-nav-item dashboard-label-item${!companyActive && lens === name ? " active" : ""}`}
key={name}
type="button"
onClick={() => selectLabel(name)}
>
<i className={`label-dot label-dot-${tone}`} />
<span>{name}</span>
<NavBadge variant="neutral" value={mailStats.labels?.[countKey]} />
</button>
))}
<button
className={`dashboard-nav-item${mailActive === label ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectMailItem(label)}
className="dashboard-nav-item dashboard-label-more"
onClick={() => setLabelsExpanded((open) => !open)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
<NavBadge value={countKey ? mailStats[countKey] : 0} />
<ChevronDown size={14} strokeWidth={1.75} className={labelsExpanded ? "open" : ""} />
<span>{labelsExpanded ? "Less" : "More"}</span>
</button>
))}
</nav>
</SidebarSection>
</nav>
</SidebarSection>
<SidebarSection title="Labels" className="dashboard-sidebar-labels">
<nav aria-label="Labels">
{labels.map(([name, tone, countKey]) => (
<button
className={`dashboard-nav-item dashboard-label-item${lens === name ? " active" : ""}`}
key={name}
type="button"
onClick={() => selectLabel(name)}
>
<i className={`label-dot label-dot-${tone}`} />
<span>{name}</span>
<NavBadge variant="neutral" value={mailStats.labels?.[countKey]} />
</button>
))}
<button
type="button"
className="dashboard-nav-item dashboard-label-more"
onClick={() => setLabelsExpanded((open) => !open)}
>
<ChevronDown size={14} strokeWidth={1.75} className={labelsExpanded ? "open" : ""} />
<span>{labelsExpanded ? "Less" : "More"}</span>
</button>
</nav>
</SidebarSection>
<SidebarSection title="Agent" className="dashboard-sidebar-agent">
<nav aria-label="Agent">
{agentItems.map(([ItemIcon, label, actionKey]) => (
<button
className={`dashboard-nav-item dashboard-agent-item${agentNav === actionKey ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectAgentItem(label, actionKey)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
<NavBadge value={actionKey === "actions" ? mailStats.actionItems : 0} />
</button>
))}
</nav>
</SidebarSection>
<SidebarSection title="Agent" className="dashboard-sidebar-agent">
<nav aria-label="Agent">
{agentItems.map(([ItemIcon, label, actionKey]) => (
<button
className={`dashboard-nav-item dashboard-agent-item${!companyActive && agentNav === actionKey ? " active" : ""}`}
key={label}
type="button"
onClick={() => selectAgentItem(label, actionKey)}
>
<ItemIcon size={16} strokeWidth={1.75} />
<span>{label}</span>
<NavBadge value={actionKey === "actions" ? mailStats.actionItems : 0} />
</button>
))}
</nav>
</SidebarSection>
</div>
<div className="dashboard-profile">
<span>{account.first_name[0]}{account.last_name[0]}</span>
@ -362,7 +367,7 @@ export default function DashboardPage() {
</aside>
<main className="dashboard-content">
{companyActive && !["Overview", "Domains"].includes(companyActive) && (
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
<div className="dashboard-content-heading">
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
<span className="dashboard-account-pill">{workspaceLabel}</span>
@ -423,7 +428,16 @@ export default function DashboardPage() {
primaryDomainName={account.company_domain}
/>
)}
{companyActive && !["Overview", "Domains"].includes(companyActive) && (
{companyActive === "People" && isDomainOwner && (
<CompanyPeople account={account} />
)}
{companyActive === "Groups" && isDomainOwner && (
<CompanyGroups account={account} />
)}
{companyActive === "Aliases" && isDomainOwner && (
<CompanyAliases account={account} />
)}
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
<section className="dashboard-empty dashboard-company-view">
<span><Sparkles size={24} /></span>
<h2>{companyActive}</h2>