use std::{any::Any, future::Future, marker::PhantomData};
use topcoat::{
context::Cx,
icon::icon,
router::{
Body, RouteFuture,
error::{forbidden, not_found, see_other},
response::{IntoResponse, Response},
},
view::{ViewExt, attributes, view},
};
use super::{
PanelUser, SignedIn, infrastructure_failure, input,
login::logout_url,
page::{AuthPage, render, switch_link},
panel_root,
session::select_tenant,
signed, unauthenticated_error,
};
use crate::{
BoxFuture,
auth::realm::{Realm, current_realm, hosted, panel_prefix},
flash::set_notification,
form::{FieldErrors, FormInput, FormValues},
outcome::Notification,
schema::{Schema, Source},
tenancy::Membership,
};
pub trait TenantRegistrar: Send + Sync + 'static {
type Input: FormInput;
type User: PanelUser;
fn validate(
&self,
_cx: &Cx,
_user: &Self::User,
_input: &Self::Input,
_ex: &mut dyn toasty::Executor,
) -> impl Future<Output = topcoat::Result<FieldErrors>> + Send {
async { Ok(FieldErrors::new()) }
}
fn register(
&self,
cx: &Cx,
user: &Self::User,
input: Self::Input,
ex: &mut dyn toasty::Executor,
) -> impl Future<Output = topcoat::Result<Membership>> + Send;
}
pub struct TenantRegistration<U> {
registrar: Box<dyn DynTenantRegistrar>,
required: bool,
user: PhantomData<fn() -> U>,
}
impl<U: PanelUser> TenantRegistration<U> {
#[must_use]
pub fn new(registrar: impl TenantRegistrar<User = U>) -> Self {
Self {
registrar: Box::new(registrar),
required: false,
user: PhantomData,
}
}
#[must_use]
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub(crate) fn erase(self) -> TenantRegistration<()> {
TenantRegistration {
registrar: self.registrar,
required: self.required,
user: PhantomData,
}
}
}
impl TenantRegistration<()> {
pub(crate) fn registrar(&self) -> &dyn DynTenantRegistrar {
&*self.registrar
}
}
impl<U> std::fmt::Debug for TenantRegistration<U> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TenantRegistration")
.field("required", &self.required)
.finish_non_exhaustive()
}
}
pub(crate) enum Outcome {
Registered(Membership),
Refused {
values: FormValues,
errors: FieldErrors,
},
}
pub(crate) trait DynTenantRegistrar: Send + Sync {
fn schema(&self) -> Schema;
fn register<'a>(
&'a self,
cx: &'a Cx,
signed: &'a SignedIn,
values: FormValues,
) -> BoxFuture<'a, topcoat::Result<Outcome>>;
}
impl<R: TenantRegistrar> DynTenantRegistrar for R {
fn schema(&self) -> Schema {
R::Input::schema()
}
fn register<'a>(
&'a self,
cx: &'a Cx,
signed: &'a SignedIn,
values: FormValues,
) -> BoxFuture<'a, topcoat::Result<Outcome>> {
Box::pin(async move {
let any: &dyn Any = &*signed.user;
let user = any
.downcast_ref::<R::User>()
.ok_or_else(|| topcoat::Error::from(forbidden()))?;
let input =
match input::parse::<R::Input>(cx, &values, &FormValues::new(), "tenant").await? {
Ok(input) => input,
Err(errors) => return Ok(Outcome::Refused { values, errors }),
};
let mut db = crate::db::db(cx)?;
let mut tx = db.transaction().await.map_err(infrastructure_failure)?;
let errors = self
.validate(cx, user, &input, &mut tx)
.await
.map_err(infrastructure_failure)?;
input::placed(
&R::Input::schema(),
&errors,
"the tenant registrar",
"tenant",
)?;
if !errors.is_empty() {
return Ok(Outcome::Refused { values, errors });
}
let membership = TenantRegistrar::register(self, cx, user, input, &mut tx)
.await
.map_err(infrastructure_failure)?;
tx.commit().await.map_err(infrastructure_failure)?;
Ok(Outcome::Registered(membership))
})
}
}
pub(crate) const RESERVED_KEYS: [&str; 1] = [crate::csrf::FIELD_NAME];
pub(crate) fn register_tenant_url(cx: &Cx) -> String {
format!("{}/register-tenant", panel_prefix(cx))
}
pub(crate) fn offered(cx: &Cx) -> bool {
current_realm(cx).is_some_and(|panel| panel.auth.tenant_registration_page().is_some())
}
pub(super) fn pending(panel: &Realm, user: &dyn PanelUser) -> bool {
panel
.auth
.tenant_registration_page()
.is_some_and(|registration| registration.required)
&& user.tenants().is_empty()
}
fn registering(cx: &Cx) -> topcoat::Result<(&SignedIn, &dyn DynTenantRegistrar)> {
let signed = signed(cx).ok_or_else(|| unauthenticated_error(cx))?;
let registration = signed
.realm
.auth
.tenant_registration_page()
.ok_or_else(not_found)?;
Ok((signed, registration.registrar()))
}
pub(super) fn register_tenant_page(cx: &Cx, _body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let (signed, registrar) = registering(cx)?;
let values = registrar.schema().defaults();
page_response(cx, signed, registrar, &values, &FieldErrors::new()).await
})
}
pub(super) fn register_tenant_post(cx: &Cx, body: Body) -> RouteFuture<'_> {
Box::pin(async move {
let (signed, registrar) = registering(cx)?;
let form = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
let input_schema = registrar.schema();
let values = input_schema.read_input(cx, &form.pairs, &RESERVED_KEYS)?;
let membership = match registrar.register(cx, signed, values).await? {
Outcome::Registered(membership) => membership,
Outcome::Refused { values, errors } => {
return page_response(cx, signed, registrar, &values, &errors).await;
}
};
select_tenant(cx, membership.tenant).await?;
set_notification(
cx,
Notification::success(crate::t!(cx, "tablo-tenant-created")).description(crate::t!(
cx,
"tablo-tenant-acting",
tenant = membership.name.clone()
)),
);
see_other(panel_root(cx)).into_response(cx)
})
}
async fn page_response(
cx: &Cx,
signed: &SignedIn,
registrar: &dyn DynTenantRegistrar,
values: &FormValues,
errors: &FieldErrors,
) -> topcoat::Result<Response> {
let csrf = crate::csrf::ensure_token(cx);
let action = register_tenant_url(cx);
let schema = registrar.schema();
let fields = schema.render(cx, Source::form(values, errors)).await?;
let first = signed.user.tenants().is_empty();
let logout = logout_url(cx);
let form = view! {
<div class="flex flex-col gap-3">
<form method="post" action=(action) class="flex flex-col gap-5">
(crate::csrf::field(cx, &csrf))
(fields)
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Primary,
attrs: attributes! { type="submit" class="w-full" },
(crate::t!(cx, "tablo-shell-create-tenant"))
)
</form>
if first {
<form method="post" action=(logout)>
(crate::csrf::field(cx, &csrf))
tablo_ui::button(
variant: tablo_ui::ButtonVariant::Outline,
attrs: attributes! { type="submit" class="w-full" },
icon(data: tablo_ui::icons::LOG_OUT)
(crate::t!(cx, "tablo-shell-sign-out"))
)
</form>
}
</div>
}
.boxed();
let root = panel_root(cx);
let brand = hosted(cx)?.brand_name(cx);
let page = render(
cx,
AuthPage {
title: crate::t!(cx, "tablo-tenant-create-title"),
description: if first {
crate::t!(cx, "tablo-tenant-create-first", brand = brand)
} else {
crate::t!(cx, "tablo-tenant-create-another", brand = brand)
},
status: (!errors.is_empty()).then_some(http::StatusCode::UNPROCESSABLE_ENTITY),
form,
switch: (!first).then(|| {
switch_link(
cx,
"tablo-tenant-changed-mind",
&root,
"tablo-tenant-go-back",
)
}),
note: None,
},
)
.await?;
page.single().await?.into_response(cx)
}
pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
use super::input::FieldFault;
use crate::{DeclarationErrorKind, declaration::TenantRegistrationFault as Fault};
let Some(registrar) = auth
.tenant_registration
.as_ref()
.map(TenantRegistration::registrar)
else {
return Vec::new();
};
if auth.authenticator().is_none() {
return vec![DeclarationErrorKind::TenantRegistration(
Fault::AuthDisabled,
)];
}
let mut kinds = Vec::new();
let schema = registrar.schema();
kinds.extend(schema.declaration_errors());
kinds.extend(schema.empty_choices());
kinds.extend(
input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
DeclarationErrorKind::TenantRegistration(match fault {
FieldFault::Reserved => Fault::ReservedField(name),
FieldFault::File => Fault::FileField(name),
FieldFault::Relationship => Fault::RelationshipField(name),
})
}),
);
kinds
}