use std::{
any::{Any, TypeId},
future::Future,
};
use topcoat::{
context::{Cx, try_context},
router::{
Body,
error::{forbidden, not_found, see_other, too_many_requests},
request::client_ip,
},
view::BoxView,
};
use super::{
PanelUser, SignedIn, email_change::Requested, input, registration::new_password_errors,
session::replace_sessions, signed, unauthenticated_error,
};
use crate::{
BoxFuture,
auth::realm::{SettingsPage, current_realm, hosted, panel_prefix},
flash::set_notification,
form::{FieldError, FieldErrors, FormInput, FormValues, parse_password, parse_scalar},
outcome::Notification,
schema::{Field, Schema},
topcoat_compat::async_page,
};
pub trait ProfileEditor: Send + Sync + 'static {
type Input: FormInput;
type User: PanelUser;
fn fill(&self, cx: &Cx, user: &Self::User) -> Self::Input;
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 save(
&self,
cx: &Cx,
user: &Self::User,
input: Self::Input,
ex: &mut dyn toasty::Executor,
) -> impl Future<Output = topcoat::Result<ProfileUpdate>> + Send;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProfileUpdate {
Details,
Credentials,
}
pub(crate) enum Outcome {
Saved { email_pending: bool },
Refused {
values: FormValues,
errors: FieldErrors,
},
}
pub(crate) trait DynProfileEditor: Send + Sync {
fn schema(&self) -> Schema;
fn values(&self, cx: &Cx, user: &dyn PanelUser) -> Option<FormValues>;
fn submit<'a>(
&'a self,
cx: &'a Cx,
signed: &'a SignedIn,
values: FormValues,
) -> BoxFuture<'a, topcoat::Result<Outcome>>;
}
impl<E: ProfileEditor> DynProfileEditor for E {
fn schema(&self) -> Schema {
E::Input::schema()
}
fn values(&self, cx: &Cx, user: &dyn PanelUser) -> Option<FormValues> {
let user: &dyn Any = user;
let user = user.downcast_ref::<E::User>()?;
let mut values = FormValues::new();
self.fill(cx, user).write(&mut values);
Some(values)
}
fn submit<'a>(
&'a self,
cx: &'a Cx,
signed: &'a SignedIn,
mut values: FormValues,
) -> BoxFuture<'a, topcoat::Result<Outcome>> {
Box::pin(async move {
let any: &dyn Any = &*signed.user;
let user = any
.downcast_ref::<E::User>()
.ok_or_else(|| topcoat::Error::from(forbidden()))?;
let schema = E::Input::schema();
let mut stored = FormValues::new();
self.fill(cx, user).write(&mut stored);
input::keep_disabled(&schema, &mut values, &stored);
schema.keep_unedited(&mut values, &stored, &crate::time_zone(cx));
let input = match input::parse::<E::Input>(cx, &values, &stored, "profile").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(crate::error::unavailable)?;
let errors = self
.validate(cx, user, &input, &mut tx)
.await
.map_err(write_failure)?;
input::placed(&schema, &errors, "the profile editor", "profile")?;
if !errors.is_empty() {
return Ok(Outcome::Refused { values, errors });
}
let update = self
.save(cx, user, input, &mut tx)
.await
.map_err(write_failure)?;
if update == ProfileUpdate::Credentials {
let session = super::session::start(cx).await?;
replace_sessions(cx, &mut tx, signed, TypeId::of::<E::User>(), &session).await?;
}
tx.commit().await.map_err(crate::error::unavailable)?;
let email_pending =
try_context::<Requested>(cx).is_some_and(|requested| requested.mail(cx));
Ok(Outcome::Saved { email_pending })
})
}
}
fn write_failure(error: topcoat::Error) -> topcoat::Error {
crate::error::driver_failure(error, crate::error::DATABASE_UNAVAILABLE)
}
#[derive(Clone, PartialEq, Eq)]
pub struct Profile {
pub name: String,
pub email: String,
pub password: Option<String>,
pub password_confirmation: String,
pub current_password: Option<String>,
}
impl Profile {
pub fn password_errors(&self) -> FieldErrors {
match &self.password {
Some(password) => new_password_errors(password, &self.password_confirmation),
None => FieldErrors::new(),
}
}
}
impl std::fmt::Debug for Profile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Profile")
.field("name", &self.name)
.field("email", &self.email)
.field("password", &self.password.as_ref().map(|_| "<redacted>"))
.field("password_confirmation", &"<redacted>")
.field(
"current_password",
&self.current_password.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
impl FormInput for Profile {
type Field = std::convert::Infallible;
fn key(field: Self::Field) -> &'static str {
match field {}
}
fn schema() -> Schema {
let required = crate::form::required_input;
Schema::new((
required(Field::text_input::<String>("name").label(crate::text!("tablo-auth-name"))),
required(
Field::text_input::<String>("email")
.email()
.label(crate::text!("tablo-auth-email")),
),
Field::text_input::<String>(PASSWORD_FIELD)
.password()
.label(crate::text!("tablo-auth-new-password"))
.help(crate::text!("tablo-profile-password-help")),
Field::text_input::<String>(CONFIRMATION_FIELD)
.password()
.label(crate::text!("tablo-auth-confirm-new-password")),
Field::text_input::<String>(CURRENT_PASSWORD_FIELD)
.current_password()
.label(crate::text!("tablo-auth-current-password"))
.help(crate::text!("tablo-profile-current-password-help")),
))
}
fn parse(_cx: &Cx, values: &FormValues) -> Result<Self, Vec<FieldError>> {
let mut errors = Vec::new();
let mut keep = |read: Result<String, FieldError>| read.map_err(|error| errors.push(error));
let name = keep(parse_scalar::<String>("name", values, None));
let email = keep(parse_scalar::<String>("email", values, None));
let mut optional = |key| {
keep(parse_password(key, values, Some(String::new())))
.ok()
.filter(|password| !password.trim().is_empty())
};
let password = optional(PASSWORD_FIELD);
let password_confirmation = optional(CONFIRMATION_FIELD).unwrap_or_default();
let current_password = optional(CURRENT_PASSWORD_FIELD);
match (name, email) {
(Ok(name), Ok(email)) if errors.is_empty() => Ok(Self {
name,
email,
password,
password_confirmation,
current_password,
}),
_ => Err(errors),
}
}
fn write(&self, out: &mut FormValues) {
out.insert("name".to_string(), self.name.clone());
out.insert("email".to_string(), self.email.clone());
}
}
const PASSWORD_FIELD: &str = "password";
const CONFIRMATION_FIELD: &str = "password_confirmation";
pub(crate) const CURRENT_PASSWORD_FIELD: &str = "current_password";
pub(crate) const RESERVED_KEYS: [&str; 1] = [crate::csrf::FIELD_NAME];
pub(crate) fn profile_url(cx: &Cx) -> String {
current_realm(cx).map_or_else(
|| format!("{}/profile", panel_prefix(cx)),
|realm| realm.pages.profile.clone(),
)
}
pub(crate) fn offered(cx: &Cx) -> bool {
current_realm(cx).is_some_and(|panel| panel.auth.profile_editor().is_some())
}
fn editing(cx: &Cx) -> topcoat::Result<(&SignedIn, &dyn DynProfileEditor)> {
let signed = signed(cx).ok_or_else(|| unauthenticated_error(cx))?;
let editor = signed.realm.auth.profile_editor().ok_or_else(not_found)?;
Ok((signed, editor))
}
pub(super) fn profile_page(cx: &Cx, _body: Body) -> BoxView<'_> {
async_page(async move {
let (signed, editor) = editing(cx)?;
let values = editor
.values(cx, signed.user.as_ref())
.ok_or_else(forbidden)?;
crate::csrf::ensure_token(cx);
render(cx, editor, &values, &FieldErrors::new()).await
})
}
pub(super) fn profile_post(cx: &Cx, body: Body) -> BoxView<'_> {
async_page(async move {
let (signed, editor) = editing(cx)?;
let form = crate::auth::input::read_form(cx, body).await?;
crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
let input_schema = editor.schema();
let values = input_schema.read_input(cx, &form.pairs, &RESERVED_KEYS)?;
let throttle = signed.realm.auth.throttle_profile();
if !throttle
.attempt(cx, "profile", &signed.user.user_id(), None)
.await?
{
tracing::warn!(
panel = %signed.realm.prefix,
client = ?client_ip(cx),
"profile save throttled"
);
return Err(too_many_requests(throttle.window_secs()).into());
}
let saving = cx.with(Requested::default());
let email_pending = match editor.submit(&saving, signed, values).await? {
Outcome::Refused { values, errors } => {
return render(cx, editor, &values, &errors).await;
}
Outcome::Saved { email_pending } => email_pending,
};
let saved = Notification::success(crate::t!(cx, "tablo-profile-saved"));
set_notification(
cx,
if email_pending {
saved.description(crate::t!(cx, "tablo-profile-email-pending"))
} else {
saved
},
);
Err(see_other(profile_url(cx)).into())
})
}
async fn render<'a>(
cx: &'a Cx,
editor: &dyn DynProfileEditor,
values: &FormValues,
errors: &FieldErrors,
) -> topcoat::Result<BoxView<'a>> {
let schema = editor.schema();
let page = SettingsPage {
title: crate::t!(cx, "tablo-shell-profile"),
schema: &schema,
hidden: None,
values,
errors,
};
hosted(cx)?.settings_page(cx, page).await
}
pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
use super::input::FieldFault;
use crate::{DeclarationErrorKind, declaration::ProfileFault};
let Some(editor) = auth.profile_editor.as_deref() else {
return Vec::new();
};
if auth.authenticator().is_none() {
return vec![DeclarationErrorKind::Profile(ProfileFault::AuthDisabled)];
}
let mut kinds = Vec::new();
let schema = editor.schema();
kinds.extend(schema.declaration_errors());
kinds.extend(schema.empty_choices());
kinds.extend(
input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
DeclarationErrorKind::Profile(match fault {
FieldFault::Reserved => ProfileFault::ReservedField(name),
FieldFault::File => ProfileFault::FileField(name),
FieldFault::Relationship => ProfileFault::RelationshipField(name),
})
}),
);
kinds
}
#[cfg(test)]
mod tests;