browsertrix/backend/btrixcloud/invites.py
Tessa Walsh 8a904c9031
feat: Rename org when accepting org invite for first admin (#1870)
Resolves https://github.com/webrecorder/browsertrix/issues/1874

Support for new two-part sign up flow if first admin user is added to org
- If new user, user registers first, then is able to change the org name / slug on following screen
- If existing user, user accepts invite, then is able to change the org name / slug on following screen
- After confirming org slug name, user is taken to dashboard, or error is shown if org name or slug already taken.
- If org name == org id, org name and slug is automatically set to `{Your Name}'s Archive` when first user is registered / accepts invite
- Email templates updated to better reflect new / existing users and not show org name if it is 'unset' (org name == org id internally)
- tests: frontend unit testing for accept + invite screens.

---------
Co-authored-by: Ilya Kreymer <ikreymer@gmail.com>
Co-authored-by: sua yoo <sua@suayoo.com>
Co-authored-by: sua yoo <sua@webrecorder.org>
Co-authored-by: Henry Wilkinson <henry@wilkinson.graphics>
Co-authored-by: Ilya Kreymer <ikreymer@users.noreply.github.com>
Co-authored-by: Emma Segal-Grossman <hi@emma.cafe>
2024-06-27 16:08:31 -07:00

209 lines
6.9 KiB
Python

""" Invite system management """
from datetime import datetime
from typing import Optional
import os
import urllib.parse
import time
from uuid import UUID, uuid4
from pymongo.errors import AutoReconnect
from fastapi import HTTPException
from .pagination import DEFAULT_PAGE_SIZE
from .models import UserRole, InvitePending, InviteRequest, User
from .users import UserManager
from .utils import is_bool
# ============================================================================
class InviteOps:
"""invite users (optionally to an org), send emails and delete invites"""
def __init__(self, mdb, email):
self.invites = mdb["invites"]
self.orgs = mdb["organizations"]
self.email = email
self.allow_dupe_invites = is_bool(os.environ.get("ALLOW_DUPE_INVITES", "0"))
async def init_index(self):
"""Create TTL index so that invites auto-expire"""
while True:
try:
# Default to 7 days
expire_after_seconds = int(
os.environ.get("INVITE_EXPIRE_SECONDS", "604800")
)
return await self.invites.create_index(
"created", expireAfterSeconds=expire_after_seconds
)
# pylint: disable=duplicate-code
except AutoReconnect:
print(
"Database connection unavailable to create index. Will try again in 5 scconds",
flush=True,
)
time.sleep(5)
async def add_new_user_invite(
self,
new_user_invite: InvitePending,
org_name: Optional[str],
headers: Optional[dict],
):
"""Add invite for new user"""
res = await self.invites.find_one(
{"email": new_user_invite.email, "oid": new_user_invite.oid}
)
if res and not self.allow_dupe_invites:
raise HTTPException(
status_code=403, detail="This user has already been invited"
)
# Invitations to a specific org via API must include role, so if it's
# absent assume this is a general invitation from superadmin.
if not new_user_invite.role:
new_user_invite.role = UserRole.OWNER
if res:
await self.invites.delete_one({"_id": res["_id"]})
await self.invites.insert_one(new_user_invite.to_dict())
self.email.send_new_user_invite(new_user_invite, org_name, headers)
async def get_valid_invite(self, invite_token: UUID, email: str):
"""Retrieve a valid invite data from db, or throw if invalid"""
invite_data = await self.invites.find_one({"_id": invite_token})
if not invite_data:
raise HTTPException(status_code=400, detail="Invalid Invite Code")
new_user_invite = InvitePending.from_dict(invite_data)
if email != new_user_invite.email:
raise HTTPException(status_code=400, detail="Invalid Invite Code")
return new_user_invite
async def remove_invite(self, invite_token: UUID):
"""remove invite from invite list"""
await self.invites.delete_one({"_id": invite_token})
async def remove_invite_by_email(self, email: str, oid: Optional[UUID] = None):
"""remove invite from invite list by email"""
query: dict[str, object] = {"email": email}
if oid:
query["oid"] = oid
# Use delete_many rather than delete_one to clean up any duplicate
# invites as well.
return await self.invites.delete_many(query)
async def accept_user_invite(self, user, invite_token: str, user_manager):
"""remove invite from user, if valid token, throw if not"""
invite = user.invites.pop(invite_token, "")
if not invite:
raise HTTPException(status_code=400, detail="Invalid Invite Code")
# update user with removed invite
await user_manager.update_invites(user)
return invite
# pylint: disable=too-many-arguments
async def invite_user(
self,
invite: InviteRequest,
user: User,
user_manager: UserManager,
org=None,
allow_existing=False,
headers: Optional[dict] = None,
):
"""Invite user to org (if not specified, to default org).
If allow_existing is false, don't allow invites to existing users.
:returns: is_new_user (bool), invite token (str)
"""
invite_code = uuid4().hex
org_name: str
if org:
oid = org.id
org_name = org.name if str(org.name) != str(org.id) else ""
else:
default_org = await self.orgs.find_one({"default": True})
oid = default_org["_id"]
org_name = default_org["name"]
invite_pending = InvitePending(
id=invite_code,
oid=oid,
created=datetime.utcnow(),
role=invite.role if hasattr(invite, "role") else None,
# URL decode email address just in case
email=urllib.parse.unquote(invite.email),
inviterEmail=user.email,
fromSuperuser=user.is_superuser,
)
# user being invited
invitee_user = await user_manager.get_by_email(invite.email)
if not invitee_user:
await self.add_new_user_invite(
invite_pending,
org_name,
headers,
)
return True
if not allow_existing:
raise HTTPException(status_code=400, detail="User already registered")
if invitee_user.email == user.email:
raise HTTPException(status_code=400, detail="Can't invite ourselves!")
if org.users.get(str(invitee_user.id)):
raise HTTPException(
status_code=400, detail="User already a member of this organization."
)
# no need to store invitee's email as adding to an existing invitee
invite_pending.email = None
invitee_user.invites[invite_code] = invite_pending
await user_manager.update_invites(invitee_user)
self.email.send_existing_user_invite(
invite_pending, org_name, invitee_user.email, invite_code, headers
)
return False
async def get_pending_invites(
self, org=None, page_size: int = DEFAULT_PAGE_SIZE, page: int = 1
):
"""return list of pending invites."""
# Zero-index page for query
page = page - 1
skip = page_size * page
match_query = {}
if org:
match_query["oid"] = org.id
total = await self.invites.count_documents(match_query)
cursor = self.invites.find(match_query, skip=skip, limit=page_size)
results = await cursor.to_list(length=page_size)
invites = [InvitePending.from_dict(res) for res in results]
return invites, total
def init_invites(mdb, email):
"""init InviteOps"""
return InviteOps(mdb, email)