diff --git a/backend/app/deps/domain_owner.py b/backend/app/deps/domain_owner.py
new file mode 100644
index 0000000..255802f
--- /dev/null
+++ b/backend/app/deps/domain_owner.py
@@ -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
diff --git a/backend/app/main.py b/backend/app/main.py
index d653a6a..4f55f1e 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 601c592..3ef4521 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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__ = (
diff --git a/backend/app/routers/org.py b/backend/app/routers/org.py
new file mode 100644
index 0000000..5c21cf0
--- /dev/null
+++ b/backend/app/routers/org.py
@@ -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
diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py
index a984168..0476a71 100644
--- a/backend/app/routers/people.py
+++ b/backend/app/routers/people.py
@@ -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
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 1ccb172..1fc53ef 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -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)
diff --git a/backend/app/services/org_members.py b/backend/app/services/org_members.py
index 9c4bc18..6421801 100644
--- a/backend/app/services/org_members.py
+++ b/backend/app/services/org_members.py
@@ -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)
diff --git a/backend/app/services/org_security.py b/backend/app/services/org_security.py
new file mode 100644
index 0000000..669980a
--- /dev/null
+++ b/backend/app/services/org_security.py
@@ -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)
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 9340f96..192ac44 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -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 });
diff --git a/frontend/src/components/CompanyAdministrators.jsx b/frontend/src/components/CompanyAdministrators.jsx
new file mode 100644
index 0000000..92d04af
--- /dev/null
+++ b/frontend/src/components/CompanyAdministrators.jsx
@@ -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 (
+
+
+
+
+
+
+
+ {error && {error}
}
+
+
+
+
+
+ | Name |
+ Email |
+ Status |
+ Role |
+
+
+
+ {loading ? (
+
+ | Loading administrators… |
+
+ ) : filteredMembers.length === 0 ? (
+
+ | No users match your search. |
+
+ ) : (
+ 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 (
+
+ |
+
+ {initials(member.first_name, member.last_name)}
+
+ {member.first_name} {member.last_name}
+ {isOwner && (
+
+ Owner
+
+ )}
+
+
+ |
+ {member.email} |
+ {member.member_status} |
+
+ {isOwner || isSelf ? (
+ {roleLabel(member)}
+ ) : (
+
+ )}
+ |
+
+ );
+ })
+ )}
+
+
+
+
+
+ User Admins can manage people, groups, aliases, and security settings.
+ Only the domain owner can assign administrator roles.
+
+
+ );
+}
diff --git a/frontend/src/components/CompanyOverviewSetup.jsx b/frontend/src/components/CompanyOverviewSetup.jsx
index 987020b..1a5cfba 100644
--- a/frontend/src/components/CompanyOverviewSetup.jsx
+++ b/frontend/src/components/CompanyOverviewSetup.jsx
@@ -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 (
+
+ {children}
+ *
+
+ );
+}
+
+function SetupField({
+ label,
+ error,
+ children,
+}) {
+ return (
+
+ );
+}
+
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
- {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 (
-
-
- {complete ? : item.id}
-
+
+
+ {complete ? : item.id}
+
+ {!isLast && }
+
{item.title}
{item.hint}
@@ -110,105 +205,118 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
{step === 4 && (
<>
-
Company details
-
Organization information
-
diff --git a/frontend/src/components/CompanySecurity.jsx b/frontend/src/components/CompanySecurity.jsx
new file mode 100644
index 0000000..55c4a5a
--- /dev/null
+++ b/frontend/src/components/CompanySecurity.jsx
@@ -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 (
+
+
+
{title}
+
{description}
+
+
+
+ );
+}
+
+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 (
+
+
+
+ {error && {error}
}
+ {notice && {notice}
}
+
+ {loading || !settings ? (
+ Loading security settings…
+ ) : (
+ <>
+
+
Authentication
+ updateToggle("require_strong_passwords", value)}
+ />
+ updateToggle("require_mfa_admins", value)}
+ />
+ updateToggle("require_mfa_all_users", value)}
+ />
+
+
+
+
Session timeout
+
+
+
Automatically sign out inactive users
+
Users are signed out after a period of inactivity.
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx
index 67e13c6..fb974a9 100644
--- a/frontend/src/components/DashboardPage.jsx
+++ b/frontend/src/components/DashboardPage.jsx
@@ -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() {
-
-
-
-
+ {!isOrgOnboarding && (
+
+
+
+
+ )}
{isDomainOwner && (
-
-
@@ -367,7 +398,7 @@ export default function DashboardPage() {
- {companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
+ {companyActive && !["Overview", "Domains", "People", "Groups", "Aliases", "Security", "Administrators"].includes(companyActive) && (
{workspaceTitle}
{companyActive}
{workspaceLabel}
@@ -437,7 +468,13 @@ export default function DashboardPage() {
{companyActive === "Aliases" && isDomainOwner && (
)}
- {companyActive && !["Overview", "Domains", "People", "Groups", "Aliases"].includes(companyActive) && (
+ {companyActive === "Security" && isDomainOwner && (
+
+ )}
+ {companyActive === "Administrators" && isDomainOwner && (
+
+ )}
+ {companyActive && !["Overview", "Domains", "People", "Groups", "Aliases", "Security", "Administrators"].includes(companyActive) && (
{companyActive}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index a7a1826..44f90a0 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -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); }