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({
Account security
++ Hi {account.first_name}, your administrator requires you to set a new password + before using AGMail. +
+{notice}
} {error &&{error}
}