relation invites
This commit is contained in:
parent
ced7eced4a
commit
10959e6c2b
16 changed files with 411 additions and 18 deletions
1
migrations/20250528060837_rel_invitations.down.sql
Normal file
1
migrations/20250528060837_rel_invitations.down.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
drop table if exists rel_invitations;
|
||||||
12
migrations/20250528060837_rel_invitations.up.sql
Normal file
12
migrations/20250528060837_rel_invitations.up.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
create table if not exists rel_invitations (
|
||||||
|
id uuid not null primary key,
|
||||||
|
email text not null,
|
||||||
|
base_id uuid not null references bases(id) on delete cascade,
|
||||||
|
class_oid oid not null,
|
||||||
|
created_by uuid not null references users(id) on delete restrict,
|
||||||
|
privilege text not null,
|
||||||
|
expires_at timestamptz,
|
||||||
|
unique (email, base_id, class_oid, privilege)
|
||||||
|
);
|
||||||
|
create index on rel_invitations (base_id, class_oid);
|
||||||
|
create index on rel_invitations (email);
|
||||||
|
|
@ -4,6 +4,18 @@ use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use validator::ValidationErrors;
|
use validator::ValidationErrors;
|
||||||
|
|
||||||
|
macro_rules! not_found {
|
||||||
|
($message:literal) => {
|
||||||
|
AppError::NotFound($message.to_owned())
|
||||||
|
};
|
||||||
|
|
||||||
|
($message:literal, $($param:expr),+) => {
|
||||||
|
AppError::NotFound(format!($message, $($param)+))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use not_found;
|
||||||
|
|
||||||
/// Custom error type that maps to appropriate HTTP responses.
|
/// Custom error type that maps to appropriate HTTP responses.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum AppError {
|
pub enum AppError {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ pub async fn sync_perms_for_base(
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|role| user_id_from_rolname(&role.rolname, &base.user_role_prefix).ok())
|
.filter_map(|role| user_id_from_rolname(&role.rolname, &base.user_role_prefix).ok())
|
||||||
.collect();
|
.collect();
|
||||||
dbg!(&all_roles);
|
|
||||||
query!(
|
query!(
|
||||||
"delete from base_user_perms where base_id = $1 and not (user_id = any($2))",
|
"delete from base_user_perms where base_id = $1 and not (user_id = any($2))",
|
||||||
base_id,
|
base_id,
|
||||||
|
|
|
||||||
12
src/bases.rs
12
src/bases.rs
|
|
@ -17,17 +17,17 @@ impl Base {
|
||||||
|
|
||||||
pub async fn fetch_by_id<'a, E: PgExecutor<'a>>(
|
pub async fn fetch_by_id<'a, E: PgExecutor<'a>>(
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
client: E,
|
app_db: E,
|
||||||
) -> Result<Option<Base>, sqlx::Error> {
|
) -> Result<Option<Base>, sqlx::Error> {
|
||||||
query_as!(Self, "select * from bases where id = $1", &id)
|
query_as!(Self, "select * from bases where id = $1", &id)
|
||||||
.fetch_optional(client)
|
.fetch_optional(app_db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_by_perm_any<'a, E: PgExecutor<'a>>(
|
pub async fn fetch_by_perm_any<'a, E: PgExecutor<'a>>(
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
perms: Vec<&str>,
|
perms: Vec<&str>,
|
||||||
client: E,
|
app_db: E,
|
||||||
) -> Result<Vec<Base>, sqlx::Error> {
|
) -> Result<Vec<Base>, sqlx::Error> {
|
||||||
let perms = perms
|
let perms = perms
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -44,7 +44,7 @@ where p.user_id = $1 and perm = ANY($2)
|
||||||
user_id,
|
user_id,
|
||||||
perms.as_slice(),
|
perms.as_slice(),
|
||||||
)
|
)
|
||||||
.fetch_all(client)
|
.fetch_all(app_db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +56,7 @@ pub struct InsertableBase {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InsertableBase {
|
impl InsertableBase {
|
||||||
pub async fn insert<'a, E: PgExecutor<'a>>(self, client: E) -> Result<Base, sqlx::Error> {
|
pub async fn insert<'a, E: PgExecutor<'a>>(self, app_db: E) -> Result<Base, sqlx::Error> {
|
||||||
query_as!(
|
query_as!(
|
||||||
Base,
|
Base,
|
||||||
"
|
"
|
||||||
|
|
@ -69,7 +69,7 @@ returning *
|
||||||
self.url,
|
self.url,
|
||||||
self.owner_id
|
self.owner_id
|
||||||
)
|
)
|
||||||
.fetch_one(client)
|
.fetch_one(app_db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ mod pg_attributes;
|
||||||
mod pg_classes;
|
mod pg_classes;
|
||||||
mod pg_databases;
|
mod pg_databases;
|
||||||
mod pg_roles;
|
mod pg_roles;
|
||||||
|
mod rel_invitations;
|
||||||
mod router;
|
mod router;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,28 @@ pub enum PgPrivilegeType {
|
||||||
Maintain,
|
Maintain,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl PgPrivilegeType {
|
||||||
|
pub fn to_abbrev(&self) -> char {
|
||||||
|
match self {
|
||||||
|
Self::Select => 'r',
|
||||||
|
Self::Insert => 'a',
|
||||||
|
Self::Update => 'w',
|
||||||
|
Self::Delete => 'd',
|
||||||
|
Self::Truncate => 'D',
|
||||||
|
Self::References => 'x',
|
||||||
|
Self::Trigger => 't',
|
||||||
|
Self::Create => 'C',
|
||||||
|
Self::Connect => 'c',
|
||||||
|
Self::Temporary => 'T',
|
||||||
|
Self::Execute => 'X',
|
||||||
|
Self::Usage => 'U',
|
||||||
|
Self::Set => 's',
|
||||||
|
Self::AlterSystem => 'A',
|
||||||
|
Self::Maintain => 'm',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'a> Decode<'a, Postgres> for PgAclItem {
|
impl<'a> Decode<'a, Postgres> for PgAclItem {
|
||||||
fn decode(value: PgValueRef<'a>) -> Result<Self, BoxDynError> {
|
fn decode(value: PgValueRef<'a>) -> Result<Self, BoxDynError> {
|
||||||
let acl_item_str = <&str as Decode<Postgres>>::decode(value)?;
|
let acl_item_str = <&str as Decode<Postgres>>::decode(value)?;
|
||||||
|
|
|
||||||
|
|
@ -39,16 +39,51 @@ pub struct PgClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PgClass {
|
impl PgClass {
|
||||||
|
pub async fn fetch_by_oid<'a, E: PgExecutor<'a>>(
|
||||||
|
oid: Oid,
|
||||||
|
client: E,
|
||||||
|
) -> Result<Option<Self>, sqlx::Error> {
|
||||||
|
query_as!(
|
||||||
|
Self,
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
oid,
|
||||||
|
relname,
|
||||||
|
relnamespace,
|
||||||
|
reltype,
|
||||||
|
reloftype,
|
||||||
|
relowner,
|
||||||
|
relkind,
|
||||||
|
relnatts,
|
||||||
|
relchecks,
|
||||||
|
relhasrules,
|
||||||
|
relhastriggers,
|
||||||
|
relhassubclass,
|
||||||
|
relrowsecurity,
|
||||||
|
relforcerowsecurity,
|
||||||
|
relispopulated,
|
||||||
|
relispartition,
|
||||||
|
relacl::text[] as "relacl: Vec<PgAclItem>"
|
||||||
|
from pg_class
|
||||||
|
where
|
||||||
|
oid = $1
|
||||||
|
"#,
|
||||||
|
oid,
|
||||||
|
)
|
||||||
|
.fetch_optional(client)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_all_by_kind_any<'a, I: IntoIterator<Item = PgRelKind>, E: PgExecutor<'a>>(
|
pub async fn fetch_all_by_kind_any<'a, I: IntoIterator<Item = PgRelKind>, E: PgExecutor<'a>>(
|
||||||
kinds: I,
|
kinds: I,
|
||||||
client: E,
|
client: E,
|
||||||
) -> Result<Vec<PgClass>, sqlx::Error> {
|
) -> Result<Vec<Self>, sqlx::Error> {
|
||||||
let kinds_i8 = kinds
|
let kinds_i8 = kinds
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|kind| kind.to_u8() as i8)
|
.map(|kind| kind.to_u8() as i8)
|
||||||
.collect::<Vec<i8>>();
|
.collect::<Vec<i8>>();
|
||||||
query_as!(
|
query_as!(
|
||||||
PgClass,
|
Self,
|
||||||
r#"
|
r#"
|
||||||
select
|
select
|
||||||
oid,
|
oid,
|
||||||
|
|
|
||||||
79
src/rel_invitations.rs
Normal file
79
src/rel_invitations.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use derive_builder::Builder;
|
||||||
|
use sqlx::{postgres::types::Oid, query_as, PgExecutor};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::pg_acls::PgPrivilegeType;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RelInvitation {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub email: String,
|
||||||
|
pub base_id: Uuid,
|
||||||
|
pub class_oid: Oid,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub privilege: String,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RelInvitation {
|
||||||
|
pub async fn fetch_by_class_oid<'a, E: PgExecutor<'a>>(
|
||||||
|
oid: Oid,
|
||||||
|
app_db: E,
|
||||||
|
) -> Result<Vec<Self>, sqlx::Error> {
|
||||||
|
query_as!(
|
||||||
|
Self,
|
||||||
|
"
|
||||||
|
select * from rel_invitations
|
||||||
|
where class_oid = $1
|
||||||
|
",
|
||||||
|
oid
|
||||||
|
)
|
||||||
|
.fetch_all(app_db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn upsertable() -> UpsertableRelInvitationBuilder {
|
||||||
|
UpsertableRelInvitationBuilder::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Builder, Clone, Debug)]
|
||||||
|
pub struct UpsertableRelInvitation {
|
||||||
|
email: String,
|
||||||
|
base_id: Uuid,
|
||||||
|
class_oid: Oid,
|
||||||
|
created_by: Uuid,
|
||||||
|
privilege: PgPrivilegeType,
|
||||||
|
#[builder(default, setter(strip_option))]
|
||||||
|
expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpsertableRelInvitation {
|
||||||
|
pub async fn upsert<'a, E: PgExecutor<'a>>(
|
||||||
|
self,
|
||||||
|
app_db: E,
|
||||||
|
) -> Result<RelInvitation, sqlx::Error> {
|
||||||
|
query_as!(
|
||||||
|
RelInvitation,
|
||||||
|
"
|
||||||
|
insert into rel_invitations
|
||||||
|
(id, email, base_id, class_oid, privilege, created_by, expires_at)
|
||||||
|
values ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
on conflict (email, base_id, class_oid, privilege) do update set
|
||||||
|
created_by = excluded.created_by,
|
||||||
|
expires_at = excluded.expires_at
|
||||||
|
returning *
|
||||||
|
",
|
||||||
|
Uuid::now_v7(),
|
||||||
|
self.email,
|
||||||
|
self.base_id,
|
||||||
|
self.class_oid,
|
||||||
|
self.privilege.to_abbrev().to_string(),
|
||||||
|
self.created_by,
|
||||||
|
self.expires_at,
|
||||||
|
)
|
||||||
|
.fetch_one(app_db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,6 +28,18 @@ pub fn new_router(state: AppState) -> Router<()> {
|
||||||
"/d/{base_id}/relations",
|
"/d/{base_id}/relations",
|
||||||
get(routes::relations::list_relations_page),
|
get(routes::relations::list_relations_page),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/d/{base_id}/r/{class_oid}/rbac",
|
||||||
|
get(routes::relations::rel_rbac_page),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/d/{base_id}/r/{class_oid}/rbac/invite",
|
||||||
|
get(routes::relations::rel_rbac_invite_page_get),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/d/{base_id}/r/{class_oid}/rbac/invite",
|
||||||
|
post(routes::relations::rel_rbac_invite_page_post),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/d/{base_id}/r/{class_oid}/viewer",
|
"/d/{base_id}/r/{class_oid}/viewer",
|
||||||
get(routes::relations::viewer_page),
|
get(routes::relations::viewer_page),
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ use crate::{
|
||||||
base_user_perms::sync_perms_for_base,
|
base_user_perms::sync_perms_for_base,
|
||||||
bases::Base,
|
bases::Base,
|
||||||
db_conns::{escape_identifier, init_role},
|
db_conns::{escape_identifier, init_role},
|
||||||
|
pg_databases::PgDatabase,
|
||||||
|
pg_roles::PgRole,
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
users::CurrentUser,
|
users::CurrentUser,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use anyhow::Context as _;
|
use anyhow::Context as _;
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::{Html, IntoResponse as _, Response},
|
response::{Html, IntoResponse as _, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
use axum_extra::extract::Form;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{
|
use sqlx::{
|
||||||
postgres::{types::Oid, PgRow},
|
postgres::{types::Oid, PgRow},
|
||||||
|
|
@ -14,18 +15,19 @@ use sqlx::{
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app_error::AppError,
|
app_error::{not_found, AppError},
|
||||||
app_state::AppDbConn,
|
app_state::AppDbConn,
|
||||||
base_pooler::{self, BasePooler},
|
base_pooler::BasePooler,
|
||||||
bases::Base,
|
bases::Base,
|
||||||
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
|
data_layer::{Field, FieldOptionsBuilder, ToHtmlString as _, Value},
|
||||||
db_conns::{escape_identifier, init_role},
|
db_conns::{escape_identifier, init_role},
|
||||||
pg_acls::{PgAclItem, PgPrivilegeType},
|
pg_acls::PgPrivilegeType,
|
||||||
pg_attributes::fetch_attributes_for_rel,
|
pg_attributes::fetch_attributes_for_rel,
|
||||||
pg_classes::{PgClass, PgRelKind},
|
pg_classes::{PgClass, PgRelKind},
|
||||||
pg_roles::{PgRole, RoleTree},
|
pg_roles::{user_id_from_rolname, PgRole, RoleTree},
|
||||||
|
rel_invitations::RelInvitation,
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
users::CurrentUser,
|
users::{CurrentUser, User},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -66,7 +68,7 @@ pub async fn list_relations_page(
|
||||||
let privileges: HashSet<PgPrivilegeType> = rel
|
let privileges: HashSet<PgPrivilegeType> = rel
|
||||||
.relacl
|
.relacl
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or(vec![])
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|item| granted_roles.contains(&item.grantee))
|
.filter(|item| granted_roles.contains(&item.grantee))
|
||||||
.flat_map(|item| item.privileges)
|
.flat_map(|item| item.privileges)
|
||||||
|
|
@ -168,3 +170,123 @@ pub async fn viewer_page(
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RelRbacPagePath {
|
||||||
|
base_id: Uuid,
|
||||||
|
class_oid: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rel_rbac_page(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
State(mut base_pooler): State<BasePooler>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// FIXME: auth
|
||||||
|
let base = Base::fetch_by_id(base_id, &mut *app_db)
|
||||||
|
.await?
|
||||||
|
.ok_or(not_found!("no base found with id {}", base_id))?;
|
||||||
|
let mut client = base_pooler.acquire_for(base_id).await?;
|
||||||
|
let rolname = format!("{}{}", &base.user_role_prefix, current_user.id.simple());
|
||||||
|
init_role(&rolname, &mut client).await?;
|
||||||
|
let class = PgClass::fetch_by_oid(Oid(class_oid), &mut *client)
|
||||||
|
.await?
|
||||||
|
.ok_or(not_found!("no relation found with oid {}", class_oid))?;
|
||||||
|
let user_ids: Vec<Uuid> = class
|
||||||
|
.relacl
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| user_id_from_rolname(&item.grantee, &base.user_role_prefix).ok())
|
||||||
|
.collect();
|
||||||
|
let all_users = User::fetch_by_ids_any(user_ids, &mut *app_db).await?;
|
||||||
|
let interim_users: HashMap<String, User> = all_users
|
||||||
|
.into_iter()
|
||||||
|
.map(|user| {
|
||||||
|
(
|
||||||
|
format!("{}{}", base.user_role_prefix, user.id.simple()),
|
||||||
|
user,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let all_invites = RelInvitation::fetch_by_class_oid(Oid(class_oid), &mut *app_db).await?;
|
||||||
|
let mut invites_by_email: HashMap<String, Vec<RelInvitation>> = HashMap::new();
|
||||||
|
for invite in all_invites {
|
||||||
|
let entry = invites_by_email
|
||||||
|
.entry(invite.email.clone())
|
||||||
|
.or_insert(vec![]);
|
||||||
|
entry.push(invite);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "rel_rbac.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
base: Base,
|
||||||
|
interim_users: HashMap<String, User>,
|
||||||
|
invites_by_email: HashMap<String, Vec<RelInvitation>>,
|
||||||
|
pg_class: PgClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Html(
|
||||||
|
ResponseTemplate {
|
||||||
|
base,
|
||||||
|
base_path,
|
||||||
|
interim_users,
|
||||||
|
invites_by_email,
|
||||||
|
pg_class: class,
|
||||||
|
}
|
||||||
|
.render()?,
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rel_rbac_invite_page_get(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "rbac_invite.html")]
|
||||||
|
struct ResponseTemplate {
|
||||||
|
base_path: String,
|
||||||
|
}
|
||||||
|
Ok(Html(ResponseTemplate { base_path }.render()?).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct RbacInvitePagePostForm {
|
||||||
|
email: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rel_rbac_invite_page_post(
|
||||||
|
State(Settings { base_path, .. }): State<Settings>,
|
||||||
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
|
CurrentUser(current_user): CurrentUser,
|
||||||
|
Path(RelRbacPagePath { base_id, class_oid }): Path<RelRbacPagePath>,
|
||||||
|
Form(form): Form<RbacInvitePagePostForm>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
// FIXME auth
|
||||||
|
// FIXME form validation
|
||||||
|
for privilege in [
|
||||||
|
PgPrivilegeType::Select,
|
||||||
|
PgPrivilegeType::Insert,
|
||||||
|
PgPrivilegeType::Update,
|
||||||
|
PgPrivilegeType::Delete,
|
||||||
|
PgPrivilegeType::Truncate,
|
||||||
|
PgPrivilegeType::References,
|
||||||
|
PgPrivilegeType::Trigger,
|
||||||
|
] {
|
||||||
|
RelInvitation::upsertable()
|
||||||
|
.email(form.email.clone())
|
||||||
|
.base_id(base_id)
|
||||||
|
.class_oid(Oid(class_oid))
|
||||||
|
.privilege(privilege)
|
||||||
|
.created_by(current_user.id)
|
||||||
|
.build()?
|
||||||
|
.upsert(&mut *app_db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(Redirect::to(&format!("{base_path}/d/{base_id}/r/{class_oid}/rbac")).into_response())
|
||||||
|
}
|
||||||
|
|
|
||||||
22
src/users.rs
22
src/users.rs
|
|
@ -9,7 +9,7 @@ use axum_extra::extract::{
|
||||||
cookie::{Cookie, SameSite},
|
cookie::{Cookie, SameSite},
|
||||||
CookieJar,
|
CookieJar,
|
||||||
};
|
};
|
||||||
use sqlx::query_as;
|
use sqlx::{query_as, PgExecutor};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -26,6 +26,26 @@ pub struct User {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
pub async fn fetch_by_ids_any<'a, I: IntoIterator<Item = Uuid>, E: PgExecutor<'a>>(
|
||||||
|
ids: I,
|
||||||
|
app_db: E,
|
||||||
|
) -> Result<Vec<Self>, sqlx::Error> {
|
||||||
|
let ids: Vec<Uuid> = ids.into_iter().collect();
|
||||||
|
query_as!(
|
||||||
|
Self,
|
||||||
|
"
|
||||||
|
select * from users
|
||||||
|
where id = any($1)
|
||||||
|
",
|
||||||
|
ids.as_slice()
|
||||||
|
)
|
||||||
|
.fetch_all(app_db)
|
||||||
|
.await
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct CurrentUser(pub User);
|
pub struct CurrentUser(pub User);
|
||||||
|
|
||||||
|
|
|
||||||
15
templates/base_config.html
Normal file
15
templates/base_config.html
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<form method="post" action="">
|
||||||
|
<div>
|
||||||
|
<label for="input-name">Name:</label>
|
||||||
|
<input type="text" name="name" value="{{ base.name }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="input-url">Database URL:</label>
|
||||||
|
<input type="text" name="url" value="{{ base.url }}">
|
||||||
|
</div>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
13
templates/rbac_invite.html
Normal file
13
templates/rbac_invite.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<form method="post" action="">
|
||||||
|
<div>
|
||||||
|
<label for="email">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input type="text" name="email" inputmode="email">
|
||||||
|
<button type="submit">Invite</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
48
templates/rel_rbac.html
Normal file
48
templates/rel_rbac.html
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<h2>Invitations</h2>
|
||||||
|
<a href="{{ base_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
|
||||||
|
Invite Collaborators
|
||||||
|
</a>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Privileges</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for (email, invites) in invites_by_email %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ email }}</td>
|
||||||
|
<td>
|
||||||
|
<code>{% for invite in invites %}{{ invite.privilege }}{% endfor %}</code>
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2>Interim Users</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>User ID</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for (grantee, user) in interim_users %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ user.email }}</td>
|
||||||
|
<td>{{ grantee }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
Loading…
Add table
Reference in a new issue