email wiring

This commit is contained in:
debu-2 2026-10-04 20:50:47 +05:30
parent ee23b71f0f
commit 42cd937f0a
12 changed files with 494 additions and 45 deletions

View file

@ -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)

View file

@ -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"

View 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

View file

@ -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)

View file

@ -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,13 +155,18 @@ 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:
try:
await send_employee_invitation( await send_employee_invitation(
session, session,
member=member, member=member,
@ -156,8 +174,13 @@ async def create_org_member(
initial_password=payload.password, initial_password=payload.password,
force_password_change=payload.force_password_change, 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(

View 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,
)
)

View file

@ -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) =>

View file

@ -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);
return {
...current, ...current,
groups: exists section_id: current.section_id === sectionId ? null : sectionId,
? 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 ? (
<span className="people-section-loading">Loading sections…</span>
) : (
sections.map((section) => (
<button <button
key={group} key={section.id}
type="button" type="button"
className={`people-group-chip${form.groups.includes(group) ? " active" : ""}`} className={`people-group-chip${form.section_id === section.id ? " active" : ""}`}
onClick={() => toggleGroup(group)} onClick={() => selectSection(section.id)}
> >
{group} {section.name}
</button>
))
)}
<button
type="button"
className="people-group-chip people-section-add"
onClick={() => setShowNewSection((current) => !current)}
>
+ Add section
</button> </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

View 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>
);
}

View file

@ -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>

View file

@ -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>
); );

View file

@ -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; }