Skip to main content

tablo_core/auth/
profile.rs

1//! The panel's profile page: the [`ProfileEditor`] trait that edits the signed-in account, the
2//! shipped [`Profile`] form, and the `{prefix}/profile` routes.
3
4use 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
33/// How a panel lets the signed-in user edit their own account on its profile page, installed
34/// with [`Auth::profile`](super::Auth::profile).
35///
36/// It mirrors a [`Registrar`](super::Registrar): [`Input`](Self::Input) is the form the page
37/// shows, [`fill`](Self::fill) seeds it from the account, [`validate`](Self::validate) refuses a
38/// submission field by field, and [`save`](Self::save) writes it. `validate` and `save` run in one
39/// transaction the framework opens.
40///
41/// The shipped [`PasswordAuth`](super::PasswordAuth) edits an [`AdminUser`](super::AdminUser)
42/// through a [`Profile`]; an app with its own user table implements it beside its
43/// [`Authenticator`](super::Authenticator), for the same user type:
44///
45/// ```rust
46/// # use tablo_core::{PanelUser, ProfileEditor, ProfileUpdate};
47/// # use topcoat::context::Cx;
48/// # #[derive(Debug, Clone, toasty::Model)]
49/// # struct Staff {
50/// #     #[key] #[auto] id: uuid::Uuid,
51/// #     name: String,
52/// # }
53/// # impl PanelUser for Staff {
54/// #     fn user_id(&self) -> String { self.id.to_string() }
55/// #     fn display_name(&self) -> &str { &self.name }
56/// #     fn can_access_panel(&self, _panel: &str) -> bool { true }
57/// # }
58/// #[derive(tablo_core::FormInput)]
59/// struct Rename {
60///     name: String,
61/// }
62///
63/// struct StaffProfile;
64///
65/// impl ProfileEditor for StaffProfile {
66///     type Input = Rename;
67///     type User = Staff;
68///
69///     fn fill(&self, _cx: &Cx, staff: &Staff) -> Rename {
70///         Rename {
71///             name: staff.name.clone(),
72///         }
73///     }
74///
75///     async fn save(
76///         &self,
77///         _cx: &Cx,
78///         staff: &Staff,
79///         rename: Rename,
80///         ex: &mut dyn toasty::Executor,
81///     ) -> topcoat::Result<ProfileUpdate> {
82///         Staff::filter(Staff::fields().id().eq(staff.id))
83///             .update()
84///             .name(rename.name)
85///             .exec(ex)
86///             .await?;
87///         Ok(ProfileUpdate::Details)
88///     }
89/// }
90/// ```
91pub trait ProfileEditor: Send + Sync + 'static {
92    /// What the profile page asks for: [`Profile`], or the app's own
93    /// [`#[derive(FormInput)]`](derive@crate::FormInput) struct.
94    type Input: FormInput;
95
96    /// The account the page edits: the type the panel's [`Authenticator`](super::Authenticator)
97    /// loads, which mounting checks.
98    type User: PanelUser;
99
100    /// The form as the page first shows it, from the signed-in `user`. The page renders what
101    /// [`FormInput::write`] writes, so an input that never writes its passwords shows them
102    /// empty.
103    fn fill(&self, cx: &Cx, user: &Self::User) -> Self::Input;
104
105    /// Refuse a submission [`save`](Self::save) should not receive, such as a wrong current
106    /// password: each error names an input field's key and renders under its control. Defaults
107    /// to none. A key no control renders is a declaration error: the page would show no message.
108    ///
109    /// It runs inside the save's transaction `ex`, after the input parses and its controls' own
110    /// rules pass.
111    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    /// Write a validated `input` to `user`'s account through the transaction `ex`, which commits
122    /// when it returns `Ok`, and say whether a credential changed.
123    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/// What a [`ProfileEditor::save`] changed, which decides what happens to the user's sessions.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134#[non_exhaustive]
135pub enum ProfileUpdate {
136    /// Nothing the user signs in with: every session stays signed in.
137    Details,
138    /// Something the user signs in with, such as a password: the user's sessions and reset links
139    /// end on every panel whose authenticator loads the same user type, in the transaction that
140    /// saves, and the browser that saved gets a new session under the tenant it acted for.
141    Credentials,
142}
143
144/// What a profile submission came to.
145pub(crate) enum Outcome {
146    /// The committed change, its sessions replaced when a credential changed; `email_pending`
147    /// when it asked a new address to confirm itself.
148    Saved { email_pending: bool },
149    /// The refused submission, its values, and why. The page renders a password control empty.
150    Refused {
151        values: FormValues,
152        errors: FieldErrors,
153    },
154}
155
156/// [`ProfileEditor`] with its input and user types erased, as the panel keeps it.
157pub(crate) trait DynProfileEditor: Send + Sync {
158    /// The profile form.
159    fn schema(&self) -> Schema;
160    /// The form's first values for `user`, or `None` for a user of another type.
161    fn values(&self, cx: &Cx, user: &dyn PanelUser) -> Option<FormValues>;
162    /// Parses, checks, validates and saves one submission for `signed`'s user, replacing their
163    /// sessions in the same transaction when a credential changed.
164    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                // The new credential and the end of the sessions the old one opened commit
221                // together, or neither does. The token only reaches the browser with the response.
222                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
233/// A failed profile write: a database failure becomes the opaque outage, logged; an editor's own
234/// error keeps its mapping.
235fn write_failure(error: topcoat::Error) -> topcoat::Error {
236    crate::error::driver_failure(error, crate::error::DATABASE_UNAVAILABLE)
237}
238
239/// The profile form [`PasswordAuth`](super::PasswordAuth) edits an
240/// [`AdminUser`](super::AdminUser) through, and a custom [`ProfileEditor`] can take as its
241/// input: a name, an email address, an optional new password typed twice, and the current
242/// password.
243#[derive(Clone, PartialEq, Eq)]
244pub struct Profile {
245    /// The display name, posted as `name`.
246    pub name: String,
247    /// The email address the account signs in with, posted as `email`.
248    pub email: String,
249    /// The new password, posted as `password`; `None` when left blank, which keeps the current
250    /// one.
251    pub password: Option<String>,
252    /// The new password typed again, posted as `password_confirmation`.
253    pub password_confirmation: String,
254    /// The password the account signs in with today, posted as `current_password`; `None` when
255    /// left blank.
256    pub current_password: Option<String>,
257}
258
259impl Profile {
260    /// The refusals [`new_password_errors`] makes of a new password; none when the profile keeps
261    /// the current one.
262    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
270/// Redacts the passwords, so a profile logged with `?` writes none to the log.
271impl 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        // A blank password is no password; any other keeps every character it was typed with.
322        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
348/// The key [`Profile`]'s new password posts under.
349const PASSWORD_FIELD: &str = "password";
350/// The key [`Profile`]'s repeated new password posts under.
351const CONFIRMATION_FIELD: &str = "password_confirmation";
352/// The key [`Profile`]'s current password posts under.
353pub(crate) const CURRENT_PASSWORD_FIELD: &str = "current_password";
354
355/// The keys a profile POST carries besides its input, which no input field may post.
356pub(crate) const RESERVED_KEYS: [&str; 1] = [crate::csrf::FIELD_NAME];
357
358/// Where the panel's profile page lives: `{prefix}/profile`, or in the area that holds it.
359pub(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
366/// Whether the request's panel installs a [`ProfileEditor`].
367pub(crate) fn offered(cx: &Cx) -> bool {
368    current_realm(cx).is_some_and(|panel| panel.auth.profile_editor().is_some())
369}
370
371/// The signed-in user and the panel's editor, or the answer to a request without them.
372fn 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
378/// `GET {prefix}/profile` — the profile form, filled from the signed-in account.
379pub(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
390/// `POST {prefix}/profile` — save the profile, then land back on the page.
391pub(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        // A stolen session must not try passwords against the current-password check at will.
399        // Counted per user alone, from any address: a lockout only holds back profile saves,
400        // never a sign-in.
401        let throttle = signed.realm.auth.throttle_profile();
402        if !throttle
403            .attempt(cx, "profile", &signed.user.user_id(), None)
404            .await?
405        {
406            tracing::warn!(
407                panel = %signed.realm.prefix,
408                client = ?client_ip(cx),
409                "profile save throttled"
410            );
411            return Err(too_many_requests(throttle.window_secs()).into());
412        }
413        // An email change the editor asks to confirm is mailed once the save commits.
414        let saving = cx.with(Requested::default());
415        let email_pending = match editor.submit(&saving, signed, values).await? {
416            Outcome::Refused { values, errors } => {
417                return render(cx, editor, &values, &errors).await;
418            }
419            Outcome::Saved { email_pending } => email_pending,
420        };
421        let saved = Notification::success(crate::t!(cx, "tablo-profile-saved"));
422        set_notification(
423            cx,
424            if email_pending {
425                saved.description(crate::t!(cx, "tablo-profile-email-pending"))
426            } else {
427                saved
428            },
429        );
430        Err(see_other(profile_url(cx)).into())
431    })
432}
433
434/// The profile page in the panel's shell: the editor's form with `values` and `errors`, saved to
435/// the page itself, cancelled to the panel's home.
436async fn render<'a>(
437    cx: &'a Cx,
438    editor: &dyn DynProfileEditor,
439    values: &FormValues,
440    errors: &FieldErrors,
441) -> topcoat::Result<BoxView<'a>> {
442    let schema = editor.schema();
443    let page = SettingsPage {
444        title: crate::t!(cx, "tablo-shell-profile"),
445        schema: &schema,
446        hidden: None,
447        values,
448        errors,
449    };
450    hosted(cx)?.settings_page(cx, page).await
451}
452
453/// The mistakes in a panel's [`Auth::profile`](super::Auth::profile): a profile without auth, an
454/// editor whose user is not the authenticator's, and a profile form field the page cannot serve.
455pub(super) fn declaration_errors(auth: &super::AuthConfig) -> Vec<crate::DeclarationErrorKind> {
456    use super::input::FieldFault;
457    use crate::{DeclarationErrorKind, declaration::ProfileFault};
458
459    let Some(editor) = auth.profile_editor.as_deref() else {
460        return Vec::new();
461    };
462    if auth.authenticator().is_none() {
463        return vec![DeclarationErrorKind::Profile(ProfileFault::AuthDisabled)];
464    }
465    let mut kinds = Vec::new();
466    let schema = editor.schema();
467    kinds.extend(schema.declaration_errors());
468    kinds.extend(schema.empty_choices());
469    kinds.extend(
470        input::field_faults(&schema, &RESERVED_KEYS).map(|(fault, name)| {
471            DeclarationErrorKind::Profile(match fault {
472                FieldFault::Reserved => ProfileFault::ReservedField(name),
473                FieldFault::File => ProfileFault::FileField(name),
474                FieldFault::Relationship => ProfileFault::RelationshipField(name),
475            })
476        }),
477    );
478    kinds
479}
480
481#[cfg(test)]
482mod tests;