openauth-plugins 0.0.4

Official OpenAuth plugin modules.
Documentation
use ::http::{Method, StatusCode};
use openauth_core::api::{create_auth_endpoint, AsyncAuthEndpoint};
use serde::Deserialize;
use time::OffsetDateTime;

use crate::organization::hooks::{
    AfterAcceptInvitation, AfterCancelInvitation, AfterRejectInvitation, BeforeAcceptInvitation,
    BeforeCancelInvitation, BeforeRejectInvitation,
};
use crate::organization::http;
use crate::organization::models::InvitationStatus;
use crate::organization::options::OrganizationOptions;
use crate::organization::permissions::{has_permission, OrganizationPermission};
use crate::organization::store::OrganizationStore;

use super::validation::require_session;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct InvitationIdBody {
    invitation_id: String,
}

pub(super) fn accept_invitation(options: OrganizationOptions) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/organization/accept-invitation",
        Method::POST,
        super::metadata::options(
            "organizationAcceptInvitation",
            vec![super::metadata::string("invitationId")],
        ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move {
                let adapter = http::adapter(context)?;
                let store = OrganizationStore::new(adapter.as_ref());
                let session = require_session(context, &request, &store).await?;
                let input: InvitationIdBody = http::body(&request)?;
                let Some(invitation) = store.invitation_by_id(&input.invitation_id).await? else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "INVITATION_NOT_FOUND",
                    );
                };
                if invitation.status != InvitationStatus::Pending
                    || invitation.expires_at < OffsetDateTime::now_utc()
                {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "INVITATION_NOT_FOUND",
                    );
                }
                if invitation.email.to_lowercase() != session.user.email.to_lowercase() {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION",
                    );
                }
                if options.require_email_verification_on_invitation && !session.user.email_verified
                {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION",
                    );
                }
                if store.count_members(&invitation.organization_id).await? as usize
                    >= options.membership_limit
                {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "ORGANIZATION_MEMBERSHIP_LIMIT_REACHED",
                    );
                }
                if store
                    .member_by_org_user(&invitation.organization_id, &session.user.id)
                    .await?
                    .is_some()
                {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION",
                    );
                }
                let Some(organization) = store
                    .organization_by_id(&invitation.organization_id)
                    .await?
                else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "ORGANIZATION_NOT_FOUND",
                    );
                };
                if let Some(hook) = &options.hooks.before_accept_invitation {
                    hook(&BeforeAcceptInvitation {
                        organization: organization.clone(),
                        invitation: invitation.clone(),
                        user: session.user.clone(),
                    })?;
                }
                let accepted = store
                    .update_invitation_status(&invitation.id, InvitationStatus::Accepted)
                    .await?;
                let member = store
                    .create_member(
                        &invitation.organization_id,
                        &session.user.id,
                        &invitation.role,
                        openauth_core::db::DbRecord::new(),
                    )
                    .await?;
                if options.teams.enabled {
                    if let Some(team_ids) = invitation.team_id.as_deref() {
                        for team_id in team_ids
                            .split(',')
                            .map(str::trim)
                            .filter(|id| !id.is_empty())
                        {
                            if let Some(max) = options.teams.maximum_members_per_team {
                                if store.count_team_members(team_id).await? as usize >= max {
                                    return http::organization_error(
                                        StatusCode::FORBIDDEN,
                                        "TEAM_MEMBER_LIMIT_REACHED",
                                    );
                                }
                            }
                            if store
                                .team_member(team_id, &session.user.id)
                                .await?
                                .is_none()
                            {
                                store
                                    .create_team_member(
                                        team_id,
                                        &session.user.id,
                                        openauth_core::db::DbRecord::new(),
                                    )
                                    .await?;
                            }
                        }
                    }
                }
                if let (Some(hook), Some(accepted)) =
                    (&options.hooks.after_accept_invitation, accepted.clone())
                {
                    hook(&AfterAcceptInvitation {
                        organization,
                        invitation: accepted,
                        member: member.clone(),
                        user: session.user.clone(),
                    })?;
                }
                store
                    .set_active_organization(
                        &session.session.token,
                        Some(&invitation.organization_id),
                    )
                    .await?;
                http::json_with_cookies(
                    StatusCode::OK,
                    &serde_json::json!({ "invitation": accepted, "member": member }),
                    http::refreshed_session_cookies(context, &session.session, &session.user)?,
                )
            })
        },
    )
}

pub(super) fn reject_invitation(options: OrganizationOptions) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/organization/reject-invitation",
        Method::POST,
        super::metadata::options(
            "organizationRejectInvitation",
            vec![super::metadata::string("invitationId")],
        ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move {
                let adapter = http::adapter(context)?;
                let store = OrganizationStore::new(adapter.as_ref());
                let session = require_session(context, &request, &store).await?;
                let input: InvitationIdBody = http::body(&request)?;
                let Some(invitation) = store.invitation_by_id(&input.invitation_id).await? else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "INVITATION_NOT_FOUND",
                    );
                };
                if invitation.email.to_lowercase() != session.user.email.to_lowercase() {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "YOU_ARE_NOT_THE_RECIPIENT_OF_THE_INVITATION",
                    );
                }
                if options.require_email_verification_on_invitation && !session.user.email_verified
                {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "EMAIL_VERIFICATION_REQUIRED_BEFORE_ACCEPTING_OR_REJECTING_INVITATION",
                    );
                }
                let Some(organization) = store
                    .organization_by_id(&invitation.organization_id)
                    .await?
                else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "ORGANIZATION_NOT_FOUND",
                    );
                };
                if let Some(hook) = &options.hooks.before_reject_invitation {
                    hook(&BeforeRejectInvitation {
                        organization: organization.clone(),
                        invitation: invitation.clone(),
                        user: session.user.clone(),
                    })?;
                }
                let rejected = store
                    .update_invitation_status(&invitation.id, InvitationStatus::Rejected)
                    .await?;
                if let (Some(hook), Some(rejected)) =
                    (&options.hooks.after_reject_invitation, rejected.clone())
                {
                    hook(&AfterRejectInvitation {
                        organization,
                        invitation: rejected,
                        user: session.user,
                    })?;
                }
                http::json(
                    StatusCode::OK,
                    &serde_json::json!({ "invitation": rejected, "member": null }),
                )
            })
        },
    )
}

pub(super) fn cancel_invitation(options: OrganizationOptions) -> AsyncAuthEndpoint {
    create_auth_endpoint(
        "/organization/cancel-invitation",
        Method::POST,
        super::metadata::options(
            "organizationCancelInvitation",
            vec![super::metadata::string("invitationId")],
        ),
        move |context, request| {
            let options = options.clone();
            Box::pin(async move {
                let adapter = http::adapter(context)?;
                let store = OrganizationStore::new(adapter.as_ref());
                let session = require_session(context, &request, &store).await?;
                let input: InvitationIdBody = http::body(&request)?;
                let Some(invitation) = store.invitation_by_id(&input.invitation_id).await? else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "INVITATION_NOT_FOUND",
                    );
                };
                let Some(actor_member) = store
                    .member_by_org_user(&invitation.organization_id, &session.user.id)
                    .await?
                else {
                    return http::organization_error(StatusCode::BAD_REQUEST, "MEMBER_NOT_FOUND");
                };
                if !has_permission(
                    &actor_member.role,
                    &options,
                    OrganizationPermission::InvitationCancel,
                ) {
                    return http::organization_error(
                        StatusCode::FORBIDDEN,
                        "YOU_ARE_NOT_ALLOWED_TO_CANCEL_THIS_INVITATION",
                    );
                }
                let Some(organization) = store
                    .organization_by_id(&invitation.organization_id)
                    .await?
                else {
                    return http::organization_error(
                        StatusCode::BAD_REQUEST,
                        "ORGANIZATION_NOT_FOUND",
                    );
                };
                if let Some(hook) = &options.hooks.before_cancel_invitation {
                    hook(&BeforeCancelInvitation {
                        organization: organization.clone(),
                        invitation: invitation.clone(),
                        cancelled_by: session.user.clone(),
                    })?;
                }
                let canceled = store
                    .update_invitation_status(&invitation.id, InvitationStatus::Canceled)
                    .await?;
                if let (Some(hook), Some(canceled)) =
                    (&options.hooks.after_cancel_invitation, canceled.clone())
                {
                    hook(&AfterCancelInvitation {
                        organization,
                        invitation: canceled,
                        cancelled_by: session.user,
                    })?;
                }
                http::json(
                    StatusCode::OK,
                    &serde_json::json!({ "invitation": canceled }),
                )
            })
        },
    )
}