//! An area's members and invitations.
//!
//! Admins invite existing users by username with a role; the invitation
//! waits until the invitee accepts or declines it from their home page.
//! (People without an account are invited by sign-up link, from /invites,
//! which can carry area grants.) Admins change roles and remove members;
//! anyone can leave. An area always keeps at least one admin.
use std::collections::{HashMap, HashSet};
use axum::extract::{Form, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, Redirect};
use chrono::Utc;
use minijinja::context;
use sea_orm::sea_query::OnConflict;
use sea_orm::*;
use serde::Deserialize;
use uuid::Uuid;
use super::areas::authorize;
use super::{internal, non_empty, render};
use crate::entities::area_invitations;
use crate::entities::area_members::{self, Role};
use crate::entities::users;
use crate::friends::{self, Link};
use crate::middleware::UserContext;
use crate::{access, AppState};
#[derive(Debug, Deserialize)]
pub struct Notice {
pub notice: Option<String>,
}
pub async fn show_members(
State(state): State<AppState>,
user: UserContext,
Path(area_id): Path<Uuid>,
Query(q): Query<Notice>,
) -> Result<Html<String>, StatusCode> {
let db = state.db.as_ref();
let (area, role) = authorize(db, area_id, user.user.id, Role::Voter).await?;
let members = access::members(db, area_id).await.map_err(internal("list members failed"))?;
let links: HashMap<Uuid, Link> =
friends::links(db, user.user.id).await.map_err(internal("load friends failed"))?.into_iter().collect();
let member_ids: HashSet<Uuid> = members.iter().map(|(u, _)| u.id).collect();
let admins = members.iter().filter(|(_, r)| *r == Role::Admin).count();
let rows: Vec<minijinja::Value> = members
.iter()
.map(|(u, r)| {
context! {
id => u.id,
username => u.username,
role => r,
me => u.id == user.user.id,
link => links.get(&u.id).copied().unwrap_or(Link::None),
last_admin => *r == Role::Admin && admins == 1,
}
})
.collect();
// Pending invitations are admin business; so are suggestions of whom to
// invite (your friends who aren't here yet).
let (invitations, suggestions) = if role == Role::Admin {
let pending = area_invitations::Entity::find()
.filter(area_invitations::Column::AreaId.eq(area_id))
.all(db)
.await
.map_err(internal("load invitations failed"))?;
let invited: HashSet<Uuid> = pending.iter().map(|i| i.user_id).collect();
let friend_ids: Vec<Uuid> = links
.iter()
.filter(|(id, l)| **l == Link::Friends && !member_ids.contains(id) && !invited.contains(id))
.map(|(id, _)| *id)
.collect();
let wanted: Vec<Uuid> = invited.iter().copied().chain(friend_ids.iter().copied()).collect();
let names: HashMap<Uuid, String> = users::Entity::find()
.filter(users::Column::Id.is_in(wanted))
.all(db)
.await
.map_err(internal("load users failed"))?
.into_iter()
.map(|u| (u.id, u.username))
.collect();
let invitations: Vec<minijinja::Value> = pending
.iter()
.filter_map(|i| Some(context! { user_id => i.user_id, username => names.get(&i.user_id)?, role => i.role }))
.collect();
let mut suggestions: Vec<(Uuid, String)> =
friend_ids.iter().filter_map(|id| Some((*id, names.get(id)?.clone()))).collect();
suggestions.sort_by(|a, b| a.1.cmp(&b.1));
let suggestions: Vec<minijinja::Value> =
suggestions.into_iter().map(|(id, username)| context! { id => id, username => username }).collect();
(invitations, suggestions)
} else {
(vec![], vec![])
};
render(
&state,
"areas/members.html",
context! {
title => format!("Members · {}", area.name),
user => user.user,
area => area,
role => role,
members => rows,
invitations => invitations,
suggestions => suggestions,
notice => q.notice,
roles => Role::ALL,
},
)
}
fn back(area_id: Uuid, notice: &str) -> Redirect {
Redirect::to(&format!("/areas/{area_id}/members?notice={notice}"))
}
#[derive(Debug, Deserialize)]
pub struct InviteForm {
/// Username, or a user id from a suggestion button.
pub username: Option<String>,
pub user_id: Option<Uuid>,
pub role: String,
}
pub async fn invite(
State(state): State<AppState>,
user: UserContext,
Path(area_id): Path<Uuid>,
Form(form): Form<InviteForm>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
authorize(db, area_id, user.user.id, Role::Admin).await?;
let role = Role::parse(&form.role).ok_or(StatusCode::BAD_REQUEST)?;
let mut q = users::Entity::find().filter(users::Column::IsActive.eq(true));
q = match (form.user_id, non_empty(form.username)) {
(Some(id), _) => q.filter(users::Column::Id.eq(id)),
(None, Some(name)) => q.filter(users::Column::Username.eq(name)),
(None, None) => return Err(StatusCode::BAD_REQUEST),
};
let Some(invitee) = q.one(db).await.map_err(internal("load user failed"))? else {
return Ok(back(area_id, "no-such-user"));
};
if access::role_in(db, area_id, invitee.id).await.map_err(internal("load membership failed"))?.is_some() {
return Ok(back(area_id, "already-member"));
}
area_invitations::Entity::insert(area_invitations::ActiveModel {
area_id: Set(area_id),
user_id: Set(invitee.id),
role: Set(role),
invited_by: Set(Some(user.user.id)),
created_at: Set(Utc::now().into()),
})
// Re-inviting updates the role offered.
.on_conflict(
OnConflict::columns([area_invitations::Column::AreaId, area_invitations::Column::UserId])
.update_columns([area_invitations::Column::Role, area_invitations::Column::InvitedBy])
.to_owned(),
)
.exec_without_returning(db)
.await
.map_err(internal("invite failed"))?;
Ok(back(area_id, "invited"))
}
pub async fn cancel_invitation(
State(state): State<AppState>,
user: UserContext,
Path((area_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
authorize(db, area_id, user.user.id, Role::Admin).await?;
area_invitations::Entity::delete_by_id((area_id, user_id))
.exec(db)
.await
.map_err(internal("cancel invitation failed"))?;
Ok(back(area_id, "cancelled"))
}
#[derive(Debug, Deserialize)]
pub struct RoleForm {
pub role: String,
}
pub async fn set_role(
State(state): State<AppState>,
user: UserContext,
Path((area_id, user_id)): Path<(Uuid, Uuid)>,
Form(form): Form<RoleForm>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
authorize(db, area_id, user.user.id, Role::Admin).await?;
let role = Role::parse(&form.role).ok_or(StatusCode::BAD_REQUEST)?;
let current = access::role_in(db, area_id, user_id)
.await
.map_err(internal("load membership failed"))?
.ok_or(StatusCode::NOT_FOUND)?;
if current == Role::Admin
&& role != Role::Admin
&& access::admin_count(db, area_id).await.map_err(internal("count admins failed"))? <= 1
{
return Ok(back(area_id, "last-admin"));
}
let mut active: area_members::ActiveModel = area_members::Entity::find_by_id((area_id, user_id))
.one(db)
.await
.map_err(internal("load membership failed"))?
.ok_or(StatusCode::NOT_FOUND)?
.into();
active.role = Set(role);
active.update(db).await.map_err(internal("set role failed"))?;
Ok(back(area_id, "updated"))
}
/// Admins remove anyone; everyone can remove themselves (leave). Never the
/// last admin.
pub async fn remove_member(
State(state): State<AppState>,
user: UserContext,
Path((area_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let leaving = user_id == user.user.id;
authorize(db, area_id, user.user.id, if leaving { Role::Voter } else { Role::Admin }).await?;
let current = access::role_in(db, area_id, user_id)
.await
.map_err(internal("load membership failed"))?
.ok_or(StatusCode::NOT_FOUND)?;
if current == Role::Admin && access::admin_count(db, area_id).await.map_err(internal("count admins failed"))? <= 1 {
return Ok(back(area_id, "last-admin"));
}
area_members::Entity::delete_by_id((area_id, user_id))
.exec(db)
.await
.map_err(internal("remove member failed"))?;
Ok(if leaving { Redirect::to("/areas") } else { back(area_id, "removed") })
}
/// Invitations waiting on `user_id`, for their home page.
pub async fn pending_for(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<minijinja::Value>, StatusCode> {
let pending = area_invitations::Entity::find()
.filter(area_invitations::Column::UserId.eq(user_id))
.all(db)
.await
.map_err(internal("load invitations failed"))?;
let mut out = Vec::with_capacity(pending.len());
for i in pending {
let area = super::areas::load_area(db, i.area_id).await?;
let by = match i.invited_by {
Some(id) => users::Entity::find_by_id(id)
.one(db)
.await
.map_err(internal("load user failed"))?
.map(|u| u.username),
None => None,
};
out.push(context! { area => area, role => i.role, invited_by => by });
}
Ok(out)
}
/// The invitee's answer. Accepting grants the offered role (never demoting
/// an existing one) and opens the area.
pub async fn answer_invitation(
State(state): State<AppState>,
user: UserContext,
Path((area_id, answer)): Path<(Uuid, String)>,
) -> Result<Redirect, StatusCode> {
let db = state.db.as_ref();
let invitation = area_invitations::Entity::find_by_id((area_id, user.user.id))
.one(db)
.await
.map_err(internal("load invitation failed"))?
.ok_or(StatusCode::NOT_FOUND)?;
let txn = db.begin().await.map_err(internal("begin failed"))?;
if answer == "accept" {
access::grant(&txn, area_id, user.user.id, invitation.role)
.await
.map_err(internal("grant failed"))?;
} else if answer != "decline" {
return Err(StatusCode::NOT_FOUND);
}
area_invitations::Entity::delete_by_id((area_id, user.user.id))
.exec(&txn)
.await
.map_err(internal("clear invitation failed"))?;
txn.commit().await.map_err(internal("commit failed"))?;
Ok(Redirect::to(&if answer == "accept" { format!("/areas/{area_id}") } else { "/".to_string() }))
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
10.8 KiB · text
History
7a00c02