- {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"
- />
-
-
-