ui enhancement company
This commit is contained in:
parent
cc3f3f3de5
commit
c01914736b
14 changed files with 876 additions and 159 deletions
158
frontend/src/components/CompanyAdministrators.jsx
Normal file
158
frontend/src/components/CompanyAdministrators.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { Crown, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { listOrgMembers, updateOrgMemberRole } from "../api";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
export default function CompanyAdministrators({ account }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [updatingId, setUpdatingId] = useState(null);
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await listOrgMembers(token);
|
||||
setMembers(data.items);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMembers();
|
||||
}, [loadMembers]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
const query = search.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);
|
||||
});
|
||||
}, [members, search]);
|
||||
|
||||
async function handleRoleChange(member, nextRole) {
|
||||
if (member.is_domain_owner || member.org_role === "owner") return;
|
||||
if (member.id === account.id) return;
|
||||
setUpdatingId(member.id);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateOrgMemberRole(member.id, { org_role: nextRole }, token);
|
||||
setMembers((current) => current.map((item) => (
|
||||
item.id === updated.id ? updated : item
|
||||
)));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="company-people">
|
||||
<header className="company-people-head">
|
||||
<div>
|
||||
<h1>Administrators</h1>
|
||||
<p>Assign roles and control who can manage your organization.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="people-filters">
|
||||
<label className="people-search">
|
||||
<Search size={16} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search users…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
|
||||
<div className="people-table-wrap">
|
||||
<table className="people-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="people-table-empty">Loading administrators…</td>
|
||||
</tr>
|
||||
) : filteredMembers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="people-table-empty">No users match your search.</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredMembers.map((member) => {
|
||||
const isOwner = member.is_domain_owner || member.org_role === "owner";
|
||||
const isSelf = member.id === account.id;
|
||||
const roleValue = isOwner ? "owner" : member.org_role;
|
||||
return (
|
||||
<tr key={member.id}>
|
||||
<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}
|
||||
{isOwner && (
|
||||
<em className="admin-owner-badge">
|
||||
<Crown size={12} /> Owner
|
||||
</em>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{member.email}</td>
|
||||
<td>{member.member_status}</td>
|
||||
<td>
|
||||
{isOwner || isSelf ? (
|
||||
<span className="admin-role-readonly">{roleLabel(member)}</span>
|
||||
) : (
|
||||
<select
|
||||
className="admin-role-select"
|
||||
value={roleValue}
|
||||
disabled={updatingId === member.id}
|
||||
onChange={(event) => handleRoleChange(member, event.target.value)}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">User Admin</option>
|
||||
</select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="admin-role-hint">
|
||||
User Admins can manage people, groups, aliases, and security settings.
|
||||
Only the domain owner can assign administrator roles.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { ArrowRight, Check } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const STEPS = [
|
||||
{ id: 1, title: "Add domain", hint: "Use your company domain for email" },
|
||||
|
|
@ -28,6 +28,8 @@ const COUNTRIES = [
|
|||
"Other",
|
||||
];
|
||||
|
||||
const EMAIL_LOCAL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
|
||||
|
||||
function storageKey(accountId, suffix) {
|
||||
return `agmail_org_${suffix}_${accountId}`;
|
||||
}
|
||||
|
|
@ -38,37 +40,126 @@ function loadStep(accountId) {
|
|||
return 4;
|
||||
}
|
||||
|
||||
function parseEmailLocal(email, domain) {
|
||||
const normalized = String(email || "").toLowerCase();
|
||||
const suffix = `@${domain}`;
|
||||
if (normalized.endsWith(suffix)) {
|
||||
return normalized.slice(0, -suffix.length);
|
||||
}
|
||||
const at = normalized.indexOf("@");
|
||||
return at > 0 ? normalized.slice(0, at) : normalized;
|
||||
}
|
||||
|
||||
function loadDetails(account, companyDomain) {
|
||||
const domain = companyDomain?.domain_name || account.company_domain || "";
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(account.id, "details"));
|
||||
if (raw) return JSON.parse(raw);
|
||||
if (raw) {
|
||||
const saved = JSON.parse(raw);
|
||||
return {
|
||||
companyName: saved.companyName || "",
|
||||
orgSize: saved.orgSize || "51-200 employees",
|
||||
country: saved.country || "India",
|
||||
contactName: saved.contactName || "",
|
||||
emailLocal: saved.emailLocal || parseEmailLocal(saved.workEmail || account.email, domain),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return {
|
||||
companyName: companyDomain?.domain_name?.split(".")[0] || "",
|
||||
companyName: domain?.split(".")[0] || "",
|
||||
orgSize: "51-200 employees",
|
||||
country: "India",
|
||||
contactName: `${account.first_name} ${account.last_name}`.trim(),
|
||||
workEmail: account.email,
|
||||
emailLocal: parseEmailLocal(account.email, domain),
|
||||
};
|
||||
}
|
||||
|
||||
function validateDetails(details) {
|
||||
const errors = {};
|
||||
if (!details.companyName.trim()) {
|
||||
errors.companyName = "Please enter your company name.";
|
||||
}
|
||||
if (!details.contactName.trim()) {
|
||||
errors.contactName = "Please enter your name.";
|
||||
}
|
||||
const local = details.emailLocal.trim();
|
||||
if (!local) {
|
||||
errors.emailLocal = "Please enter your admin email.";
|
||||
} else if (!EMAIL_LOCAL_PATTERN.test(local)) {
|
||||
errors.emailLocal = "Enter a valid email address before @.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function isOrgSetupComplete(accountId) {
|
||||
return localStorage.getItem(storageKey(accountId, "complete")) === "1";
|
||||
}
|
||||
|
||||
function RequiredLabel({ children }) {
|
||||
return (
|
||||
<span className="org-setup-label-text">
|
||||
{children}
|
||||
<span className="org-setup-required" aria-hidden="true">*</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupField({
|
||||
label,
|
||||
error,
|
||||
children,
|
||||
}) {
|
||||
return (
|
||||
<label className={`org-setup-field${error ? " has-error" : ""}`}>
|
||||
{label}
|
||||
{children}
|
||||
{error && <span className="org-setup-field-error" role="alert">{error}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanyOverviewSetup({ account, companyDomain, onComplete }) {
|
||||
const companyDomainName = companyDomain?.domain_name || account.company_domain || "";
|
||||
const [step, setStep] = useState(() => loadStep(account.id));
|
||||
const [details, setDetails] = useState(() => loadDetails(account, companyDomain));
|
||||
const [fieldErrors, setFieldErrors] = useState({});
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(storageKey(account.id, "step"), String(step));
|
||||
}, [account.id, step]);
|
||||
|
||||
const formValid = useMemo(() => Object.keys(validateDetails(details)).length === 0, [details]);
|
||||
|
||||
function updateField(field, value) {
|
||||
setDetails((current) => {
|
||||
const next = { ...current, [field]: value };
|
||||
if (submitAttempted || fieldErrors[field]) {
|
||||
const nextErrors = validateDetails(next);
|
||||
setFieldErrors((errors) => ({
|
||||
...errors,
|
||||
[field]: nextErrors[field],
|
||||
}));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function saveDetails(event) {
|
||||
event.preventDefault();
|
||||
localStorage.setItem(storageKey(account.id, "details"), JSON.stringify(details));
|
||||
setSubmitAttempted(true);
|
||||
const errors = validateDetails(details);
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...details,
|
||||
workEmail: `${details.emailLocal.trim().toLowerCase()}@${companyDomainName}`,
|
||||
};
|
||||
localStorage.setItem(storageKey(account.id, "details"), JSON.stringify(payload));
|
||||
setStep(5);
|
||||
}
|
||||
|
||||
|
|
@ -87,17 +178,21 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
|
||||
<div className="org-setup-layout">
|
||||
<ol className="org-setup-steps" aria-label="Setup progress">
|
||||
{STEPS.map((item) => {
|
||||
{STEPS.map((item, index) => {
|
||||
const complete = item.id < step || (item.id <= 3);
|
||||
const active = item.id === step;
|
||||
const isLast = index === STEPS.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`org-setup-step${complete ? " complete" : ""}${active ? " active" : ""}`}
|
||||
>
|
||||
<span className="org-setup-step-mark" aria-hidden="true">
|
||||
{complete ? <Check size={14} strokeWidth={2.5} /> : item.id}
|
||||
</span>
|
||||
<div className="org-setup-step-indicator">
|
||||
<span className="org-setup-step-mark" aria-hidden="true">
|
||||
{complete ? <Check size={14} strokeWidth={2.5} /> : item.id}
|
||||
</span>
|
||||
{!isLast && <span className="org-setup-step-connector" aria-hidden="true" />}
|
||||
</div>
|
||||
<span className="org-setup-step-copy">
|
||||
<strong>{item.title}</strong>
|
||||
<small>{item.hint}</small>
|
||||
|
|
@ -110,105 +205,118 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
<div className="org-setup-panel">
|
||||
{step === 4 && (
|
||||
<>
|
||||
<h2>Company details</h2>
|
||||
<p className="org-setup-section-label">Organization information</p>
|
||||
<form className="org-setup-form" onSubmit={saveDetails}>
|
||||
<label>
|
||||
Company name <span aria-hidden="true">*</span>
|
||||
<input
|
||||
required
|
||||
value={details.companyName}
|
||||
onChange={(event) => setDetails((current) => ({
|
||||
...current,
|
||||
companyName: event.target.value,
|
||||
}))}
|
||||
placeholder="Acme Pvt Ltd"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Organization size <span aria-hidden="true">*</span>
|
||||
<select
|
||||
required
|
||||
value={details.orgSize}
|
||||
onChange={(event) => setDetails((current) => ({
|
||||
...current,
|
||||
orgSize: event.target.value,
|
||||
}))}
|
||||
<div className="org-setup-panel-intro">
|
||||
<h2>Company details</h2>
|
||||
<p className="org-setup-section-label">Tell us a little about your organization.</p>
|
||||
</div>
|
||||
<form className="org-setup-form" onSubmit={saveDetails} noValidate>
|
||||
<div className="org-setup-form-shell">
|
||||
<SetupField
|
||||
label={<RequiredLabel>Company name</RequiredLabel>}
|
||||
error={fieldErrors.companyName}
|
||||
>
|
||||
{ORG_SIZES.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Country <span aria-hidden="true">*</span>
|
||||
<select
|
||||
required
|
||||
value={details.country}
|
||||
onChange={(event) => setDetails((current) => ({
|
||||
...current,
|
||||
country: event.target.value,
|
||||
}))}
|
||||
<input
|
||||
value={details.companyName}
|
||||
onChange={(event) => updateField("companyName", event.target.value)}
|
||||
placeholder="Acme Pvt Ltd"
|
||||
aria-invalid={Boolean(fieldErrors.companyName)}
|
||||
/>
|
||||
</SetupField>
|
||||
|
||||
<div className="org-setup-form-row">
|
||||
<SetupField
|
||||
label={<RequiredLabel>Organization size</RequiredLabel>}
|
||||
error={fieldErrors.orgSize}
|
||||
>
|
||||
<select
|
||||
value={details.orgSize}
|
||||
onChange={(event) => updateField("orgSize", event.target.value)}
|
||||
>
|
||||
{ORG_SIZES.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</SetupField>
|
||||
<SetupField
|
||||
label={<RequiredLabel>Country</RequiredLabel>}
|
||||
error={fieldErrors.country}
|
||||
>
|
||||
<select
|
||||
value={details.country}
|
||||
onChange={(event) => updateField("country", event.target.value)}
|
||||
>
|
||||
{COUNTRIES.map((country) => (
|
||||
<option key={country} value={country}>{country}</option>
|
||||
))}
|
||||
</select>
|
||||
</SetupField>
|
||||
</div>
|
||||
|
||||
<SetupField
|
||||
label={<RequiredLabel>Your name</RequiredLabel>}
|
||||
error={fieldErrors.contactName}
|
||||
>
|
||||
{COUNTRIES.map((country) => (
|
||||
<option key={country} value={country}>{country}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Your name <span aria-hidden="true">*</span>
|
||||
<input
|
||||
required
|
||||
value={details.contactName}
|
||||
onChange={(event) => setDetails((current) => ({
|
||||
...current,
|
||||
contactName: event.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Your work email <span aria-hidden="true">*</span>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
value={details.workEmail}
|
||||
onChange={(event) => setDetails((current) => ({
|
||||
...current,
|
||||
workEmail: event.target.value,
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<div className="org-setup-actions">
|
||||
<button type="submit" className="button button-purple">
|
||||
Continue <ArrowRight size={16} />
|
||||
</button>
|
||||
<input
|
||||
value={details.contactName}
|
||||
onChange={(event) => updateField("contactName", event.target.value)}
|
||||
aria-invalid={Boolean(fieldErrors.contactName)}
|
||||
/>
|
||||
</SetupField>
|
||||
|
||||
<SetupField
|
||||
label={<RequiredLabel>Your admin email</RequiredLabel>}
|
||||
error={fieldErrors.emailLocal}
|
||||
>
|
||||
<div className="org-setup-email-field">
|
||||
<input
|
||||
value={details.emailLocal}
|
||||
onChange={(event) => updateField("emailLocal", event.target.value)}
|
||||
placeholder="suyash"
|
||||
aria-invalid={Boolean(fieldErrors.emailLocal)}
|
||||
/>
|
||||
<span>@{companyDomainName}</span>
|
||||
</div>
|
||||
<small className="org-setup-field-hint">
|
||||
This becomes your login address on your company domain.
|
||||
</small>
|
||||
</SetupField>
|
||||
|
||||
<div className="org-setup-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="button button-purple org-setup-submit"
|
||||
disabled={!formValid}
|
||||
>
|
||||
Continue <ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<>
|
||||
<div className="org-setup-form-shell">
|
||||
<h2>Add employees</h2>
|
||||
<p className="org-setup-lead">
|
||||
Invite teammates to <strong>{companyDomain?.domain_name}</strong>. You can add people now or skip and do this later from People.
|
||||
Invite teammates to <strong>{companyDomainName}</strong>. You can add people now or skip and do this later from People.
|
||||
</p>
|
||||
<div className="org-setup-placeholder">
|
||||
Employee invites will be available here soon.
|
||||
</div>
|
||||
<div className="org-setup-actions">
|
||||
<button type="button" className="button button-purple" onClick={() => setStep(6)}>
|
||||
<button type="button" className="button button-purple org-setup-submit" onClick={() => setStep(6)}>
|
||||
Continue <ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<>
|
||||
<div className="org-setup-form-shell">
|
||||
<h2>Finish setup</h2>
|
||||
<p className="org-setup-lead">
|
||||
Domain <strong>{companyDomain?.domain_name}</strong> is connected. You're ready to use AGMail.
|
||||
Domain <strong>{companyDomainName}</strong> is connected. You're ready to use AGMail.
|
||||
</p>
|
||||
<ul className="org-setup-summary">
|
||||
<li><Check size={14} /> Domain added and verified</li>
|
||||
|
|
@ -216,11 +324,11 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
|
|||
<li><Check size={14} /> Organization details saved</li>
|
||||
</ul>
|
||||
<div className="org-setup-actions">
|
||||
<button type="button" className="button button-purple" onClick={finishSetup}>
|
||||
<button type="button" className="button button-purple org-setup-submit" onClick={finishSetup}>
|
||||
Go to inbox <ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
151
frontend/src/components/CompanySecurity.jsx
Normal file
151
frontend/src/components/CompanySecurity.jsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getOrgSecurity, updateOrgSecurity } from "../api";
|
||||
|
||||
const SESSION_TIMEOUTS = [
|
||||
{ value: 15, label: "15 minutes" },
|
||||
{ value: 30, label: "30 minutes" },
|
||||
{ value: 60, label: "1 hour" },
|
||||
{ value: 120, label: "2 hours" },
|
||||
{ value: 480, label: "8 hours" },
|
||||
{ value: 1440, label: "24 hours" },
|
||||
];
|
||||
|
||||
function ToggleRow({ title, description, checked, disabled, onChange }) {
|
||||
return (
|
||||
<div className="security-setting-row">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<label className="security-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span aria-hidden="true" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanySecurity() {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await getOrgSecurity(token);
|
||||
setSettings(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
async function savePatch(patch) {
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const data = await updateOrgSecurity(patch, token);
|
||||
setSettings(data);
|
||||
setNotice("Security settings saved.");
|
||||
window.setTimeout(() => setNotice(""), 2500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateToggle(field, value) {
|
||||
setSettings((current) => ({ ...current, [field]: value }));
|
||||
savePatch({ [field]: value });
|
||||
}
|
||||
|
||||
function updateTimeout(value) {
|
||||
const minutes = Number(value);
|
||||
setSettings((current) => ({ ...current, session_timeout_minutes: minutes }));
|
||||
savePatch({ session_timeout_minutes: minutes });
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="company-people company-security">
|
||||
<header className="company-people-head">
|
||||
<div>
|
||||
<h1>Security</h1>
|
||||
<p>Protect your organization and manage access.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
{notice && <p className="security-notice" role="status">{notice}</p>}
|
||||
|
||||
{loading || !settings ? (
|
||||
<p className="people-table-empty">Loading security settings…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="security-panel">
|
||||
<h2>Authentication</h2>
|
||||
<ToggleRow
|
||||
title="Require strong passwords"
|
||||
description="Minimum 8 characters with numbers and symbols."
|
||||
checked={settings.require_strong_passwords}
|
||||
disabled={saving}
|
||||
onChange={(value) => updateToggle("require_strong_passwords", value)}
|
||||
/>
|
||||
<ToggleRow
|
||||
title="Require MFA for administrators"
|
||||
description="Recommended for better security."
|
||||
checked={settings.require_mfa_admins}
|
||||
disabled={saving}
|
||||
onChange={(value) => updateToggle("require_mfa_admins", value)}
|
||||
/>
|
||||
<ToggleRow
|
||||
title="Require MFA for all users"
|
||||
description="Users will be prompted to set up MFA on first login."
|
||||
checked={settings.require_mfa_all_users}
|
||||
disabled={saving}
|
||||
onChange={(value) => updateToggle("require_mfa_all_users", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="security-panel">
|
||||
<h2>Session timeout</h2>
|
||||
<div className="security-setting-row">
|
||||
<div>
|
||||
<strong>Automatically sign out inactive users</strong>
|
||||
<p>Users are signed out after a period of inactivity.</p>
|
||||
</div>
|
||||
<select
|
||||
className="security-timeout-select"
|
||||
value={settings.session_timeout_minutes}
|
||||
disabled={saving}
|
||||
onChange={(event) => updateTimeout(event.target.value)}
|
||||
>
|
||||
{SESSION_TIMEOUTS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,9 +29,11 @@ import { getCurrentAccount, getMyCompanyDomain } from "../api";
|
|||
import Brand from "./Brand";
|
||||
import CompanyDnsSetup from "./CompanyDnsSetup";
|
||||
import CompanyDomains from "./CompanyDomains";
|
||||
import CompanyAdministrators from "./CompanyAdministrators";
|
||||
import CompanyAliases from "./CompanyAliases";
|
||||
import CompanyGroups from "./CompanyGroups";
|
||||
import CompanyPeople from "./CompanyPeople";
|
||||
import CompanySecurity from "./CompanySecurity";
|
||||
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||
import ComposeModal from "./ComposeModal";
|
||||
import MailboxView from "./MailboxView";
|
||||
|
|
@ -62,12 +64,15 @@ const agentItems = [
|
|||
[Plug, "Integrations", "integrations"],
|
||||
];
|
||||
|
||||
const companyItems = [
|
||||
const companyNavItems = [
|
||||
[Home, "Overview"],
|
||||
[Users, "People"],
|
||||
[UsersRound, "Groups"],
|
||||
[Globe, "Domains"],
|
||||
[CircleEllipsis, "Aliases"],
|
||||
];
|
||||
|
||||
const manageNavItems = [
|
||||
[Shield, "Security"],
|
||||
[Crown, "Administrators"],
|
||||
[CircleDollarSign, "Billing"],
|
||||
|
|
@ -244,6 +249,7 @@ export default function DashboardPage() {
|
|||
? companyDomain.domain_name
|
||||
: "Personal workspace";
|
||||
const isDomainOwner = account.account_type === "company" && account.is_domain_owner;
|
||||
const isOrgOnboarding = isDomainOwner && !isOrgSetupComplete(account.id);
|
||||
const mailActive = lens === "starred"
|
||||
? "Starred"
|
||||
: active === "Outbox"
|
||||
|
|
@ -268,54 +274,76 @@ export default function DashboardPage() {
|
|||
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{!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>
|
||||
)}
|
||||
|
||||
<div className="dashboard-sidebar-scroll">
|
||||
{isDomainOwner && (
|
||||
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
|
||||
<nav aria-label="Company">
|
||||
{companyItems.map(([ItemIcon, label]) => (
|
||||
<>
|
||||
<SidebarSection title="Company" className="dashboard-sidebar-company dashboard-sidebar-first">
|
||||
<nav aria-label="Company">
|
||||
{companyNavItems.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="Manage" className="dashboard-sidebar-manage">
|
||||
<nav aria-label="Manage">
|
||||
{manageNavItems.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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isOrgOnboarding && (
|
||||
<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]) => (
|
||||
<button
|
||||
className={`dashboard-nav-item${!companyActive && mailActive === label ? " active" : ""}`}
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => selectMailItem(label)}
|
||||
>
|
||||
<ItemIcon size={16} strokeWidth={1.75} />
|
||||
<span>{label}</span>
|
||||
<NavBadge value={countKey ? mailStats[countKey] : 0} />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</SidebarSection>
|
||||
|
||||
{!isOrgOnboarding && (
|
||||
<SidebarSection title="Labels" className="dashboard-sidebar-labels">
|
||||
<nav aria-label="Labels">
|
||||
{labels.map(([name, tone, countKey]) => (
|
||||
|
|
@ -340,7 +368,9 @@ export default function DashboardPage() {
|
|||
</button>
|
||||
</nav>
|
||||
</SidebarSection>
|
||||
)}
|
||||
|
||||
{!isOrgOnboarding && (
|
||||
<SidebarSection title="Agent" className="dashboard-sidebar-agent">
|
||||
<nav aria-label="Agent">
|
||||
{agentItems.map(([ItemIcon, label, actionKey]) => (
|
||||
|
|
@ -357,6 +387,7 @@ export default function DashboardPage() {
|
|||
))}
|
||||
</nav>
|
||||
</SidebarSection>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-profile">
|
||||
|
|
@ -367,7 +398,7 @@ export default function DashboardPage() {
|
|||
</aside>
|
||||
|
||||
<main className="dashboard-content">
|
||||
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
|
||||
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases", "Security", "Administrators"].includes(companyActive) && (
|
||||
<div className="dashboard-content-heading">
|
||||
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
|
||||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||
|
|
@ -437,7 +468,13 @@ export default function DashboardPage() {
|
|||
{companyActive === "Aliases" && isDomainOwner && (
|
||||
<CompanyAliases account={account} />
|
||||
)}
|
||||
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
|
||||
{companyActive === "Security" && isDomainOwner && (
|
||||
<CompanySecurity />
|
||||
)}
|
||||
{companyActive === "Administrators" && isDomainOwner && (
|
||||
<CompanyAdministrators account={account} />
|
||||
)}
|
||||
{companyActive && !["Overview", "Domains", "People", "Groups", "Aliases", "Security", "Administrators"].includes(companyActive) && (
|
||||
<section className="dashboard-empty dashboard-company-view">
|
||||
<span><Sparkles size={24} /></span>
|
||||
<h2>{companyActive}</h2>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue