diff --git a/backend/app/models.py b/backend/app/models.py index 789940d..eee3e7c 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -144,6 +144,32 @@ class SmtpCredential(Base): domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials") +class MemberInvitation(Base): + __tablename__ = "member_invitations" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + account_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + index=True, + ) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + used_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + account: Mapped[Account] = relationship() + + class OrgSection(Base): __tablename__ = "org_sections" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index de9f687..4a173ca 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -18,9 +18,12 @@ from app.schemas import ( AccountRegister, AccountRead, AuthResponse, + InviteAcceptRequest, + InvitePreview, LoginRequest, PasswordChangeRequest, ) +from app.services.member_invitations import get_valid_invitation 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 ( @@ -230,8 +233,65 @@ async def login( detail="Incorrect email or password.", ) if account.member_status == "invited": - account.member_status = "active" - await session.commit() + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Accept your invitation email to set your password before signing in.", + ) + return AuthResponse( + access_token=create_access_token(account), + account=await account_read(session, account), + ) + + +@router.get("/invites/{token}", response_model=InvitePreview) +async def preview_invite( + token: str, + session: AsyncSession = Depends(get_postgres_session), +) -> InvitePreview: + try: + invitation, account = await get_valid_invitation(session, token) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(error), + ) from error + + manager_name = "Your administrator" + if account.created_by_id: + manager = await session.get(Account, account.created_by_id) + if manager is not None: + manager_name = f"{manager.first_name} {manager.last_name}".strip() + + return InvitePreview( + work_email=account.email, + first_name=account.first_name, + organization=account.company_domain or settings.platform_domain, + invited_by=manager_name, + expires_at=invitation.expires_at, + ) + + +@router.post("/invites/{token}/accept", response_model=AuthResponse) +async def accept_invite( + token: str, + payload: InviteAcceptRequest, + session: AsyncSession = Depends(get_postgres_session), +) -> AuthResponse: + validate_password(payload.password) + try: + invitation, account = await get_valid_invitation(session, token) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(error), + ) from error + + account.password_hash = password_hash.hash(payload.password) + account.member_status = "active" + account.must_change_password = False + invitation.used_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(account) return AuthResponse( access_token=create_access_token(account), account=await account_read(session, account), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 7282965..b9ecd4f 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -3,7 +3,7 @@ from datetime import date from typing import Literal from uuid import UUID -from pydantic import BaseModel, ConfigDict, EmailStr, Field +from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator class AccountRegister(BaseModel): @@ -61,7 +61,13 @@ class OrgMemberCreate(BaseModel): org_role: Literal["user", "admin"] = "user" send_invitation: bool = True force_password_change: bool = False - password: str = Field(min_length=8, max_length=128) + password: str | None = Field(default=None, min_length=8, max_length=128) + + @model_validator(mode="after") + def password_required_without_invite(self) -> OrgMemberCreate: + if not self.send_invitation and not self.password: + raise ValueError("Password is required when not sending an invitation.") + return self class OrgMemberRead(BaseModel): @@ -99,6 +105,18 @@ class PasswordChangeRequest(BaseModel): new_password: str = Field(min_length=8, max_length=128) +class InvitePreview(BaseModel): + work_email: EmailStr + first_name: str + organization: str + invited_by: str + expires_at: datetime + + +class InviteAcceptRequest(BaseModel): + password: str = Field(min_length=8, max_length=128) + + class OrgMemberListResponse(BaseModel): items: list[OrgMemberRead] counts: dict[str, int] @@ -109,7 +127,7 @@ class OrgMemberImportRow(BaseModel): last_name: str = Field(min_length=1, max_length=80) work_email: str = Field(min_length=1, max_length=320) invitation_email: EmailStr | None = None - password: str = Field(min_length=8, max_length=128) + password: str | None = Field(default=None, min_length=8, max_length=128) class OrgMemberImportRequest(BaseModel): diff --git a/backend/app/services/email_templates.py b/backend/app/services/email_templates.py index 7dc9f63..9bbbca2 100644 --- a/backend/app/services/email_templates.py +++ b/backend/app/services/email_templates.py @@ -17,77 +17,43 @@ def build_employee_invitation_email( member_first_name: str, member_email: str, manager_name: str, - manager_email: str, company_domain: str, - login_url: str, - initial_password: str, - force_password_change: bool, + invite_url: str, + expires_hours: int = 48, ) -> tuple[str, str, str]: first_name = member_first_name.strip() or "there" organization = company_domain.strip() manager = manager_name.strip() or "Your administrator" - manager_address = manager_email.strip() - - security_note = ( - "For your security, you will be asked to create a new password " - "immediately after your first sign-in." - if force_password_change - else ( - "We recommend updating your password after your first sign-in " - "from your account settings." - ) - ) subject = f"{manager} invited you to join {organization}" - why_received = ( - f"You are receiving this because {manager} invited you to a work email " - f"account at {organization} on {BRAND_NAME}. It was sent to the personal " - f"email address your administrator provided so you can complete your " - f"first sign-in." - ) - - unexpected_contact = ( - f"If you were not expecting this invitation, please contact " - f"{manager} at {manager_address}." - if manager_address - else "If you were not expecting this invitation, please contact your administrator." - ) - body_text = ( f"Hello {first_name},\n\n" - f"{why_received}\n\n" - f"Organization: {organization}\n" - f"Work email address: {member_email}\n" - f"Sign-in page: {login_url}\n\n" - f"Sign-in credentials\n" - f" Email: {member_email}\n" - f" Initial password: {initial_password}\n\n" - f"{security_note}\n\n" - f"{unexpected_contact}\n\n" + f"{manager} invited you to join {organization}.\n\n" + f"Your work email:\n" + f"{member_email}\n\n" + f"Set up your account:\n" + f"{invite_url}\n\n" + f"This invitation expires in {expires_hours} hours.\n\n" + f"If you weren't expecting this invitation,\n" + f"you can safely ignore this email.\n\n" f"—\n" f"{BRAND_NAME}\n" - f"{login_url.rsplit('/login', 1)[0] or login_url}\n" - f"This is a one-time account notification from {BRAND_NAME} on behalf of " - f"{organization}. You cannot unsubscribe from required account emails." + f"This invitation was sent because {manager} created an account " + f"for you at {organization}." ) preheader = ( f"{manager} invited you to join {organization}. " - f"Use the sign-in details below to access your work inbox." + f"Set up your work email account." ) safe_first = _escape(first_name) safe_manager = _escape(manager) safe_org = _escape(organization) safe_email = _escape(member_email) - safe_password = _escape(initial_password) - safe_login = _escape(login_url) - safe_manager_email = _escape(manager_address) + safe_invite = _escape(invite_url) safe_preheader = _escape(preheader) - safe_security = _escape(security_note) - safe_unexpected = _escape(unexpected_contact) - safe_why_received = _escape(why_received) body_html = f""" @@ -142,63 +108,43 @@ def build_employee_invitation_email( -

+

Hello {safe_first},

-

- {safe_why_received} -

-

Account details

- - - - - - - - - - - - - -
Organization{safe_org}
Work email{safe_email}
Initial password{safe_password}
+

Your work email

+

{safe_email}

- - Sign in to {BRAND_NAME} + Set up your account

- {safe_security} + This invitation expires in {expires_hours} hours.

- {safe_unexpected} + If you weren't expecting this invitation,
+ you can safely ignore this email.

-

- {BRAND_NAME} · {BRAND_HANDLE} -

- One-time account notification from {BRAND_NAME} on behalf of {safe_org}. - {" Questions? Reply to " + safe_manager_email + "." if manager_address else ""} + This invitation was sent because {safe_manager} created an account + for you at {safe_org}.

diff --git a/backend/app/services/member_invitations.py b/backend/app/services/member_invitations.py new file mode 100644 index 0000000..b28dec1 --- /dev/null +++ b/backend/app/services/member_invitations.py @@ -0,0 +1,79 @@ +"""Single-use invitation tokens for employee onboarding.""" + +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.models import Account, MemberInvitation + +INVITE_EXPIRY_HOURS = 48 +INVITE_FROM_LOCAL = "invites" + + +def hash_invite_token(raw_token: str) -> str: + return hashlib.sha256(raw_token.encode("utf-8")).hexdigest() + + +def generate_invite_token() -> str: + return secrets.token_urlsafe(32) + + +async def invalidate_pending_invitations( + session: AsyncSession, + account_id, +) -> None: + invitations = await session.scalars( + select(MemberInvitation).where( + MemberInvitation.account_id == account_id, + MemberInvitation.used_at.is_(None), + ) + ) + now = datetime.now(timezone.utc) + for invitation in invitations.all(): + invitation.used_at = now + + +async def create_member_invitation( + session: AsyncSession, + member: Account, +) -> str: + await invalidate_pending_invitations(session, member.id) + raw_token = generate_invite_token() + invitation = MemberInvitation( + account_id=member.id, + token_hash=hash_invite_token(raw_token), + expires_at=datetime.now(timezone.utc) + + timedelta(hours=INVITE_EXPIRY_HOURS), + ) + session.add(invitation) + await session.flush() + return raw_token + + +async def get_valid_invitation( + session: AsyncSession, + raw_token: str, +) -> tuple[MemberInvitation, Account]: + token_hash = hash_invite_token(raw_token.strip()) + invitation = await session.scalar( + select(MemberInvitation) + .options(selectinload(MemberInvitation.account)) + .where(MemberInvitation.token_hash == token_hash) + ) + if invitation is None: + raise ValueError("This invitation link is invalid.") + if invitation.used_at is not None: + raise ValueError("This invitation has already been used.") + now = datetime.now(timezone.utc) + if invitation.expires_at <= now: + raise ValueError("This invitation has expired.") + account = invitation.account + if account is None or account.member_status != "invited": + raise ValueError("This invitation is no longer valid.") + return invitation, account diff --git a/backend/app/services/org_invitations.py b/backend/app/services/org_invitations.py index a669ddd..238a99d 100644 --- a/backend/app/services/org_invitations.py +++ b/backend/app/services/org_invitations.py @@ -9,6 +9,11 @@ from app.models import Account from app.services.domain_provisioning import get_domain_by_name from app.services.email_templates import BRAND_NAME, build_employee_invitation_email from app.services.mail_sender import deliver_email +from app.services.member_invitations import ( + INVITE_EXPIRY_HOURS, + INVITE_FROM_LOCAL, + create_member_invitation, +) async def send_employee_invitation( @@ -16,8 +21,6 @@ async def send_employee_invitation( *, member: Account, manager: Account, - initial_password: str, - force_password_change: bool, ) -> None: if not member.invitation_email: raise ValueError("Invitation email is required.") @@ -27,7 +30,8 @@ async def send_employee_invitation( if platform_domain is None: raise ValueError("Platform mail domain is not configured.") - login_url = f"https://{settings.app_public_host}/login" + raw_token = await create_member_invitation(session, member) + invite_url = f"https://{settings.app_public_host}/invite/{raw_token}" manager_name = f"{manager.first_name} {manager.last_name}".strip() company = member.company_domain or settings.platform_domain @@ -35,14 +39,12 @@ async def send_employee_invitation( member_first_name=member.first_name, member_email=member.email, manager_name=manager_name, - manager_email=manager.email, company_domain=company, - login_url=login_url, - initial_password=initial_password, - force_password_change=force_password_change, + invite_url=invite_url, + expires_hours=INVITE_EXPIRY_HOURS, ) - sender = f"{BRAND_NAME} " + sender = f"{BRAND_NAME} <{INVITE_FROM_LOCAL}@{settings.platform_domain}>" await deliver_email( sender=sender, recipients=[member.invitation_email], diff --git a/backend/app/services/org_member_import.py b/backend/app/services/org_member_import.py index ef9580e..3cc7e6c 100644 --- a/backend/app/services/org_member_import.py +++ b/backend/app/services/org_member_import.py @@ -86,10 +86,16 @@ async def import_org_members( row_errors.append("Duplicate work email in this file.") seen_emails.add(work_email) - password = row.password.strip() - password_issue = _password_error(password) - if password_issue: - row_errors.append(password_issue) + password = (row.password or "").strip() + if payload.send_invitation: + if password: + row_errors.append( + "Password is not needed when sending invitation emails." + ) + else: + password_issue = _password_error(password) + if password_issue: + row_errors.append(password_issue) invitation_email = ( str(row.invitation_email).lower().strip() @@ -132,7 +138,7 @@ async def import_org_members( org_role=payload.org_role, send_invitation=payload.send_invitation, force_password_change=payload.force_password_change, - password=password, + password=password or None, ), ) ) diff --git a/backend/app/services/org_members.py b/backend/app/services/org_members.py index dbda878..e5697dd 100644 --- a/backend/app/services/org_members.py +++ b/backend/app/services/org_members.py @@ -154,10 +154,16 @@ async def create_org_member( org_role=payload.org_role, member_status=member_status, invitation_email=invitation_email, - must_change_password=payload.force_password_change, + must_change_password=( + payload.force_password_change if not payload.send_invitation else False + ), section_id=section.id if section else None, created_by_id=manager.id, - password_hash=password_hasher.hash(payload.password), + password_hash=password_hasher.hash( + payload.password + if payload.password + else secrets.token_urlsafe(48) + ), ) session.add(member) await session.flush() @@ -171,8 +177,6 @@ async def create_org_member( session, member=member, manager=manager, - initial_password=payload.password, - force_password_change=payload.force_password_change, ) invitation_sent = True except Exception as error: diff --git a/backend/tests/test_email_templates.py b/backend/tests/test_email_templates.py index ece3b16..2ff5975 100644 --- a/backend/tests/test_email_templates.py +++ b/backend/tests/test_email_templates.py @@ -1,30 +1,24 @@ from app.services.email_templates import build_employee_invitation_email -def test_employee_invitation_uses_gentic_branding(): +def test_employee_invitation_uses_token_link_not_password(): subject, body_text, body_html = build_employee_invitation_email( member_first_name="Anurag", member_email="anurag@road.rentals", manager_name="Suyash Dixit", - manager_email="suyash@road.rentals", company_domain="road.rentals", - login_url="https://gentic.email/login", - initial_password="Roadrentals123", - force_password_change=True, + invite_url="https://gentic.email/invite/test-token", + expires_hours=48, ) assert subject == "Suyash Dixit invited you to join road.rentals" - assert "AGMail" not in subject - assert "aGentic Email" not in subject + assert "password" not in body_text.lower() + assert "Sign-in credentials" not in body_text + assert "https://gentic.email/invite/test-token" in body_text + assert "expires in 48 hours" in body_text + assert "safely ignore this email" in body_text - assert "aGentic Email" in body_text - assert "AGMail" not in body_text - assert "You are receiving this because Suyash Dixit invited you" in body_text - assert "cannot unsubscribe from required account emails" in body_text - assert "anurag@road.rentals" in body_text - - assert "gentic.email" in body_html - assert "Work email invitation" in body_html - assert "invited you to join" in body_html - assert "Sign in to aGentic Email" in body_html - assert "AGMail" not in body_html + assert "Set up your account" in body_html + assert "test-token" in body_html + assert "Initial password" not in body_html + assert "unsubscribe" not in body_html.lower() diff --git a/backend/tests/test_member_invitations.py b/backend/tests/test_member_invitations.py new file mode 100644 index 0000000..66998bf --- /dev/null +++ b/backend/tests/test_member_invitations.py @@ -0,0 +1,8 @@ +import hashlib + +from app.services.member_invitations import hash_invite_token + + +def test_hash_invite_token_is_sha256_hex(): + token = "example-token" + assert hash_invite_token(token) == hashlib.sha256(token.encode("utf-8")).hexdigest() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6414866..701da23 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,4 +1,5 @@ import DashboardPage from "./components/DashboardPage"; +import InviteAcceptPage from "./components/InviteAcceptPage"; import LoginPage from "./components/LoginPage"; import MobileLanding from "./components/MobileLanding"; import SignupPage from "./components/SignupPage"; @@ -12,6 +13,9 @@ export default function App() { if (window.location.pathname === "/login") { return ; } + if (window.location.pathname.startsWith("/invite/")) { + return ; + } if (window.location.pathname === "/signup") { return ; } diff --git a/frontend/src/api.js b/frontend/src/api.js index 32a7f78..6bc5b81 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -28,6 +28,13 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {}) export const login = (payload) => request("/auth/login", { method: "POST", payload }); +export const previewInvite = (token) => + request(`/auth/invites/${encodeURIComponent(token)}`); +export const acceptInvite = (token, payload) => + request(`/auth/invites/${encodeURIComponent(token)}/accept`, { + method: "POST", + payload, + }); export const register = (payload) => request("/auth/register", { method: "POST", payload }); export const getCurrentAccount = (token) => diff --git a/frontend/src/components/AddEmployeeForm.jsx b/frontend/src/components/AddEmployeeForm.jsx index a1db5c1..5cae01e 100644 --- a/frontend/src/components/AddEmployeeForm.jsx +++ b/frontend/src/components/AddEmployeeForm.jsx @@ -43,7 +43,7 @@ function validateForm(form, companyDomain) { } else if (!EMAIL_LOCAL_PATTERN.test(local)) { errors.email_local = "Enter a valid email address before @."; } - if (!form.password.trim()) { + if (!form.send_invitation && !form.password.trim()) { errors.password = "Please set an initial password."; } if (form.send_invitation) { @@ -178,8 +178,8 @@ export default function AddEmployeeForm({ section_id: form.section_id || undefined, org_role: form.org_role, send_invitation: form.send_invitation, - force_password_change: form.force_password_change, - password: form.password, + force_password_change: form.send_invitation ? false : form.force_password_change, + password: form.send_invitation ? undefined : form.password, }, token, ); @@ -327,7 +327,7 @@ export default function AddEmployeeForm({ Invitation email : "Invitation email"} error={fieldErrors.invitation_email} - hint={`Personal email where aGentic Email sends the setup invite from @${platformDomain}.`} + hint={`Personal email where we send the setup invite from invites@${platformDomain}.`} > - Initial password} - error={fieldErrors.password} - hint="Must include uppercase, lowercase, and a number." - > - - updateField("password", event.target.value)} - placeholder="Set an initial password" - /> - - -
- -
+ {!form.send_invitation && ( + <> + 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}

}