1use std::{
5 any::{Any, TypeId},
6 future::Future,
7};
8
9use topcoat::{
10 context::{Cx, try_context},
11 router::{
12 Body,
13 error::{forbidden, not_found, see_other, too_many_requests},
14 request::client_ip,
15 },
16 view::BoxView,
17};
18
19use super::{
20 PanelUser, SignedIn, email_change::Requested, input, registration::new_password_errors,
21 session::replace_sessions, signed, unauthenticated_error,
22};
23use crate::{
24 BoxFuture,
25 auth::realm::{SettingsPage, current_realm, hosted, panel_prefix},
26 flash::set_notification,
27 form::{FieldError, FieldErrors, FormInput, FormValues, parse_password, parse_scalar},
28 outcome::Notification,
29 schema::{Field, Schema},
30 topcoat_compat::async_page,
31};
32
33pub trait ProfileEditor: Send + Sync + 'static {
92 type Input: FormInput;
95
96 type User: PanelUser;
99
100 fn fill(&self, cx: &Cx, user: &Self::User) -> Self::Input;
104
105 fn validate(
112 &self,
113 _cx: &Cx,
114 _user: &Self::User,
115 _input: &Self::Input,
116 _ex: &mut dyn toasty::Executor,
117 ) -> impl Future<Output = topcoat::Result<FieldErrors>> + Send {
118 async { Ok(FieldErrors::new()) }
119 }
120
121 fn save(
124 &self,
125 cx: &Cx,
126 user: &Self::User,
127 input: Self::Input,
128 ex: &mut dyn toasty::Executor,
129 ) -> impl Future<Output = topcoat::Result<ProfileUpdate>> + Send;
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum ProfileUpdate {
136 Details,
138 Credentials,
142}
143
144pub(crate) enum Outcome {
146 Saved { email_pending: bool },
149 Refused {
151 values: FormValues,
152 errors: FieldErrors,
153 },
154}
155
156pub(crate) trait DynProfileEditor: Send + Sync {
158 fn schema(&self) -> Schema;
160 fn values(&self, cx: &Cx, user: &dyn PanelUser) -> Option<FormValues>;
162 fn submit<'a>(
165 &'a self,
166 cx: &'a Cx,
167 signed: &'a SignedIn,
168 values: FormValues,
169 ) -> BoxFuture<'a, topcoat::Result<Outcome>>;
170}
171
172impl<E: ProfileEditor> DynProfileEditor for E {
173 fn schema(&self) -> Schema {
174 E::Input::schema()
175 }
176
177 fn values(&self, cx: &Cx, user: &dyn PanelUser) -> Option<FormValues> {
178 let user: &dyn Any = user;
179 let user = user.downcast_ref::<E::User>()?;
180 let mut values = FormValues::new();
181 self.fill(cx, user).write(&mut values);
182 Some(values)
183 }
184
185 fn submit<'a>(
186 &'a self,
187 cx: &'a Cx,
188 signed: &'a SignedIn,
189 mut values: FormValues,
190 ) -> BoxFuture<'a, topcoat::Result<Outcome>> {
191 Box::pin(async move {
192 let any: &dyn Any = &*signed.user;
193 let user = any
194 .downcast_ref::<E::User>()
195 .ok_or_else(|| topcoat::Error::from(forbidden()))?;
196 let schema = E::Input::schema();
197 let mut stored = FormValues::new();
198 self.fill(cx, user).write(&mut stored);
199 input::keep_disabled(&schema, &mut values, &stored);
200 schema.keep_unedited(&mut values, &stored, &crate::time_zone(cx));
201 let input = match input::parse::<E::Input>(cx, &values, &stored, "profile").await? {
202 Ok(input) => input,
203 Err(errors) => return Ok(Outcome::Refused { values, errors }),
204 };
205 let mut db = crate::db::db(cx)?;
206 let mut tx = db.transaction().await.map_err(crate::error::unavailable)?;
207 let errors = self
208 .validate(cx, user, &input, &mut tx)
209 .await
210 .map_err(write_failure)?;
211 input::placed(&schema, &errors, "the profile editor", "profile")?;
212 if !errors.is_empty() {
213 return Ok(Outcome::Refused { values, errors });
214 }
215 let update = self
216 .save(cx, user, input, &mut tx)
217 .await
218 .map_err(write_failure)?;
219 if update == ProfileUpdate::Credentials {
220 let session = super::session::start(cx).await?;
223 replace_sessions(cx, &mut tx, signed, TypeId::of::<E::User>(), &session).await?;
224 }
225 tx.commit().await.map_err(crate::error::unavailable)?;
226 let email_pending =
227 try_context::<Requested>(cx).is_some_and(|requested| requested.mail(cx));
228 Ok(Outcome::Saved { email_pending })
229 })
230 }
231}
232
233fn write_failure(error: topcoat::Error) -> topcoat::Error {
236 crate::error::driver_failure(error, crate::error::DATABASE_UNAVAILABLE)
237}
238
239#[derive(Clone, PartialEq, Eq)]
244pub struct Profile {
245 pub name: String,
247 pub email: String,
249 pub password: Option<String>,
252 pub password_confirmation: String,
254 pub current_password: Option<String>,
257}
258
259impl Profile {
260 pub fn password_errors(&self) -> FieldErrors {
263 match &self.password {
264 Some(password) => new_password_errors(password, &self.password_confirmation),
265 None => FieldErrors::new(),
266 }
267 }
268}
269
270impl std::fmt::Debug for Profile {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 f.debug_struct("Profile")
274 .field("name", &self.name)
275 .field("email", &self.email)
276 .field("password", &self.password.as_ref().map(|_| "<redacted>"))
277 .field("password_confirmation", &"<redacted>")
278 .field(
279 "current_password",
280 &self.current_password.as_ref().map(|_| "<redacted>"),
281 )
282 .finish()
283 }
284}
285
286impl FormInput for Profile {
287 type Field = std::convert::Infallible;
288
289 fn key(field: Self::Field) -> &'static str {
290 match field {}
291 }
292
293 fn schema() -> Schema {
294 let required = crate::form::required_input;
295 Schema::new((
296 required(Field::text_input::<String>("name").label(crate::text!("tablo-auth-name"))),
297 required(
298 Field::text_input::<String>("email")
299 .email()
300 .label(crate::text!("tablo-auth-email")),
301 ),
302 Field::text_input::<String>(PASSWORD_FIELD)
303 .password()
304 .label(crate::text!("tablo-auth-new-password"))
305 .help(crate::text!("tablo-profile-password-help")),
306 Field::text_input::<String>(CONFIRMATION_FIELD)
307 .password()
308 .label(crate::text!("tablo-auth-confirm-new-password")),
309 Field::text_input::<String>(CURRENT_PASSWORD_FIELD)
310 .current_password()
311 .label(crate::text!("tablo-auth-current-password"))
312 .help(crate::text!("tablo-profile-current-password-help")),
313 ))
314 }
315
316 fn parse(_cx: &Cx, values: &FormValues) -> Result<Self, Vec<FieldError>> {
317 let mut errors = Vec::new();
318 let mut keep = |read: Result<String, FieldError>| read.map_err(|error| errors.push(error));
319 let name = keep(parse_scalar::<String>("name", values, None));
320 let email = keep(parse_scalar::<String>("email", values, None));
321 let mut optional = |key| {
323 keep(parse_password(key, values, Some(String::new())))
324 .ok()
325 .filter(|password| !password.trim().is_empty())
326 };
327 let password = optional(PASSWORD_FIELD);
328 let password_confirmation = optional(CONFIRMATION_FIELD).unwrap_or_default();
329 let current_password = optional(CURRENT_PASSWORD_FIELD);
330 match (name, email) {
331 (Ok(name), Ok(email)) if errors.is_empty() => Ok(Self {
332 name,
333 email,
334 password,
335 password_confirmation,
336 current_password,
337 }),
338 _ => Err(errors),
339 }
340 }
341
342 fn write(&self, out: &mut FormValues) {
343 out.insert("name".to_string(), self.name.clone());
344 out.insert("email".to_string(), self.email.clone());
345 }
346}
347
348const PASSWORD_FIELD: &str = "password";
350const CONFIRMATION_FIELD: &str = "password_confirmation";
352pub(crate) const CURRENT_PASSWORD_FIELD: &str = "current_password";
354
355pub(crate) const RESERVED_KEYS: [&str; 1] = [crate::csrf::FIELD_NAME];
357
358pub(crate) fn profile_url(cx: &Cx) -> String {
360 current_realm(cx).map_or_else(
361 || format!("{}/profile", panel_prefix(cx)),
362 |realm| realm.pages.profile.clone(),
363 )
364}
365
366pub(crate) fn offered(cx: &Cx) -> bool {
368 current_realm(cx).is_some_and(|panel| panel.auth.profile_editor().is_some())
369}
370
371fn editing(cx: &Cx) -> topcoat::Result<(&SignedIn, &dyn DynProfileEditor)> {
373 let signed = signed(cx).ok_or_else(|| unauthenticated_error(cx))?;
374 let editor = signed.realm.auth.profile_editor().ok_or_else(not_found)?;
375 Ok((signed, editor))
376}
377
378pub(super) fn profile_page(cx: &Cx, _body: Body) -> BoxView<'_> {
380 async_page(async move {
381 let (signed, editor) = editing(cx)?;
382 let values = editor
383 .values(cx, signed.user.as_ref())
384 .ok_or_else(forbidden)?;
385 crate::csrf::ensure_token(cx);
386 render(cx, editor, &values, &FieldErrors::new()).await
387 })
388}
389
390pub(super) fn profile_post(cx: &Cx, body: Body) -> BoxView<'_> {
392 async_page(async move {
393 let (signed, editor) = editing(cx)?;
394 let form = crate::auth::input::read_form(cx, body).await?;
395 crate::csrf::verify(cx, form.last(crate::csrf::FIELD_NAME))?;
396 let input_schema = editor.schema();
397 let values = input_schema.read_input(cx, &form.pairs, &RESERVED_KEYS)?;
398 let throttle = signed.realm.auth.throttle_profile();
402 if !throttle.attempt(&signed.user.user_id(), None) {
403 tracing::warn!(
404 panel = %signed.realm.prefix,
405 client = ?client_ip(cx),
406 "profile save throttled"
407 );
408 return Err(too_many_requests(throttle.window_secs()).into());
409 }
410 let saving = cx.with(Requested::default());
412 let email_pending = match editor.submit(&saving, signed, values).await? {
413 Outcome::Refused { values, errors } => {
414 return render(cx, editor, &values, &errors).await;
415 }
416 Outcome::Saved { email_pending } => email_pending,
417 };
418 let saved = Notification::success(crate::t!(cx, "tablo-profile-saved"));
419 set_notification(
420 cx,
421 if email_pending {
422 saved.description(crate::t!(cx, "tablo-profile-email-pending"))
423 } else {
424 saved
425 },
426 );
427 Err(see_other(profile_url(cx)).into())
428 })
429}
430
431async fn render<'a>(
434 cx: &'a Cx,
435 editor: &dyn DynProfileEditor,
436 values: &FormValues,
437 errors: &FieldErrors,
438) -> topcoat::Result<BoxView<'a>> {
439 let schema = editor.schema();
440 let page = SettingsPage {
441 title: crate::t!(cx, "tablo-shell-profile"),
442 schema: &schema,
443 hidden: None,
444 values,
445 errors,
446 };
447 hosted(cx)?.settings_page(cx, page).await
448}
449
450pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
453 use super::input::FieldFault;
454 use crate::{DeclarationErrorKind, declaration::ProfileFault};
455
456 let Some(editor) = auth.profile_editor.as_deref() else {
457 return Vec::new();
458 };
459 if auth.authenticator().is_none() {
460 return vec![DeclarationErrorKind::Profile(ProfileFault::AuthDisabled)];
461 }
462 let mut kinds = Vec::new();
463 let schema = editor.schema();
464 kinds.extend(schema.declaration_errors());
465 kinds.extend(schema.empty_choices());
466 kinds.extend(
467 input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
468 DeclarationErrorKind::Profile(match fault {
469 FieldFault::Reserved => ProfileFault::ReservedField(name),
470 FieldFault::File => ProfileFault::FileField(name),
471 FieldFault::Relationship => ProfileFault::RelationshipField(name),
472 })
473 }),
474 );
475 kinds
476}
477
478#[cfg(test)]
479mod tests;