ui enhancement company

This commit is contained in:
debu-2 2026-10-04 20:32:18 +05:30
parent cc3f3f3de5
commit c01914736b
14 changed files with 876 additions and 159 deletions

View file

@ -0,0 +1,24 @@
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.models import Account
from app.routers.auth import get_current_account
from app.services.domain_provisioning import account_is_domain_owner
async def require_domain_owner(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if account.account_type != "company" or not account.company_domain:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Company accounts only.",
)
if not await account_is_domain_owner(session, account.id, account.company_domain):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the domain owner can manage administrators.",
)
return account

View file

@ -14,6 +14,7 @@ from app.routers import (
domains,
emails,
groups,
org,
people,
sticky_notes,
)
@ -160,6 +161,7 @@ app.include_router(domains.router, prefix=settings.api_prefix)
app.include_router(people.router, prefix=settings.api_prefix)
app.include_router(groups.router, prefix=settings.api_prefix)
app.include_router(aliases.router, prefix=settings.api_prefix)
app.include_router(org.router, prefix=settings.api_prefix)
app.include_router(dns_setup.router, prefix=settings.api_prefix)

View file

@ -136,6 +136,26 @@ class SmtpCredential(Base):
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
class OrgSecuritySettings(Base):
__tablename__ = "org_security_settings"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
company_domain: Mapped[str] = mapped_column(String(253), unique=True, index=True)
require_strong_passwords: Mapped[bool] = mapped_column(Boolean, default=True)
require_mfa_admins: Mapped[bool] = mapped_column(Boolean, default=True)
require_mfa_all_users: Mapped[bool] = mapped_column(Boolean, default=False)
session_timeout_minutes: Mapped[int] = mapped_column(Integer, default=60)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)
class MailGroup(Base):
__tablename__ = "mail_groups"
__table_args__ = (

View file

@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.org_manager import require_org_manager
from app.models import Account
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
from app.services.org_security import read_security_settings, update_security_settings
router = APIRouter(prefix="/org", tags=["organization"])
@router.get("/security", response_model=OrgSecuritySettingsRead)
async def get_org_security(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgSecuritySettingsRead:
return await read_security_settings(session, manager.company_domain)
@router.patch("/security", response_model=OrgSecuritySettingsRead)
async def patch_org_security(
payload: OrgSecuritySettingsUpdate,
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgSecuritySettingsRead:
try:
settings = await update_security_settings(
session,
manager.company_domain,
payload,
)
await session.commit()
return settings
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error

View file

@ -1,32 +1,29 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_postgres_session
from app.deps.domain_owner import require_domain_owner
from app.models import Account
from app.routers.auth import get_current_account, validate_password
from app.schemas import OrgMemberCreate, OrgMemberListResponse, OrgMemberRead
from app.routers.auth import validate_password
from app.schemas import (
OrgMemberCreate,
OrgMemberListResponse,
OrgMemberRead,
OrgMemberRoleUpdate,
)
from app.services.org_members import (
can_manage_org_members,
create_org_member,
list_org_members,
update_org_member_role,
)
from app.deps.org_manager import require_org_manager
router = APIRouter(prefix="/people", tags=["people"])
async def require_org_manager(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
if not await can_manage_org_members(session, account):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only company admins can manage people.",
)
return account
@router.get("", response_model=OrgMemberListResponse)
async def get_people(
manager: Account = Depends(require_org_manager),
@ -64,3 +61,26 @@ async def add_person(
status_code=status.HTTP_409_CONFLICT,
detail="An account with those details already exists.",
) from error
@router.patch("/{member_id}/role", response_model=OrgMemberRead)
async def update_member_role(
member_id: UUID,
payload: OrgMemberRoleUpdate,
owner: Account = Depends(require_domain_owner),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberRead:
try:
member = await update_org_member_role(
session,
manager=owner,
member_id=member_id,
org_role=payload.org_role,
)
await session.commit()
return member
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error

View file

@ -126,6 +126,26 @@ class MailAliasListResponse(BaseModel):
counts: dict[str, int]
class OrgSecuritySettingsRead(BaseModel):
require_strong_passwords: bool
require_mfa_admins: bool
require_mfa_all_users: bool
session_timeout_minutes: int
model_config = ConfigDict(from_attributes=True)
class OrgSecuritySettingsUpdate(BaseModel):
require_strong_passwords: bool | None = None
require_mfa_admins: bool | None = None
require_mfa_all_users: bool | None = None
session_timeout_minutes: int | None = Field(default=None, ge=15, le=1440)
class OrgMemberRoleUpdate(BaseModel):
org_role: Literal["user", "admin"]
class EmailCreate(BaseModel):
recipients: list[EmailStr] = Field(min_length=1)
cc: list[EmailStr] = Field(default_factory=list)

View file

@ -141,3 +141,41 @@ async def create_org_member(
session.add(member)
await session.flush()
return await member_read(session, member)
async def update_org_member_role(
session: AsyncSession,
*,
manager: Account,
member_id,
org_role: str,
) -> OrgMemberRead:
company_domain = manager.company_domain
if company_domain is None:
raise ValueError("Company domain is required.")
if not await account_is_domain_owner(
session,
manager.id,
company_domain,
):
raise ValueError("Only the domain owner can assign roles.")
member = await session.scalar(
select(Account).where(
Account.id == member_id,
Account.company_domain == company_domain,
)
)
if member is None:
raise ValueError("User not found in your organization.")
if await account_is_domain_owner(session, member.id, company_domain):
raise ValueError("The domain owner role cannot be changed.")
if member.id == manager.id:
raise ValueError("You cannot change your own role.")
member.org_role = org_role
await session.flush()
return await member_read(session, member)

View file

@ -0,0 +1,55 @@
"""Organization security settings."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import OrgSecuritySettings
from app.schemas import OrgSecuritySettingsRead, OrgSecuritySettingsUpdate
ALLOWED_SESSION_TIMEOUTS = {15, 30, 60, 120, 480, 1440}
async def get_or_create_security_settings(
session: AsyncSession,
company_domain: str,
) -> OrgSecuritySettings:
settings = await session.scalar(
select(OrgSecuritySettings).where(
OrgSecuritySettings.company_domain == company_domain
)
)
if settings is None:
settings = OrgSecuritySettings(company_domain=company_domain)
session.add(settings)
await session.flush()
return settings
async def read_security_settings(
session: AsyncSession,
company_domain: str,
) -> OrgSecuritySettingsRead:
settings = await get_or_create_security_settings(session, company_domain)
return OrgSecuritySettingsRead.model_validate(settings)
async def update_security_settings(
session: AsyncSession,
company_domain: str,
payload: OrgSecuritySettingsUpdate,
) -> OrgSecuritySettingsRead:
settings = await get_or_create_security_settings(session, company_domain)
updates = payload.model_dump(exclude_unset=True)
if "session_timeout_minutes" in updates:
timeout = updates["session_timeout_minutes"]
if timeout not in ALLOWED_SESSION_TIMEOUTS:
raise ValueError(
"Session timeout must be one of: "
+ ", ".join(str(value) for value in sorted(ALLOWED_SESSION_TIMEOUTS))
)
for field, value in updates.items():
setattr(settings, field, value)
await session.flush()
return OrgSecuritySettingsRead.model_validate(settings)

View file

@ -60,6 +60,12 @@ export const listMailAliases = (token) =>
request("/aliases", { token });
export const createMailAlias = (payload, token) =>
request("/aliases", { method: "POST", payload, token });
export const getOrgSecurity = (token) =>
request("/org/security", { token });
export const updateOrgSecurity = (payload, token) =>
request("/org/security", { method: "PATCH", payload, token });
export const updateOrgMemberRole = (memberId, payload, token) =>
request(`/people/${memberId}/role`, { method: "PATCH", payload, token });
export const sendEmail = (payload, token) =>
request("/emails", { method: "POST", payload, token });

View 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>
);
}

View file

@ -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" : ""}`}
>
<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 && (
<>
<div className="org-setup-panel-intro">
<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>
<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}
>
<input
required
value={details.companyName}
onChange={(event) => setDetails((current) => ({
...current,
companyName: event.target.value,
}))}
onChange={(event) => updateField("companyName", event.target.value)}
placeholder="Acme Pvt Ltd"
aria-invalid={Boolean(fieldErrors.companyName)}
/>
</label>
<label>
Organization size <span aria-hidden="true">*</span>
</SetupField>
<div className="org-setup-form-row">
<SetupField
label={<RequiredLabel>Organization size</RequiredLabel>}
error={fieldErrors.orgSize}
>
<select
required
value={details.orgSize}
onChange={(event) => setDetails((current) => ({
...current,
orgSize: event.target.value,
}))}
onChange={(event) => updateField("orgSize", event.target.value)}
>
{ORG_SIZES.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</label>
<label>
Country <span aria-hidden="true">*</span>
</SetupField>
<SetupField
label={<RequiredLabel>Country</RequiredLabel>}
error={fieldErrors.country}
>
<select
required
value={details.country}
onChange={(event) => setDetails((current) => ({
...current,
country: event.target.value,
}))}
onChange={(event) => updateField("country", event.target.value)}
>
{COUNTRIES.map((country) => (
<option key={country} value={country}>{country}</option>
))}
</select>
</label>
<label>
Your name <span aria-hidden="true">*</span>
</SetupField>
</div>
<SetupField
label={<RequiredLabel>Your name</RequiredLabel>}
error={fieldErrors.contactName}
>
<input
required
value={details.contactName}
onChange={(event) => setDetails((current) => ({
...current,
contactName: event.target.value,
}))}
onChange={(event) => updateField("contactName", event.target.value)}
aria-invalid={Boolean(fieldErrors.contactName)}
/>
</label>
<label>
Your work email <span aria-hidden="true">*</span>
</SetupField>
<SetupField
label={<RequiredLabel>Your admin email</RequiredLabel>}
error={fieldErrors.emailLocal}
>
<div className="org-setup-email-field">
<input
required
type="email"
value={details.workEmail}
onChange={(event) => setDetails((current) => ({
...current,
workEmail: event.target.value,
}))}
value={details.emailLocal}
onChange={(event) => updateField("emailLocal", event.target.value)}
placeholder="suyash"
aria-invalid={Boolean(fieldErrors.emailLocal)}
/>
</label>
<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">
<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&apos;re ready to use AGMail.
Domain <strong>{companyDomainName}</strong> is connected. You&apos;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>

View 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>
);
}

View file

@ -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,6 +274,7 @@ export default function DashboardPage() {
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
</div>
{!isOrgOnboarding && (
<div className="dashboard-compose-wrap">
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
<span className="dashboard-compose-main">
@ -279,12 +286,14 @@ export default function DashboardPage() {
<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]) => (
{companyNavItems.map(([ItemIcon, label]) => (
<button
className={`dashboard-nav-item dashboard-company-item${companyActive === label ? " active" : ""}`}
key={label}
@ -297,8 +306,25 @@ export default function DashboardPage() {
))}
</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]) => (
@ -315,7 +341,9 @@ export default function DashboardPage() {
))}
</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>

View file

@ -179,37 +179,58 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.button-purple { min-height:44px; padding:0 20px; display:inline-flex; align-items:center; gap:8px; border:0; border-radius:10px; background:#7357F6; color:#fff; font-size:13px; font-weight:600; cursor:pointer; box-shadow:0 8px 20px rgba(115,87,246,0.28); }
.button-purple:hover { filter:brightness(1.03); }
.org-setup { padding:8px 0 24px; }
.org-setup-head { margin-bottom:28px; }
.org-setup-head h1 { margin:0 0 8px; font:700 clamp(28px,3vw,36px)/1.15 "DM Sans"; letter-spacing:-.6px; color:#1a1628; }
.org-setup-head p { margin:0; color:#77758A; font-size:14px; }
.org-setup-layout { display:grid; grid-template-columns:minmax(220px,280px) minmax(0,1fr); gap:28px; align-items:start; }
.org-setup-head { margin-bottom:24px; }
.org-setup-head h1 { margin:0 0 8px; font:700 31px/1.15 "DM Sans"; letter-spacing:-.5px; color:#1a1628; }
.org-setup-head p { margin:0; color:#77758A; font-size:14px; line-height:1.5; }
.org-setup-layout { display:grid; grid-template-columns:minmax(260px,280px) minmax(0,1fr); gap:32px; align-items:start; }
.org-setup-steps { margin:0; padding:0; list-style:none; display:grid; gap:0; }
.org-setup-step { padding:12px 14px; display:grid; grid-template-columns:32px 1fr; gap:12px; align-items:start; border-radius:12px; color:#6f6c7b; }
.org-setup-step-mark { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; border:1px solid #E4E1E8; background:#fff; font-size:13px; font-weight:600; color:#6f6c7b; }
.org-setup-step-copy { display:grid; gap:2px; }
.org-setup-step-copy strong { font-size:13px; font-weight:600; color:#3d3948; }
.org-setup-step-copy small { font-size:11px; color:#8d8792; line-height:1.35; }
.org-setup-step.complete .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; }
.org-setup-step.complete .org-setup-step-copy strong { color:#5a4fcf; }
.org-setup-step { padding:10px 12px 10px 8px; display:grid; grid-template-columns:32px 1fr; gap:12px; align-items:stretch; border-radius:12px; color:#8d8792; }
.org-setup-step-indicator { display:flex; flex-direction:column; align-items:center; min-height:100%; }
.org-setup-step-mark { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; border:1px solid #D9D5E0; background:#fff; font-size:12px; font-weight:600; color:#9b96a3; flex-shrink:0; }
.org-setup-step-connector { width:1px; flex:1; min-height:18px; margin:6px 0; background:#E8E4F0; }
.org-setup-step-copy { display:grid; gap:3px; padding-top:4px; }
.org-setup-step-copy strong { font-size:13px; font-weight:600; color:#8d8792; line-height:1.3; }
.org-setup-step-copy small { font-size:11px; font-weight:400; color:#a8a3ae; line-height:1.35; }
.org-setup-step.complete .org-setup-step-mark { border-color:#B9A8F8; background:#9B84F2; color:#fff; }
.org-setup-step.complete .org-setup-step-connector { background:#D8CCFF; }
.org-setup-step.complete .org-setup-step-copy strong { color:#8d8792; font-weight:500; }
.org-setup-step.complete .org-setup-step-copy small { color:#a8a3ae; }
.org-setup-step.active { background:#F6F2FF; }
.org-setup-step.active .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; }
.org-setup-step.active .org-setup-step-copy strong { color:#5a4fcf; }
.org-setup-panel { padding:28px 32px; border:1px solid #ECE9F2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); }
.org-setup-panel h2 { margin:0 0 18px; font:700 22px/1.2 "DM Sans"; color:#1a1628; }
.org-setup-section-label { margin:0 0 16px; font-size:12px; font-weight:600; color:#8d8792; }
.org-setup-step.active .org-setup-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; box-shadow:0 0 0 4px rgba(115,87,246,0.10); }
.org-setup-step.active .org-setup-step-copy strong { color:#1a1628; font-weight:600; }
.org-setup-step.active .org-setup-step-copy small { color:#6f6c7b; }
.org-setup-step:not(.complete):not(.active) .org-setup-step-mark { border-color:#E4E1E8; background:transparent; color:#b4afba; }
.org-setup-step:not(.complete):not(.active) .org-setup-step-copy strong { color:#9b96a3; font-weight:500; }
.org-setup-panel { padding:28px 32px; border:1px solid rgba(20,20,30,0.055); border-radius:16px; background:#fff; box-shadow:0 1px 2px rgba(20,18,30,0.03),0 10px 30px rgba(20,18,30,0.04); }
.org-setup-panel-intro { margin-bottom:24px; }
.org-setup-panel h2 { margin:0 0 6px; font:700 19px/1.25 "DM Sans"; color:#1a1628; }
.org-setup-section-label { margin:0; font-size:11px; font-weight:400; color:#8d8792; line-height:1.45; }
.org-setup-form-shell { max-width:720px; display:grid; gap:16px; }
.org-setup-lead { margin:0 0 20px; color:#6f6c7b; font-size:14px; line-height:1.5; }
.org-setup-form { display:grid; gap:14px; }
.org-setup-form label { display:grid; gap:6px; font-size:12px; font-weight:600; color:#4a4652; }
.org-setup-form label span { color:#e15b45; }
.org-setup-form input,.org-setup-form select { height:42px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#1a1628; }
.org-setup-form input:focus,.org-setup-form select:focus { outline:none; border-color:#C8B8FF; box-shadow:0 0 0 3px rgba(115,87,246,0.12); }
.org-setup-form { margin:0; }
.org-setup-field { display:grid; gap:6px; }
.org-setup-label-text { display:inline-flex; align-items:center; gap:3px; font-size:12px; font-weight:600; color:#4a4652; line-height:1.2; white-space:nowrap; }
.org-setup-required { color:#e15b45; font-weight:700; }
.org-setup-form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.org-setup-form input,.org-setup-form select { width:100%; height:44px; padding:0 14px; border:1px solid #E2E0E8; border-radius:10px; background:#fff; font-size:13px; color:#1a1628; transition:border-color .15s,box-shadow .15s; }
.org-setup-form input:focus,.org-setup-form select:focus { outline:none; border-color:#7357F6; box-shadow:0 0 0 3px rgba(115,87,246,0.10); }
.org-setup-field.has-error input,.org-setup-field.has-error select,.org-setup-field.has-error .org-setup-email-field input { border-color:#e15b45; box-shadow:none; }
.org-setup-field-error { color:#c44b38; font-size:11px; font-weight:400; line-height:1.35; }
.org-setup-field-hint { margin-top:-2px; color:#8d8792; font-size:11px; font-weight:400; line-height:1.35; }
.org-setup-email-field { display:grid; grid-template-columns:1fr auto; gap:10px; align-items:center; }
.org-setup-email-field span { color:#8d8792; font-size:13px; font-weight:600; white-space:nowrap; }
.org-setup-actions { margin-top:8px; display:flex; justify-content:flex-end; }
.org-setup-submit { min-height:42px; padding:0 20px; }
.org-setup-submit:disabled { opacity:.48; cursor:not-allowed; box-shadow:none; }
.org-setup-placeholder { margin-bottom:20px; padding:24px; border:1px dashed #E4E1E8; border-radius:12px; background:#FAFAFC; color:#8d8792; font-size:13px; text-align:center; }
.org-setup-summary { margin:0 0 24px; padding:0; list-style:none; display:grid; gap:10px; }
.org-setup-summary li { display:flex; align-items:center; gap:8px; color:#3d3948; font-size:13px; font-weight:500; }
.org-setup-summary svg { color:#7357F6; flex-shrink:0; }
.dashboard-sidebar-manage { margin-top:var(--space-4); }
@media(max-width:900px) {
.org-setup-head h1 { font-size:28px; }
.org-setup-layout { grid-template-columns:1fr; }
.org-setup-form-row { grid-template-columns:1fr; }
}
.company-domains,.company-domains-add,.company-domain-detail { padding:8px 0 24px; }
@ -348,6 +369,24 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.org-member-picker-empty { margin:0; color:#8d8792; font-size:12px; }
.org-member-picker-row { display:grid; grid-template-columns:auto 1fr; gap:4px 10px; align-items:center; font-size:13px; color:#3d3948; cursor:pointer; }
.org-member-picker-row small { grid-column:2; color:#8d8792; font-size:11px; }
.company-security .security-panel { margin-bottom:16px; padding:24px 28px; border:1px solid #ECE9F2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); }
.company-security .security-panel h2 { margin:0 0 18px; font:700 18px/1.2 "DM Sans"; color:#1a1628; }
.security-setting-row { padding:14px 0; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; border-top:1px solid #F0EEF5; }
.security-setting-row:first-of-type { border-top:0; padding-top:0; }
.security-setting-row strong { display:block; margin-bottom:4px; font-size:14px; color:#1a1628; }
.security-setting-row p { margin:0; color:#8d8792; font-size:12px; line-height:1.45; }
.security-toggle { position:relative; width:44px; height:24px; flex-shrink:0; }
.security-toggle input { position:absolute; opacity:0; width:0; height:0; }
.security-toggle span { position:absolute; inset:0; border-radius:999px; background:#D9D5E0; transition:background .2s; cursor:pointer; }
.security-toggle span::after { content:""; position:absolute; top:3px; left:3px; width:18px; height:18px; border-radius:50%; background:#fff; box-shadow:0 1px 3px rgba(20,18,30,0.18); transition:transform .2s; }
.security-toggle input:checked + span { background:#7357F6; }
.security-toggle input:checked + span::after { transform:translateX(20px); }
.security-toggle input:disabled + span { opacity:.55; cursor:not-allowed; }
.security-timeout-select,.admin-role-select { min-width:140px; height:40px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#4a4652; }
.security-notice { margin:0 0 12px; color:#2f6b43; font-size:13px; font-weight:600; }
.admin-owner-badge { margin-left:8px; display:inline-flex; align-items:center; gap:4px; padding:2px 8px; border-radius:999px; background:#FFF4E5; color:#c4842d; font-size:10px; font-weight:700; font-style:normal; text-transform:uppercase; letter-spacing:.3px; }
.admin-role-readonly { color:#6f6c7b; font-size:13px; font-weight:600; }
.admin-role-hint { margin-top:16px; color:#8d8792; font-size:12px; line-height:1.5; }
.dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; }
.dns-copy:hover { background:#fff0eb; color:var(--coral); }