From c01914736b58ebb3074ff4b1025d3ada07afb76f Mon Sep 17 00:00:00 2001 From: debu-2 Date: Sun, 4 Oct 2026 20:32:18 +0530 Subject: [PATCH] ui enhancement company --- backend/app/deps/domain_owner.py | 24 ++ backend/app/main.py | 2 + backend/app/models.py | 20 ++ backend/app/routers/org.py | 39 +++ backend/app/routers/people.py | 50 +++- backend/app/schemas.py | 20 ++ backend/app/services/org_members.py | 38 +++ backend/app/services/org_security.py | 55 ++++ frontend/src/api.js | 6 + .../src/components/CompanyAdministrators.jsx | 158 ++++++++++ .../src/components/CompanyOverviewSetup.jsx | 282 ++++++++++++------ frontend/src/components/CompanySecurity.jsx | 151 ++++++++++ frontend/src/components/DashboardPage.jsx | 109 ++++--- frontend/src/styles.css | 81 +++-- 14 files changed, 876 insertions(+), 159 deletions(-) create mode 100644 backend/app/deps/domain_owner.py create mode 100644 backend/app/routers/org.py create mode 100644 backend/app/services/org_security.py create mode 100644 frontend/src/components/CompanyAdministrators.jsx create mode 100644 frontend/src/components/CompanySecurity.jsx 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 ( +
+
+
+

Administrators

+

Assign roles and control who can manage your organization.

+
+
+ +
+ +
+ + {error &&

{error}

} + +
+ + + + + + + + + + + {loading ? ( + + + + ) : filteredMembers.length === 0 ? ( + + + + ) : ( + 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 ( + + + + + + + ); + }) + )} + +
NameEmailStatusRole
Loading administrators…
No users match your search.
+
+ {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 (
  1. - +
    + + {!isLast &&
    {item.title} {item.hint} @@ -110,105 +205,118 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
    {step === 4 && ( <> -

    Company details

    -

    Organization information

    -
    - - - - - -
    - + updateField("contactName", event.target.value)} + aria-invalid={Boolean(fieldErrors.contactName)} + /> + + + Your admin email} + error={fieldErrors.emailLocal} + > +
    + updateField("emailLocal", event.target.value)} + placeholder="suyash" + aria-invalid={Boolean(fieldErrors.emailLocal)} + /> + @{companyDomainName} +
    + + This becomes your login address on your company domain. + +
    + +
    + +
    )} {step === 5 && ( - <> +

    Add employees

    - Invite teammates to {companyDomain?.domain_name}. You can add people now or skip and do this later from People. + Invite teammates to {companyDomainName}. You can add people now or skip and do this later from People.

    Employee invites will be available here soon.
    -
    - +
    )} {step === 6 && ( - <> +

    Finish setup

    - Domain {companyDomain?.domain_name} is connected. You're ready to use AGMail. + Domain {companyDomainName} is connected. You're ready to use AGMail.

    • Domain added and verified
    • @@ -216,11 +324,11 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
    • Organization details saved
    -
    - +
    )}
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 ( +
+
+
+

Security

+

Protect your organization and manage access.

+
+
+ + {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); }