Skip to main content

mas_templates/
forms.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::{collections::HashMap, hash::Hash};
9
10use serde::{Deserialize, Serialize};
11
12/// A trait which should be used for form field enums
13pub trait FormField: Copy + Hash + PartialEq + Eq + Serialize + for<'de> Deserialize<'de> {
14    /// Return false for fields where values should not be kept (e.g. password
15    /// fields)
16    fn keep(&self) -> bool;
17}
18
19/// An error on a form field
20#[derive(Debug, Serialize)]
21#[serde(rename_all = "snake_case", tag = "kind")]
22pub enum FieldError {
23    /// A required field is missing
24    Required,
25
26    /// An unspecified error on the field
27    Unspecified,
28
29    /// Invalid value for this field
30    Invalid,
31
32    /// The password confirmation doesn't match the password
33    PasswordMismatch,
34
35    /// That value already exists
36    Exists,
37
38    /// The value doesn't fit what the rest of the flow already holds
39    Mismatch,
40
41    /// Denied by the policy
42    Policy {
43        /// Well-known policy code
44        code: Option<&'static str>,
45
46        /// Message for this policy violation
47        message: String,
48    },
49}
50
51/// An error on the whole form
52#[derive(Debug, Serialize)]
53#[serde(rename_all = "snake_case", tag = "kind")]
54pub enum FormError {
55    /// The given credentials are not valid
56    InvalidCredentials,
57
58    /// Password fields don't match
59    PasswordMismatch,
60
61    /// There was an internal error
62    Internal,
63
64    /// Rate limit exceeded
65    RateLimitExceeded,
66
67    /// Denied by the policy
68    Policy {
69        /// Well-known policy code
70        code: Option<&'static str>,
71
72        /// Message for this policy violation
73        message: String,
74    },
75
76    /// Failed to validate CAPTCHA
77    Captcha,
78}
79
80#[derive(Debug, Default, Serialize)]
81struct FieldState {
82    value: Option<String>,
83    errors: Vec<FieldError>,
84}
85
86/// The state of a form and its fields
87#[derive(Debug, Serialize)]
88pub struct FormState<K: Hash + Eq> {
89    fields: HashMap<K, FieldState>,
90    errors: Vec<FormError>,
91
92    #[serde(skip)]
93    has_errors: bool,
94}
95
96impl<K: Hash + Eq> Default for FormState<K> {
97    fn default() -> Self {
98        FormState {
99            fields: HashMap::default(),
100            errors: Vec::default(),
101            has_errors: false,
102        }
103    }
104}
105
106#[derive(Deserialize, PartialEq, Eq, Hash)]
107#[serde(untagged)]
108enum KeyOrOther<K> {
109    Key(K),
110    Other(String),
111}
112
113impl<K> KeyOrOther<K> {
114    fn key(self) -> Option<K> {
115        match self {
116            Self::Key(key) => Some(key),
117            Self::Other(_) => None,
118        }
119    }
120}
121
122impl<K: FormField> FormState<K> {
123    /// Generate a [`FormState`] out of a form
124    ///
125    /// # Panics
126    ///
127    /// If the form fails to serialize, or the form field keys fail to
128    /// deserialize
129    pub fn from_form<F: Serialize>(form: &F) -> Self {
130        let form = serde_json::to_value(form).unwrap();
131        let fields: HashMap<KeyOrOther<K>, Option<String>> = serde_json::from_value(form).unwrap();
132
133        let fields = fields
134            .into_iter()
135            .filter_map(|(key, value)| {
136                let key = key.key()?;
137                let value = key.keep().then_some(value).flatten();
138                let field = FieldState {
139                    value,
140                    errors: Vec::new(),
141                };
142                Some((key, field))
143            })
144            .collect();
145
146        FormState {
147            fields,
148            errors: Vec::new(),
149            has_errors: false,
150        }
151    }
152
153    /// Add an error on a form field
154    pub fn add_error_on_field(&mut self, field: K, error: FieldError) {
155        self.fields.entry(field).or_default().errors.push(error);
156        self.has_errors = true;
157    }
158
159    /// Add an error on a form field
160    #[must_use]
161    pub fn with_error_on_field(mut self, field: K, error: FieldError) -> Self {
162        self.add_error_on_field(field, error);
163        self
164    }
165
166    /// Add an error on the form
167    pub fn add_error_on_form(&mut self, error: FormError) {
168        self.errors.push(error);
169        self.has_errors = true;
170    }
171
172    /// Add an error on the form
173    #[must_use]
174    pub fn with_error_on_form(mut self, error: FormError) -> Self {
175        self.add_error_on_form(error);
176        self
177    }
178
179    /// Set a value on the form
180    pub fn set_value(&mut self, field: K, value: Option<String>) {
181        self.fields.entry(field).or_default().value = value;
182    }
183
184    /// Checks if a field contains a value
185    pub fn has_value(&self, field: K) -> bool {
186        self.fields.get(&field).is_some_and(|f| f.value.is_some())
187    }
188
189    /// Returns `true` if the form has no error attached to it
190    #[must_use]
191    pub fn is_valid(&self) -> bool {
192        !self.has_errors
193    }
194}
195
196/// Utility trait to help creating [`FormState`] out of a form
197pub trait ToFormState: Serialize {
198    /// The enum used for field names
199    type Field: FormField;
200
201    /// Generate a [`FormState`] out of [`Self`]
202    ///
203    /// # Panics
204    ///
205    /// If the form fails to serialize or [`Self::Field`] fails to deserialize
206    fn to_form_state(&self) -> FormState<Self::Field> {
207        FormState::from_form(&self)
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[derive(Serialize)]
216    struct TestForm {
217        foo: String,
218        bar: String,
219    }
220
221    #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
222    #[serde(rename_all = "snake_case")]
223    enum TestFormField {
224        Foo,
225        Bar,
226    }
227
228    impl FormField for TestFormField {
229        fn keep(&self) -> bool {
230            match self {
231                Self::Foo => true,
232                Self::Bar => false,
233            }
234        }
235    }
236
237    impl ToFormState for TestForm {
238        type Field = TestFormField;
239    }
240
241    #[test]
242    fn form_state_serialization() {
243        let form = TestForm {
244            foo: "john".to_owned(),
245            bar: "hunter2".to_owned(),
246        };
247
248        let state = form.to_form_state();
249        let state = serde_json::to_value(state).unwrap();
250        assert_eq!(
251            state,
252            serde_json::json!({
253                "errors": [],
254                "fields": {
255                    "foo": {
256                        "errors": [],
257                        "value": "john",
258                    },
259                    "bar": {
260                        "errors": [],
261                        "value": null
262                    },
263                }
264            })
265        );
266
267        let form = TestForm {
268            foo: String::new(),
269            bar: String::new(),
270        };
271        let state = form
272            .to_form_state()
273            .with_error_on_field(TestFormField::Foo, FieldError::Required)
274            .with_error_on_field(TestFormField::Bar, FieldError::Required)
275            .with_error_on_form(FormError::InvalidCredentials);
276
277        let state = serde_json::to_value(state).unwrap();
278        assert_eq!(
279            state,
280            serde_json::json!({
281                "errors": [{"kind": "invalid_credentials"}],
282                "fields": {
283                    "foo": {
284                        "errors": [{"kind": "required"}],
285                        "value": "",
286                    },
287                    "bar": {
288                        "errors": [{"kind": "required"}],
289                        "value": null
290                    },
291                }
292            })
293        );
294    }
295}