email template

This commit is contained in:
debu-2 2026-10-05 21:00:05 +05:30
parent ebbb582e99
commit 2451045693
15 changed files with 447 additions and 156 deletions

View file

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

View file

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

View file

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

View file

@ -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"""<!DOCTYPE html>
<html lang="en">
@ -142,63 +108,43 @@ def build_employee_invitation_email(
</tr>
<tr>
<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},
</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"
style="margin:0 0 24px;background-color:#fff3ef;border:1px solid #ffe1d8;border-radius:14px;">
<tr>
<td style="padding:18px 20px;">
<p style="margin:0 0 12px;font-size:12px;font-weight:700;letter-spacing:0.4px;
text-transform:uppercase;color:#c85e49;">Account details</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0"
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>
<p style="margin:0 0 8px;font-size:12px;font-weight:700;letter-spacing:0.4px;
text-transform:uppercase;color:#c85e49;">Your work email</p>
<p style="margin:0;font-size:15px;font-weight:600;color:#080821;">{safe_email}</p>
</td>
</tr>
</table>
<table role="presentation" cellspacing="0" cellpadding="0" style="margin:0 0 24px;">
<tr>
<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;">
Sign in to {BRAND_NAME}
Set up your account
</a>
</td>
</tr>
</table>
<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 style="margin:0;font-size:14px;line-height:1.65;color:#67677a;">
{safe_unexpected}
If you weren&apos;t expecting this invitation,<br />
you can safely ignore this email.
</p>
</td>
</tr>
<tr>
<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;">
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}.
</p>
</td>
</tr>

View 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

View file

@ -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} <noreply@{settings.platform_domain}>"
sender = f"{BRAND_NAME} <{INVITE_FROM_LOCAL}@{settings.platform_domain}>"
await deliver_email(
sender=sender,
recipients=[member.invitation_email],

View file

@ -86,7 +86,13 @@ async def import_org_members(
row_errors.append("Duplicate work email in this file.")
seen_emails.add(work_email)
password = row.password.strip()
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)
@ -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,
),
)
)

View file

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

View file

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

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

View file

@ -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 <LoginPage />;
}
if (window.location.pathname.startsWith("/invite/")) {
return <InviteAcceptPage />;
}
if (window.location.pathname === "/signup") {
return <SignupPage />;
}

View file

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

View file

@ -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({
<SetupField
label={form.send_invitation ? <RequiredLabel>Invitation email</RequiredLabel> : "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}>
<input
@ -340,6 +340,22 @@ export default function AddEmployeeForm({
</InputWrap>
</SetupField>
<div className="add-user-checkboxes">
<label className="add-user-checkbox">
<input
type="checkbox"
checked={form.send_invitation}
onChange={(event) => updateField("send_invitation", event.target.checked)}
/>
<span>
<strong>Send invitation email</strong>
<small>They will receive a secure link to set their password (expires in 48 hours).</small>
</span>
</label>
</div>
{!form.send_invitation && (
<>
<SetupField
label={<RequiredLabel>Initial password</RequiredLabel>}
error={fieldErrors.password}
@ -355,19 +371,6 @@ export default function AddEmployeeForm({
</InputWrap>
</SetupField>
<div className="add-user-checkboxes">
<label className="add-user-checkbox">
<input
type="checkbox"
checked={form.send_invitation}
onChange={(event) => updateField("send_invitation", event.target.checked)}
/>
<span>
<strong>Send invitation email</strong>
<small>They will receive an activation link to set their password.</small>
</span>
</label>
<label className="add-user-checkbox">
<input
type="checkbox"
@ -379,7 +382,8 @@ export default function AddEmployeeForm({
<small>User must create a new password when they sign in for the first time.</small>
</span>
</label>
</div>
</>
)}
{status.error && <p className="form-error" role="alert">{status.error}</p>}

View 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>
);
}

View file

@ -131,11 +131,15 @@ export function validateImportRows(rows, { companyDomain, sendInvitation }) {
seenEmails.add(workEmail);
const password = row.password.trim();
if (!sendInvitation) {
if (!password) {
rowErrors.push("Initial password is required.");
} 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();
if (sendInvitation) {
@ -171,7 +175,7 @@ export function rowsToImportPayload(rows, options) {
? row.work_email.trim().toLowerCase()
: `${row.work_email.trim().toLowerCase()}@${companyDomain}`,
invitation_email: row.invitation_email.trim() || undefined,
password: row.password.trim(),
password: row.password.trim() || undefined,
})),
};
}