diff --git a/.env.example b/.env.example index 3bcd885..377179f 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,4 @@ DB_NAME= # Resend RESEND_API_KEY= +EMAIL_FROM= \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3b188a1..a7069df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -70,6 +70,7 @@ jobs: echo "SECRET_KEY=$(openssl rand -base64 32)" >> _environment echo "BASE_URL=http://localhost:8000" >> _environment echo "RESEND_API_KEY=resend_api_key" >> _environment + echo "EMAIL_FROM=noreply@promptlytechnologies.com" >> _environment - name: Setup Graphviz uses: ts-graphviz/setup-graphviz@v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5a6172d..ac966e0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,7 @@ jobs: echo "SECRET_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV echo "BASE_URL=http://localhost:8000" >> $GITHUB_ENV echo "RESEND_API_KEY=resend_api_key" >> $GITHUB_ENV + echo "EMAIL_FROM=noreply@promptlytechnologies.com" >> $GITHUB_ENV - name: Verify environment variables run: | diff --git a/README.md b/README.md index 68a82c4..6b75e03 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,13 @@ it into the .env file. Set your desired database name, username, and password in the .env file. -To use password recovery, register a [Resend](https://resend.com/) -account, verify a domain, get an API key, and paste the API key into the -.env file. +To use password recovery and other email features, register a +[Resend](https://resend.com/) account, verify a domain, get an API key, +and paste the API key and the email address you want to send emails from +into the .env file. Note that you will need to [verify a domain through +the Resend +dashboard](https://resend.com/docs/dashboard/domains/introduction) to +send emails from that domain. ### Start development database diff --git a/docs/installation.qmd b/docs/installation.qmd index 2505dd2..defb4f7 100644 --- a/docs/installation.qmd +++ b/docs/installation.qmd @@ -132,7 +132,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t Set your desired database name, username, and password in the .env file. -To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. +To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and sender email address into the .env file. If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.) diff --git a/docs/static/documentation.txt b/docs/static/documentation.txt index a883df9..cfda3b6 100644 --- a/docs/static/documentation.txt +++ b/docs/static/documentation.txt @@ -1,6 +1,6 @@ # FastAPI, Jinja2, PostgreSQL Webapp Template -![Screenshot of homepage](docs/static/Screenshot.png) +![Screenshot of homepage](docs/static/screenshot.jpg) ## Quickstart @@ -114,7 +114,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t Set your desired database name, username, and password in the .env file. -To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. +To use password recovery and other email features, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and the email address you want to send emails from into the .env file. Note that you will need to [verify a domain through the Resend dashboard](https://resend.com/docs/dashboard/domains/introduction) to send emails from that domain. ### Start development database @@ -542,7 +542,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t Set your desired database name, username, and password in the .env file. -To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. +To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and sender email address into the .env file. If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.) @@ -988,7 +988,193 @@ Server-side validation remains essential as a security measure against malicious # Deployment -## Under construction +This application requires two services to be deployed and connected to each other: + +1. A PostgreSQL database (the storage layer) +2. A FastAPI app (the application layer) + +There are *many* hosting options available for each of these services; this guide will cover only a few of them. + +## Deploying and Configuring the PostgreSQL Database + +### On Digital Ocean + +#### Getting Started + +- Create a [DigitalOcean](mdc:https:/www.digitalocean.com) account +- Install the [`doctl` CLI tool](mdc:https:/docs.digitalocean.com/reference/doctl) and authenticate with `doctl auth init` +- Install the [`psql` client](mdc:https:/www.postgresql.org/download) + +#### Create a Project + +Create a new project to organize your resources: + +```bash +# List existing projects +doctl projects list + +# Create a new project +doctl projects create --name "YOUR-PROJECT-NAME" --purpose "YOUR-PROJECT-PURPOSE" --environment "Production" +``` + +#### Set Up a Managed PostgreSQL Database + +Create a managed, serverless PostgreSQL database instance: + +```bash +doctl databases create your-db-name --engine pg --version 17 --size db-s-1vcpu-1gb --num-nodes 1 --wait +``` + +Get the database ID from the output of the create command and use it to retrieve the database connection details: + +```bash +# Get the database connection details +doctl databases connection "your-database-id" --format Host,Port,User,Password,Database +``` + +Store these details securely in a `.env.production` file (you will need to set them later in application deployment as production secrets): + +```bash +# Database connection parameters +DB_HOST=your-host +DB_PORT=your-port +DB_USER=your-user +DB_PASS=your-password +DB_NAME=your-database +``` + +You may also want to save your database id, although you can always find it again later by listing your databases with `doctl databases list`. + +#### Setting Up a Firewall Rule (after Deploying Your Application Layer) + +Note that by default your database is publicly accessible from the Internet, so you should create a firewall rule to restrict access to only your application's IP address once you have deployed the application. The command to do this is: + +```bash +doctl databases firewalls append --rule : +``` + +where `` is `ip_addr` and `` is the IP address of the application server. See the [DigitalOcean documentation](https://docs.digitalocean.com/reference/doctl/reference/databases/firewalls/append/) for more details. + +**Note:** You can only complete this step after you have deployed your application layer and obtained a static IP address for the application server. + +## Deploying and Configuring the FastAPI App + +### On Modal.com + +The big advantages of deploying on Modal.com are: +1. that they offer $30/month of free credits for each user, plus generous additional free credit allotments for startups and researchers, and +2. that it's a very user-friendly platform. + +The disadvantages are: +1. that Modal is a Python-only platform and cannot run the database layer, so you'll have to deploy that somewhere else, +2. that you'll need to make some modest changes to the codebase to get it to work on Modal, and +3. that Modal offers a [static IP address for the application server](https://modal.com/docs/guide/proxy-ips) only if you pay for a higher-tier plan starting at $250/year, which makes securing the database layer with a firewall rule cost prohibitive. + +#### Getting Started + +- [Sign up for a Modal.com account](https://modal.com/signup) +- Install modal in the project directory with `uv add modal` +- Run `uv run modal setup` to authenticate with Modal + +#### Defining the Modal Image and App + +Create a new Python file in the root of your project, for example, `deploy.py`. This file will define the Modal Image and the ASGI app deployment. + +1. **Define the Modal Image in `deploy.py`:** + - Use `modal.Image` to define the container environment. Chain methods to install dependencies and add code/files. + - Start with a Debian base image matching your Python version (e.g., 3.13). + - Install necessary system packages (`libpq-dev` for `psycopg2`, `libwebp-dev` for Pillow WebP support). + - Install Python dependencies using `run_commands` with `uv`. + - Add your local Python modules (`routers`, `utils`, `exceptions`) using `add_local_python_source`. + - Add the `static` and `templates` directories using `add_local_dir`. The default behaviour (copying on container startup) is usually fine for development, but consider `copy=True` for production stability if these files are large or rarely change. + + ```python + # deploy.py + import modal + import os + + # Define the base image + image = ( + modal.Image.debian_slim(python_version="3.13") + .apt_install("libpq-dev", "libwebp-dev") + .pip_install_from_pyproject("pyproject.toml") + .add_local_python_source("main") + .add_local_python_source("routers") + .add_local_python_source("utils") + .add_local_python_source("exceptions") + .add_local_dir("static", remote_path="/root/static") + .add_local_dir("templates", remote_path="/root/templates") + ) + + # Define the Modal App + app = modal.App( + name="your-app-name", + image=image, + secrets=[modal.Secret.from_name("your-app-name-secret")] + ) + ``` + +2. **Define the ASGI App Function in `deploy.py`:** + - Create a function decorated with `@app.function()` and `@modal.asgi_app()`. + - Inside this function, import your FastAPI application instance from `main.py`. + - Return the FastAPI app instance. + - Use `@modal.concurrent()` to allow the container to handle multiple requests concurrently. + + ```python + # deploy.py (continued) + + # Define the ASGI app function + @app.function( + allow_concurrent_inputs=100 # Adjust concurrency as needed + ) + @modal.asgi_app() + def fastapi_app(): + # Important: Import the app *inside* the function + # This ensures it runs within the Modal container environment + # and has access to the installed packages and secrets. + # It also ensures the lifespan function (db setup) runs correctly + # with the environment variables provided by the Modal Secret. + from main import app as web_app + + return web_app + ``` + +For more information on Modal FastAPI images and applications, see [this guide](https://modal.com/docs/guide/webhooks#how-do-web-endpoints-run-in-the-cloud). + +#### Deploying the App + +From your terminal, in the root directory of your project, run: + +```bash +modal deploy deploy.py +``` + +Modal will build the image (if it hasn't been built before or if dependencies changed) and deploy the ASGI app. It will output a public URL (e.g., `https://your-username--your-app-name.modal.run`). + +#### Setting Up Modal Secrets + +The application relies on environment variables stored in `.env` (like `SECRET_KEY`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `RESEND_API_KEY`, `BASE_URL`). These sensitive values should be stored securely using Modal Secrets. + +Create a Modal Secret either through the Modal UI or CLI. Note that the name of the secret has to match the secret name you used in the `deploy.py` file, above (e.g., `your-app-name-secret`). + +```bash +# Example using CLI +modal secret create your-app-name-secret \ + SECRET_KEY='your_actual_secret_key' \ + DB_USER='your_db_user' \ + DB_PASSWORD='your_db_password' \ + DB_HOST='your_external_db_host' \ + DB_PORT='your_db_port' \ + DB_NAME='your_db_name' \ + RESEND_API_KEY='your_resend_api_key' \ + BASE_URL='https://your-username--your-app-name-serve.modal.run' +``` + +**Important:** Ensure `DB_HOST` points to your *cloud* database host address, not `localhost` or `host.docker.internal`. + +#### Testing the Deployment + +Access the provided Modal URL in your browser. Browse the site and test the registration and password reset features to ensure database and Resend connections work. # Contributing diff --git a/docs/static/schema.png b/docs/static/schema.png index df86a9b..f108a96 100644 Binary files a/docs/static/schema.png and b/docs/static/schema.png differ diff --git a/exceptions/exceptions.py b/exceptions/exceptions.py index 65637f3..3bf28cd 100644 --- a/exceptions/exceptions.py +++ b/exceptions/exceptions.py @@ -6,3 +6,9 @@ def __init__(self, user: User, access_token: str, refresh_token: str): self.user = user self.access_token = access_token self.refresh_token = refresh_token + + +# Define custom exception for email sending failure +class EmailSendFailedError(Exception): + """Custom exception for email sending failures.""" + pass \ No newline at end of file diff --git a/exceptions/http_exceptions.py b/exceptions/http_exceptions.py index c6c1494..9420375 100644 --- a/exceptions/http_exceptions.py +++ b/exceptions/http_exceptions.py @@ -144,4 +144,71 @@ class InvalidImageError(HTTPException): """Raised when an invalid image is uploaded""" def __init__(self, message: str = "Invalid image file"): - super().__init__(status_code=400, detail=message) \ No newline at end of file + super().__init__(status_code=400, detail=message) + + +# --- Invitation-specific Errors --- + +class UserIsAlreadyMemberError(HTTPException): + """Raised when trying to invite a user who is already a member of the organization.""" + def __init__(self): + super().__init__( + status_code=409, + detail="This user is already a member of the organization." + ) + + +class ActiveInvitationExistsError(HTTPException): + """Raised when trying to invite a user for whom an active invitation already exists.""" + def __init__(self): + super().__init__( + status_code=409, + detail="An active invitation already exists for this email address in this organization." + ) + + +class InvalidRoleForOrganizationError(HTTPException): + """Raised when a role provided does not belong to the target organization. + Note: If the role ID simply doesn't exist, a standard 404 RoleNotFoundError should be raised. + """ + def __init__(self): + super().__init__( + status_code=400, + detail="The selected role does not belong to this organization." + ) + + +class InvitationEmailSendError(HTTPException): + """Raised when the invitation email fails to send.""" + def __init__(self): + super().__init__( + status_code=500, # Internal Server Error seems appropriate + detail="Failed to send invitation email. Please try again later or contact support." + ) + + +class InvalidInvitationTokenError(HTTPException): + """Raised when an invitation token is invalid, expired, or not found.""" + def __init__(self): + super().__init__( + status_code=404, + detail="Invitation not found or expired" + ) + + +class InvitationEmailMismatchError(HTTPException): + """Raised when a user attempts to accept an invitation sent to a different email address.""" + def __init__(self): + super().__init__( + status_code=403, + detail="This invitation was sent to a different email address" + ) + + +class InvitationProcessingError(HTTPException): + """Raised when an error occurs during the processing of a valid invitation.""" + def __init__(self, detail: str = "Failed to process invitation. Please try again later."): + super().__init__( + status_code=500, # Internal Server Error + detail=detail + ) \ No newline at end of file diff --git a/index.qmd b/index.qmd index 6beb43f..ddd59f2 100644 --- a/index.qmd +++ b/index.qmd @@ -116,7 +116,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t Set your desired database name, username, and password in the .env file. -To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file. +To use password recovery and other email features, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and the email address you want to send emails from into the .env file. Note that you will need to [verify a domain through the Resend dashboard](https://resend.com/docs/dashboard/domains/introduction) to send emails from that domain. ### Start development database diff --git a/main.py b/main.py index b7afbfd..6a640ab 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates from fastapi.exceptions import RequestValidationError from starlette.exceptions import HTTPException as StarletteHTTPException -from routers import account, dashboard, organization, role, user, static_pages +from routers import account, dashboard, organization, role, user, static_pages, invitation from utils.dependencies import ( get_optional_user ) @@ -46,6 +46,7 @@ async def lifespan(app: FastAPI): app.include_router(account.router) app.include_router(dashboard.router) +app.include_router(invitation.router) app.include_router(organization.router) app.include_router(role.router) app.include_router(static_pages.router) diff --git a/routers/account.py b/routers/account.py index ef081cd..b5368f8 100644 --- a/routers/account.py +++ b/routers/account.py @@ -2,13 +2,13 @@ from logging import getLogger from typing import Optional, Tuple from urllib.parse import urlparse -from fastapi import APIRouter, Depends, BackgroundTasks, Form, Request +from fastapi import APIRouter, Depends, BackgroundTasks, Form, Request, Query from fastapi.responses import RedirectResponse from fastapi.templating import Jinja2Templates from starlette.datastructures import URLPath from pydantic import EmailStr from sqlmodel import Session, select -from utils.models import User, DataIntegrityError, Account +from utils.models import User, DataIntegrityError, Account, Invitation from utils.db import get_session from utils.auth import ( HTML_PASSWORD_PATTERN, @@ -32,10 +32,15 @@ from exceptions.http_exceptions import ( EmailAlreadyRegisteredError, CredentialsError, - PasswordValidationError + PasswordValidationError, + InvalidInvitationTokenError, + InvitationEmailMismatchError, + InvitationProcessingError ) from routers.dashboard import router as dashboard_router from routers.user import router as user_router +from routers.organization import router as org_router +from utils.invitations import process_invitation logger = getLogger("uvicorn.error") router = APIRouter(prefix="/account", tags=["account"]) @@ -97,7 +102,8 @@ def logout(): async def read_login( request: Request, user: Optional[User] = Depends(get_optional_user), - email_updated: Optional[str] = "false" + email_updated: Optional[str] = Query("false"), + invitation_token: Optional[str] = Query(None) ): """ Render login page or redirect to dashboard if already logged in. @@ -106,14 +112,21 @@ async def read_login( return RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=302) return templates.TemplateResponse( "account/login.html", - {"request": request, "user": user, "email_updated": email_updated} + { + "request": request, + "user": user, + "email_updated": email_updated, + "invitation_token": invitation_token + } ) @router.get("/register") async def read_register( request: Request, - user: Optional[User] = Depends(get_optional_user) + user: Optional[User] = Depends(get_optional_user), + email: Optional[EmailStr] = Query(None), + invitation_token: Optional[str] = Query(None) ): """ Render registration page or redirect to dashboard if already logged in. @@ -123,7 +136,13 @@ async def read_register( return templates.TemplateResponse( "account/register.html", - {"request": request, "user": user, "password_pattern": HTML_PASSWORD_PATTERN} + { + "request": request, + "user": user, + "password_pattern": HTML_PASSWORD_PATTERN, + "email": email, + "invitation_token": invitation_token + } ) @@ -204,38 +223,98 @@ async def register( email: EmailStr = Form(...), session: Session = Depends(get_session), _: None = Depends(validate_password_strength_and_match), - password: str = Form(...) + password: str = Form(...), + invitation_token: Optional[str] = Form(None) ) -> RedirectResponse: """ - Register a new user account. + Register a new user account, optionally processing an invitation. """ # Check if the email is already registered - account: Optional[Account] = session.exec(select(Account).where( + existing_account: Optional[Account] = session.exec(select(Account).where( Account.email == email)).one_or_none() - if account: + if existing_account: raise EmailAlreadyRegisteredError() # Hash the password hashed_password = get_password_hash(password) - # Create the account + # Create the account and user instances (don't commit yet) account = Account(email=email, hashed_password=hashed_password) session.add(account) - session.flush() # Flush to get the account ID - - # Create the user - account.user = User(name=name, account_id=account.id) - session.add(account) - session.commit() + session.flush() # Flush here to get account.id before creating User + + # Ensure account has an ID after flush + if not account.id: + logger.error(f"Account ID not generated after flush for email {email}. Aborting registration.") + session.rollback() # Rollback the account add + raise DataIntegrityError(resource="Account ID generation") + + new_user = User(name=name, account_id=account.id) # Use account.id + session.add(new_user) + + # Default redirect target + redirect_url = dashboard_router.url_path_for("read_dashboard") + + # Process invitation if token is provided (BEFORE final commit) + if invitation_token: + logger.info(f"Registration attempt with invitation token: {invitation_token} for email {email}") + # Fetch the invitation + statement = select(Invitation).where(Invitation.token == invitation_token) + invitation = session.exec(statement).first() + + if not invitation or not invitation.is_active(): + logger.warning(f"Invalid or inactive invitation token provided during registration: {invitation_token}") + # Consider raising a more generic error to avoid exposing token validity + raise InvalidInvitationTokenError() + + # Verify email matches + if email != invitation.invitee_email: + logger.warning( + f"Invitation email mismatch for token {invitation_token} during registration. " + f"Account: {email}, Invitation: {invitation.invitee_email}" + ) + # Consider raising a more generic error to avoid confirming email existence + raise InvitationEmailMismatchError() + + # Process the invitation (adds changes to the session) + try: + logger.info(f"Processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration.") + process_invitation(invitation, new_user, session) + # Set redirect to the organization page + redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id) + logger.info(f"Redirecting new user {new_user.name} to organization {invitation.organization_id} after accepting invitation {invitation.id}.") + except Exception as e: + logger.error( + f"Error processing invitation {invitation.id} for new user {new_user.name} ({email}) during registration: {e}", + exc_info=True + ) + session.rollback() + raise InvitationProcessingError() + + else: + logger.info(f"Standard registration for email {email}. Redirecting to dashboard.") + + # Commit all changes (Account, User, potentially Invitation) + try: + session.commit() + except Exception as e: + logger.error(f"Error committing transaction during registration for {email}: {e}", exc_info=True) + session.rollback() + # Use DataIntegrityError for commit failure + raise DataIntegrityError(resource="Account/User registration") + + # Refresh the account to ensure all relationships (like user) are loaded after commit session.refresh(account) + # We might need the user object refreshed too if process_invitation modified it directly + # session.refresh(new_user) # Let's assume process_invitation only modifies the invitation object for now - # Create access token - access_token = create_access_token(data={"sub": email}) - refresh_token = create_refresh_token(data={"sub": email}) + # Create access token using the committed account's email + access_token = create_access_token(data={"sub": account.email, "fresh": True}) + refresh_token = create_refresh_token(data={"sub": account.email}) # Set cookie - response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303) + response = RedirectResponse(url=str(redirect_url), status_code=303) # Use determined redirect_url response.set_cookie( key="access_token", value=access_token, @@ -256,13 +335,64 @@ async def register( @router.post("/login", response_class=RedirectResponse) async def login( - account_and_session: Tuple[Account, Session] = Depends(get_account_from_credentials) + account_and_session: Tuple[Account, Session] = Depends(get_account_from_credentials), + invitation_token: Optional[str] = Form(None) ) -> RedirectResponse: """ - Log in a user with valid credentials. + Log in a user with valid credentials and process invitation if token is provided. """ account, session = account_and_session + # Default redirect target + redirect_url = dashboard_router.url_path_for("read_dashboard") + + if invitation_token: + logger.info(f"Login attempt with invitation token: {invitation_token} for account {account.email}") + # Fetch the invitation + statement = select(Invitation).where(Invitation.token == invitation_token) + invitation = session.exec(statement).first() + + if not invitation or not invitation.is_active(): + logger.warning(f"Invalid or inactive invitation token provided during login: {invitation_token}") + raise InvalidInvitationTokenError() + + # Verify email matches + if account.email != invitation.invitee_email: + logger.warning( + f"Invitation email mismatch for token {invitation_token}. " + f"Account: {account.email}, Invitation: {invitation.invitee_email}" + ) + raise InvitationEmailMismatchError() + + # Ensure user relationship is loaded for process_invitation + if not account.user: + logger.debug(f"Refreshing user relationship for account {account.id}") + session.refresh(account, attribute_names=["user"]) + if not account.user: + # This should not happen if the account has a valid user relationship + logger.error(f"Failed to load user for account {account.id} during invitation processing.") + raise DataIntegrityError(resource="User relation") + + # Process the invitation + try: + logger.info(f"Processing invitation {invitation.id} for user {account.user.id} during login.") + process_invitation(invitation, account.user, session) + session.commit() + # Set redirect to the organization page + redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id) + logger.info(f"Redirecting user {account.user.id} to organization {invitation.organization_id} after accepting invitation {invitation.id}.") + except Exception as e: + logger.error( + f"Error processing invitation {invitation.id} for user {account.user.id} during login: {e}", + exc_info=True + ) + session.rollback() + # Raise the specific invitation processing error + raise InvitationProcessingError() + + else: + logger.info(f"Standard login for account {account.email}. Redirecting to dashboard.") + # Create access token access_token = create_access_token( data={"sub": account.email, "fresh": True} @@ -270,7 +400,7 @@ async def login( refresh_token = create_refresh_token(data={"sub": account.email}) # Set cookie - response = RedirectResponse(url=dashboard_router.url_path_for("read_dashboard"), status_code=303) + response = RedirectResponse(url=str(redirect_url), status_code=303) response.set_cookie( key="access_token", value=access_token, diff --git a/routers/invitation.py b/routers/invitation.py new file mode 100644 index 0000000..2b8b245 --- /dev/null +++ b/routers/invitation.py @@ -0,0 +1,194 @@ +from uuid import uuid4 +from typing import Optional +from fastapi import APIRouter, Depends, Form, Query, status +from fastapi.responses import RedirectResponse +from fastapi.exceptions import HTTPException +from pydantic import EmailStr +from sqlmodel import Session, select +from logging import getLogger + +from utils.dependencies import get_authenticated_user, get_optional_user +from utils.db import get_session +from utils.models import User, Role, Account, Invitation, ValidPermissions, Organization +from utils.invitations import send_invitation_email, process_invitation +from exceptions.http_exceptions import ( + UserIsAlreadyMemberError, + ActiveInvitationExistsError, + InvalidRoleForOrganizationError, + OrganizationNotFoundError, + InvitationEmailSendError, + InvalidInvitationTokenError, + InvitationEmailMismatchError, +) +from exceptions.exceptions import EmailSendFailedError +# Import the account router to generate URLs for login/register +from routers.account import router as account_router +from routers.organization import router as org_router # Already imported, check usage + +# Setup logger +logger = getLogger("uvicorn.error") + +router = APIRouter( + prefix="/invitations", + tags=["invitations"], +) + + +# Dependency to get a valid invitation +def get_valid_invitation( + token: str = Query(...), + session: Session = Depends(get_session) +) -> Invitation: + """Dependency to retrieve a valid, active invitation based on the token.""" + statement = select(Invitation).where(Invitation.token == token) + invitation = session.exec(statement).first() + if not invitation or not invitation.is_active(): + raise InvalidInvitationTokenError() + return invitation + + +@router.post("/", name="create_invitation") +async def create_invitation( + current_user: User = Depends(get_authenticated_user), + session: Session = Depends(get_session), + invitee_email: EmailStr = Form(...), + role_id: int = Form(...), + organization_id: int = Form(...), +): + # Fetch the organization + organization = session.get(Organization, organization_id) + if not organization: + raise OrganizationNotFoundError() + + # Check if the current user has permission to invite users to this organization + if not current_user.has_permission(ValidPermissions.INVITE_USER, organization): + raise HTTPException(status_code=403, detail="You don't have permission to invite users to this organization") + + # Verify the role exists and belongs to this organization + role = session.get(Role, role_id) + if not role: + raise HTTPException(status_code=404, detail="Role not found") + if role.organization_id != organization_id: + raise InvalidRoleForOrganizationError() + + # Check if invitee is already a member of the organization + existing_account = session.exec(select(Account).where(Account.email == invitee_email)).first() + if existing_account: + # Check if any user with this account is already a member + existing_user = session.exec(select(User).where(User.account_id == existing_account.id)).first() + if existing_user: + # Check if user has any role in this organization + if any(role.organization_id == organization_id for role in existing_user.roles): + raise UserIsAlreadyMemberError() + + # Check for active invitations with the same email + active_invitations = Invitation.get_active_for_org(session, organization_id) + if any(invitation.invitee_email == invitee_email for invitation in active_invitations): + raise ActiveInvitationExistsError() + + # Create the invitation + token = str(uuid4()) + invitation = Invitation( + organization_id=organization_id, + role_id=role_id, + invitee_email=invitee_email, + token=token, + ) + + session.add(invitation) + + try: + # Refresh to ensure relationships are loaded *before* sending email + session.flush() # Ensure invitation gets an ID if needed by email sender, flush changes + session.refresh(invitation) + # Ensure organization is loaded before passing to email function + # (May already be loaded, but explicit refresh is safer) + if not invitation.organization: + session.refresh(organization) # Refresh the org object fetched earlier + invitation.organization = organization # Assign if needed + + # Send email synchronously BEFORE committing + send_invitation_email(invitation, session) + + # Commit *only* if email sending was successful + session.commit() + session.refresh(invitation) # Refresh again after commit if needed elsewhere + + except EmailSendFailedError as e: + logger.error(f"Invitation email failed for {invitee_email} in org {organization_id}: {e}") + session.rollback() # Rollback the invitation creation + raise InvitationEmailSendError() # Raise HTTP 500 + except Exception as e: + # Catch any other unexpected errors during flush/refresh/email/commit + logger.error( + f"Unexpected error during invitation creation/sending for {invitee_email} " + f"in org {organization_id}: {e}", + exc_info=True + ) + session.rollback() + raise HTTPException(status_code=500, detail="An unexpected error occurred.") + + # Redirect back to organization page (PRG pattern) + return RedirectResponse(url=f"/organizations/{organization_id}", status_code=303) + + +@router.get("/accept", name="accept_invitation") +async def accept_invitation( + invitation: Invitation = Depends(get_valid_invitation), + current_user: Optional[User] = Depends(get_optional_user), + session: Session = Depends(get_session), +): + """Handles the acceptance of an invitation via the link in the email.""" + # Check if an account exists for the invitee email + account_statement = select(Account).where(Account.email == invitation.invitee_email) + existing_account = session.exec(account_statement).first() + + if existing_account: + # Account exists - check if user is logged in and matches the invitation + if current_user and current_user.account_id == existing_account.id: + # Ensure the account relationship is loaded before accessing its email + if not current_user.account: + session.refresh(current_user, attribute_names=["account"]) + + # Check if refreshed account has an email (should always exist, but good practice) + if not current_user.account or not current_user.account.email: + logger.error(f"User {current_user.id} is missing account details after refresh.") + raise HTTPException(status_code=500, detail="Internal server error retrieving user account.") + + # Logged in as the correct user, process the invitation directly + logger.info( + f"User {current_user.id} ({current_user.account.email}) accepting invitation {invitation.id} directly." + ) + try: + process_invitation(invitation, current_user, session) + session.commit() + # Redirect to the organization page + redirect_url = org_router.url_path_for("read_organization", org_id=invitation.organization_id) + return RedirectResponse(url=str(redirect_url), status_code=status.HTTP_303_SEE_OTHER) + except Exception as e: + logger.error( + f"Error processing invitation {invitation.id} for user {current_user.id}: {e}", + exc_info=True + ) + session.rollback() + # Re-raise or return a generic error response + raise HTTPException(status_code=500, detail="Failed to process invitation.") + else: + # Account exists, but user is not logged in or is the wrong user + # Redirect to login, passing the token + logger.info( + f"Invitation {invitation.id} requires login for {invitation.invitee_email}. Redirecting." + ) + login_url = account_router.url_path_for("read_login") + redirect_url_with_token = f"{login_url}?invitation_token={invitation.token}" + return RedirectResponse(url=redirect_url_with_token, status_code=status.HTTP_303_SEE_OTHER) + else: + # Account does not exist - redirect to registration + logger.info( + f"Invitation {invitation.id} requires registration for {invitation.invitee_email}. Redirecting." + ) + register_url = account_router.url_path_for("read_register") + redirect_url_with_params = ( + f"{register_url}?email={invitation.invitee_email}&invitation_token={invitation.token}" + ) + return RedirectResponse(url=redirect_url_with_params, status_code=status.HTTP_303_SEE_OTHER) diff --git a/routers/organization.py b/routers/organization.py index dc8b4d7..e3989a1 100644 --- a/routers/organization.py +++ b/routers/organization.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import selectinload from utils.db import get_session, create_default_roles from utils.dependencies import get_authenticated_user, get_user_with_relations -from utils.models import Organization, User, Role, Account, utc_time +from utils.models import Organization, User, Role, Account, utc_now, Invitation from utils.enums import ValidPermissions from exceptions.http_exceptions import ( OrganizationNotFoundError, OrganizationNameTakenError, @@ -58,6 +58,9 @@ async def read_organization( ) ).first() + # Fetch active invitations for the organization + active_invitations = Invitation.get_active_for_org(session, org_id) + # Pass all required context to the template return templates.TemplateResponse( request, @@ -66,7 +69,8 @@ async def read_organization( "organization": organization, "user": user, "user_permissions": user_permissions, - "ValidPermissions": ValidPermissions + "ValidPermissions": ValidPermissions, + "active_invitations": active_invitations } ) @@ -176,7 +180,7 @@ def update_organization( # Update organization name organization.name = name - organization.updated_at = utc_time() + organization.updated_at = utc_now() session.add(organization) session.commit() @@ -229,10 +233,10 @@ def invite_member( selectinload(Organization.roles).selectinload(Role.users) ) ).first() - + if not organization: raise OrganizationNotFoundError() - + # Find the account and associated user by email account = session.exec( select(Account) @@ -244,28 +248,28 @@ def invite_member( if not account or not account.user: raise UserNotFoundError() - + invited_user = account.user - + # Check if user is already a member of this organization is_already_member = False for role in organization.roles: if invited_user.id in [u.id for u in role.users]: is_already_member = True break - + if is_already_member: raise UserAlreadyMemberError() - + # Find the default "Member" role for this organization member_role = next( (role for role in organization.roles if role.name == "Member"), None ) - + if not member_role: raise DataIntegrityError(resource="Organization roles") - + # Add the invited user to the Member role try: member_role.users.append(invited_user) @@ -273,7 +277,7 @@ def invite_member( except Exception as e: session.rollback() raise - + # Return to the organization page return RedirectResponse( url=router.url_path_for("read_organization", org_id=org_id), diff --git a/routers/role.py b/routers/role.py index 95c5b1e..7e9aa11 100644 --- a/routers/role.py +++ b/routers/role.py @@ -9,7 +9,7 @@ from sqlalchemy.exc import IntegrityError from utils.db import get_session from utils.dependencies import get_authenticated_user -from utils.models import Role, Permission, ValidPermissions, utc_time, User, DataIntegrityError +from utils.models import Role, Permission, ValidPermissions, utc_now, User, DataIntegrityError from exceptions.http_exceptions import InsufficientPermissionsError, InvalidPermissionError, RoleAlreadyExistsError, RoleNotFoundError, RoleHasUsersError, CannotModifyDefaultRoleError from routers.organization import router as organization_router @@ -128,7 +128,7 @@ def update_role( # Update role name and updated_at timestamp db_role.name = name - db_role.updated_at = utc_time() + db_role.updated_at = utc_now() try: session.commit() diff --git a/templates/account/login.html b/templates/account/login.html index 023da31..a6cec61 100644 --- a/templates/account/login.html +++ b/templates/account/login.html @@ -7,6 +7,11 @@ {% block auth_content %}