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,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
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)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,13 +155,18 @@ 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:
|
||||
try:
|
||||
await send_employee_invitation(
|
||||
session,
|
||||
member=member,
|
||||
|
|
@ -156,8 +174,13 @@ async def create_org_member(
|
|||
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(
|
||||
|
|
|
|||
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 });
|
||||
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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
function selectSection(sectionId) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
groups: exists
|
||||
? current.groups.filter((item) => item !== group)
|
||||
: [...current.groups, group],
|
||||
};
|
||||
});
|
||||
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({
|
|||
</SetupField>
|
||||
|
||||
<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">
|
||||
{GROUP_OPTIONS.map((group) => (
|
||||
{sectionsLoading ? (
|
||||
<span className="people-section-loading">Loading sections…</span>
|
||||
) : (
|
||||
sections.map((section) => (
|
||||
<button
|
||||
key={group}
|
||||
key={section.id}
|
||||
type="button"
|
||||
className={`people-group-chip${form.groups.includes(group) ? " active" : ""}`}
|
||||
onClick={() => toggleGroup(group)}
|
||||
className={`people-group-chip${form.section_id === section.id ? " active" : ""}`}
|
||||
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>
|
||||
))}
|
||||
</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>
|
||||
|
||||
<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";
|
||||
}
|
||||
|
||||
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 }) {
|
|||
</select>
|
||||
</div>
|
||||
|
||||
{notice && <p className="people-notice" role="status">{notice}</p>}
|
||||
{error && <p className="form-error" role="alert">{error}</p>}
|
||||
|
||||
<div className="people-table-wrap">
|
||||
|
|
@ -198,7 +197,7 @@ export default function CompanyPeople({ account }) {
|
|||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Role</th>
|
||||
<th>Group</th>
|
||||
<th>Section</th>
|
||||
<th>Storage</th>
|
||||
<th>Last active</th>
|
||||
<th aria-label="Actions" />
|
||||
|
|
@ -230,7 +229,7 @@ export default function CompanyPeople({ account }) {
|
|||
<td>{member.email}</td>
|
||||
<td><StatusBadge status={member.member_status} /></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>
|
||||
|
|
@ -249,7 +248,17 @@ export default function CompanyPeople({ account }) {
|
|||
<AddUserPanel
|
||||
companyDomain={companyDomain}
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
</main>
|
||||
{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} />}
|
||||
</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-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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue