diff --git a/BTCPayServer.Abstractions/Form/Field.cs b/BTCPayServer.Abstractions/Form/Field.cs index 29bc20b93..3074e8579 100644 --- a/BTCPayServer.Abstractions/Form/Field.cs +++ b/BTCPayServer.Abstractions/Form/Field.cs @@ -10,32 +10,55 @@ namespace BTCPayServer.Abstractions.Form; public class Field { + public static Field Create(string label, string name, string value, bool required, string helpText, string type = "text") + { + return new Field() + { + Label = label, + Name = name, + Value = value, + OriginalValue = value, + Required = required, + HelpText = helpText, + Type = type + }; + } // The name of the HTML5 node. Should be used as the key for the posted data. public string Name; + public bool Hidden; + // HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options). public string Type; + public static Field CreateFieldset() + { + return new Field() { Type = "fieldset" }; + } + // The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors. // If this is the first the user sees the form, then value and original value are the same. Value changes as the user starts interacting with the form. public string Value; public bool Required; + + // The translated label of the field. + public string Label; + + // The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor. + public string OriginalValue; + + // A useful note shown below the field or via a tooltip / info icon. Should be translated for the user. + public string HelpText; + [JsonExtensionData] public IDictionary AdditionalData { get; set; } public List Fields { get; set; } = new(); - public virtual void Validate(ModelStateDictionary modelState) - { - if (Required && string.IsNullOrEmpty(Value)) - { - modelState.AddModelError(Name, "This field is required"); - } - } + // The field is considered "valid" if there are no validation errors + public List ValidationErrors = new List(); - public bool IsValid() + public virtual bool IsValid() { - ModelStateDictionary modelState = new ModelStateDictionary(); - Validate(modelState); - return modelState.IsValid; + return ValidationErrors.Count == 0 && Fields.All(field => field.IsValid()); } } diff --git a/BTCPayServer.Abstractions/Form/Fieldset.cs b/BTCPayServer.Abstractions/Form/Fieldset.cs deleted file mode 100644 index fbb034a03..000000000 --- a/BTCPayServer.Abstractions/Form/Fieldset.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace BTCPayServer.Abstractions.Form; - -public class Fieldset : Field -{ - public bool Hidden { get; set; } - public string Label { get; set; } - - public Fieldset() - { - Type = "fieldset"; - } -} diff --git a/BTCPayServer.Abstractions/Form/Form.cs b/BTCPayServer.Abstractions/Form/Form.cs index 077aa5d24..b0fb1c947 100644 --- a/BTCPayServer.Abstractions/Form/Form.cs +++ b/BTCPayServer.Abstractions/Form/Form.cs @@ -29,7 +29,7 @@ public class Form // Are all the fields valid in the form? public bool IsValid() { - return Validate(null); + return Fields.Select(f => f.IsValid()).All(o => o); } public Field GetFieldByName(string name) @@ -65,16 +65,6 @@ public class Form return null; } -#nullable enable - public bool Validate(ModelStateDictionary? modelState) - { - modelState ??= new ModelStateDictionary(); - foreach (var field in Fields) - field.Validate(modelState); - return modelState.IsValid; - } -#nullable restore - public List GetAllNames() { return GetAllNames(Fields); diff --git a/BTCPayServer.Abstractions/Form/HtmlInputField.cs b/BTCPayServer.Abstractions/Form/HtmlInputField.cs deleted file mode 100644 index 96a35ba32..000000000 --- a/BTCPayServer.Abstractions/Form/HtmlInputField.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace BTCPayServer.Abstractions.Form; - -public class HtmlInputField : Field -{ - // The translated label of the field. - public string Label; - - // The original value is the value that is currently saved in the backend. A "reset" button can be used to revert back to this. Should only be set from the constructor. - public string OriginalValue; - - // A useful note shown below the field or via a tooltip / info icon. Should be translated for the user. - public string HelpText; - - public HtmlInputField(string label, string name, string value, bool required, string helpText, string type = "text") - { - Label = label; - Name = name; - Value = value; - OriginalValue = value; - Required = required; - HelpText = helpText; - Type = type; - } - // TODO JSON parsing from string to objects again probably won't work out of the box because of the different field types. -} diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs index 6f499df61..dd44887a2 100644 --- a/BTCPayServer/Controllers/UIPaymentRequestController.cs +++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs @@ -10,6 +10,7 @@ using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Filters; +using BTCPayServer.Forms; using BTCPayServer.Models; using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.PaymentRequest; @@ -41,6 +42,8 @@ namespace BTCPayServer.Controllers private readonly InvoiceRepository _InvoiceRepository; private readonly StoreRepository _storeRepository; + public FormComponentProviders FormProviders { get; } + public UIPaymentRequestController( UIInvoiceController invoiceController, UserManager userManager, @@ -49,7 +52,8 @@ namespace BTCPayServer.Controllers EventAggregator eventAggregator, CurrencyNameTable currencies, StoreRepository storeRepository, - InvoiceRepository invoiceRepository) + InvoiceRepository invoiceRepository, + FormComponentProviders formProviders) { _InvoiceController = invoiceController; _UserManager = userManager; @@ -59,6 +63,7 @@ namespace BTCPayServer.Controllers _Currencies = currencies; _storeRepository = storeRepository; _InvoiceRepository = invoiceRepository; + FormProviders = formProviders; } [BitpayAPIConstraint(false)] @@ -204,7 +209,7 @@ namespace BTCPayServer.Controllers // POST case: Handle form submit var formData = Form.Parse(Forms.UIFormsController.GetFormData(prFormId).Config); formData.ApplyValuesFromForm(this.Request.Form); - if (formData.IsValid()) + if (FormProviders.Validate(formData, ModelState)) { prBlob.FormResponse = JObject.FromObject(formData.GetValues()); result.SetBlob(prBlob); diff --git a/BTCPayServer/Forms/FormComponentProvider.cs b/BTCPayServer/Forms/FormComponentProvider.cs deleted file mode 100644 index 0b1de331d..000000000 --- a/BTCPayServer/Forms/FormComponentProvider.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using BTCPayServer.Abstractions.Form; - -namespace BTCPayServer.Forms; - -public class FormComponentProvider : IFormComponentProvider -{ - private readonly IEnumerable _formComponentProviders; - - public FormComponentProvider(IEnumerable formComponentProviders) - { - _formComponentProviders = formComponentProviders; - } - - public string CanHandle(Field field) - { - return _formComponentProviders.Select(formComponentProvider => formComponentProvider.CanHandle(field)).FirstOrDefault(result => !string.IsNullOrEmpty(result)); - } -} \ No newline at end of file diff --git a/BTCPayServer/Forms/FormComponentProviders.cs b/BTCPayServer/Forms/FormComponentProviders.cs new file mode 100644 index 000000000..bcaa83414 --- /dev/null +++ b/BTCPayServer/Forms/FormComponentProviders.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Linq; +using BTCPayServer.Abstractions.Form; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace BTCPayServer.Forms; + +public class FormComponentProviders +{ + private readonly IEnumerable _formComponentProviders; + + public Dictionary TypeToComponentProvider = new Dictionary(); + + public FormComponentProviders(IEnumerable formComponentProviders) + { + _formComponentProviders = formComponentProviders; + foreach (var prov in _formComponentProviders) + prov.Register(TypeToComponentProvider); + } + + public bool Validate(Form form, ModelStateDictionary modelState) + { + foreach (var field in form.Fields) + { + if (TypeToComponentProvider.TryGetValue(field.Type, out var provider)) + { + provider.Validate(form, field); + foreach (var err in field.ValidationErrors) + modelState.TryAddModelError(field.Name, err); + } + } + return modelState.IsValid; + } +} diff --git a/BTCPayServer/Forms/FormDataExtensions.cs b/BTCPayServer/Forms/FormDataExtensions.cs index 14ddd3636..2a793d165 100644 --- a/BTCPayServer/Forms/FormDataExtensions.cs +++ b/BTCPayServer/Forms/FormDataExtensions.cs @@ -10,7 +10,7 @@ public static class FormDataExtensions public static void AddForms(this IServiceCollection serviceCollection) { serviceCollection.AddSingleton(); - serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); } diff --git a/BTCPayServer/Forms/FormDataService.cs b/BTCPayServer/Forms/FormDataService.cs index 025d0450c..b16c57b2d 100644 --- a/BTCPayServer/Forms/FormDataService.cs +++ b/BTCPayServer/Forms/FormDataService.cs @@ -15,21 +15,21 @@ public class FormDataService public static readonly Form StaticFormEmail = new() { - Fields = new List() {new HtmlInputField("Enter your email", "buyerEmail", null, true, null)} + Fields = new List() {Field.Create("Enter your email", "buyerEmail", null, true, null, "email")} }; public static readonly Form StaticFormAddress = new() { Fields = new List() { - new HtmlInputField("Enter your email", "buyerEmail", null, true, null, "email"), - new HtmlInputField("Name", "buyerName", null, true, null), - new HtmlInputField("Address Line 1", "buyerAddress1", null, true, null), - new HtmlInputField("Address Line 2", "buyerAddress2", null, false, null), - new HtmlInputField("City", "buyerCity", null, true, null), - new HtmlInputField("Postcode", "buyerZip", null, false, null), - new HtmlInputField("State", "buyerState", null, false, null), - new HtmlInputField("Country", "buyerCountry", null, true, null) + Field.Create("Enter your email", "buyerEmail", null, true, null, "email"), + Field.Create("Name", "buyerName", null, true, null), + Field.Create("Address Line 1", "buyerAddress1", null, true, null), + Field.Create("Address Line 2", "buyerAddress2", null, false, null), + Field.Create("City", "buyerCity", null, true, null), + Field.Create("Postcode", "buyerZip", null, false, null), + Field.Create("State", "buyerState", null, false, null), + Field.Create("Country", "buyerCountry", null, true, null) } }; } diff --git a/BTCPayServer/Forms/HtmlFieldsetFormProvider.cs b/BTCPayServer/Forms/HtmlFieldsetFormProvider.cs index 88d257868..03c5a16a5 100644 --- a/BTCPayServer/Forms/HtmlFieldsetFormProvider.cs +++ b/BTCPayServer/Forms/HtmlFieldsetFormProvider.cs @@ -1,12 +1,23 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using BTCPayServer.Abstractions.Form; namespace BTCPayServer.Forms; public class HtmlFieldsetFormProvider: IFormComponentProvider { - public string CanHandle(Field field) + public string View => "Forms/FieldSetElement"; + + public void Register(Dictionary typeToComponentProvider) { - return new[] { "fieldset"}.Contains(field.Type) ? "Forms/FieldSetElement" : null; + typeToComponentProvider.Add("fieldset", this); } -} \ No newline at end of file + + public void Validate(Field field) + { + } + + public void Validate(Form form, Field field) + { + } +} diff --git a/BTCPayServer/Forms/HtmlInputFormProvider.cs b/BTCPayServer/Forms/HtmlInputFormProvider.cs index b0a128b34..72712ea12 100644 --- a/BTCPayServer/Forms/HtmlInputFormProvider.cs +++ b/BTCPayServer/Forms/HtmlInputFormProvider.cs @@ -1,13 +1,16 @@ -using System.Linq; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; using BTCPayServer.Abstractions.Form; +using BTCPayServer.Validation; namespace BTCPayServer.Forms; -public class HtmlInputFormProvider: IFormComponentProvider +public class HtmlInputFormProvider: FormComponentProviderBase { - public string CanHandle(Field field) + public override void Register(Dictionary typeToComponentProvider) { - return new[] { + foreach (var t in new[] { "text", "radio", "checkbox", @@ -29,6 +32,20 @@ public class HtmlInputFormProvider: IFormComponentProvider "search", "url", "tel", - "reset"}.Contains(field.Type) ? "Forms/InputElement" : null; + "reset"}) + typeToComponentProvider.Add(t, this); } -} \ No newline at end of file + public override string View => "Forms/InputElement"; + + public override void Validate(Form form, Field field) + { + if (field.Required) + { + ValidateField(field); + } + if (field.Type == "email") + { + ValidateField(field); + } + } +} diff --git a/BTCPayServer/Forms/IFormComponentProvider.cs b/BTCPayServer/Forms/IFormComponentProvider.cs index ca8c3a042..3612da422 100644 --- a/BTCPayServer/Forms/IFormComponentProvider.cs +++ b/BTCPayServer/Forms/IFormComponentProvider.cs @@ -1,8 +1,26 @@ -using BTCPayServer.Abstractions.Form; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using BTCPayServer.Abstractions.Form; namespace BTCPayServer.Forms; public interface IFormComponentProvider { - public string CanHandle(Field field); + string View { get; } + void Validate(Form form, Field field); + void Register(Dictionary typeToComponentProvider); +} + +public abstract class FormComponentProviderBase : IFormComponentProvider +{ + public abstract string View { get; } + public abstract void Register(Dictionary typeToComponentProvider); + public abstract void Validate(Form form, Field field); + + public void ValidateField(Field field) where T : ValidationAttribute, new() + { + var result = new T().GetValidationResult(field.Value, new ValidationContext(field) { DisplayName = field.Label, MemberName = field.Name }); + if (result != null) + field.ValidationErrors.Add(result.ErrorMessage); + } } diff --git a/BTCPayServer/Forms/UIFormsController.cs b/BTCPayServer/Forms/UIFormsController.cs index 2d8f00bae..0e62f2c4b 100644 --- a/BTCPayServer/Forms/UIFormsController.cs +++ b/BTCPayServer/Forms/UIFormsController.cs @@ -26,6 +26,12 @@ namespace BTCPayServer.Forms; [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public class UIFormsController : Controller { + public FormComponentProviders FormProviders { get; } + + public UIFormsController(FormComponentProviders formProviders) + { + FormProviders = formProviders; + } [AllowAnonymous] [HttpGet("~/forms/{formId}")] [HttpPost("~/forms")] @@ -39,24 +45,32 @@ public class UIFormsController : Controller : Redirect(redirectUrl); } - return View("View", new FormViewModel() { FormData = formData, RedirectUrl = redirectUrl }); + return GetFormView(formData, redirectUrl); } + ViewResult GetFormView(FormData formData, string? redirectUrl) + { + return View("View", new FormViewModel() { FormData = formData, RedirectUrl = redirectUrl }); + } [AllowAnonymous] [HttpPost("~/forms/{formId}")] public IActionResult SubmitForm( string formId, string? redirectUrl, + string? command, [FromServices] StoreRepository storeRepository, [FromServices] UIInvoiceController invoiceController) { var formData = GetFormData(formId); if (formData?.Config is null) return NotFound(); + if (command is not "Submit") + return GetFormView(formData, redirectUrl); + var conf = Form.Parse(formData.Config); conf.ApplyValuesFromForm(Request.Form); - if (!conf.Validate(ModelState)) - return View("View", new FormViewModel() { FormData = formData, RedirectUrl = redirectUrl }); + if (!FormProviders.Validate(conf, ModelState)) + return GetFormView(formData, redirectUrl); var form = new MultiValueDictionary(); foreach (var kv in Request.Form) diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs index a17cf41a7..df31b1272 100644 --- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs +++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs @@ -15,6 +15,7 @@ using BTCPayServer.Client; using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Filters; +using BTCPayServer.Forms; using BTCPayServer.ModelBinders; using BTCPayServer.Models; using BTCPayServer.Plugins.PointOfSale.Models; @@ -40,19 +41,23 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers AppService appService, CurrencyNameTable currencies, StoreRepository storeRepository, - UIInvoiceController invoiceController) + UIInvoiceController invoiceController, + FormComponentProviders formProviders) { _currencies = currencies; _appService = appService; _storeRepository = storeRepository; _invoiceController = invoiceController; + FormProviders = formProviders; } private readonly CurrencyNameTable _currencies; private readonly StoreRepository _storeRepository; private readonly AppService _appService; private readonly UIInvoiceController _invoiceController; - + + public FormComponentProviders FormProviders { get; } + [HttpGet("/")] [HttpGet("/apps/{appId}/pos/{viewType?}")] [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.AllowAll)] @@ -232,7 +237,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers var formData = Form.Parse(Forms.UIFormsController.GetFormData(posFormId).Config); formData.ApplyValuesFromForm(this.Request.Form); - if (formData.IsValid()) + if (FormProviders.Validate(formData, ModelState)) { formResponse = JObject.FromObject(formData.GetValues()); break; diff --git a/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml b/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml index 77e10755f..27db6ce70 100644 --- a/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml +++ b/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml @@ -1,27 +1,19 @@ -@using BTCPayServer.Abstractions.Form +@using BTCPayServer.Abstractions.Form @using BTCPayServer.Forms @using Microsoft.AspNetCore.Mvc.TagHelpers @using Newtonsoft.Json.Linq -@inject FormComponentProvider FormComponentProvider +@inject FormComponentProviders FormComponentProviders @model BTCPayServer.Abstractions.Form.Field -@{ - if (Model is not Fieldset fieldset) - { - fieldset = JObject.FromObject(Model).ToObject
(); - } -} -@if (!fieldset.Hidden) +@if (!Model.Hidden) {
- @fieldset.Label - @foreach (var field in fieldset.Fields) + @Model.Label + @foreach (var field in Model.Fields) { - var partial = FormComponentProvider.CanHandle(field); - if (string.IsNullOrEmpty(partial)) - { - continue; - } - + if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial)) + { + + } }
} diff --git a/BTCPayServer/Views/Shared/Forms/InputElement.cshtml b/BTCPayServer/Views/Shared/Forms/InputElement.cshtml index a4fbf0e4c..c853a073f 100644 --- a/BTCPayServer/Views/Shared/Forms/InputElement.cshtml +++ b/BTCPayServer/Views/Shared/Forms/InputElement.cshtml @@ -1,31 +1,34 @@ -@using BTCPayServer.Abstractions.Form +@using BTCPayServer.Abstractions.Form @using Newtonsoft.Json.Linq @model BTCPayServer.Abstractions.Form.Field @{ - if (Model is not HtmlInputField field) - { - field = JObject.FromObject(Model).ToObject(); - } + var isInvalid = this.ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid; + var error = isInvalid ? this.ViewContext.ModelState[Model.Name].Errors[0].ErrorMessage : null; + }
- @if (field.Required) + @if (Model.Required) { -
diff --git a/Plugins/BTCPayServer.Plugins.Custodians.FakeCustodian/FakeCustodian.cs b/Plugins/BTCPayServer.Plugins.Custodians.FakeCustodian/FakeCustodian.cs index 9233f8f76..66939f526 100644 --- a/Plugins/BTCPayServer.Plugins.Custodians.FakeCustodian/FakeCustodian.cs +++ b/Plugins/BTCPayServer.Plugins.Custodians.FakeCustodian/FakeCustodian.cs @@ -34,16 +34,16 @@ public class FakeCustodian : ICustodian var fakeConfig = ParseConfig(config); var form = new Form(); - var fieldset = new Fieldset(); + var fieldset = Field.CreateFieldset(); // Maybe a decimal type field would be better? - var fakeBTCBalance = new HtmlInputField("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true, + var fakeBTCBalance = Field.Create("BTC Balance", "BTCBalance", fakeConfig?.BTCBalance.ToString(), true, "Enter the amount of BTC you want to have."); - var fakeLTCBalance = new HtmlInputField("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true, + var fakeLTCBalance = Field.Create("LTC Balance", "LTCBalance", fakeConfig?.LTCBalance.ToString(), true, "Enter the amount of LTC you want to have."); - var fakeEURBalance = new HtmlInputField("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true, + var fakeEURBalance = Field.Create("EUR Balance", "EURBalance", fakeConfig?.EURBalance.ToString(), true, "Enter the amount of EUR you want to have."); - var fakeUSDBalance = new HtmlInputField("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true, + var fakeUSDBalance = Field.Create("USD Balance", "USDBalance", fakeConfig?.USDBalance.ToString(), true, "Enter the amount of USD you want to have."); fieldset.Label = "Your fake balances";