diff --git a/backend/app/main.py b/backend/app/main.py
index 4f55f1e..7f181b1 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -46,6 +46,17 @@ async def migrate_user_org_columns(connection) -> None:
await connection.execute(
text("UPDATE users SET member_status = 'active' WHERE member_status IS NULL")
)
+ await connection.execute(
+ text(
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS invitation_email VARCHAR(320)"
+ )
+ )
+ await connection.execute(
+ text(
+ "ALTER TABLE users ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN "
+ "DEFAULT FALSE"
+ )
+ )
async def migrate_legacy_user_tables(connection) -> None:
diff --git a/backend/app/models.py b/backend/app/models.py
index 3ef4521..44d42c6 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -48,6 +48,8 @@ class Account(Base):
ForeignKey("users.id"),
nullable=True,
)
+ invitation_email: Mapped[str | None] = mapped_column(String(320), nullable=True)
+ must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 22038cb..de9f687 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -14,7 +14,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_postgres_session
from app.models import Account
-from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest
+from app.schemas import (
+ AccountRegister,
+ AccountRead,
+ AuthResponse,
+ LoginRequest,
+ PasswordChangeRequest,
+)
from app.services.domain_policy import assert_company_domain_allowed
from app.services.domain_provisioning import account_is_domain_owner
from app.services.domain_provisioning import (
@@ -223,12 +229,35 @@ async def login(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password.",
)
+ if account.member_status == "invited":
+ account.member_status = "active"
+ await session.commit()
return AuthResponse(
access_token=create_access_token(account),
account=await account_read(session, account),
)
+@router.post("/change-password", response_model=AccountRead)
+async def change_password(
+ payload: PasswordChangeRequest,
+ account: Account = Depends(get_current_account),
+ session: AsyncSession = Depends(get_postgres_session),
+) -> AccountRead:
+ validate_password(payload.new_password)
+ if not password_hash.verify(payload.current_password, account.password_hash):
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Current password is incorrect.",
+ )
+ account.password_hash = password_hash.hash(payload.new_password)
+ account.must_change_password = False
+ if account.member_status == "invited":
+ account.member_status = "active"
+ await session.commit()
+ return await account_read(session, account)
+
+
@router.get("/me", response_model=AccountRead)
async def get_me(
account: Account = Depends(get_current_account),
diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py
index 0476a71..66d1106 100644
--- a/backend/app/routers/people.py
+++ b/backend/app/routers/people.py
@@ -39,8 +39,7 @@ async def add_person(
manager: Account = Depends(require_org_manager),
session: AsyncSession = Depends(get_postgres_session),
) -> OrgMemberRead:
- if not payload.send_invitation:
- validate_password(payload.password or "")
+ validate_password(payload.password)
try:
member = await create_org_member(
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 1fc53ef..549b2e9 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -35,6 +35,7 @@ class AccountRead(BaseModel):
phone_number: str
company_domain: str | None
is_domain_owner: bool = False
+ must_change_password: bool = False
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@@ -55,18 +56,22 @@ class OrgMemberCreate(BaseModel):
first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80)
email: EmailStr
+ invitation_email: EmailStr | None = None
org_role: Literal["user", "admin"] = "user"
send_invitation: bool = True
- password: str | None = Field(default=None, min_length=8, max_length=128)
+ force_password_change: bool = False
+ password: str = Field(min_length=8, max_length=128)
class OrgMemberRead(BaseModel):
id: UUID
email: EmailStr
+ invitation_email: EmailStr | None = None
first_name: str
last_name: str
org_role: str
member_status: str
+ must_change_password: bool = False
is_domain_owner: bool = False
groups: list[str] = Field(default_factory=list)
created_at: datetime
@@ -74,6 +79,11 @@ class OrgMemberRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
+class PasswordChangeRequest(BaseModel):
+ current_password: str = Field(min_length=1, max_length=128)
+ new_password: str = Field(min_length=8, max_length=128)
+
+
class OrgMemberListResponse(BaseModel):
items: list[OrgMemberRead]
counts: dict[str, int]
diff --git a/backend/app/services/org_invitations.py b/backend/app/services/org_invitations.py
new file mode 100644
index 0000000..4a2cbab
--- /dev/null
+++ b/backend/app/services/org_invitations.py
@@ -0,0 +1,71 @@
+"""Employee invitation emails sent from the platform domain."""
+
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import get_settings
+from app.models import Account, MailDomain
+from app.services.domain_provisioning import get_domain_by_name
+from app.services.mail_sender import deliver_email
+
+
+async def send_employee_invitation(
+ session: AsyncSession,
+ *,
+ member: Account,
+ manager: Account,
+ initial_password: str,
+ force_password_change: bool,
+) -> None:
+ if not member.invitation_email:
+ raise ValueError("Invitation email is required.")
+
+ settings = get_settings()
+ platform_domain = await get_domain_by_name(session, settings.platform_domain)
+ if platform_domain is None:
+ raise ValueError("Platform mail domain is not configured.")
+
+ login_url = f"https://{settings.app_public_host}/login"
+ manager_name = f"{manager.first_name} {manager.last_name}".strip()
+ company = member.company_domain or settings.platform_domain
+
+ change_note = (
+ "You will be asked to choose a new password immediately after signing in."
+ if force_password_change
+ else "You can change your password later from account settings."
+ )
+
+ body_text = (
+ f"Hi {member.first_name},\n\n"
+ f"{manager_name} invited you to AGMail for {company}.\n\n"
+ f"Sign in at: {login_url}\n"
+ f"Work email: {member.email}\n"
+ f"Temporary password: {initial_password}\n\n"
+ f"{change_note}\n\n"
+ "— AGMail"
+ )
+ body_html = (
+ f"
Hi {member.first_name},
"
+ f"{manager_name} invited you to AGMail for "
+ f"{company}.
"
+ f"Sign in to AGMail
"
+ f""
+ f"- Work email: {member.email}
"
+ f"- Temporary password: {initial_password}
"
+ f"
"
+ f"{change_note}
"
+ f"— AGMail
"
+ )
+
+ sender = f"AGMail "
+ await deliver_email(
+ sender=sender,
+ recipients=[member.invitation_email],
+ cc=[],
+ bcc=[],
+ subject=f"You're invited to {company} on AGMail",
+ body_text=body_text,
+ body_html=body_html,
+ domain=platform_domain,
+ )
diff --git a/backend/app/services/org_members.py b/backend/app/services/org_members.py
index 6421801..2bae670 100644
--- a/backend/app/services/org_members.py
+++ b/backend/app/services/org_members.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import secrets
from datetime import date
+
from pwdlib import PasswordHash
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
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
password_hasher = PasswordHash.recommended()
PLACEHOLDER_BIRTHDATE = date(1990, 1, 1)
@@ -46,10 +48,12 @@ async def member_read(
return OrgMemberRead(
id=member.id,
email=member.email,
+ invitation_email=member.invitation_email,
first_name=member.first_name,
last_name=member.last_name,
org_role=role,
member_status=member.member_status,
+ must_change_password=member.must_change_password,
is_domain_owner=is_owner,
groups=[],
created_at=member.created_at,
@@ -84,10 +88,6 @@ def _placeholder_phone() -> str:
return f"+1{digits}"
-def _email_local_part(email: str) -> str:
- return email.split("@", 1)[0].lower()
-
-
async def create_org_member(
session: AsyncSession,
*,
@@ -102,13 +102,18 @@ async def create_org_member(
if not email.endswith(f"@{company_domain}"):
raise ValueError(f"Email must use your company domain @{company_domain}.")
+ invitation_email = (
+ str(payload.invitation_email).lower() if payload.invitation_email else None
+ )
if payload.send_invitation:
- raw_password = secrets.token_urlsafe(24)
+ if not invitation_email:
+ raise ValueError("A personal invitation email is required.")
+ if invitation_email.endswith(f"@{company_domain}"):
+ raise ValueError(
+ "Use a personal email address for the invitation, not your company domain."
+ )
member_status = "invited"
else:
- if not payload.password:
- raise ValueError("Password is required when invitation email is disabled.")
- raw_password = payload.password
member_status = "active"
existing = await session.scalar(
@@ -135,11 +140,23 @@ async def create_org_member(
company_domain=company_domain,
org_role=payload.org_role,
member_status=member_status,
+ invitation_email=invitation_email,
+ must_change_password=payload.force_password_change,
created_by_id=manager.id,
- password_hash=password_hasher.hash(raw_password),
+ password_hash=password_hasher.hash(payload.password),
)
session.add(member)
await session.flush()
+
+ if payload.send_invitation:
+ await send_employee_invitation(
+ session,
+ member=member,
+ manager=manager,
+ initial_password=payload.password,
+ force_password_change=payload.force_password_change,
+ )
+
return await member_read(session, member)
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 192ac44..00289d7 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -66,6 +66,8 @@ export const updateOrgSecurity = (payload, token) =>
request("/org/security", { method: "PATCH", payload, token });
export const updateOrgMemberRole = (memberId, payload, token) =>
request(`/people/${memberId}/role`, { method: "PATCH", payload, token });
+export const changePassword = (payload, token) =>
+ request("/auth/change-password", { method: "POST", payload, token });
export const sendEmail = (payload, token) =>
request("/emails", { method: "POST", payload, token });
diff --git a/frontend/src/components/AddEmployeeForm.jsx b/frontend/src/components/AddEmployeeForm.jsx
new file mode 100644
index 0000000..1608dd2
--- /dev/null
+++ b/frontend/src/components/AddEmployeeForm.jsx
@@ -0,0 +1,299 @@
+import { ArrowRight, Plus } from "lucide-react";
+import { useMemo, useState } from "react";
+import { createOrgMember } 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 }) {
+ return (
+
+ {children}
+ *
+
+ );
+}
+
+function SetupField({ label, error, children, hint }) {
+ return (
+
+ );
+}
+
+function validateForm(form, companyDomain) {
+ const errors = {};
+ if (!form.first_name.trim()) errors.first_name = "Please enter a first name.";
+ if (!form.last_name.trim()) errors.last_name = "Please enter a last name.";
+ const local = form.email_local.trim();
+ if (!local) {
+ errors.email_local = "Please enter a work email address.";
+ } else if (!EMAIL_LOCAL_PATTERN.test(local)) {
+ errors.email_local = "Enter a valid email address before @.";
+ }
+ if (!form.password.trim()) {
+ errors.password = "Please set an initial password.";
+ }
+ if (form.send_invitation) {
+ const invite = form.invitation_email.trim();
+ if (!invite) {
+ errors.invitation_email = "Please enter a personal email for the invitation.";
+ } else if (invite.endsWith(`@${companyDomain}`)) {
+ errors.invitation_email = "Use a personal email, not your company domain.";
+ }
+ }
+ return errors;
+}
+
+export default function AddEmployeeForm({
+ companyDomain,
+ platformDomain = "gentic.email",
+ variant = "drawer",
+ submitLabel = "Create user",
+ onCreated,
+ onCancel,
+}) {
+ const token = sessionStorage.getItem("agmail_access_token");
+ const [form, setForm] = useState({
+ first_name: "",
+ last_name: "",
+ email_local: "",
+ invitation_email: "",
+ org_role: "user",
+ groups: [],
+ send_invitation: true,
+ force_password_change: true,
+ password: "",
+ });
+ const [fieldErrors, setFieldErrors] = useState({});
+ const [submitAttempted, setSubmitAttempted] = useState(false);
+ const [status, setStatus] = useState({ loading: false, error: "" });
+
+ const formValid = useMemo(
+ () => Object.keys(validateForm(form, companyDomain)).length === 0,
+ [form, companyDomain],
+ );
+
+ function updateField(field, value) {
+ setForm((current) => {
+ const next = { ...current, [field]: value };
+ if (submitAttempted || fieldErrors[field]) {
+ const nextErrors = validateForm(next, companyDomain);
+ setFieldErrors((errors) => ({ ...errors, [field]: nextErrors[field] }));
+ }
+ return next;
+ });
+ }
+
+ function toggleGroup(group) {
+ setForm((current) => {
+ const exists = current.groups.includes(group);
+ return {
+ ...current,
+ groups: exists
+ ? current.groups.filter((item) => item !== group)
+ : [...current.groups, group],
+ };
+ });
+ }
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+ setSubmitAttempted(true);
+ const errors = validateForm(form, companyDomain);
+ setFieldErrors(errors);
+ if (Object.keys(errors).length > 0) {
+ return;
+ }
+
+ setStatus({ loading: true, error: "" });
+ const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`;
+ try {
+ const member = await createOrgMember(
+ {
+ first_name: form.first_name.trim(),
+ last_name: form.last_name.trim(),
+ email,
+ invitation_email: form.send_invitation
+ ? form.invitation_email.trim().toLowerCase()
+ : undefined,
+ org_role: form.org_role,
+ send_invitation: form.send_invitation,
+ force_password_change: form.force_password_change,
+ password: form.password,
+ },
+ token,
+ );
+ setForm({
+ first_name: "",
+ last_name: "",
+ email_local: "",
+ invitation_email: "",
+ org_role: "user",
+ groups: [],
+ send_invitation: true,
+ force_password_change: true,
+ password: "",
+ });
+ setFieldErrors({});
+ setSubmitAttempted(false);
+ setStatus({ loading: false, error: "" });
+ onCreated?.(member);
+ } catch (error) {
+ setStatus({ loading: false, error: error.message });
+ }
+ }
+
+ const formClass = variant === "inline"
+ ? "org-setup-form employee-add-form"
+ : "people-drawer-form employee-add-form";
+
+ return (
+
+ );
+}
diff --git a/frontend/src/components/CompanyOverviewSetup.jsx b/frontend/src/components/CompanyOverviewSetup.jsx
index 1a5cfba..d3e9e50 100644
--- a/frontend/src/components/CompanyOverviewSetup.jsx
+++ b/frontend/src/components/CompanyOverviewSetup.jsx
@@ -1,5 +1,6 @@
import { ArrowRight, Check } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
+import AddEmployeeForm from "./AddEmployeeForm";
const STEPS = [
{ id: 1, title: "Add domain", hint: "Use your company domain for email" },
@@ -126,6 +127,7 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
const [details, setDetails] = useState(() => loadDetails(account, companyDomain));
const [fieldErrors, setFieldErrors] = useState({});
const [submitAttempted, setSubmitAttempted] = useState(false);
+ const [addedEmployees, setAddedEmployees] = useState([]);
useEffect(() => {
localStorage.setItem(storageKey(account.id, "step"), String(step));
@@ -296,20 +298,41 @@ export default function CompanyOverviewSetup({ account, companyDomain, onComplet
)}
{step === 5 && (
-
-
Add employees
-
- Invite teammates to {companyDomainName}. You can add people now or skip and do this later from People.
-
-
- Employee invites will be available here soon.
+ <>
+
+
Add employees
+
+ Invite teammates to {companyDomainName}. You can add people now or skip and do this later from People.
+
-
-
+
+ {addedEmployees.length > 0 && (
+
+ {addedEmployees.map((employee) => (
+ -
+ {employee.first_name} {employee.last_name}
+ {employee.email}
+ {employee.member_status === "invited" ? "Invited" : "Active"}
+
+ ))}
+
+ )}
+
+
setAddedEmployees((current) => [...current, member])}
+ />
+
+
+
+
+
-
+ >
)}
{step === 6 && (
diff --git a/frontend/src/components/CompanyPeople.jsx b/frontend/src/components/CompanyPeople.jsx
index 4c0e234..97954f7 100644
--- a/frontend/src/components/CompanyPeople.jsx
+++ b/frontend/src/components/CompanyPeople.jsx
@@ -9,7 +9,8 @@ import {
XCircle,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { createOrgMember, listOrgMembers } from "../api";
+import { listOrgMembers } from "../api";
+import AddEmployeeForm from "./AddEmployeeForm";
const STATUS_TABS = [
{ key: "all", label: "All users" },
@@ -18,8 +19,6 @@ const STATUS_TABS = [
{ key: "suspended", label: "Suspended" },
];
-const GROUP_OPTIONS = ["Admin", "HR", "Engineering", "Finance"];
-
function initials(firstName, lastName) {
return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase();
}
@@ -60,57 +59,6 @@ function StatusBadge({ status }) {
}
function AddUserPanel({ companyDomain, onClose, onCreated }) {
- const token = sessionStorage.getItem("agmail_access_token");
- const [form, setForm] = useState({
- first_name: "",
- last_name: "",
- email_local: "",
- org_role: "user",
- groups: [],
- send_invitation: true,
- password: "",
- });
- const [status, setStatus] = useState({ loading: false, error: "" });
-
- function updateField(field, value) {
- setForm((current) => ({ ...current, [field]: value }));
- }
-
- function toggleGroup(group) {
- setForm((current) => {
- const exists = current.groups.includes(group);
- return {
- ...current,
- groups: exists
- ? current.groups.filter((item) => item !== group)
- : [...current.groups, group],
- };
- });
- }
-
- async function handleSubmit(event) {
- event.preventDefault();
- setStatus({ loading: true, error: "" });
- const email = `${form.email_local.trim().toLowerCase()}@${companyDomain}`;
- try {
- const member = await createOrgMember(
- {
- first_name: form.first_name.trim(),
- last_name: form.last_name.trim(),
- email,
- org_role: form.org_role,
- send_invitation: form.send_invitation,
- password: form.send_invitation ? undefined : form.password,
- },
- token,
- );
- onCreated?.(member);
- onClose();
- } catch (error) {
- setStatus({ loading: false, error: error.message });
- }
- }
-
return (
);
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 44f90a0..640432c 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -226,6 +226,14 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.org-setup-summary { margin:0 0 24px; padding:0; list-style:none; display:grid; gap:10px; }
.org-setup-summary li { display:flex; align-items:center; gap:8px; color:#3d3948; font-size:13px; font-weight:500; }
.org-setup-summary svg { color:#7357F6; flex-shrink:0; }
+.employee-add-form .people-drawer-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
+.employee-add-form .people-groups-field { margin:0; padding:0; border:0; }
+.employee-added-list { margin:0 0 20px; padding:0; max-width:720px; list-style:none; display:grid; gap:8px; }
+.employee-added-list li { padding:12px 14px; display:grid; grid-template-columns:1fr auto auto; gap:12px; align-items:center; border:1px solid #ECE9F2; border-radius:10px; background:#FAFAFC; font-size:13px; }
+.employee-added-list strong { color:#1a1628; }
+.employee-added-list span { color:#6f6c7b; }
+.employee-added-list em { color:#7357F6; font-size:11px; font-weight:700; font-style:normal; text-transform:uppercase; letter-spacing:.3px; }
+.org-setup-step-actions { margin-top:8px; }
.dashboard-sidebar-manage { margin-top:var(--space-4); }
@media(max-width:900px) {
.org-setup-head h1 { font-size:28px; }