From ee23b71f0fe925a540cbad3bc76bf44cffa7ed35 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Sun, 4 Oct 2026 20:42:40 +0530 Subject: [PATCH] add users --- backend/app/main.py | 11 + backend/app/models.py | 2 + backend/app/routers/auth.py | 31 +- backend/app/routers/people.py | 3 +- backend/app/schemas.py | 12 +- backend/app/services/org_invitations.py | 71 +++++ backend/app/services/org_members.py | 35 +- frontend/src/api.js | 2 + frontend/src/components/AddEmployeeForm.jsx | 299 ++++++++++++++++++ .../src/components/CompanyOverviewSetup.jsx | 47 ++- frontend/src/components/CompanyPeople.jsx | 167 +--------- frontend/src/styles.css | 8 + 12 files changed, 506 insertions(+), 182 deletions(-) create mode 100644 backend/app/services/org_invitations.py create mode 100644 frontend/src/components/AddEmployeeForm.jsx 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"

{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 ( +
+
+
+ First name} + error={fieldErrors.first_name} + > + updateField("first_name", event.target.value)} + placeholder="Rahul" + /> + + Last name} + error={fieldErrors.last_name} + > + updateField("last_name", event.target.value)} + placeholder="Sharma" + /> + +
+ + Work email} + error={fieldErrors.email_local} + hint="This will be their login email address on your company domain." + > +
+ updateField("email_local", event.target.value)} + placeholder="rahul" + /> + @{companyDomain} +
+
+ + Invitation email} + error={fieldErrors.invitation_email} + hint={`Personal email where AGMail sends the setup invite from @${platformDomain}.`} + > + updateField("invitation_email", event.target.value)} + placeholder="rahul@gmail.com" + disabled={!form.send_invitation} + /> + + + + + + +
+ Groups +
+ {GROUP_OPTIONS.map((group) => ( + + ))} +
+
+ + Initial password} + error={fieldErrors.password} + hint="Must include uppercase, lowercase, and a number." + > + updateField("password", event.target.value)} + placeholder="Set an initial password" + /> + + + + + + + {status.error &&

{status.error}

} + +
+ {onCancel && ( + + )} + +
+
+
+ ); +} 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; }