add users

This commit is contained in:
debu-2 2026-10-04 20:42:40 +05:30
parent c01914736b
commit ee23b71f0f
12 changed files with 506 additions and 182 deletions

View file

@ -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:

View file

@ -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),

View file

@ -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),

View file

@ -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(

View file

@ -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]

View 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,
)

View file

@ -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)