add users
This commit is contained in:
parent
c01914736b
commit
ee23b71f0f
12 changed files with 506 additions and 182 deletions
71
backend/app/services/org_invitations.py
Normal file
71
backend/app/services/org_invitations.py
Normal file
|
|
@ -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"<p>Hi {member.first_name},</p>"
|
||||
f"<p><strong>{manager_name}</strong> invited you to AGMail for "
|
||||
f"<strong>{company}</strong>.</p>"
|
||||
f"<p><a href=\"{login_url}\">Sign in to AGMail</a></p>"
|
||||
f"<ul>"
|
||||
f"<li><strong>Work email:</strong> {member.email}</li>"
|
||||
f"<li><strong>Temporary password:</strong> {initial_password}</li>"
|
||||
f"</ul>"
|
||||
f"<p>{change_note}</p>"
|
||||
f"<p>— AGMail</p>"
|
||||
)
|
||||
|
||||
sender = f"AGMail <noreply@{settings.platform_domain}>"
|
||||
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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue