email template
This commit is contained in:
parent
ebbb582e99
commit
2451045693
15 changed files with 447 additions and 156 deletions
|
|
@ -144,6 +144,32 @@ class SmtpCredential(Base):
|
||||||
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
|
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):
|
class OrgSection(Base):
|
||||||
__tablename__ = "org_sections"
|
__tablename__ = "org_sections"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,12 @@ from app.schemas import (
|
||||||
AccountRegister,
|
AccountRegister,
|
||||||
AccountRead,
|
AccountRead,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
InviteAcceptRequest,
|
||||||
|
InvitePreview,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
PasswordChangeRequest,
|
PasswordChangeRequest,
|
||||||
)
|
)
|
||||||
|
from app.services.member_invitations import get_valid_invitation
|
||||||
from app.services.domain_policy import assert_company_domain_allowed
|
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 account_is_domain_owner
|
||||||
from app.services.domain_provisioning import (
|
from app.services.domain_provisioning import (
|
||||||
|
|
@ -230,8 +233,65 @@ async def login(
|
||||||
detail="Incorrect email or password.",
|
detail="Incorrect email or password.",
|
||||||
)
|
)
|
||||||
if account.member_status == "invited":
|
if account.member_status == "invited":
|
||||||
account.member_status = "active"
|
raise HTTPException(
|
||||||
await session.commit()
|
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(
|
return AuthResponse(
|
||||||
access_token=create_access_token(account),
|
access_token=create_access_token(account),
|
||||||
account=await account_read(session, account),
|
account=await account_read(session, account),
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from datetime import date
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
class AccountRegister(BaseModel):
|
class AccountRegister(BaseModel):
|
||||||
|
|
@ -61,7 +61,13 @@ class OrgMemberCreate(BaseModel):
|
||||||
org_role: Literal["user", "admin"] = "user"
|
org_role: Literal["user", "admin"] = "user"
|
||||||
send_invitation: bool = True
|
send_invitation: bool = True
|
||||||
force_password_change: bool = False
|
force_password_change: bool = False
|
||||||
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):
|
class OrgMemberRead(BaseModel):
|
||||||
|
|
@ -99,6 +105,18 @@ class PasswordChangeRequest(BaseModel):
|
||||||
new_password: str = Field(min_length=8, max_length=128)
|
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):
|
class OrgMemberListResponse(BaseModel):
|
||||||
items: list[OrgMemberRead]
|
items: list[OrgMemberRead]
|
||||||
counts: dict[str, int]
|
counts: dict[str, int]
|
||||||
|
|
@ -109,7 +127,7 @@ class OrgMemberImportRow(BaseModel):
|
||||||
last_name: str = Field(min_length=1, max_length=80)
|
last_name: str = Field(min_length=1, max_length=80)
|
||||||
work_email: str = Field(min_length=1, max_length=320)
|
work_email: str = Field(min_length=1, max_length=320)
|
||||||
invitation_email: EmailStr | None = None
|
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):
|
class OrgMemberImportRequest(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -17,77 +17,43 @@ def build_employee_invitation_email(
|
||||||
member_first_name: str,
|
member_first_name: str,
|
||||||
member_email: str,
|
member_email: str,
|
||||||
manager_name: str,
|
manager_name: str,
|
||||||
manager_email: str,
|
|
||||||
company_domain: str,
|
company_domain: str,
|
||||||
login_url: str,
|
invite_url: str,
|
||||||
initial_password: str,
|
expires_hours: int = 48,
|
||||||
force_password_change: bool,
|
|
||||||
) -> tuple[str, str, str]:
|
) -> tuple[str, str, str]:
|
||||||
first_name = member_first_name.strip() or "there"
|
first_name = member_first_name.strip() or "there"
|
||||||
organization = company_domain.strip()
|
organization = company_domain.strip()
|
||||||
manager = manager_name.strip() or "Your administrator"
|
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}"
|
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 = (
|
body_text = (
|
||||||
f"Hello {first_name},\n\n"
|
f"Hello {first_name},\n\n"
|
||||||
f"{why_received}\n\n"
|
f"{manager} invited you to join {organization}.\n\n"
|
||||||
f"Organization: {organization}\n"
|
f"Your work email:\n"
|
||||||
f"Work email address: {member_email}\n"
|
f"{member_email}\n\n"
|
||||||
f"Sign-in page: {login_url}\n\n"
|
f"Set up your account:\n"
|
||||||
f"Sign-in credentials\n"
|
f"{invite_url}\n\n"
|
||||||
f" Email: {member_email}\n"
|
f"This invitation expires in {expires_hours} hours.\n\n"
|
||||||
f" Initial password: {initial_password}\n\n"
|
f"If you weren't expecting this invitation,\n"
|
||||||
f"{security_note}\n\n"
|
f"you can safely ignore this email.\n\n"
|
||||||
f"{unexpected_contact}\n\n"
|
|
||||||
f"—\n"
|
f"—\n"
|
||||||
f"{BRAND_NAME}\n"
|
f"{BRAND_NAME}\n"
|
||||||
f"{login_url.rsplit('/login', 1)[0] or login_url}\n"
|
f"This invitation was sent because {manager} created an account "
|
||||||
f"This is a one-time account notification from {BRAND_NAME} on behalf of "
|
f"for you at {organization}."
|
||||||
f"{organization}. You cannot unsubscribe from required account emails."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
preheader = (
|
preheader = (
|
||||||
f"{manager} invited you to join {organization}. "
|
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_first = _escape(first_name)
|
||||||
safe_manager = _escape(manager)
|
safe_manager = _escape(manager)
|
||||||
safe_org = _escape(organization)
|
safe_org = _escape(organization)
|
||||||
safe_email = _escape(member_email)
|
safe_email = _escape(member_email)
|
||||||
safe_password = _escape(initial_password)
|
safe_invite = _escape(invite_url)
|
||||||
safe_login = _escape(login_url)
|
|
||||||
safe_manager_email = _escape(manager_address)
|
|
||||||
safe_preheader = _escape(preheader)
|
safe_preheader = _escape(preheader)
|
||||||
safe_security = _escape(security_note)
|
|
||||||
safe_unexpected = _escape(unexpected_contact)
|
|
||||||
safe_why_received = _escape(why_received)
|
|
||||||
|
|
||||||
body_html = f"""<!DOCTYPE html>
|
body_html = f"""<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -142,63 +108,43 @@ def build_employee_invitation_email(
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:28px 32px 8px;">
|
<td style="padding:28px 32px 8px;">
|
||||||
<p style="margin:0 0 16px;font-size:16px;line-height:1.65;color:#080821;">
|
<p style="margin:0 0 24px;font-size:16px;line-height:1.65;color:#080821;">
|
||||||
Hello {safe_first},
|
Hello {safe_first},
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0 0 24px;font-size:15px;line-height:1.7;color:#67677a;">
|
|
||||||
{safe_why_received}
|
|
||||||
</p>
|
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
||||||
style="margin:0 0 24px;background-color:#fff3ef;border:1px solid #ffe1d8;border-radius:14px;">
|
style="margin:0 0 24px;background-color:#fff3ef;border:1px solid #ffe1d8;border-radius:14px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:18px 20px;">
|
<td style="padding:18px 20px;">
|
||||||
<p style="margin:0 0 12px;font-size:12px;font-weight:700;letter-spacing:0.4px;
|
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:0.4px;
|
||||||
text-transform:uppercase;color:#c85e49;">Account details</p>
|
text-transform:uppercase;color:#c85e49;">Your work email</p>
|
||||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
|
<p style="margin:0;font-size:15px;font-weight:600;color:#080821;">{safe_email}</p>
|
||||||
style="font-size:14px;line-height:1.6;color:#080821;">
|
|
||||||
<tr>
|
|
||||||
<td style="padding:4px 0;color:#67677a;width:130px;vertical-align:top;">Organization</td>
|
|
||||||
<td style="padding:4px 0;font-weight:600;">{safe_org}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:4px 0;color:#67677a;vertical-align:top;">Work email</td>
|
|
||||||
<td style="padding:4px 0;font-weight:600;">{safe_email}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td style="padding:4px 0;color:#67677a;vertical-align:top;">Initial password</td>
|
|
||||||
<td style="padding:4px 0;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
||||||
font-weight:600;letter-spacing:0.3px;">{safe_password}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:0 0 24px;">
|
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:0 0 24px;">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="border-radius:12px;background-color:#080821;">
|
<td style="border-radius:12px;background-color:#080821;">
|
||||||
<a href="{safe_login}" style="display:inline-block;padding:14px 24px;font-size:15px;
|
<a href="{safe_invite}" style="display:inline-block;padding:14px 24px;font-size:15px;
|
||||||
font-weight:700;color:#ffffff;text-decoration:none;border-radius:12px;">
|
font-weight:700;color:#ffffff;text-decoration:none;border-radius:12px;">
|
||||||
Sign in to {BRAND_NAME}
|
Set up your account
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="margin:0 0 16px;font-size:14px;line-height:1.65;color:#67677a;">
|
<p style="margin:0 0 16px;font-size:14px;line-height:1.65;color:#67677a;">
|
||||||
{safe_security}
|
This invitation expires in {expires_hours} hours.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;font-size:14px;line-height:1.65;color:#67677a;">
|
<p style="margin:0;font-size:14px;line-height:1.65;color:#67677a;">
|
||||||
{safe_unexpected}
|
If you weren't expecting this invitation,<br />
|
||||||
|
you can safely ignore this email.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 32px 28px;border-top:1px solid #f0ebe7;background-color:#faf8f6;">
|
<td style="padding:20px 32px 28px;border-top:1px solid #f0ebe7;background-color:#faf8f6;">
|
||||||
<p style="margin:0 0 6px;font-size:12px;line-height:1.6;color:#888795;">
|
|
||||||
{BRAND_NAME} · {BRAND_HANDLE}
|
|
||||||
</p>
|
|
||||||
<p style="margin:0;font-size:12px;line-height:1.6;color:#a8a4ae;">
|
<p style="margin:0;font-size:12px;line-height:1.6;color:#a8a4ae;">
|
||||||
One-time account notification from {BRAND_NAME} on behalf of {safe_org}.
|
This invitation was sent because {safe_manager} created an account
|
||||||
{" Questions? Reply to " + safe_manager_email + "." if manager_address else ""}
|
for you at {safe_org}.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
79
backend/app/services/member_invitations.py
Normal file
79
backend/app/services/member_invitations.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -9,6 +9,11 @@ from app.models import Account
|
||||||
from app.services.domain_provisioning import get_domain_by_name
|
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.email_templates import BRAND_NAME, build_employee_invitation_email
|
||||||
from app.services.mail_sender import deliver_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(
|
async def send_employee_invitation(
|
||||||
|
|
@ -16,8 +21,6 @@ async def send_employee_invitation(
|
||||||
*,
|
*,
|
||||||
member: Account,
|
member: Account,
|
||||||
manager: Account,
|
manager: Account,
|
||||||
initial_password: str,
|
|
||||||
force_password_change: bool,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if not member.invitation_email:
|
if not member.invitation_email:
|
||||||
raise ValueError("Invitation email is required.")
|
raise ValueError("Invitation email is required.")
|
||||||
|
|
@ -27,7 +30,8 @@ async def send_employee_invitation(
|
||||||
if platform_domain is None:
|
if platform_domain is None:
|
||||||
raise ValueError("Platform mail domain is not configured.")
|
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()
|
manager_name = f"{manager.first_name} {manager.last_name}".strip()
|
||||||
company = member.company_domain or settings.platform_domain
|
company = member.company_domain or settings.platform_domain
|
||||||
|
|
||||||
|
|
@ -35,14 +39,12 @@ async def send_employee_invitation(
|
||||||
member_first_name=member.first_name,
|
member_first_name=member.first_name,
|
||||||
member_email=member.email,
|
member_email=member.email,
|
||||||
manager_name=manager_name,
|
manager_name=manager_name,
|
||||||
manager_email=manager.email,
|
|
||||||
company_domain=company,
|
company_domain=company,
|
||||||
login_url=login_url,
|
invite_url=invite_url,
|
||||||
initial_password=initial_password,
|
expires_hours=INVITE_EXPIRY_HOURS,
|
||||||
force_password_change=force_password_change,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
sender = f"{BRAND_NAME} <noreply@{settings.platform_domain}>"
|
sender = f"{BRAND_NAME} <{INVITE_FROM_LOCAL}@{settings.platform_domain}>"
|
||||||
await deliver_email(
|
await deliver_email(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
recipients=[member.invitation_email],
|
recipients=[member.invitation_email],
|
||||||
|
|
|
||||||
|
|
@ -86,10 +86,16 @@ async def import_org_members(
|
||||||
row_errors.append("Duplicate work email in this file.")
|
row_errors.append("Duplicate work email in this file.")
|
||||||
seen_emails.add(work_email)
|
seen_emails.add(work_email)
|
||||||
|
|
||||||
password = row.password.strip()
|
password = (row.password or "").strip()
|
||||||
password_issue = _password_error(password)
|
if payload.send_invitation:
|
||||||
if password_issue:
|
if password:
|
||||||
row_errors.append(password_issue)
|
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 = (
|
invitation_email = (
|
||||||
str(row.invitation_email).lower().strip()
|
str(row.invitation_email).lower().strip()
|
||||||
|
|
@ -132,7 +138,7 @@ async def import_org_members(
|
||||||
org_role=payload.org_role,
|
org_role=payload.org_role,
|
||||||
send_invitation=payload.send_invitation,
|
send_invitation=payload.send_invitation,
|
||||||
force_password_change=payload.force_password_change,
|
force_password_change=payload.force_password_change,
|
||||||
password=password,
|
password=password or None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -154,10 +154,16 @@ async def create_org_member(
|
||||||
org_role=payload.org_role,
|
org_role=payload.org_role,
|
||||||
member_status=member_status,
|
member_status=member_status,
|
||||||
invitation_email=invitation_email,
|
invitation_email=invitation_email,
|
||||||
must_change_password=payload.force_password_change,
|
must_change_password=(
|
||||||
|
payload.force_password_change if not payload.send_invitation else False
|
||||||
|
),
|
||||||
section_id=section.id if section else None,
|
section_id=section.id if section else None,
|
||||||
created_by_id=manager.id,
|
created_by_id=manager.id,
|
||||||
password_hash=password_hasher.hash(payload.password),
|
password_hash=password_hasher.hash(
|
||||||
|
payload.password
|
||||||
|
if payload.password
|
||||||
|
else secrets.token_urlsafe(48)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
session.add(member)
|
session.add(member)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|
@ -171,8 +177,6 @@ async def create_org_member(
|
||||||
session,
|
session,
|
||||||
member=member,
|
member=member,
|
||||||
manager=manager,
|
manager=manager,
|
||||||
initial_password=payload.password,
|
|
||||||
force_password_change=payload.force_password_change,
|
|
||||||
)
|
)
|
||||||
invitation_sent = True
|
invitation_sent = True
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,24 @@
|
||||||
from app.services.email_templates import build_employee_invitation_email
|
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(
|
subject, body_text, body_html = build_employee_invitation_email(
|
||||||
member_first_name="Anurag",
|
member_first_name="Anurag",
|
||||||
member_email="anurag@road.rentals",
|
member_email="anurag@road.rentals",
|
||||||
manager_name="Suyash Dixit",
|
manager_name="Suyash Dixit",
|
||||||
manager_email="suyash@road.rentals",
|
|
||||||
company_domain="road.rentals",
|
company_domain="road.rentals",
|
||||||
login_url="https://gentic.email/login",
|
invite_url="https://gentic.email/invite/test-token",
|
||||||
initial_password="Roadrentals123",
|
expires_hours=48,
|
||||||
force_password_change=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert subject == "Suyash Dixit invited you to join road.rentals"
|
assert subject == "Suyash Dixit invited you to join road.rentals"
|
||||||
assert "AGMail" not in subject
|
assert "password" not in body_text.lower()
|
||||||
assert "aGentic Email" not in subject
|
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 "Set up your account" in body_html
|
||||||
assert "AGMail" not in body_text
|
assert "test-token" in body_html
|
||||||
assert "You are receiving this because Suyash Dixit invited you" in body_text
|
assert "Initial password" not in body_html
|
||||||
assert "cannot unsubscribe from required account emails" in body_text
|
assert "unsubscribe" not in body_html.lower()
|
||||||
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
|
|
||||||
|
|
|
||||||
8
backend/tests/test_member_invitations.py
Normal file
8
backend/tests/test_member_invitations.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import DashboardPage from "./components/DashboardPage";
|
import DashboardPage from "./components/DashboardPage";
|
||||||
|
import InviteAcceptPage from "./components/InviteAcceptPage";
|
||||||
import LoginPage from "./components/LoginPage";
|
import LoginPage from "./components/LoginPage";
|
||||||
import MobileLanding from "./components/MobileLanding";
|
import MobileLanding from "./components/MobileLanding";
|
||||||
import SignupPage from "./components/SignupPage";
|
import SignupPage from "./components/SignupPage";
|
||||||
|
|
@ -12,6 +13,9 @@ export default function App() {
|
||||||
if (window.location.pathname === "/login") {
|
if (window.location.pathname === "/login") {
|
||||||
return <LoginPage />;
|
return <LoginPage />;
|
||||||
}
|
}
|
||||||
|
if (window.location.pathname.startsWith("/invite/")) {
|
||||||
|
return <InviteAcceptPage />;
|
||||||
|
}
|
||||||
if (window.location.pathname === "/signup") {
|
if (window.location.pathname === "/signup") {
|
||||||
return <SignupPage />;
|
return <SignupPage />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,13 @@ async function request(path, { method = "GET", payload, token, basicAuth } = {})
|
||||||
|
|
||||||
export const login = (payload) =>
|
export const login = (payload) =>
|
||||||
request("/auth/login", { method: "POST", 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) =>
|
export const register = (payload) =>
|
||||||
request("/auth/register", { method: "POST", payload });
|
request("/auth/register", { method: "POST", payload });
|
||||||
export const getCurrentAccount = (token) =>
|
export const getCurrentAccount = (token) =>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ function validateForm(form, companyDomain) {
|
||||||
} else if (!EMAIL_LOCAL_PATTERN.test(local)) {
|
} else if (!EMAIL_LOCAL_PATTERN.test(local)) {
|
||||||
errors.email_local = "Enter a valid email address before @.";
|
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.";
|
errors.password = "Please set an initial password.";
|
||||||
}
|
}
|
||||||
if (form.send_invitation) {
|
if (form.send_invitation) {
|
||||||
|
|
@ -178,8 +178,8 @@ export default function AddEmployeeForm({
|
||||||
section_id: form.section_id || undefined,
|
section_id: form.section_id || undefined,
|
||||||
org_role: form.org_role,
|
org_role: form.org_role,
|
||||||
send_invitation: form.send_invitation,
|
send_invitation: form.send_invitation,
|
||||||
force_password_change: form.force_password_change,
|
force_password_change: form.send_invitation ? false : form.force_password_change,
|
||||||
password: form.password,
|
password: form.send_invitation ? undefined : form.password,
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
);
|
);
|
||||||
|
|
@ -327,7 +327,7 @@ export default function AddEmployeeForm({
|
||||||
<SetupField
|
<SetupField
|
||||||
label={form.send_invitation ? <RequiredLabel>Invitation email</RequiredLabel> : "Invitation email"}
|
label={form.send_invitation ? <RequiredLabel>Invitation email</RequiredLabel> : "Invitation email"}
|
||||||
error={fieldErrors.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}.`}
|
||||||
>
|
>
|
||||||
<InputWrap icon={Mail}>
|
<InputWrap icon={Mail}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -340,21 +340,6 @@ export default function AddEmployeeForm({
|
||||||
</InputWrap>
|
</InputWrap>
|
||||||
</SetupField>
|
</SetupField>
|
||||||
|
|
||||||
<SetupField
|
|
||||||
label={<RequiredLabel>Initial password</RequiredLabel>}
|
|
||||||
error={fieldErrors.password}
|
|
||||||
hint="Must include uppercase, lowercase, and a number."
|
|
||||||
>
|
|
||||||
<InputWrap icon={Lock}>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(event) => updateField("password", event.target.value)}
|
|
||||||
placeholder="Set an initial password"
|
|
||||||
/>
|
|
||||||
</InputWrap>
|
|
||||||
</SetupField>
|
|
||||||
|
|
||||||
<div className="add-user-checkboxes">
|
<div className="add-user-checkboxes">
|
||||||
<label className="add-user-checkbox">
|
<label className="add-user-checkbox">
|
||||||
<input
|
<input
|
||||||
|
|
@ -364,23 +349,42 @@ export default function AddEmployeeForm({
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
<strong>Send invitation email</strong>
|
<strong>Send invitation email</strong>
|
||||||
<small>They will receive an activation link to set their password.</small>
|
<small>They will receive a secure link to set their password (expires in 48 hours).</small>
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="add-user-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={form.force_password_change}
|
|
||||||
onChange={(event) => updateField("force_password_change", event.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<strong>Require password change on first login</strong>
|
|
||||||
<small>User must create a new password when they sign in for the first time.</small>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!form.send_invitation && (
|
||||||
|
<>
|
||||||
|
<SetupField
|
||||||
|
label={<RequiredLabel>Initial password</RequiredLabel>}
|
||||||
|
error={fieldErrors.password}
|
||||||
|
hint="Must include uppercase, lowercase, and a number."
|
||||||
|
>
|
||||||
|
<InputWrap icon={Lock}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(event) => updateField("password", event.target.value)}
|
||||||
|
placeholder="Set an initial password"
|
||||||
|
/>
|
||||||
|
</InputWrap>
|
||||||
|
</SetupField>
|
||||||
|
|
||||||
|
<label className="add-user-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.force_password_change}
|
||||||
|
onChange={(event) => updateField("force_password_change", event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong>Require password change on first login</strong>
|
||||||
|
<small>User must create a new password when they sign in for the first time.</small>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||||
|
|
||||||
<footer className={variant === "inline" ? "org-setup-actions" : "add-user-actions"}>
|
<footer className={variant === "inline" ? "org-setup-actions" : "add-user-actions"}>
|
||||||
|
|
|
||||||
129
frontend/src/components/InviteAcceptPage.jsx
Normal file
129
frontend/src/components/InviteAcceptPage.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { ArrowRight, LockKeyhole } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { acceptInvite, previewInvite } from "../api";
|
||||||
|
import AuthLayout from "./AuthLayout";
|
||||||
|
|
||||||
|
function inviteTokenFromPath() {
|
||||||
|
const match = window.location.pathname.match(/^\/invite\/([^/]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InviteAcceptPage() {
|
||||||
|
const token = inviteTokenFromPath();
|
||||||
|
const [invite, setInvite] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState("");
|
||||||
|
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setLoadError("This invitation link is invalid.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewInvite(token)
|
||||||
|
.then((data) => setInvite(data))
|
||||||
|
.catch((error) => setLoadError(error.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
async function handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus({ loading: true, error: "" });
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
const password = form.get("password");
|
||||||
|
const confirm = form.get("confirm_password");
|
||||||
|
if (password !== confirm) {
|
||||||
|
setStatus({ loading: false, error: "Passwords do not match." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await acceptInvite(token, { password });
|
||||||
|
sessionStorage.setItem("agmail_access_token", result.access_token);
|
||||||
|
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
|
||||||
|
window.location.assign("/app");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus({ loading: false, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
eyebrow="Invitation"
|
||||||
|
title={<>Setting up<br /><em>your account.</em></>}
|
||||||
|
description="Verifying your invitation link…"
|
||||||
|
>
|
||||||
|
<p>Loading invitation…</p>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError || !invite) {
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
eyebrow="Invitation"
|
||||||
|
title={<>This link<br /><em>is not valid.</em></>}
|
||||||
|
description="The invitation may have expired or already been used."
|
||||||
|
>
|
||||||
|
<p className="form-error" role="alert">{loadError || "Invitation not found."}</p>
|
||||||
|
<p className="auth-switch"><a href="/login">Go to sign in</a></p>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout
|
||||||
|
eyebrow="Invitation"
|
||||||
|
title={<>Join <em>{invite.organization}</em></>}
|
||||||
|
description={`${invite.invited_by} invited you to set up your work email.`}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<div className="card-heading">
|
||||||
|
<h2>Set up your account</h2>
|
||||||
|
<p>
|
||||||
|
Your work email is <strong>{invite.work_email}</strong>.
|
||||||
|
Choose a password to activate your inbox.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form className="auth-form" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<div className="input-wrap">
|
||||||
|
<LockKeyhole size={16} />
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Create a password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Confirm password
|
||||||
|
<div className="input-wrap">
|
||||||
|
<LockKeyhole size={16} />
|
||||||
|
<input
|
||||||
|
name="confirm_password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<p className="auth-switch" style={{ marginTop: 0 }}>
|
||||||
|
Must include uppercase, lowercase, and a number.
|
||||||
|
</p>
|
||||||
|
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||||
|
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||||
|
{status.loading ? "Activating…" : "Set up your account"} <ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -131,10 +131,14 @@ export function validateImportRows(rows, { companyDomain, sendInvitation }) {
|
||||||
seenEmails.add(workEmail);
|
seenEmails.add(workEmail);
|
||||||
|
|
||||||
const password = row.password.trim();
|
const password = row.password.trim();
|
||||||
if (!password) {
|
if (!sendInvitation) {
|
||||||
rowErrors.push("Initial password is required.");
|
if (!password) {
|
||||||
} else if (!PASSWORD_PATTERN.test(password)) {
|
rowErrors.push("Initial password is required.");
|
||||||
rowErrors.push("Password must be at least 8 characters with upper, lower, and a number.");
|
} else if (!PASSWORD_PATTERN.test(password)) {
|
||||||
|
rowErrors.push("Password must be at least 8 characters with upper, lower, and a number.");
|
||||||
|
}
|
||||||
|
} else if (password) {
|
||||||
|
rowErrors.push("Password is not needed when sending invitation emails.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const invitationEmail = row.invitation_email.trim().toLowerCase();
|
const invitationEmail = row.invitation_email.trim().toLowerCase();
|
||||||
|
|
@ -171,7 +175,7 @@ export function rowsToImportPayload(rows, options) {
|
||||||
? row.work_email.trim().toLowerCase()
|
? row.work_email.trim().toLowerCase()
|
||||||
: `${row.work_email.trim().toLowerCase()}@${companyDomain}`,
|
: `${row.work_email.trim().toLowerCase()}@${companyDomain}`,
|
||||||
invitation_email: row.invitation_email.trim() || undefined,
|
invitation_email: row.invitation_email.trim() || undefined,
|
||||||
password: row.password.trim(),
|
password: row.password.trim() || undefined,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue