email wiring
This commit is contained in:
parent
ee23b71f0f
commit
42cd937f0a
12 changed files with 494 additions and 45 deletions
|
|
@ -16,6 +16,7 @@ from app.routers import (
|
||||||
groups,
|
groups,
|
||||||
org,
|
org,
|
||||||
people,
|
people,
|
||||||
|
sections,
|
||||||
sticky_notes,
|
sticky_notes,
|
||||||
)
|
)
|
||||||
from app.services.domain_provisioning import ensure_platform_domain
|
from app.services.domain_provisioning import ensure_platform_domain
|
||||||
|
|
@ -57,6 +58,12 @@ async def migrate_user_org_columns(connection) -> None:
|
||||||
"DEFAULT FALSE"
|
"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:
|
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(auth.router, prefix=settings.api_prefix)
|
||||||
app.include_router(domains.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(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(groups.router, prefix=settings.api_prefix)
|
||||||
app.include_router(aliases.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(org.router, prefix=settings.api_prefix)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,13 @@ class Account(Base):
|
||||||
)
|
)
|
||||||
invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
|
||||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
|
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))
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
section: Mapped["OrgSection | None"] = relationship()
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
server_default=func.now(),
|
server_default=func.now(),
|
||||||
|
|
@ -138,6 +144,22 @@ class SmtpCredential(Base):
|
||||||
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
|
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):
|
class OrgSecuritySettings(Base):
|
||||||
__tablename__ = "org_security_settings"
|
__tablename__ = "org_security_settings"
|
||||||
|
|
||||||
|
|
|
||||||
48
backend/app/routers/sections.py
Normal file
48
backend/app/routers/sections.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -57,6 +57,7 @@ class OrgMemberCreate(BaseModel):
|
||||||
last_name: str = Field(min_length=1, max_length=80)
|
last_name: str = Field(min_length=1, max_length=80)
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
invitation_email: EmailStr | None = None
|
invitation_email: EmailStr | None = None
|
||||||
|
section_id: UUID | None = None
|
||||||
org_role: Literal["user", "admin"] = "user"
|
org_role: Literal["user", "admin"] = "user"
|
||||||
send_invitation: bool = True
|
send_invitation: bool = True
|
||||||
force_password_change: bool = False
|
force_password_change: bool = False
|
||||||
|
|
@ -73,7 +74,21 @@ class OrgMemberRead(BaseModel):
|
||||||
member_status: str
|
member_status: str
|
||||||
must_change_password: bool = False
|
must_change_password: bool = False
|
||||||
is_domain_owner: 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
|
created_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,13 @@ from datetime import date
|
||||||
from pwdlib import PasswordHash
|
from pwdlib import PasswordHash
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.models import Account
|
from app.models import Account
|
||||||
from app.schemas import OrgMemberCreate, OrgMemberRead
|
from app.schemas import OrgMemberCreate, OrgMemberRead
|
||||||
from app.services.domain_provisioning import account_is_domain_owner
|
from app.services.domain_provisioning import account_is_domain_owner
|
||||||
from app.services.org_invitations import send_employee_invitation
|
from app.services.org_invitations import send_employee_invitation
|
||||||
|
from app.services.org_sections import get_org_section
|
||||||
|
|
||||||
password_hasher = PasswordHash.recommended()
|
password_hasher = PasswordHash.recommended()
|
||||||
PLACEHOLDER_BIRTHDATE = date(1990, 1, 1)
|
PLACEHOLDER_BIRTHDATE = date(1990, 1, 1)
|
||||||
|
|
@ -38,6 +40,8 @@ async def can_manage_org_members(
|
||||||
async def member_read(
|
async def member_read(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
member: Account,
|
member: Account,
|
||||||
|
*,
|
||||||
|
invitation_sent: bool = False,
|
||||||
) -> OrgMemberRead:
|
) -> OrgMemberRead:
|
||||||
is_owner = await account_is_domain_owner(
|
is_owner = await account_is_domain_owner(
|
||||||
session,
|
session,
|
||||||
|
|
@ -55,7 +59,9 @@ async def member_read(
|
||||||
member_status=member.member_status,
|
member_status=member.member_status,
|
||||||
must_change_password=member.must_change_password,
|
must_change_password=member.must_change_password,
|
||||||
is_domain_owner=is_owner,
|
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,
|
created_at=member.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -66,6 +72,7 @@ async def list_org_members(
|
||||||
) -> tuple[list[OrgMemberRead], dict[str, int]]:
|
) -> tuple[list[OrgMemberRead], dict[str, int]]:
|
||||||
result = await session.scalars(
|
result = await session.scalars(
|
||||||
select(Account)
|
select(Account)
|
||||||
|
.options(selectinload(Account.section))
|
||||||
.where(
|
.where(
|
||||||
Account.account_type == "company",
|
Account.account_type == "company",
|
||||||
Account.company_domain == company_domain,
|
Account.company_domain == company_domain,
|
||||||
|
|
@ -124,6 +131,12 @@ async def create_org_member(
|
||||||
if existing:
|
if existing:
|
||||||
raise ValueError("That email address is already registered.")
|
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()
|
phone_number = _placeholder_phone()
|
||||||
while await session.scalar(
|
while await session.scalar(
|
||||||
select(Account).where(Account.phone_number == phone_number)
|
select(Account).where(Account.phone_number == phone_number)
|
||||||
|
|
@ -142,22 +155,32 @@ async def create_org_member(
|
||||||
member_status=member_status,
|
member_status=member_status,
|
||||||
invitation_email=invitation_email,
|
invitation_email=invitation_email,
|
||||||
must_change_password=payload.force_password_change,
|
must_change_password=payload.force_password_change,
|
||||||
|
section_id=section.id if section else None,
|
||||||
created_by_id=manager.id,
|
created_by_id=manager.id,
|
||||||
password_hash=password_hasher.hash(payload.password),
|
password_hash=password_hasher.hash(payload.password),
|
||||||
)
|
)
|
||||||
session.add(member)
|
session.add(member)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
if section:
|
||||||
|
member.section = section
|
||||||
|
|
||||||
|
invitation_sent = False
|
||||||
if payload.send_invitation:
|
if payload.send_invitation:
|
||||||
await send_employee_invitation(
|
try:
|
||||||
session,
|
await send_employee_invitation(
|
||||||
member=member,
|
session,
|
||||||
manager=manager,
|
member=member,
|
||||||
initial_password=payload.password,
|
manager=manager,
|
||||||
force_password_change=payload.force_password_change,
|
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(
|
async def update_org_member_role(
|
||||||
|
|
|
||||||
77
backend/app/services/org_sections.py
Normal file
77
backend/app/services/org_sections.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -52,6 +52,10 @@ export const listOrgMembers = (token) =>
|
||||||
request("/people", { token });
|
request("/people", { token });
|
||||||
export const createOrgMember = (payload, token) =>
|
export const createOrgMember = (payload, token) =>
|
||||||
request("/people", { method: "POST", 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) =>
|
export const listMailGroups = (token) =>
|
||||||
request("/groups", { token });
|
request("/groups", { token });
|
||||||
export const createMailGroup = (payload, token) =>
|
export const createMailGroup = (payload, token) =>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { ArrowRight, Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { createOrgMember } from "../api";
|
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])?$/;
|
const EMAIL_LOCAL_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
|
||||||
|
|
||||||
function RequiredLabel({ children }) {
|
function RequiredLabel({ children }) {
|
||||||
|
|
@ -58,13 +57,19 @@ export default function AddEmployeeForm({
|
||||||
onCancel,
|
onCancel,
|
||||||
}) {
|
}) {
|
||||||
const token = sessionStorage.getItem("agmail_access_token");
|
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({
|
const [form, setForm] = useState({
|
||||||
first_name: "",
|
first_name: "",
|
||||||
last_name: "",
|
last_name: "",
|
||||||
email_local: "",
|
email_local: "",
|
||||||
invitation_email: "",
|
invitation_email: "",
|
||||||
org_role: "user",
|
org_role: "user",
|
||||||
groups: [],
|
section_id: null,
|
||||||
send_invitation: true,
|
send_invitation: true,
|
||||||
force_password_change: true,
|
force_password_change: true,
|
||||||
password: "",
|
password: "",
|
||||||
|
|
@ -73,6 +78,30 @@ export default function AddEmployeeForm({
|
||||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
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(
|
const formValid = useMemo(
|
||||||
() => Object.keys(validateForm(form, companyDomain)).length === 0,
|
() => Object.keys(validateForm(form, companyDomain)).length === 0,
|
||||||
[form, companyDomain],
|
[form, companyDomain],
|
||||||
|
|
@ -89,16 +118,32 @@ export default function AddEmployeeForm({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleGroup(group) {
|
function selectSection(sectionId) {
|
||||||
setForm((current) => {
|
setForm((current) => ({
|
||||||
const exists = current.groups.includes(group);
|
...current,
|
||||||
return {
|
section_id: current.section_id === sectionId ? null : sectionId,
|
||||||
...current,
|
}));
|
||||||
groups: exists
|
}
|
||||||
? current.groups.filter((item) => item !== group)
|
|
||||||
: [...current.groups, group],
|
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) {
|
async function handleSubmit(event) {
|
||||||
|
|
@ -121,6 +166,7 @@ export default function AddEmployeeForm({
|
||||||
invitation_email: form.send_invitation
|
invitation_email: form.send_invitation
|
||||||
? form.invitation_email.trim().toLowerCase()
|
? form.invitation_email.trim().toLowerCase()
|
||||||
: undefined,
|
: undefined,
|
||||||
|
section_id: form.section_id || undefined,
|
||||||
org_role: form.org_role,
|
org_role: form.org_role,
|
||||||
send_invitation: form.send_invitation,
|
send_invitation: form.send_invitation,
|
||||||
force_password_change: form.force_password_change,
|
force_password_change: form.force_password_change,
|
||||||
|
|
@ -134,7 +180,7 @@ export default function AddEmployeeForm({
|
||||||
email_local: "",
|
email_local: "",
|
||||||
invitation_email: "",
|
invitation_email: "",
|
||||||
org_role: "user",
|
org_role: "user",
|
||||||
groups: [],
|
section_id: null,
|
||||||
send_invitation: true,
|
send_invitation: true,
|
||||||
force_password_change: true,
|
force_password_change: true,
|
||||||
password: "",
|
password: "",
|
||||||
|
|
@ -221,19 +267,51 @@ export default function AddEmployeeForm({
|
||||||
</SetupField>
|
</SetupField>
|
||||||
|
|
||||||
<fieldset className="people-groups-field">
|
<fieldset className="people-groups-field">
|
||||||
<legend>Groups</legend>
|
<legend>Section</legend>
|
||||||
|
<p className="org-setup-field-hint people-section-hint">
|
||||||
|
Department or team. Group emails are managed separately under Groups.
|
||||||
|
</p>
|
||||||
<div className="people-groups-options">
|
<div className="people-groups-options">
|
||||||
{GROUP_OPTIONS.map((group) => (
|
{sectionsLoading ? (
|
||||||
<button
|
<span className="people-section-loading">Loading sections…</span>
|
||||||
key={group}
|
) : (
|
||||||
type="button"
|
sections.map((section) => (
|
||||||
className={`people-group-chip${form.groups.includes(group) ? " active" : ""}`}
|
<button
|
||||||
onClick={() => toggleGroup(group)}
|
key={section.id}
|
||||||
>
|
type="button"
|
||||||
{group}
|
className={`people-group-chip${form.section_id === section.id ? " active" : ""}`}
|
||||||
</button>
|
onClick={() => selectSection(section.id)}
|
||||||
))}
|
>
|
||||||
|
{section.name}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="people-group-chip people-section-add"
|
||||||
|
onClick={() => setShowNewSection((current) => !current)}
|
||||||
|
>
|
||||||
|
+ Add section
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{showNewSection && (
|
||||||
|
<div className="people-section-create">
|
||||||
|
<input
|
||||||
|
value={newSectionName}
|
||||||
|
onChange={(event) => setNewSectionName(event.target.value)}
|
||||||
|
placeholder="e.g. Marketing"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button button-outline"
|
||||||
|
onClick={handleCreateSection}
|
||||||
|
disabled={creatingSection}
|
||||||
|
>
|
||||||
|
{creatingSection ? "Creating…" : "Create"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{sectionError && <span className="org-setup-field-error" role="alert">{sectionError}</span>}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<SetupField
|
<SetupField
|
||||||
|
|
|
||||||
138
frontend/src/components/ChangePasswordModal.jsx
Normal file
138
frontend/src/components/ChangePasswordModal.jsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { changePassword } from "../api";
|
||||||
|
|
||||||
|
function validateForm(form) {
|
||||||
|
const errors = {};
|
||||||
|
if (!form.current_password.trim()) {
|
||||||
|
errors.current_password = "Enter your current password.";
|
||||||
|
}
|
||||||
|
if (!form.new_password.trim()) {
|
||||||
|
errors.new_password = "Enter a new password.";
|
||||||
|
} else if (form.new_password.length < 8) {
|
||||||
|
errors.new_password = "Password must be at least 8 characters.";
|
||||||
|
}
|
||||||
|
if (!form.confirm_password.trim()) {
|
||||||
|
errors.confirm_password = "Confirm your new password.";
|
||||||
|
} else if (form.new_password !== form.confirm_password) {
|
||||||
|
errors.confirm_password = "Passwords do not match.";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChangePasswordModal({ account, onComplete }) {
|
||||||
|
const token = sessionStorage.getItem("agmail_access_token");
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
current_password: "",
|
||||||
|
new_password: "",
|
||||||
|
confirm_password: "",
|
||||||
|
});
|
||||||
|
const [fieldErrors, setFieldErrors] = useState({});
|
||||||
|
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||||
|
|
||||||
|
function updateField(field, value) {
|
||||||
|
setForm((current) => ({ ...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 (
|
||||||
|
<div className="change-password-scrim" role="presentation">
|
||||||
|
<section
|
||||||
|
className="change-password-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="change-password-title"
|
||||||
|
>
|
||||||
|
<header className="change-password-head">
|
||||||
|
<p className="eyebrow">Account security</p>
|
||||||
|
<h2 id="change-password-title">Choose a new password</h2>
|
||||||
|
<p>
|
||||||
|
Hi {account.first_name}, your administrator requires you to set a new password
|
||||||
|
before using AGMail.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="change-password-form" onSubmit={handleSubmit} noValidate>
|
||||||
|
<label>
|
||||||
|
Current password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={form.current_password}
|
||||||
|
onChange={(event) => updateField("current_password", event.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.current_password && (
|
||||||
|
<span className="org-setup-field-error" role="alert">
|
||||||
|
{fieldErrors.current_password}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
New password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.new_password}
|
||||||
|
onChange={(event) => updateField("new_password", event.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.new_password && (
|
||||||
|
<span className="org-setup-field-error" role="alert">
|
||||||
|
{fieldErrors.new_password}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Confirm new password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.confirm_password}
|
||||||
|
onChange={(event) => updateField("confirm_password", event.target.value)}
|
||||||
|
/>
|
||||||
|
{fieldErrors.confirm_password && (
|
||||||
|
<span className="org-setup-field-error" role="alert">
|
||||||
|
{fieldErrors.confirm_password}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="button button-purple"
|
||||||
|
type="submit"
|
||||||
|
disabled={status.loading}
|
||||||
|
>
|
||||||
|
{status.loading ? "Updating…" : "Update password"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,11 +29,8 @@ function roleLabel(member) {
|
||||||
return "User";
|
return "User";
|
||||||
}
|
}
|
||||||
|
|
||||||
function groupLabel(member) {
|
function sectionLabel(member) {
|
||||||
if (member.is_domain_owner || member.org_role === "owner" || member.org_role === "admin") {
|
return member.section_name || "—";
|
||||||
return "Admin";
|
|
||||||
}
|
|
||||||
return "—";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }) {
|
function StatusBadge({ status }) {
|
||||||
|
|
@ -98,6 +95,7 @@ export default function CompanyPeople({ account }) {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [roleFilter, setRoleFilter] = useState("all");
|
const [roleFilter, setRoleFilter] = useState("all");
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
const loadMembers = useCallback(async () => {
|
const loadMembers = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -187,6 +185,7 @@ export default function CompanyPeople({ account }) {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{notice && <p className="people-notice" role="status">{notice}</p>}
|
||||||
{error && <p className="form-error" role="alert">{error}</p>}
|
{error && <p className="form-error" role="alert">{error}</p>}
|
||||||
|
|
||||||
<div className="people-table-wrap">
|
<div className="people-table-wrap">
|
||||||
|
|
@ -198,7 +197,7 @@ export default function CompanyPeople({ account }) {
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th>Group</th>
|
<th>Section</th>
|
||||||
<th>Storage</th>
|
<th>Storage</th>
|
||||||
<th>Last active</th>
|
<th>Last active</th>
|
||||||
<th aria-label="Actions" />
|
<th aria-label="Actions" />
|
||||||
|
|
@ -230,7 +229,7 @@ export default function CompanyPeople({ account }) {
|
||||||
<td>{member.email}</td>
|
<td>{member.email}</td>
|
||||||
<td><StatusBadge status={member.member_status} /></td>
|
<td><StatusBadge status={member.member_status} /></td>
|
||||||
<td>{roleLabel(member)}</td>
|
<td>{roleLabel(member)}</td>
|
||||||
<td>{groupLabel(member)}</td>
|
<td>{sectionLabel(member)}</td>
|
||||||
<td>{member.member_status === "invited" ? "—" : "—"}</td>
|
<td>{member.member_status === "invited" ? "—" : "—"}</td>
|
||||||
<td>{member.member_status === "invited" ? "—" : "—"}</td>
|
<td>{member.member_status === "invited" ? "—" : "—"}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -249,7 +248,17 @@ export default function CompanyPeople({ account }) {
|
||||||
<AddUserPanel
|
<AddUserPanel
|
||||||
companyDomain={companyDomain}
|
companyDomain={companyDomain}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => 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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import CompanyGroups from "./CompanyGroups";
|
||||||
import CompanyPeople from "./CompanyPeople";
|
import CompanyPeople from "./CompanyPeople";
|
||||||
import CompanySecurity from "./CompanySecurity";
|
import CompanySecurity from "./CompanySecurity";
|
||||||
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||||
|
import ChangePasswordModal from "./ChangePasswordModal";
|
||||||
import ComposeModal from "./ComposeModal";
|
import ComposeModal from "./ComposeModal";
|
||||||
import MailboxView from "./MailboxView";
|
import MailboxView from "./MailboxView";
|
||||||
|
|
||||||
|
|
@ -113,6 +114,7 @@ export default function DashboardPage() {
|
||||||
const [agentNav, setAgentNav] = useState(null);
|
const [agentNav, setAgentNav] = useState(null);
|
||||||
const [labelsExpanded, setLabelsExpanded] = useState(false);
|
const [labelsExpanded, setLabelsExpanded] = useState(false);
|
||||||
const [companyActive, setCompanyActive] = useState(null);
|
const [companyActive, setCompanyActive] = useState(null);
|
||||||
|
const [mustChangePassword, setMustChangePassword] = useState(false);
|
||||||
const [mailStats, setMailStats] = useState({
|
const [mailStats, setMailStats] = useState({
|
||||||
unread: 0,
|
unread: 0,
|
||||||
starred: 0,
|
starred: 0,
|
||||||
|
|
@ -134,6 +136,7 @@ export default function DashboardPage() {
|
||||||
.then(async (currentAccount) => {
|
.then(async (currentAccount) => {
|
||||||
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
|
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
|
||||||
setAccount(currentAccount);
|
setAccount(currentAccount);
|
||||||
|
setMustChangePassword(Boolean(currentAccount.must_change_password));
|
||||||
if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) {
|
if (currentAccount.account_type === "company" && currentAccount.is_domain_owner) {
|
||||||
try {
|
try {
|
||||||
const domain = await getMyCompanyDomain(token);
|
const domain = await getMyCompanyDomain(token);
|
||||||
|
|
@ -490,6 +493,16 @@ export default function DashboardPage() {
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
{notice && <div className="dashboard-notice" role="status">{notice}</div>}
|
{notice && <div className="dashboard-notice" role="status">{notice}</div>}
|
||||||
|
{mustChangePassword && (
|
||||||
|
<ChangePasswordModal
|
||||||
|
account={account}
|
||||||
|
onComplete={(updated) => {
|
||||||
|
setAccount(updated);
|
||||||
|
setMustChangePassword(false);
|
||||||
|
sessionStorage.setItem("agmail_account", JSON.stringify(updated));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{composeOpen && <ComposeModal account={account} onClose={() => setComposeOpen(false)} onSent={emailSent} />}
|
{composeOpen && <ComposeModal account={account} onClose={() => setComposeOpen(false)} onSent={emailSent} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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-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 { 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-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 { 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 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; }
|
.people-toggle-row input { width:18px; height:18px; margin-top:4px; accent-color:#7357F6; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue