diff --git a/backend/app/main.py b/backend/app/main.py index 7f181b1..3ede0c8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from app.routers import ( groups, org, people, + sections, sticky_notes, ) from app.services.domain_provisioning import ensure_platform_domain @@ -57,6 +58,12 @@ async def migrate_user_org_columns(connection) -> None: "DEFAULT FALSE" ) ) + await connection.execute( + text( + "ALTER TABLE users ADD COLUMN IF NOT EXISTS section_id UUID " + "REFERENCES org_sections(id)" + ) + ) async def migrate_legacy_user_tables(connection) -> None: @@ -170,6 +177,7 @@ app.include_router(sticky_notes.router, prefix=settings.api_prefix) app.include_router(auth.router, prefix=settings.api_prefix) app.include_router(domains.router, prefix=settings.api_prefix) app.include_router(people.router, prefix=settings.api_prefix) +app.include_router(sections.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) diff --git a/backend/app/models.py b/backend/app/models.py index 44d42c6..a97622e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -50,7 +50,13 @@ class Account(Base): ) invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True) must_change_password: Mapped[bool] = mapped_column(Boolean, default=False) + section_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("org_sections.id"), + nullable=True, + ) password_hash: Mapped[str] = mapped_column(String(255)) + section: Mapped["OrgSection | None"] = relationship() created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), @@ -138,6 +144,22 @@ class SmtpCredential(Base): domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials") +class OrgSection(Base): + __tablename__ = "org_sections" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + company_domain: Mapped[str] = mapped_column(String(253), index=True) + name: Mapped[str] = mapped_column(String(120)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + + class OrgSecuritySettings(Base): __tablename__ = "org_security_settings" diff --git a/backend/app/routers/sections.py b/backend/app/routers/sections.py new file mode 100644 index 0000000..0e61278 --- /dev/null +++ b/backend/app/routers/sections.py @@ -0,0 +1,48 @@ +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.org_manager import require_org_manager +from app.models import Account +from app.schemas import OrgSectionCreate, OrgSectionRead +from app.services.org_sections import create_org_section, list_org_sections + +router = APIRouter(prefix="/sections", tags=["sections"]) + + +@router.get("", response_model=list[OrgSectionRead]) +async def get_sections( + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> list[OrgSectionRead]: + sections = await list_org_sections(session, manager.company_domain) + await session.commit() + return sections + + +@router.post("", response_model=OrgSectionRead, status_code=status.HTTP_201_CREATED) +async def add_section( + payload: OrgSectionCreate, + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> OrgSectionRead: + try: + section = await create_org_section( + session, + manager.company_domain, + payload, + ) + await session.commit() + return section + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(error), + ) from error + except IntegrityError as error: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="That section could not be created.", + ) from error diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 549b2e9..39bfa52 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -57,6 +57,7 @@ class OrgMemberCreate(BaseModel): last_name: str = Field(min_length=1, max_length=80) email: EmailStr invitation_email: EmailStr | None = None + section_id: UUID | None = None org_role: Literal["user", "admin"] = "user" send_invitation: bool = True force_password_change: bool = False @@ -73,7 +74,21 @@ class OrgMemberRead(BaseModel): member_status: str must_change_password: bool = False is_domain_owner: bool = False - groups: list[str] = Field(default_factory=list) + section_id: UUID | None = None + section_name: str | None = None + invitation_sent: bool = False + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class OrgSectionCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +class OrgSectionRead(BaseModel): + id: UUID + name: str created_at: datetime model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/services/org_members.py b/backend/app/services/org_members.py index 2bae670..dbda878 100644 --- a/backend/app/services/org_members.py +++ b/backend/app/services/org_members.py @@ -8,11 +8,13 @@ from datetime import date from pwdlib import PasswordHash from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models import Account from app.schemas import OrgMemberCreate, OrgMemberRead from app.services.domain_provisioning import account_is_domain_owner from app.services.org_invitations import send_employee_invitation +from app.services.org_sections import get_org_section password_hasher = PasswordHash.recommended() PLACEHOLDER_BIRTHDATE = date(1990, 1, 1) @@ -38,6 +40,8 @@ async def can_manage_org_members( async def member_read( session: AsyncSession, member: Account, + *, + invitation_sent: bool = False, ) -> OrgMemberRead: is_owner = await account_is_domain_owner( session, @@ -55,7 +59,9 @@ async def member_read( member_status=member.member_status, must_change_password=member.must_change_password, is_domain_owner=is_owner, - groups=[], + section_id=member.section_id, + section_name=member.section.name if member.section else None, + invitation_sent=invitation_sent, created_at=member.created_at, ) @@ -66,6 +72,7 @@ async def list_org_members( ) -> tuple[list[OrgMemberRead], dict[str, int]]: result = await session.scalars( select(Account) + .options(selectinload(Account.section)) .where( Account.account_type == "company", Account.company_domain == company_domain, @@ -124,6 +131,12 @@ async def create_org_member( if existing: raise ValueError("That email address is already registered.") + section = None + if payload.section_id: + section = await get_org_section(session, company_domain, payload.section_id) + if section is None: + raise ValueError("Selected section was not found.") + phone_number = _placeholder_phone() while await session.scalar( select(Account).where(Account.phone_number == phone_number) @@ -142,22 +155,32 @@ async def create_org_member( member_status=member_status, invitation_email=invitation_email, must_change_password=payload.force_password_change, + section_id=section.id if section else None, created_by_id=manager.id, password_hash=password_hasher.hash(payload.password), ) session.add(member) await session.flush() + if section: + member.section = section + invitation_sent = False if payload.send_invitation: - await send_employee_invitation( - session, - member=member, - manager=manager, - initial_password=payload.password, - force_password_change=payload.force_password_change, - ) + try: + await send_employee_invitation( + session, + member=member, + manager=manager, + initial_password=payload.password, + force_password_change=payload.force_password_change, + ) + invitation_sent = True + except Exception as error: + raise ValueError( + f"Could not send invitation email: {error}" + ) from error - return await member_read(session, member) + return await member_read(session, member, invitation_sent=invitation_sent) async def update_org_member_role( diff --git a/backend/app/services/org_sections.py b/backend/app/services/org_sections.py new file mode 100644 index 0000000..88c468a --- /dev/null +++ b/backend/app/services/org_sections.py @@ -0,0 +1,77 @@ +"""Department sections within an organization.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import OrgSection +from app.schemas import OrgSectionCreate, OrgSectionRead + +DEFAULT_SECTIONS = ["Admin", "HR", "Engineering", "Finance"] + + +async def ensure_default_sections( + session: AsyncSession, + company_domain: str, +) -> None: + existing = await session.scalar( + select(OrgSection.id) + .where(OrgSection.company_domain == company_domain) + .limit(1) + ) + if existing: + return + for name in DEFAULT_SECTIONS: + session.add(OrgSection(company_domain=company_domain, name=name)) + await session.flush() + + +async def list_org_sections( + session: AsyncSession, + company_domain: str, +) -> list[OrgSectionRead]: + await ensure_default_sections(session, company_domain) + result = await session.scalars( + select(OrgSection) + .where(OrgSection.company_domain == company_domain) + .order_by(OrgSection.name.asc()) + ) + return [OrgSectionRead.model_validate(section) for section in result.all()] + + +async def create_org_section( + session: AsyncSession, + company_domain: str, + payload: OrgSectionCreate, +) -> OrgSectionRead: + name = payload.name.strip() + if not name: + raise ValueError("Section name is required.") + + existing = await session.scalar( + select(OrgSection).where( + OrgSection.company_domain == company_domain, + OrgSection.name.ilike(name), + ) + ) + if existing: + raise ValueError("That section already exists.") + + section = OrgSection(company_domain=company_domain, name=name) + session.add(section) + await session.flush() + return OrgSectionRead.model_validate(section) + + +async def get_org_section( + session: AsyncSession, + company_domain: str, + section_id, +) -> OrgSection | None: + return await session.scalar( + select(OrgSection).where( + OrgSection.id == section_id, + OrgSection.company_domain == company_domain, + ) + ) diff --git a/frontend/src/api.js b/frontend/src/api.js index 00289d7..2b41537 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -52,6 +52,10 @@ export const listOrgMembers = (token) => request("/people", { token }); export const createOrgMember = (payload, token) => request("/people", { method: "POST", payload, token }); +export const listOrgSections = (token) => + request("/sections", { token }); +export const createOrgSection = (payload, token) => + request("/sections", { method: "POST", payload, token }); export const listMailGroups = (token) => request("/groups", { token }); export const createMailGroup = (payload, token) => diff --git a/frontend/src/components/AddEmployeeForm.jsx b/frontend/src/components/AddEmployeeForm.jsx index 1608dd2..967c61c 100644 --- a/frontend/src/components/AddEmployeeForm.jsx +++ b/frontend/src/components/AddEmployeeForm.jsx @@ -1,8 +1,7 @@ -import { ArrowRight, Plus } from "lucide-react"; -import { useMemo, useState } from "react"; -import { createOrgMember } from "../api"; +import { Plus } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { createOrgMember, createOrgSection, listOrgSections } from "../api"; -const GROUP_OPTIONS = ["Admin", "HR", "Engineering", "Finance"]; const EMAIL_LOCAL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/; function RequiredLabel({ children }) { @@ -58,13 +57,19 @@ export default function AddEmployeeForm({ onCancel, }) { const token = sessionStorage.getItem("agmail_access_token"); + const [sections, setSections] = useState([]); + const [sectionsLoading, setSectionsLoading] = useState(true); + const [showNewSection, setShowNewSection] = useState(false); + const [newSectionName, setNewSectionName] = useState(""); + const [sectionError, setSectionError] = useState(""); + const [creatingSection, setCreatingSection] = useState(false); const [form, setForm] = useState({ first_name: "", last_name: "", email_local: "", invitation_email: "", org_role: "user", - groups: [], + section_id: null, send_invitation: true, force_password_change: true, password: "", @@ -73,6 +78,30 @@ export default function AddEmployeeForm({ const [submitAttempted, setSubmitAttempted] = useState(false); const [status, setStatus] = useState({ loading: false, error: "" }); + useEffect(() => { + let cancelled = false; + setSectionsLoading(true); + listOrgSections(token) + .then((items) => { + if (!cancelled) { + setSections(items); + } + }) + .catch(() => { + if (!cancelled) { + setSections([]); + } + }) + .finally(() => { + if (!cancelled) { + setSectionsLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [token]); + const formValid = useMemo( () => Object.keys(validateForm(form, companyDomain)).length === 0, [form, companyDomain], @@ -89,16 +118,32 @@ export default function AddEmployeeForm({ }); } - function toggleGroup(group) { - setForm((current) => { - const exists = current.groups.includes(group); - return { - ...current, - groups: exists - ? current.groups.filter((item) => item !== group) - : [...current.groups, group], - }; - }); + function selectSection(sectionId) { + setForm((current) => ({ + ...current, + section_id: current.section_id === sectionId ? null : sectionId, + })); + } + + async function handleCreateSection() { + const name = newSectionName.trim(); + if (!name) { + setSectionError("Enter a section name."); + return; + } + setCreatingSection(true); + setSectionError(""); + try { + const section = await createOrgSection({ name }, token); + setSections((current) => [...current, section].sort((a, b) => a.name.localeCompare(b.name))); + setForm((current) => ({ ...current, section_id: section.id })); + setNewSectionName(""); + setShowNewSection(false); + } catch (error) { + setSectionError(error.message); + } finally { + setCreatingSection(false); + } } async function handleSubmit(event) { @@ -121,6 +166,7 @@ export default function AddEmployeeForm({ invitation_email: form.send_invitation ? form.invitation_email.trim().toLowerCase() : undefined, + section_id: form.section_id || undefined, org_role: form.org_role, send_invitation: form.send_invitation, force_password_change: form.force_password_change, @@ -134,7 +180,7 @@ export default function AddEmployeeForm({ email_local: "", invitation_email: "", org_role: "user", - groups: [], + section_id: null, send_invitation: true, force_password_change: true, password: "", @@ -221,19 +267,51 @@ export default function AddEmployeeForm({
- Groups + Section +

+ Department or team. Group emails are managed separately under Groups. +

- {GROUP_OPTIONS.map((group) => ( - - ))} + {sectionsLoading ? ( + Loading sections… + ) : ( + sections.map((section) => ( + + )) + )} +
+ {showNewSection && ( +
+ setNewSectionName(event.target.value)} + placeholder="e.g. Marketing" + /> + +
+ )} + {sectionError && {sectionError}}
({ ...current, [field]: value })); + if (fieldErrors[field]) { + setFieldErrors((errors) => ({ ...errors, [field]: undefined })); + } + } + + async function handleSubmit(event) { + event.preventDefault(); + const errors = validateForm(form); + setFieldErrors(errors); + if (Object.keys(errors).length > 0) { + return; + } + + setStatus({ loading: true, error: "" }); + try { + const updated = await changePassword( + { + current_password: form.current_password, + new_password: form.new_password, + }, + token, + ); + onComplete(updated); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + } + + return ( +
+
+
+

Account security

+

Choose a new password

+

+ Hi {account.first_name}, your administrator requires you to set a new password + before using AGMail. +

+
+ +
+ + + + + + + {status.error &&

{status.error}

} + + +
+
+
+ ); +} diff --git a/frontend/src/components/CompanyPeople.jsx b/frontend/src/components/CompanyPeople.jsx index 97954f7..d66e885 100644 --- a/frontend/src/components/CompanyPeople.jsx +++ b/frontend/src/components/CompanyPeople.jsx @@ -29,11 +29,8 @@ function roleLabel(member) { return "User"; } -function groupLabel(member) { - if (member.is_domain_owner || member.org_role === "owner" || member.org_role === "admin") { - return "Admin"; - } - return "—"; +function sectionLabel(member) { + return member.section_name || "—"; } function StatusBadge({ status }) { @@ -98,6 +95,7 @@ export default function CompanyPeople({ account }) { const [search, setSearch] = useState(""); const [roleFilter, setRoleFilter] = useState("all"); const [drawerOpen, setDrawerOpen] = useState(false); + const [notice, setNotice] = useState(""); const loadMembers = useCallback(async () => { setLoading(true); @@ -187,6 +185,7 @@ export default function CompanyPeople({ account }) { + {notice &&

{notice}

} {error &&

{error}

}
@@ -198,7 +197,7 @@ export default function CompanyPeople({ account }) { Email Status Role - Group + Section Storage Last active @@ -230,7 +229,7 @@ export default function CompanyPeople({ account }) { {member.email} {roleLabel(member)} - {groupLabel(member)} + {sectionLabel(member)} {member.member_status === "invited" ? "—" : "—"} {member.member_status === "invited" ? "—" : "—"} @@ -249,7 +248,17 @@ export default function CompanyPeople({ account }) { setDrawerOpen(false)} - onCreated={() => loadMembers()} + onCreated={(member) => { + loadMembers(); + if (member.invitation_sent) { + setNotice(`Invitation email sent to ${member.invitation_email}.`); + } else if (member.member_status === "invited") { + setNotice("User created. Invitation email was not sent."); + } else { + setNotice("User created."); + } + window.setTimeout(() => setNotice(""), 5000); + }} /> )} diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx index fb974a9..70280ee 100644 --- a/frontend/src/components/DashboardPage.jsx +++ b/frontend/src/components/DashboardPage.jsx @@ -35,6 +35,7 @@ import CompanyGroups from "./CompanyGroups"; import CompanyPeople from "./CompanyPeople"; import CompanySecurity from "./CompanySecurity"; import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup"; +import ChangePasswordModal from "./ChangePasswordModal"; import ComposeModal from "./ComposeModal"; import MailboxView from "./MailboxView"; @@ -113,6 +114,7 @@ export default function DashboardPage() { const [agentNav, setAgentNav] = useState(null); const [labelsExpanded, setLabelsExpanded] = useState(false); const [companyActive, setCompanyActive] = useState(null); + const [mustChangePassword, setMustChangePassword] = useState(false); const [mailStats, setMailStats] = useState({ unread: 0, starred: 0, @@ -134,6 +136,7 @@ export default function DashboardPage() { .then(async (currentAccount) => { sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount)); setAccount(currentAccount); + setMustChangePassword(Boolean(currentAccount.must_change_password)); if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) { try { const domain = await getMyCompanyDomain(token); @@ -490,6 +493,16 @@ export default function DashboardPage() { )} {notice &&
{notice}
} + {mustChangePassword && ( + { + setAccount(updated); + setMustChangePassword(false); + sessionStorage.setItem("agmail_account", JSON.stringify(updated)); + }} + /> + )} {composeOpen && setComposeOpen(false)} onSent={emailSent} />}
); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 640432c..be84706 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -358,6 +358,20 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } .people-groups-options { display:flex; flex-wrap:wrap; gap:8px; } .people-group-chip { padding:6px 12px; border:1px solid #E1DFE7; border-radius:999px; background:#fff; color:#6f6c7b; font-size:12px; font-weight:600; cursor:pointer; } .people-group-chip.active { border-color:#C8B8FF; background:#F6F2FF; color:#5a4fcf; } +.people-section-hint { margin:-2px 0 8px; } +.people-section-loading { font-size:12px; color:#8b8796; } +.people-section-add { border-style:dashed; } +.people-section-create { display:flex; gap:8px; margin-top:10px; } +.people-section-create input { flex:1; } +.people-notice { margin:0 0 12px; padding:10px 14px; border-radius:10px; background:#eef8f0; color:#2f6b3a; font-size:13px; } +.change-password-scrim { position:fixed; inset:0; z-index:120; display:flex; align-items:center; justify-content:center; padding:24px; background:rgba(18,16,28,.55); } +.change-password-modal { width:min(440px,100%); padding:28px; border-radius:16px; background:#fff; box-shadow:0 24px 60px rgba(18,16,28,.22); } +.change-password-head { display:grid; gap:8px; margin-bottom:20px; } +.change-password-head h2 { margin:0; font-size:22px; color:#1f1c28; } +.change-password-head p { margin:0; font-size:14px; color:#6f6c7b; line-height:1.5; } +.change-password-form { display:grid; gap:14px; } +.change-password-form label { display:grid; gap:6px; font-size:12px; font-weight:600; color:#4a4652; } +.change-password-form input { padding:10px 12px; border:1px solid #E1DFE7; border-radius:10px; font-size:14px; } .people-toggle-row { display:flex !important; align-items:flex-start; justify-content:space-between; gap:12px; } .people-toggle-row strong { display:block; margin-bottom:2px; font-size:13px; color:#1a1628; } .people-toggle-row input { width:18px; height:18px; margin-top:4px; accent-color:#7357F6; }