Skip to content

Allow sending email invites to people who are not already users on the platform #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
chriscarrollsmith opened this issue Apr 8, 2025 · 2 comments · Fixed by #123
Closed

Comments

@chriscarrollsmith
Copy link
Contributor

chriscarrollsmith commented Apr 8, 2025

Currently the invite functionality on the organization page doesn't trigger any email flow. It only adds a user if they have already registered.

flowchart TD
    %% Invitation Creation Process
    A[Inviter enters email & selects role] --> B{Validation}
    B -->|Valid| C[Generate unique token]
    B -->|Invalid| B1[Show error message]
    C --> D[Create Invitation record]
    D --> E[Send email with token link]
    E --> F[Show success message]
    
    %% Invitation Acceptance Process
    G[Invitee clicks link in email] --> H[Browser loads /invitations/accept]
    H --> I{Validate token}
    I -->|Invalid/Expired| I1[Show error page]
    I -->|Valid| J{Invitee has account?}
    
    %% User has account branch
    J -->|Yes| K{User logged in?}
    K -->|Yes| L{Email matches?}
    L -->|Yes| M[Add user to org with role]
    L -->|No| N[Show error: wrong account]
    K -->|No| O[Redirect to login with token]
    O --> P[User logs in]
    P --> L
    
    %% User doesn't have account branch
    J -->|No| Q[Redirect to register with email & token]
    Q --> R[User registers]
    R --> S[Add new user to org with role]
    
    %% Final steps
    M --> T[Mark invitation as used]
    S --> T
    T --> U[Redirect to organization page]
Loading
@chriscarrollsmith
Copy link
Contributor Author

chriscarrollsmith commented Apr 10, 2025

Core Problem: The current system only adds existing registered users directly to a role. We need a flow that:

  1. Sends an email invitation.
  2. Works whether the invitee is registered or not.
  3. Uses a secure token to track and validate the invitation.
  4. Allows the inviter to specify the role for the invitee.
  5. Prevents duplicate active invitations and adding already present members.

High-Level Implementation Plan & Flow:

  1. Initiation (Inviter Action):

    • On the organization page (/organizations/{org_id}), the inviter (User A) enters the invitee's email (User B's email) and selects a target role from a list of available roles in that organization.
    • The backend (POST /organizations/invite/{org_id}) validates:
      • User A has INVITE_USER permission for the organization.
      • The selected Role ID is valid for this organization.
      • User B (by email) is not already a member of the organization.
      • There isn't an active, unused, unexpired invitation already pending for User B's email in this organization.
    • If validation passes, the system generates a unique, time-limited invitation token.
    • A new Invitation record is created in the database, storing the inviter ID, organization ID, target role ID, invitee's email, the token, and its expiry time.
    • An email is sent (using resend and a template) to User B's email address containing a unique link like BASE_URL/invitations/accept?token=UNIQUE_TOKEN.
    • User A gets feedback (e.g., a success message on the organization page, maybe via flash message).
  2. Acceptance (Invitee Action):

    • User B clicks the link in the email.
    • The browser navigates to GET /invitations/accept?token=UNIQUE_TOKEN.
    • The backend validates the token: Is it present in the Invitation table? Is it unused? Has it expired?
    • If the token is invalid/expired/used, show an error page ("Invalid or Expired Invitation").
    • If the token is valid:
      • The system checks if an Account exists for the invitee_email stored in the valid Invitation record.
      • Scenario A: Invitee Has an Account:
        • Check if the user is currently logged in.
        • If logged in: Verify the logged-in user's email matches the invitee_email from the invitation. If yes, add the user to the specified organization and role, mark the invitation token as used, commit changes, and redirect them to the organization page (/organizations/{org_id}). If no match, show an error ("Please log in with the invited email address").
        • If not logged in: Redirect the user to the login page (/account/login), passing the invitation token along (e.g., /account/login?invitation_token=UNIQUE_TOKEN). After successful login, the login endpoint logic will detect the token, re-validate it, verify email match, add the user to the org/role, mark the token used, and redirect to the org page.
      • Scenario B: Invitee Does NOT Have an Account:
        • Redirect the user to the registration page (/account/register), pre-filling the email address and passing the invitation token along (e.g., /account/[email protected]&invitation_token=UNIQUE_TOKEN).
        • After successful registration, the registration endpoint logic will detect the token, re-validate it, add the newly created user to the specified org/role, mark the token used, and redirect to the org page (or perhaps the dashboard initially).

Detailed Component Breakdown:

  1. New Data Model (Invitation):

    • Purpose: To track pending invitations.
    • File: utils/models.py
    • Definition:
      from uuid import uuid4
      from datetime import datetime, UTC, timedelta
      from typing import Optional
      from pydantic import EmailStr
      from sqlmodel import SQLModel, Field, Relationship
      from sqlalchemy import Column, UniqueConstraint
      
      def utc_time():
          return datetime.now(UTC)
      
      class Invitation(SQLModel, table=True):
          id: Optional[int] = Field(default=None, primary_key=True)
          organization_id: int = Field(foreign_key="organization.id", index=True)
          role_id: int = Field(foreign_key="role.id") # Role they will get
          inviter_user_id: int = Field(foreign_key="user.id") # Who sent it
          invitee_email: EmailStr = Field(index=True) # Email invited
      
          token: str = Field(default_factory=lambda: str(uuid4()), index=True, unique=True)
          expires_at: datetime = Field(default_factory=lambda: utc_time() + timedelta(days=7)) # e.g., 7 days validity
          created_at: datetime = Field(default_factory=utc_time)
          used: bool = Field(default=False, index=True)
          accepted_at: Optional[datetime] = Field(default=None)
          accepted_by_user_id: Optional[int] = Field(default=None, foreign_key="user.id") # Which user accepted
      
          # Relationships (optional but helpful for queries)
          organization: "Organization" = Relationship() # Define back_populates if needed
          role: "Role" = Relationship()
          inviter: "User" = Relationship(sa_relationship_kwargs={'foreign_keys': '[Invitation.inviter_user_id]'})
          accepted_by: Optional["User"] = Relationship(sa_relationship_kwargs={'foreign_keys': '[Invitation.accepted_by_user_id]'})
      
          # Maybe a constraint to prevent multiple active invites for the same email/org?
          # This is tricky with SQL constraints alone due to the 'active' condition (unused + unexpired).
          # Add a check in the invite logic instead.
          # __table_args__ = (
          #     UniqueConstraint("organization_id", "invitee_email", "used", name="uq_invitation_org_email_used"),
          # )
      
          def is_expired(self) -> bool:
              return utc_time() > self.expires_at.replace(tzinfo=UTC)
      
          def is_active(self) -> bool:
              return not self.used and not self.is_expired()
      
      # Add relationships to Organization, Role, User if needed (e.g., list of invitations sent by user)
      # In Organization:
      # invitations: Mapped[List["Invitation"]] = Relationship(back_populates="organization")
      # In Role:
      # invitations: Mapped[List["Invitation"]] = Relationship(back_populates="role")
      # In User: (need two for inviter/accepter)
      # sent_invitations: Mapped[List["Invitation"]] = Relationship(back_populates="inviter", sa_relationship_kwargs={'foreign_keys': '[Invitation.inviter_user_id]'})
      # accepted_invitations: Mapped[List["Invitation"]] = Relationship(back_populates="accepted_by", sa_relationship_kwargs={'foreign_keys': '[Invitation.accepted_by_user_id]'})
  2. New Helper Functions (utils/invitations.py or similar):

    • Purpose: Encapsulate invitation logic.
    • generate_invitation_link(token: str) -> str:
      • Constructs the full BASE_URL/invitations/accept?token=... URL.
    • send_invitation_email(invitation: Invitation, session: Session):
      • Uses resend and a new Jinja template (templates/emails/organization_invite.html).
      • Takes the Invitation object, generates the link.
      • Sends email to invitation.invitee_email. Needs inviter name, org name for the template.
    • process_invitation(invitation: Invitation, accepted_by_user: User, session: Session):
      • Adds accepted_by_user to invitation.role within invitation.organization.
      • Marks invitation as used (used = True, accepted_at = utc_time(), accepted_by_user_id = accepted_by_user.id).
      • Adds and commits changes to the session. Handles potential errors.
  3. Modified Routes:

    • POST /organizations/invite/{org_id} (routers/organizations.py):
      • Accept role_id: int = Form(...) in addition to email.
      • Perform the validation checks described in step 1 (permissions, role validity, not already member, no active pending invite).
      • Fetch the Role object.
      • Create the Invitation instance.
      • Add Invitation to session.
      • Use BackgroundTasks to call send_invitation_email.
      • Commit the Invitation.
      • Redirect back to the organization page, potentially with a flash message parameter.
    • GET /account/login (routers/auth.py):
      • Accept optional invitation_token: Optional[str] = None.
      • Pass invitation_token to the login.html template (e.g., as a hidden input field).
    • POST /account/login (routers/auth.py):
      • Accept optional invitation_token: Optional[str] = Form(None).
      • After successful authentication (account_and_session dependency):
        • If invitation_token is present:
          • Fetch the Invitation using the token.
          • Validate the token again (active, not used, not expired).
          • Verify account.email == invitation.invitee_email.
          • If valid, call process_invitation(invitation, account.user, session). Handle potential errors.
          • Decide redirect: Maybe redirect to the specific organization (/organizations/{invitation.organization_id}) instead of the generic dashboard.
    • GET /account/register (routers/auth.py):
      • Accept optional invitation_token: Optional[str] = None and email: Optional[EmailStr] = None.
      • Pass invitation_token and email to register.html template (hidden field for token, pre-fill email).
    • POST /account/register (routers/auth.py):
      • Accept optional invitation_token: Optional[str] = Form(None).
      • After successful user/account creation:
        • If invitation_token is present:
          • Fetch the Invitation using the token.
          • Validate the token again (active, not used, not expired).
          • Verify account.email == invitation.invitee_email.
          • If valid, call process_invitation(invitation, account.user, session). Handle potential errors.
          • Decide redirect: Maybe redirect to the specific organization.
  4. New Routes (routers/invitations.py):

    • GET /invitations/accept:
      • Takes token: str as a query parameter.
      • Dependency: get_valid_invitation(token: str, session: Session = Depends(get_session)) -> Invitation: Finds invitation by token, checks is_active(), raises InvalidInvitationTokenError or similar if invalid.
      • Get the invitation from the dependency.
      • Check if Account exists for invitation.invitee_email.
      • If exists:
        • Dependency current_user: Optional[User] = Depends(get_optional_user)
        • If current_user and current_user.account.email == invitation.invitee_email:
          • Call process_invitation(invitation, current_user, session).
          • Redirect to /organizations/{invitation.organization_id}.
        • Else (not logged in or wrong user):
          • Redirect to /account/login?invitation_token={token}.
      • If not exists:
        • Redirect to /account/register?email={invitation.invitee_email}&invitation_token={token}.
  5. New Error Classes (exceptions/http_exceptions.py):

    • UserIsAlreadyMemberError(HTTPException): Status 409 (Conflict) or 400. Detail: "User is already a member of this organization."
    • ActiveInvitationExistsError(HTTPException): Status 409 or 400. Detail: "An active invitation already exists for this email address in this organization."
    • InvalidInvitationTokenError(HTTPException): Status 404 or 400. Detail: "Invitation link is invalid or has expired."
    • InvitationEmailMismatchError(HTTPException): Status 403. Detail: "This invitation is for a different email address."
  6. New Templates:

    • templates/emails/organization_invite.html: Email body, including inviter name, organization name, and the acceptance link.
    • Update templates/organization/organization.html: Add a form for inviting members including a dropdown/select for roles (populated from organization.roles).
    • Update templates/account/login.html and templates/account/register.html: Add a hidden input field for invitation_token if it's passed to the context.
  7. New Tests (tests/):

    • Unit tests for Invitation.is_expired(), Invitation.is_active().
    • Unit tests for generate_invitation_link, process_invitation.
    • Integration tests covering the full flow:
      • Inviting a new user -> register -> auto-join org.
      • Inviting an existing user -> login -> auto-join org.
      • Inviting an existing user already logged in -> auto-join org.
      • Attempting to invite an existing member (fail).
      • Attempting to invite with a pending active invite (fail).
      • Using an expired/invalid/used token (fail).
      • Accepting an invite when logged in as the wrong user (fail).
      • Permission checks for inviter.

This plan provides a comprehensive approach to adding a robust invitation system, handling various user states and ensuring security through token validation. Remember to handle database transactions carefully in the new/modified routes and helper functions.

@chriscarrollsmith
Copy link
Contributor Author

ChatGPT's effort to diagram in terms of functions and classes. Definitely needs some cleanup/optimization before we proceed.

flowchart TD
    subgraph "Invitation Creation"
        direction LR
        A_UI["User enters email/role in organization.html"] --> B_EP("POST /organizations/invite/ORG_ID <br> routers.organizations.invite_member")
        B_EP --> C_Val{"Validation <br> Check Perms, Role, Member, Invite <br> Models: User, Org, Role, Invitation <br> Exceptions: UserIsAlreadyMemberError, ActiveInvitationExistsError"}
        C_Val -- Valid --> D_Token["Generate Token <br> token = str(uuid4())"]
        C_Val -- Invalid --> E_Err["Show Error / Redirect"]
        D_Token --> F_DB_Create["Create Invitation Record <br> Model: Invitation <br> session.add(invitation)"]
        F_DB_Create --> G_EmailTask["BackgroundTasks.add_task <br> send_invitation_email"]
        G_EmailTask --> H_Commit["session.commit()"]
        H_Commit --> I_Redirect["Redirect to organization.html <br> w/ Flash Message"]

        subgraph "Email Sending (Background)"
            direction TB
            J_SendFunc("utils.invitations.send_invitation_email") --> K_LinkFunc("utils.invitations.generate_invitation_link")
            K_LinkFunc --> L_Template("templates/emails/organization_invite.html")
            L_Template --> M_Send["Send Email via Resend"]
        end
        G_EmailTask -.-> J_SendFunc
    end

    subgraph "Invitation Acceptance"
        direction TB
        N_Click["Invitee clicks link <br> GET /invitations/accept?token=TOKEN"] 
            --> O_EP("GET /invitations/accept <br> routers.invitations.accept_invitation")
        O_EP --> P_TokenDep("Dependency: get_valid_invitation")
        P_TokenDep --> Q_DB_Fetch["Fetch Invitation by Token <br> Model: Invitation"]
        Q_DB_Fetch --> R_CheckActive{"Is Invitation Active? <br> invitation.is_active()"}
        R_CheckActive -- Valid --> S_CheckAcct{"Account Exists? <br> Query Account by invitation.invitee_email"}
        R_CheckActive -- Invalid --> T_ErrPage["Show Error Page <br> InvalidInvitationTokenError"]

        S_CheckAcct -- Yes --> U_CheckLogin{"User Logged In? <br> Depends(get_optional_user)"}
        S_CheckAcct -- No --> V_RedirectRegister["Redirect to <br> GET /account/register <br> w/ email & token"]

        U_CheckLogin -- Yes --> W_CheckEmail{"Email Match? <br> current_user.account.email == invitee_email"}
        U_CheckLogin -- No --> X_RedirectLogin["Redirect to <br> GET /account/login <br> w/ token"]

        W_CheckEmail -- Yes --> Y_Process("Call process_invitation <br> utils.invitations.process_invitation")
        W_CheckEmail -- No --> Z_EmailMismatch["Show Error Page <br> InvitationEmailMismatchError"]

        Y_Process --> AA_DB_Update["Add User to Org/Role <br> Mark Invitation Used <br> session.commit()"]
        AA_DB_Update --> AB_RedirectOrg["Redirect to <br> /organizations/ORG_ID"]

        subgraph "Login Flow with Token"
            direction TB
            X_RedirectLogin --> BA_LoginGet("GET /account/login <br> routers.auth.login_get")
            BA_LoginGet --> BB_LoginTmpl("templates/account/login.html <br> includes hidden token")
            BB_LoginTmpl --> BC_UserLogin["User Submits Login Form"]
            BC_UserLogin --> BD_LoginPost("POST /account/login <br> routers.auth.login_post")
            BD_LoginPost --> BE_Auth{"Authenticate User"}
            BE_Auth -- Success --> BF_CheckToken{"Has Invitation Token?"}
            BF_CheckToken -- Yes --> BG_ValidateToken{"Validate Token & Email Match"}
            BG_ValidateToken -- Valid --> BH_Process("Call process_invitation")
            BH_Process --> AB_RedirectOrg
            BF_CheckToken -- No --> BI_RedirectDash["Redirect to Dashboard"]
            BG_ValidateToken -- Invalid --> T_ErrPage
        end

        subgraph "Registration Flow with Token"
            direction TB
            V_RedirectRegister --> CA_RegisterGet("GET /account/register <br> routers.auth.register_get")
            CA_RegisterGet --> CB_RegisterTmpl("templates/account/register.html <br> pre-fills email, hidden token")
            CB_RegisterTmpl --> CC_UserRegister["User Submits Registration Form"]
            CC_UserRegister --> CD_RegisterPost("POST /account/register <br> routers.auth.register_post")
            CD_RegisterPost --> CE_CreateUser{"Create User/Account"}
            CE_CreateUser --> CF_CheckToken{"Has Invitation Token?"}
            CF_CheckToken -- Yes --> CG_ValidateToken{"Validate Token & Email Match"}
            CG_ValidateToken -- Valid --> CH_Process("Call process_invitation")
            CH_Process --> AB_RedirectOrg
            CF_CheckToken -- No --> CI_RedirectDash["Redirect to Dashboard"]
            CG_ValidateToken -- Invalid --> T_ErrPage
        end
    end

    %% Link Subgraphs
    I_Redirect -.-> A_UI
    M_Send -.-> N_Click
    AB_RedirectOrg -.-> A_UI
    BI_RedirectDash -.-> A_UI
    CI_RedirectDash -.-> A_UI

    class B_EP,O_EP,BD_LoginPost,BA_LoginGet,CD_RegisterPost,CA_RegisterGet endpoint;
    class G_EmailTask,J_SendFunc,K_LinkFunc,P_TokenDep,Y_Process,BH_Process,CH_Process function;
    class C_Val,F_DB_Create,Q_DB_Fetch,S_CheckAcct,AA_DB_Update,BE_Auth,BF_CheckToken,BG_ValidateToken,CE_CreateUser,CF_CheckToken,CG_ValidateToken db;
    class L_Template,BB_LoginTmpl,CB_RegisterTmpl template;
    class A_UI,N_Click,BC_UserLogin,CC_UserRegister ui;
    class E_Err,T_ErrPage,Z_EmailMismatch error;
Loading

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
1 participant