company flows
This commit is contained in:
parent
6541284a4c
commit
cc3f3f3de5
17 changed files with 1960 additions and 66 deletions
404
frontend/src/components/CompanyPeople.jsx
Normal file
404
frontend/src/components/CompanyPeople.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue