Refactor: Move Forms in its own plugin folder (#7261)
What changed, and why it matters
This commit is a code reorganization: the existing Forms feature is moved from the core BTCPayServer project into its own plugin folder. Files are renamed and namespaces adjusted, but the actual form logic, validation, and behavior remain essentially unchanged. There is no indication this change fixes or introduces a security vulnerability.
No security action required. Treat as routine refactoring. Verify that plugin loading and view resolution work correctly in the target deployment, and that the removed services.AddForms() registration is fully replaced by the plugin's Execute method.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit refactors the Forms subsystem into a plugin under BTCPayServer/Plugins/Forms. It deletes the old BTCPayServer/Forms/ files and recreates equivalent files under BTCPayServer/Plugins/Forms, adds a FormsPlugin class that registers services and a UI navigation extension, removes the old services.AddForms() call from BTCPayServerServices, and updates view paths in UIPaymentRequestController, UICrowdfundController, and UIPointOfSaleController to /Plugins/Forms/Views/View.cshtml. UIFormsController is converted to primary-constructor syntax and decorated with [Area(FormsPlugin.Area)], and the MainNav no longer hardcodes the Forms link (it is now injected via the plugin extension point). No security-relevant logic changes are visible in the diff.
Changed components
BTCPayServer/Plugins/Forms (new plugin folder)BTCPayServer/Forms (removed)BTCPayServer/Hosting/BTCPayServerServices.csBTCPayServer/Components/MainNav/Default.cshtmlBTCPayServer/Controllers/UIInvoiceController.csBTCPayServer/Controllers/UIPaymentRequestController.csBTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.csBTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.csInspect captured patch +1251 / −1247
diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj
index 0d00d47..5b701c8 100644
--- a/BTCPayServer/BTCPayServer.csproj
+++ b/BTCPayServer/BTCPayServer.csproj
@@ -71,6 +71,5 @@
<ProjectReference Include="..\BTCPayServer.Common\BTCPayServer.Common.csproj" />
</ItemGroup>
-
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1misc_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-payment-methods_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
</Project>
diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml
index b5d0821..f116205 100644
--- a/BTCPayServer/Components/MainNav/Default.cshtml
+++ b/BTCPayServer/Components/MainNav/Default.cshtml
@@ -87,9 +87,6 @@
<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
<a layout-menu-item="@(nameof(StoreNavPages.Emails))" asp-area="@EmailsPlugin.Area" asp-controller="UIStoresEmail" asp-action="StoreEmailSettings" asp-route-storeId="@Model.Store.Id" text-translate="true">Emails</a>
</li>
- <li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
- <a layout-menu-item="@(nameof(StoreNavPages.Forms))" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@Model.Store.Id" text-translate="true">Forms</a>
- </li>
<vc:ui-extension-point location="store-category-nav" model="@Model"/>
}
<vc:ui-extension-point location="store-nav" model="@Model"/>
diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs
index 5525d2d..72ed7cd 100644
--- a/BTCPayServer/Controllers/UIInvoiceController.cs
+++ b/BTCPayServer/Controllers/UIInvoiceController.cs
@@ -39,7 +39,6 @@ namespace BTCPayServer.Controllers
readonly InvoiceRepository _InvoiceRepository;
readonly RateFetcher _RateProvider;
readonly StoreRepository _StoreRepository;
- readonly UserManager<ApplicationUser> _UserManager;
private readonly CurrencyNameTable _CurrencyNameTable;
private readonly DisplayFormatter _displayFormatter;
readonly EventAggregator _EventAggregator;
@@ -71,7 +70,6 @@ namespace BTCPayServer.Controllers
InvoiceRepository invoiceRepository,
DisplayFormatter displayFormatter,
CurrencyNameTable currencyNameTable,
- UserManager<ApplicationUser> userManager,
RateFetcher rateProvider,
StoreRepository storeRepository,
EventAggregator eventAggregator,
@@ -103,7 +101,6 @@ namespace BTCPayServer.Controllers
_StoreRepository = storeRepository ?? throw new ArgumentNullException(nameof(storeRepository));
_InvoiceRepository = invoiceRepository ?? throw new ArgumentNullException(nameof(invoiceRepository));
_RateProvider = rateProvider ?? throw new ArgumentNullException(nameof(rateProvider));
- _UserManager = userManager;
_EventAggregator = eventAggregator;
_NetworkProvider = networkProvider;
this._payoutHandlers = payoutHandlers;
diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs
index cc3af6a..bf59e03 100644
--- a/BTCPayServer/Controllers/UIPaymentRequestController.cs
+++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs
@@ -395,7 +395,7 @@ namespace BTCPayServer.Controllers
var storeBlob = result.StoreData.GetStoreBlob();
viewModel.StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob);
- return View("Views/UIForms/View", viewModel);
+ return View("/Plugins/Forms/Views/View.cshtml", viewModel);
}
[HttpGet("{payReqId}/pay")]
diff --git a/BTCPayServer/Forms/FieldValueMirror.cs b/BTCPayServer/Forms/FieldValueMirror.cs
deleted file mode 100644
index 1a07d42..0000000
--- a/BTCPayServer/Forms/FieldValueMirror.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System.Collections.Generic;
-using BTCPayServer.Abstractions.Form;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Forms;
-
-public class FieldValueMirror : IFormComponentProvider
-{
- public string View { get; } = null;
- public void Validate(Form form, Field field)
- {
- if (form.GetFieldByFullName(field.Value) is null)
- {
- field.ValidationErrors = new List<string> {$"{field.Name} requires {field.Value} to be present"};
- }
- }
-
- public void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
- {
- typeToComponentProvider.Add("mirror", this);
- }
-
- public string GetValue(Form form, Field field)
- {
- var rawValue = form.GetFieldByFullName(field.Value)?.Value;
- if (string.IsNullOrEmpty(rawValue))
- return null;
-
- if (field.AdditionalData?.TryGetValue("valuemap", out var valueMap) is true &&
- valueMap is JObject map)
- {
- return map.TryGetValue(rawValue, out var mappedValue)
- ? mappedValue.Value<string>()
- : null;
- }
-
- return rawValue;
- }
-
- public void SetValue(Field field, JToken value)
- {
- //ignored
- }
-}
diff --git a/BTCPayServer/Forms/FormComponentProviders.cs b/BTCPayServer/Forms/FormComponentProviders.cs
deleted file mode 100644
index fd97059..0000000
--- a/BTCPayServer/Forms/FormComponentProviders.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Collections.Generic;
-using BTCPayServer.Abstractions.Form;
-using Microsoft.AspNetCore.Mvc.ModelBinding;
-
-namespace BTCPayServer.Forms;
-
-public class FormComponentProviders
-{
- private readonly IEnumerable<IFormComponentProvider> _formComponentProviders;
-
- public Dictionary<string, IFormComponentProvider> TypeToComponentProvider = new();
-
- public FormComponentProviders(IEnumerable<IFormComponentProvider> formComponentProviders)
- {
- _formComponentProviders = formComponentProviders;
- foreach (var prov in _formComponentProviders)
- prov.Register(TypeToComponentProvider);
- }
-
- public bool Validate(Form form, ModelStateDictionary modelState)
- {
- foreach (var field in form.GetAllFields())
- {
- if (TypeToComponentProvider.TryGetValue(field.Field.Type, out var provider))
- {
- provider.Validate(form, field.Field);
- foreach (var err in field.Field.ValidationErrors)
- modelState.TryAddModelError(field.Field.Name, err);
- }
- }
- return modelState.IsValid;
- }
-}
diff --git a/BTCPayServer/Forms/FormDataExtensions.cs b/BTCPayServer/Forms/FormDataExtensions.cs
deleted file mode 100644
index f20b09f..0000000
--- a/BTCPayServer/Forms/FormDataExtensions.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using BTCPayServer.Data;
-using Microsoft.Extensions.DependencyInjection;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Forms;
-
-public static class FormDataExtensions
-{
- public static void AddForms(this IServiceCollection serviceCollection)
- {
- serviceCollection.AddSingleton<FormDataService>();
- serviceCollection.AddSingleton<FormComponentProviders>();
- serviceCollection.AddSingleton<IFormComponentProvider, HtmlInputFormProvider>();
- serviceCollection.AddSingleton<IFormComponentProvider, HtmlTextareaFormProvider>();
- serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
- serviceCollection.AddSingleton<IFormComponentProvider, HtmlSelectFormProvider>();
- serviceCollection.AddSingleton<IFormComponentProvider, FieldValueMirror>();
- }
-
- public static JObject Deserialize(this FormData form)
- {
- return JsonConvert.DeserializeObject<JObject>(form.Config);
- }
-
- public static string Serialize(this JObject form)
- {
- return JsonConvert.SerializeObject(form);
- }
-}
diff --git a/BTCPayServer/Forms/FormDataService.cs b/BTCPayServer/Forms/FormDataService.cs
deleted file mode 100644
index 02d2f9a..0000000
--- a/BTCPayServer/Forms/FormDataService.cs
+++ /dev/null
@@ -1,259 +0,0 @@
-#nullable enable
-using System;
-using System.Collections.Generic;
-using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
-using System.Linq;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Form;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Data;
-using Microsoft.AspNetCore.Mvc.ModelBinding;
-using Microsoft.AspNetCore.Mvc.Rendering;
-using Microsoft.EntityFrameworkCore;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Forms;
-
-public class FormDataService
-{
- public const string InvoiceParameterPrefix = "invoice_";
- private readonly ApplicationDbContextFactory _applicationDbContextFactory;
- private readonly FormComponentProviders _formProviders;
-
- public FormDataService(
- ApplicationDbContextFactory applicationDbContextFactory,
- FormComponentProviders formProviders)
- {
- _applicationDbContextFactory = applicationDbContextFactory;
- _formProviders = formProviders;
- }
-
- public static readonly Form StaticFormEmail = new()
- {
- Fields = new List<Field> { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") }
- };
-
- public static readonly Form StaticFormAddress = new()
- {
- Fields = new List<Field>
- {
- 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, true, null),
- Field.Create("State", "buyerState", null, false, null),
- new SelectField
- {
- Name = "buyerCountry",
- Label = "Country",
- Required = true,
- Type = "select",
- Options = "Afghanistan, Albania, Algeria, Andorra, Angola, Antigua and Barbuda, Argentina, Armenia, Australia, Austria, Azerbaijan, The Bahamas, Bahrain, Bangladesh, Barbados, Belarus, Belgium, Belize, Benin, Bhutan, Bolivia, Bosnia and Herzegovina, Botswana, Brazil, Brunei, Bulgaria, Burkina Faso, Burundi, Cabo Verde, Cambodia, Cameroon, Canada, Central African Republic (CAR), Chad, Chile, China, Colombia, Comoros, Democratic Republic of the Congo, Republic of the Congo, Costa Rica, Cote d'Ivoire, Croatia, Cuba, Cyprus, Czech Republic, Denmark, Djibouti, Dominica, Dominican Republic, Ecuador, Egypt, El Salvador, Equatorial Guinea, Eritrea, Estonia, Eswatini (formerly Swaziland), Ethiopia, Fiji, Finland, France, Gabon, The Gambia, Georgia, Germany, Ghana, Greece, Grenada, Guatemala, Guinea, Guinea-Bissau, Guyana, Haiti, Honduras, Hungary, Iceland, India, Indonesia, Iran, Iraq, Ireland, Israel, Italy, Jamaica, Japan, Jordan, Kazakhstan, Kenya, Kiribati, Kosovo, Kuwait, Kyrgyzstan, Laos, Latvia, Lebanon, Lesotho, Liberia, Libya, Liechtenstein, Lithuania, Luxembourg, Madagascar, Malawi, Malaysia, Maldives, Mali, Malta, Marshall Islands, Mauritania, Mauritius, Mexico, Micronesia, Moldova, Monaco, Mongolia, Montenegro, Morocco, Mozambique, Myanmar (formerly Burma), Namibia, Nauru, Nepal, Netherlands, New Zealand, Nicaragua, Niger, Nigeria, North Korea, North Macedonia (formerly Macedonia), Norway, Oman, Pakistan, Palau, Palestine, Panama, Papua New Guinea, Paraguay, Peru, Philippines, Poland, Portugal, Qatar, Romania, Russia, Rwanda, Saint Kitts and Nevis, Saint Lucia, Saint Vincent and the Grenadines, Samoa, San Marino, Sao Tome and Principe, Saudi Arabia, Senegal, Serbia, Seychelles, Sierra Leone, Singapore, Slovakia, Slovenia, Solomon Islands, Somalia, South Africa, South Korea, South Sudan, Spain, Sri Lanka, Sudan, Suriname, Sweden, Switzerland, Syria, Taiwan, Tajikistan, Tanzania, Thailand, Timor-Leste (formerly East Timor), Togo, Tonga, Trinidad and Tobago, Tunisia, Turkey, Turkmenistan, Tuvalu, Uganda, Ukraine, United Arab Emirates (UAE), United Kingdom (UK), United States of America (USA), Uruguay, Uzbekistan, Vanuatu, Vatican City (Holy See), Venezuela, Vietnam, Yemen, Zambia, Zimbabwe.".Split(',').Select(s => new SelectListItem(s,s)).ToList()
-
- }
- }
- };
-
- private static readonly Dictionary<string, (string selectText, string name, Form form)> _hardcodedOptions = new()
- {
- {"", ("Do not request any information", null, null)!},
- {"Email", ("Request email address only", "Provide your email address", StaticFormEmail )},
- {"Address", ("Request shipping address", "Provide your address", StaticFormAddress)},
- };
-
- public async Task<SelectList> GetSelect(string storeId, string selectedFormId)
- {
- var forms = await GetForms(storeId);
- return new SelectList(_hardcodedOptions.Select(pair => new SelectListItem(pair.Value.selectText, pair.Key, selectedFormId == pair.Key)).Concat(forms.Select(data => new SelectListItem(data.Name, data.Id, data.Id == selectedFormId))),
- nameof(SelectListItem.Value), nameof(SelectListItem.Text));
- }
-
- public async Task<List<FormData>> GetForms(string storeId)
- {
- ArgumentNullException.ThrowIfNull(storeId);
- await using var context = _applicationDbContextFactory.CreateContext();
- return await context.Forms.Where(data => data.StoreId == storeId).ToListAsync();
- }
-
- public async Task<FormData?> GetForm(string storeId, string? id)
- {
- if (id is null)
- {
- return null;
- }
- await using var context = _applicationDbContextFactory.CreateContext();
- return await context.Forms.Where(data => data.Id == id && data.StoreId == storeId).FirstOrDefaultAsync();
- }
- public async Task<FormData?> GetForm(string? id)
- {
- if (id is null)
- {
- return null;
- }
-
- if (_hardcodedOptions.TryGetValue(id, out var hardcodedForm))
- {
- return new FormData
- {
- Config = hardcodedForm.form.ToString(),
- Id = id,
- Name = hardcodedForm.name,
- Public = false
- };
- }
- await using var context = _applicationDbContextFactory.CreateContext();
- return await context.Forms.Where(data => data.Id == id).FirstOrDefaultAsync();
- }
-
- public async Task RemoveForm(string id, string storeId)
- {
- await using var context = _applicationDbContextFactory.CreateContext();
- var item = await context.Forms.SingleOrDefaultAsync(data => data.StoreId == storeId && id == data.Id);
- if (item is not null)
- context.Remove(item);
- await context.SaveChangesAsync();
- }
-
- public async Task AddOrUpdateForm(FormData data)
- {
- await using var context = _applicationDbContextFactory.CreateContext();
-
- context.Update(data);
- await context.SaveChangesAsync();
- }
-
- public bool Validate(Form form, ModelStateDictionary modelState)
- {
- return _formProviders.Validate(form, modelState);
- }
-
- public bool IsFormSchemaValid(string schema, [MaybeNullWhen(false)] out Form form, [MaybeNullWhen(false)] out string error)
- {
- error = null;
- form = null;
- try
- {
- form = Form.Parse(schema);
- if (!form.ValidateFieldNames(out var errors))
- {
- error = errors.First();
- }
- }
- catch (Exception ex)
- {
- error = $"Form config was invalid: {ex.Message}";
- }
- return error is null && form is not null;
- }
-
- public CreateInvoiceRequest GenerateInvoiceParametersFromForm(Form form)
- {
- var amtRaw = GetValue(form, $"{InvoiceParameterPrefix}amount");
- var amt = string.IsNullOrEmpty(amtRaw) ? (decimal?) null : decimal.Parse(amtRaw, CultureInfo.InvariantCulture);
- foreach (var f in form.GetAllFields())
- {
- if (f.FullName.StartsWith($"{InvoiceParameterPrefix}amount_adjustment") && decimal.TryParse(GetValue(form, f.Field), out var adjustment))
- {
- if (amt is null)
- {
- amt = adjustment;
- }
- else
- {
- amt += adjustment;
- }
- }
- if (f.FullName.StartsWith($"{InvoiceParameterPrefix}amount_multiply_adjustment") && decimal.TryParse(GetValue(form, f.Field), out var adjustmentM))
- {
- if (amt is not null)
- {
- amt *= adjustmentM;
- }
- }
- }
-
- if(amt is not null)
- {
- amt = Math.Max(0, amt.Value);
- }
- return new CreateInvoiceRequest
- {
- Currency = GetValue(form, $"{InvoiceParameterPrefix}currency"),
- Amount = amt,
- Metadata = GetValues(form),
- };
- }
-
- public string? GetValue(Form form, string field)
- {
- return GetValue(form, form.GetFieldByFullName(field));
- }
-
- public string? GetValue(Form form, Field? field)
- {
- if (field is null)
- {
- return null;
- }
- return _formProviders.TypeToComponentProvider.TryGetValue(field.Type, out var formComponentProvider) ? formComponentProvider.GetValue(form, field) : field.Value;
- }
-
- public JObject GetValues(Form form)
- {
- var r = new JObject();
-
- foreach (var f in form.GetAllFields())
- {
- var node = r;
- for (int i = 0; i < f.Path.Count - 1; i++)
- {
- var p = f.Path[i];
- var child = node[p] as JObject;
- if (child is null)
- {
- child = new JObject();
- node[p] = child;
- }
- node = child;
- }
-
- node[f.Field.Name] = GetValue(form, f.FullName);
- }
- return r;
- }
-
- public void SetValues(Form form, JObject values)
- {
-
- var fields = form.GetAllFields().ToDictionary(k => k.FullName, k => k.Field);
- SetValues(fields, new List<string>(), values);
- }
-
- private void SetValues(Dictionary<string, Field> fields, List<string> path, JObject values)
- {
- foreach (var prop in values.Properties())
- {
- List<string> propPath = new List<string>(path.Count + 1);
- propPath.AddRange(path);
- propPath.Add(prop.Name);
- if (prop.Value.Type == JTokenType.Object)
- {
- SetValues(fields, propPath, (JObject)prop.Value);
- }
- else if (prop.Value.Type == JTokenType.String)
- {
- var fullName = string.Join('_', propPath.Where(s => !string.IsNullOrEmpty(s)));
- if (fields.TryGetValue(fullName, out var f) && !f.Constant)
- {
- if (_formProviders.TypeToComponentProvider.TryGetValue(f.Type, out var formComponentProvider))
- {
- formComponentProvider.SetValue(f, prop.Value);
- }
- }
- }
- }
- }
-}
diff --git a/BTCPayServer/Forms/HtmlFieldsetFormProvider.cs b/BTCPayServer/Forms/HtmlFieldsetFormProvider.cs
deleted file mode 100644
index b49d7d9..0000000
--- a/BTCPayServer/Forms/HtmlFieldsetFormProvider.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using System.Collections.Generic;
-using BTCPayServer.Abstractions.Form;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Forms;
-
-public class HtmlFieldsetFormProvider : IFormComponentProvider
-{
- public string View => "Forms/FieldSetElement";
-
- public void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
- {
- typeToComponentProvider.Add("fieldset", this);
- }
-
- public string GetValue(Form form, Field field)
- {
- return null;
- }
-
- public void SetValue(Field field, JToken value)
- {
- //ignored
- }
-
- public void Validate(Form form, Field field)
- {
- }
-}
diff --git a/BTCPayServer/Forms/HtmlInputFormProvider.cs b/BTCPayServer/Forms/HtmlInputFormProvider.cs
deleted file mode 100644
index 472271d..0000000
--- a/BTCPayServer/Forms/HtmlInputFormProvider.cs
+++ /dev/null
@@ -1,47 +0,0 @@
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using BTCPayServer.Abstractions.Form;
-using BTCPayServer.Validation;
-
-namespace BTCPayServer.Forms;
-
-public class HtmlInputFormProvider : FormComponentProviderBase
-{
- public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
- {
- foreach (var t in new[] {
- "text",
- "checkbox",
- "password",
- "hidden",
- "color",
- "date",
- "datetime-local",
- "month",
- "week",
- "time",
- "email",
- "number",
- "url",
- "tel"})
- typeToComponentProvider.Add(t, this);
- }
- public override string View => "Forms/InputElement";
-
- public override void Validate(Form form, Field field)
- {
- if (field.Required)
- {
- ValidateField<RequiredAttribute>(field);
- if (field.ValidationErrors.Count != 0)
- return;
- }
- else if (string.IsNullOrEmpty(field.Value))
- return;
-
- if (field.Type == "email")
- {
- ValidateField<MailboxAddressAttribute>(field);
- }
- }
-}
diff --git a/BTCPayServer/Forms/HtmlSelectFormProvider.cs b/BTCPayServer/Forms/HtmlSelectFormProvider.cs
deleted file mode 100644
index 900e17e..0000000
--- a/BTCPayServer/Forms/HtmlSelectFormProvider.cs
+++ /dev/null
@@ -1,41 +0,0 @@
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using System.Linq;
-using BTCPayServer.Abstractions.Form;
-using Microsoft.AspNetCore.Mvc.Rendering;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Forms;
-
-public class HtmlSelectFormProvider : FormComponentProviderBase
-{
- public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
- {
- typeToComponentProvider.Add("select", this);
- }
-
- public override string View => "Forms/SelectElement";
-
- public override void Validate(Form form, Field field)
- {
- if (field.Required)
- {
- ValidateField<RequiredAttribute>(field);
- }
-
- if (field.ValidationErrors.Count != 0 || string.IsNullOrEmpty(field.Value))
- return;
-
- var selectField = field as SelectField ?? JObject.FromObject(field).ToObject<SelectField>();
- if (selectField?.Options != null &&
- !selectField.Options.Any(o => string.Equals(o.Value, field.Value, System.StringComparison.Ordinal)))
- {
- field.ValidationErrors.Add($"{field.Label} contains an invalid option");
- }
- }
-}
-
-public class SelectField : Field
-{
- public List<SelectListItem> Options { get; set; }
-}
diff --git a/BTCPayServer/Forms/HtmlTextareaFormProvider.cs b/BTCPayServer/Forms/HtmlTextareaFormProvider.cs
deleted file mode 100644
index c209af1..0000000
--- a/BTCPayServer/Forms/HtmlTextareaFormProvider.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using BTCPayServer.Abstractions.Form;
-
-namespace BTCPayServer.Forms;
-
-public class HtmlTextareaFormProvider : FormComponentProviderBase
-{
- public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
- {
- typeToComponentProvider.Add("textarea", this);
- }
-
- public override string View => "Forms/TextareaElement";
-
- public override void Validate(Form form, Field field)
- {
- if (field.Required)
- {
- ValidateField<RequiredAttribute>(field);
- }
- }
-}
diff --git a/BTCPayServer/Forms/IFormComponentProvider.cs b/BTCPayServer/Forms/IFormComponentProvider.cs
deleted file mode 100644
index d59a46d..0000000
--- a/BTCPayServer/Forms/IFormComponentProvider.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using System.Collections.Generic;
-using System.ComponentModel.DataAnnotations;
-using BTCPayServer.Abstractions.Form;
-using Newtonsoft.Json.Linq;
-
-namespace BTCPayServer.Forms;
-
-public interface IFormComponentProvider
-{
- string View { get; }
- void Validate(Form form, Field field);
- void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider);
- string GetValue(Form form, Field field);
- void SetValue(Field field, JToken value);
-}
-
-public abstract class FormComponentProviderBase : IFormComponentProvider
-{
- public abstract string View { get; }
- public abstract void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider);
- public virtual string GetValue(Form form, Field field)
- {
- return field.Value;
- }
-
- public void SetValue(Field field, JToken value)
- {
- field.Value = value.ToString();
- }
-
- public abstract void Validate(Form form, Field field);
-
- public void ValidateField<T>(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/Models/FormViewModel.cs b/BTCPayServer/Forms/Models/FormViewModel.cs
deleted file mode 100644
index c258f74..0000000
--- a/BTCPayServer/Forms/Models/FormViewModel.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-using System.Collections.Generic;
-using BTCPayServer.Abstractions.Form;
-using BTCPayServer.Models;
-
-namespace BTCPayServer.Forms.Models;
-
-public class FormViewModel
-{
- public string StoreName { get; set; }
- public string FormName { get; set; }
- public Form Form { get; set; }
- public string AspController { get; set; }
- public string AspAction { get; set; }
- public Dictionary<string, string> RouteParameters { get; set; } = new();
- public MultiValueDictionary<string, string> FormParameters { get; set; } = new();
- public StoreBrandingViewModel StoreBranding { get; set; }
- public string FormParameterPrefix { get; set; }
-}
diff --git a/BTCPayServer/Forms/ModifyForm.cs b/BTCPayServer/Forms/ModifyForm.cs
deleted file mode 100644
index 1531650..0000000
--- a/BTCPayServer/Forms/ModifyForm.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System.ComponentModel;
-
-namespace BTCPayServer.Forms;
-
-public class ModifyForm
-{
- [DisplayName("Name")]
- public string Name { get; set; }
-
- [DisplayName("Form configuration (JSON)")]
- public string FormConfig { get; set; }
-
- [DisplayName("Allow form for public use")]
- public bool Public { get; set; }
-}
diff --git a/BTCPayServer/Forms/UIFormsController.cs b/BTCPayServer/Forms/UIFormsController.cs
deleted file mode 100644
index ca95ad1..0000000
--- a/BTCPayServer/Forms/UIFormsController.cs
+++ /dev/null
@@ -1,234 +0,0 @@
-#nullable enable
-using System;
-using System.Threading.Tasks;
-using BTCPayServer.Abstractions.Constants;
-using BTCPayServer.Abstractions.Extensions;
-using BTCPayServer.Abstractions.Form;
-using BTCPayServer.Abstractions.Models;
-using BTCPayServer.Client;
-using BTCPayServer.Client.Models;
-using BTCPayServer.Controllers;
-using BTCPayServer.Data;
-using BTCPayServer.Filters;
-using BTCPayServer.Forms.Models;
-using BTCPayServer.Models;
-using BTCPayServer.Services;
-using BTCPayServer.Services.Stores;
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.Extensions.Localization;
-
-namespace BTCPayServer.Forms;
-
-[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
-public class UIFormsController : Controller
-{
- private readonly FormDataService _formDataService;
- private readonly UriResolver _uriResolver;
- private readonly IAuthorizationService _authorizationService;
- private readonly StoreRepository _storeRepository;
- private FormComponentProviders FormProviders { get; }
- private IStringLocalizer StringLocalizer { get; }
-
- public UIFormsController(FormComponentProviders formProviders, FormDataService formDataService,
- UriResolver uriResolver,
- IStringLocalizer stringLocalizer,
- StoreRepository storeRepository, IAuthorizationService authorizationService)
- {
- FormProviders = formProviders;
- _formDataService = formDataService;
- _uriResolver = uriResolver;
- _authorizationService = authorizationService;
- _storeRepository = storeRepository;
- StringLocalizer = stringLocalizer;
- }
-
- [HttpGet("~/stores/{storeId}/forms")]
- public async Task<IActionResult> FormsList(string storeId)
- {
- var forms = await _formDataService.GetForms(storeId);
-
- return View(forms);
- }
-
- [HttpGet("~/stores/{storeId}/forms/new")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public IActionResult Create(string storeId)
- {
- var vm = new ModifyForm { FormConfig = new Form().ToString() };
- return View("Modify", vm);
- }
-
- [HttpGet("~/stores/{storeId}/forms/modify/{id}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> Modify(string storeId, string id)
- {
- var form = await _formDataService.GetForm(storeId, id);
- if (form is null)
- return NotFound();
-
- var config = Form.Parse(form.Config);
- return View(new ModifyForm { Name = form.Name, FormConfig = config.ToString(), Public = form.Public });
- }
-
- [HttpPost("~/stores/{storeId}/forms/modify/{id?}")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> Modify(string storeId, string? id, ModifyForm modifyForm)
- {
- if (id is not null)
- {
- if (await _formDataService.GetForm(storeId, id) is null)
- {
- return NotFound();
- }
- }
-
- if (!_formDataService.IsFormSchemaValid(modifyForm.FormConfig, out var form, out var error))
- {
- ModelState.AddModelError(nameof(modifyForm.FormConfig), StringLocalizer["Form config was invalid: {0}", error!]);
- }
- else
- {
- modifyForm.FormConfig = form.ToString();
- }
-
- if (!ModelState.IsValid)
- {
- return View(modifyForm);
- }
-
- try
- {
- var formData = new FormData
- {
- Id = id,
- StoreId = storeId,
- Name = modifyForm.Name,
- Config = modifyForm.FormConfig,
- Public = modifyForm.Public
- };
- var isNew = id is null;
- await _formDataService.AddOrUpdateForm(formData);
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Success,
- Message = isNew
- ? StringLocalizer["Form created successfully."].Value
- : StringLocalizer["Form updated successfully."].Value
- });
- if (isNew)
- {
- return RedirectToAction("Modify", new { storeId, id = formData.Id });
- }
- }
- catch (Exception e)
- {
- ModelState.AddModelError("", StringLocalizer["An error occurred while saving: {0}", e.Message]);
- }
-
- return View(modifyForm);
- }
-
- [HttpPost("~/stores/{storeId}/forms/{id}/remove")]
- [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
- public async Task<IActionResult> Remove(string storeId, string id)
- {
- await _formDataService.RemoveForm(id, storeId);
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Success,
- Message = StringLocalizer["Form removed"].Value
- });
- return RedirectToAction("FormsList", new { storeId });
- }
-
- [AllowAnonymous]
- [HttpGet("~/forms/{formId}")]
- [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
- public async Task<IActionResult> ViewPublicForm(string? formId)
- {
- FormData? formData = await _formDataService.GetForm(formId);
- if (formData?.Config is null)
- {
- return NotFound();
- }
-
- if (!formData.Public &&
- !(await _authorizationService.AuthorizeAsync(User, formData.StoreId, Policies.CanViewStoreSettings)).Succeeded)
- {
- return NotFound();
- }
-
- return await GetFormView(formData);
- }
-
- async Task<ViewResult> GetFormView(FormData formData, Form? form = null)
- {
- form ??= Form.Parse(formData.Config);
- form.ApplyValuesFromForm(Request.Query);
- var store = formData.Store ?? await _storeRepository.FindStore(formData.StoreId);
- var storeBlob = store?.GetStoreBlob();
-
- return View("View", new FormViewModel
- {
- FormName = formData.Name,
- Form = form,
- StoreName = store?.StoreName,
- StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob)
- });
- }
-
- [AllowAnonymous]
- [HttpPost("~/forms/{formId}")]
- [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
- public async Task<IActionResult> SubmitForm(string formId,
- [FromServices] StoreRepository storeRepository,
- [FromServices] UIInvoiceController invoiceController)
- {
- var formData = await _formDataService.GetForm(formId);
- if (formData?.Config is null)
- {
- return NotFound();
- }
-
- if (!formData.Public &&
- !(await _authorizationService.AuthorizeAsync(User, formData.StoreId, Policies.CanViewStoreSettings)).Succeeded)
- {
- return NotFound();
- }
-
- if (!Request.HasFormContentType)
- return await GetFormView(formData);
-
- var form = Form.Parse(formData.Config);
- form.ApplyValuesFromForm(Request.Form);
-
- if (!_formDataService.Validate(form, ModelState))
- return await GetFormView(formData, form);
-
- // Create invoice after public form has been filled
- var store = await storeRepository.FindStore(formData.StoreId);
- if (store is null)
- return NotFound();
-
- try
- {
- var request = _formDataService.GenerateInvoiceParametersFromForm(form);
- var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot());
- if (inv.Price == 0 && inv.Type == InvoiceType.Standard && inv.ReceiptOptions?.Enabled is not false)
- {
- return RedirectToAction("InvoiceReceipt", "UIInvoice", new { invoiceId = inv.Id });
- }
- return RedirectToAction("Checkout", "UIInvoice", new { invoiceId = inv.Id });
- }
- catch (Exception e)
- {
- TempData.SetStatusMessageModel(new StatusMessageModel
- {
- Severity = StatusMessageModel.StatusSeverity.Error,
- Message = StringLocalizer["Could not generate invoice: {0}", e.Message].Value
- });
- return await GetFormView(formData, form);
- }
- }
-}
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 5a3c27f..8a73747 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -484,7 +484,6 @@ namespace BTCPayServer.Hosting
services.AddSingleton<InvoiceActivator>();
services.AddPayoutProcesors();
- services.AddForms();
services.AddSingleton<APIKeyRepository>();
services.AddSingleton<IPermissionHandler, BuiltInPermissionHandler>();
diff --git a/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs b/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs
index 49a563c..607cabb 100644
--- a/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs
+++ b/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs
@@ -304,7 +304,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
FormParameterPrefix = prefix
};
- return View("Views/UIForms/View", vm);
+ return View("/Plugins/Forms/Views/View.cshtml", vm);
}
[HttpPost("/apps/{appId}/crowdfund/form/submit")]
@@ -355,7 +355,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
viewModel.Form = form;
viewModel.FormParameters = formParameters;
- return View("Views/UIForms/View", viewModel);
+ return View("/Plugins/Forms/Views/View.cshtml", viewModel);
}
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
diff --git a/BTCPayServer/Plugins/Forms/FieldValueMirror.cs b/BTCPayServer/Plugins/Forms/FieldValueMirror.cs
new file mode 100644
index 0000000..1a07d42
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/FieldValueMirror.cs
@@ -0,0 +1,44 @@
+using System.Collections.Generic;
+using BTCPayServer.Abstractions.Form;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Forms;
+
+public class FieldValueMirror : IFormComponentProvider
+{
+ public string View { get; } = null;
+ public void Validate(Form form, Field field)
+ {
+ if (form.GetFieldByFullName(field.Value) is null)
+ {
+ field.ValidationErrors = new List<string> {$"{field.Name} requires {field.Value} to be present"};
+ }
+ }
+
+ public void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
+ {
+ typeToComponentProvider.Add("mirror", this);
+ }
+
+ public string GetValue(Form form, Field field)
+ {
+ var rawValue = form.GetFieldByFullName(field.Value)?.Value;
+ if (string.IsNullOrEmpty(rawValue))
+ return null;
+
+ if (field.AdditionalData?.TryGetValue("valuemap", out var valueMap) is true &&
+ valueMap is JObject map)
+ {
+ return map.TryGetValue(rawValue, out var mappedValue)
+ ? mappedValue.Value<string>()
+ : null;
+ }
+
+ return rawValue;
+ }
+
+ public void SetValue(Field field, JToken value)
+ {
+ //ignored
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/FormComponentProviders.cs b/BTCPayServer/Plugins/Forms/FormComponentProviders.cs
new file mode 100644
index 0000000..fd97059
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/FormComponentProviders.cs
@@ -0,0 +1,33 @@
+using System.Collections.Generic;
+using BTCPayServer.Abstractions.Form;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace BTCPayServer.Forms;
+
+public class FormComponentProviders
+{
+ private readonly IEnumerable<IFormComponentProvider> _formComponentProviders;
+
+ public Dictionary<string, IFormComponentProvider> TypeToComponentProvider = new();
+
+ public FormComponentProviders(IEnumerable<IFormComponentProvider> formComponentProviders)
+ {
+ _formComponentProviders = formComponentProviders;
+ foreach (var prov in _formComponentProviders)
+ prov.Register(TypeToComponentProvider);
+ }
+
+ public bool Validate(Form form, ModelStateDictionary modelState)
+ {
+ foreach (var field in form.GetAllFields())
+ {
+ if (TypeToComponentProvider.TryGetValue(field.Field.Type, out var provider))
+ {
+ provider.Validate(form, field.Field);
+ foreach (var err in field.Field.ValidationErrors)
+ modelState.TryAddModelError(field.Field.Name, err);
+ }
+ }
+ return modelState.IsValid;
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/FormDataExtensions.cs b/BTCPayServer/Plugins/Forms/FormDataExtensions.cs
new file mode 100644
index 0000000..923e80b
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/FormDataExtensions.cs
@@ -0,0 +1,19 @@
+using BTCPayServer.Data;
+using Microsoft.Extensions.DependencyInjection;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Forms;
+
+public static class FormDataExtensions
+{
+ public static JObject Deserialize(this FormData form)
+ {
+ return JsonConvert.DeserializeObject<JObject>(form.Config);
+ }
+
+ public static string Serialize(this JObject form)
+ {
+ return JsonConvert.SerializeObject(form);
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/FormDataService.cs b/BTCPayServer/Plugins/Forms/FormDataService.cs
new file mode 100644
index 0000000..02d2f9a
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/FormDataService.cs
@@ -0,0 +1,259 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Form;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Microsoft.EntityFrameworkCore;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Forms;
+
+public class FormDataService
+{
+ public const string InvoiceParameterPrefix = "invoice_";
+ private readonly ApplicationDbContextFactory _applicationDbContextFactory;
+ private readonly FormComponentProviders _formProviders;
+
+ public FormDataService(
+ ApplicationDbContextFactory applicationDbContextFactory,
+ FormComponentProviders formProviders)
+ {
+ _applicationDbContextFactory = applicationDbContextFactory;
+ _formProviders = formProviders;
+ }
+
+ public static readonly Form StaticFormEmail = new()
+ {
+ Fields = new List<Field> { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") }
+ };
+
+ public static readonly Form StaticFormAddress = new()
+ {
+ Fields = new List<Field>
+ {
+ 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, true, null),
+ Field.Create("State", "buyerState", null, false, null),
+ new SelectField
+ {
+ Name = "buyerCountry",
+ Label = "Country",
+ Required = true,
+ Type = "select",
+ Options = "Afghanistan, Albania, Algeria, Andorra, Angola, Antigua and Barbuda, Argentina, Armenia, Australia, Austria, Azerbaijan, The Bahamas, Bahrain, Bangladesh, Barbados, Belarus, Belgium, Belize, Benin, Bhutan, Bolivia, Bosnia and Herzegovina, Botswana, Brazil, Brunei, Bulgaria, Burkina Faso, Burundi, Cabo Verde, Cambodia, Cameroon, Canada, Central African Republic (CAR), Chad, Chile, China, Colombia, Comoros, Democratic Republic of the Congo, Republic of the Congo, Costa Rica, Cote d'Ivoire, Croatia, Cuba, Cyprus, Czech Republic, Denmark, Djibouti, Dominica, Dominican Republic, Ecuador, Egypt, El Salvador, Equatorial Guinea, Eritrea, Estonia, Eswatini (formerly Swaziland), Ethiopia, Fiji, Finland, France, Gabon, The Gambia, Georgia, Germany, Ghana, Greece, Grenada, Guatemala, Guinea, Guinea-Bissau, Guyana, Haiti, Honduras, Hungary, Iceland, India, Indonesia, Iran, Iraq, Ireland, Israel, Italy, Jamaica, Japan, Jordan, Kazakhstan, Kenya, Kiribati, Kosovo, Kuwait, Kyrgyzstan, Laos, Latvia, Lebanon, Lesotho, Liberia, Libya, Liechtenstein, Lithuania, Luxembourg, Madagascar, Malawi, Malaysia, Maldives, Mali, Malta, Marshall Islands, Mauritania, Mauritius, Mexico, Micronesia, Moldova, Monaco, Mongolia, Montenegro, Morocco, Mozambique, Myanmar (formerly Burma), Namibia, Nauru, Nepal, Netherlands, New Zealand, Nicaragua, Niger, Nigeria, North Korea, North Macedonia (formerly Macedonia), Norway, Oman, Pakistan, Palau, Palestine, Panama, Papua New Guinea, Paraguay, Peru, Philippines, Poland, Portugal, Qatar, Romania, Russia, Rwanda, Saint Kitts and Nevis, Saint Lucia, Saint Vincent and the Grenadines, Samoa, San Marino, Sao Tome and Principe, Saudi Arabia, Senegal, Serbia, Seychelles, Sierra Leone, Singapore, Slovakia, Slovenia, Solomon Islands, Somalia, South Africa, South Korea, South Sudan, Spain, Sri Lanka, Sudan, Suriname, Sweden, Switzerland, Syria, Taiwan, Tajikistan, Tanzania, Thailand, Timor-Leste (formerly East Timor), Togo, Tonga, Trinidad and Tobago, Tunisia, Turkey, Turkmenistan, Tuvalu, Uganda, Ukraine, United Arab Emirates (UAE), United Kingdom (UK), United States of America (USA), Uruguay, Uzbekistan, Vanuatu, Vatican City (Holy See), Venezuela, Vietnam, Yemen, Zambia, Zimbabwe.".Split(',').Select(s => new SelectListItem(s,s)).ToList()
+
+ }
+ }
+ };
+
+ private static readonly Dictionary<string, (string selectText, string name, Form form)> _hardcodedOptions = new()
+ {
+ {"", ("Do not request any information", null, null)!},
+ {"Email", ("Request email address only", "Provide your email address", StaticFormEmail )},
+ {"Address", ("Request shipping address", "Provide your address", StaticFormAddress)},
+ };
+
+ public async Task<SelectList> GetSelect(string storeId, string selectedFormId)
+ {
+ var forms = await GetForms(storeId);
+ return new SelectList(_hardcodedOptions.Select(pair => new SelectListItem(pair.Value.selectText, pair.Key, selectedFormId == pair.Key)).Concat(forms.Select(data => new SelectListItem(data.Name, data.Id, data.Id == selectedFormId))),
+ nameof(SelectListItem.Value), nameof(SelectListItem.Text));
+ }
+
+ public async Task<List<FormData>> GetForms(string storeId)
+ {
+ ArgumentNullException.ThrowIfNull(storeId);
+ await using var context = _applicationDbContextFactory.CreateContext();
+ return await context.Forms.Where(data => data.StoreId == storeId).ToListAsync();
+ }
+
+ public async Task<FormData?> GetForm(string storeId, string? id)
+ {
+ if (id is null)
+ {
+ return null;
+ }
+ await using var context = _applicationDbContextFactory.CreateContext();
+ return await context.Forms.Where(data => data.Id == id && data.StoreId == storeId).FirstOrDefaultAsync();
+ }
+ public async Task<FormData?> GetForm(string? id)
+ {
+ if (id is null)
+ {
+ return null;
+ }
+
+ if (_hardcodedOptions.TryGetValue(id, out var hardcodedForm))
+ {
+ return new FormData
+ {
+ Config = hardcodedForm.form.ToString(),
+ Id = id,
+ Name = hardcodedForm.name,
+ Public = false
+ };
+ }
+ await using var context = _applicationDbContextFactory.CreateContext();
+ return await context.Forms.Where(data => data.Id == id).FirstOrDefaultAsync();
+ }
+
+ public async Task RemoveForm(string id, string storeId)
+ {
+ await using var context = _applicationDbContextFactory.CreateContext();
+ var item = await context.Forms.SingleOrDefaultAsync(data => data.StoreId == storeId && id == data.Id);
+ if (item is not null)
+ context.Remove(item);
+ await context.SaveChangesAsync();
+ }
+
+ public async Task AddOrUpdateForm(FormData data)
+ {
+ await using var context = _applicationDbContextFactory.CreateContext();
+
+ context.Update(data);
+ await context.SaveChangesAsync();
+ }
+
+ public bool Validate(Form form, ModelStateDictionary modelState)
+ {
+ return _formProviders.Validate(form, modelState);
+ }
+
+ public bool IsFormSchemaValid(string schema, [MaybeNullWhen(false)] out Form form, [MaybeNullWhen(false)] out string error)
+ {
+ error = null;
+ form = null;
+ try
+ {
+ form = Form.Parse(schema);
+ if (!form.ValidateFieldNames(out var errors))
+ {
+ error = errors.First();
+ }
+ }
+ catch (Exception ex)
+ {
+ error = $"Form config was invalid: {ex.Message}";
+ }
+ return error is null && form is not null;
+ }
+
+ public CreateInvoiceRequest GenerateInvoiceParametersFromForm(Form form)
+ {
+ var amtRaw = GetValue(form, $"{InvoiceParameterPrefix}amount");
+ var amt = string.IsNullOrEmpty(amtRaw) ? (decimal?) null : decimal.Parse(amtRaw, CultureInfo.InvariantCulture);
+ foreach (var f in form.GetAllFields())
+ {
+ if (f.FullName.StartsWith($"{InvoiceParameterPrefix}amount_adjustment") && decimal.TryParse(GetValue(form, f.Field), out var adjustment))
+ {
+ if (amt is null)
+ {
+ amt = adjustment;
+ }
+ else
+ {
+ amt += adjustment;
+ }
+ }
+ if (f.FullName.StartsWith($"{InvoiceParameterPrefix}amount_multiply_adjustment") && decimal.TryParse(GetValue(form, f.Field), out var adjustmentM))
+ {
+ if (amt is not null)
+ {
+ amt *= adjustmentM;
+ }
+ }
+ }
+
+ if(amt is not null)
+ {
+ amt = Math.Max(0, amt.Value);
+ }
+ return new CreateInvoiceRequest
+ {
+ Currency = GetValue(form, $"{InvoiceParameterPrefix}currency"),
+ Amount = amt,
+ Metadata = GetValues(form),
+ };
+ }
+
+ public string? GetValue(Form form, string field)
+ {
+ return GetValue(form, form.GetFieldByFullName(field));
+ }
+
+ public string? GetValue(Form form, Field? field)
+ {
+ if (field is null)
+ {
+ return null;
+ }
+ return _formProviders.TypeToComponentProvider.TryGetValue(field.Type, out var formComponentProvider) ? formComponentProvider.GetValue(form, field) : field.Value;
+ }
+
+ public JObject GetValues(Form form)
+ {
+ var r = new JObject();
+
+ foreach (var f in form.GetAllFields())
+ {
+ var node = r;
+ for (int i = 0; i < f.Path.Count - 1; i++)
+ {
+ var p = f.Path[i];
+ var child = node[p] as JObject;
+ if (child is null)
+ {
+ child = new JObject();
+ node[p] = child;
+ }
+ node = child;
+ }
+
+ node[f.Field.Name] = GetValue(form, f.FullName);
+ }
+ return r;
+ }
+
+ public void SetValues(Form form, JObject values)
+ {
+
+ var fields = form.GetAllFields().ToDictionary(k => k.FullName, k => k.Field);
+ SetValues(fields, new List<string>(), values);
+ }
+
+ private void SetValues(Dictionary<string, Field> fields, List<string> path, JObject values)
+ {
+ foreach (var prop in values.Properties())
+ {
+ List<string> propPath = new List<string>(path.Count + 1);
+ propPath.AddRange(path);
+ propPath.Add(prop.Name);
+ if (prop.Value.Type == JTokenType.Object)
+ {
+ SetValues(fields, propPath, (JObject)prop.Value);
+ }
+ else if (prop.Value.Type == JTokenType.String)
+ {
+ var fullName = string.Join('_', propPath.Where(s => !string.IsNullOrEmpty(s)));
+ if (fields.TryGetValue(fullName, out var f) && !f.Constant)
+ {
+ if (_formProviders.TypeToComponentProvider.TryGetValue(f.Type, out var formComponentProvider))
+ {
+ formComponentProvider.SetValue(f, prop.Value);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/FormsPlugin.cs b/BTCPayServer/Plugins/Forms/FormsPlugin.cs
new file mode 100644
index 0000000..6da30a7
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/FormsPlugin.cs
@@ -0,0 +1,25 @@
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Forms;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BTCPayServer.Plugins.Forms;
+
+public class FormsPlugin : BaseBTCPayServerPlugin
+{
+ public const string Area = "Forms";
+ public override string Identifier => "BTCPayServer.Plugins.Forms";
+ public override string Name => "Forms";
+ public override string Description => "Create forms to collect additional data from customers";
+
+ public override void Execute(IServiceCollection services)
+ {
+ services.AddUIExtension("store-category-nav", "/Plugins/Forms/Views/NavExtension.cshtml");
+ services.AddSingleton<FormDataService>();
+ services.AddSingleton<FormComponentProviders>();
+ services.AddSingleton<IFormComponentProvider, HtmlInputFormProvider>();
+ services.AddSingleton<IFormComponentProvider, HtmlTextareaFormProvider>();
+ services.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
+ services.AddSingleton<IFormComponentProvider, HtmlSelectFormProvider>();
+ services.AddSingleton<IFormComponentProvider, FieldValueMirror>();
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/HtmlFieldsetFormProvider.cs b/BTCPayServer/Plugins/Forms/HtmlFieldsetFormProvider.cs
new file mode 100644
index 0000000..b49d7d9
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/HtmlFieldsetFormProvider.cs
@@ -0,0 +1,29 @@
+using System.Collections.Generic;
+using BTCPayServer.Abstractions.Form;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Forms;
+
+public class HtmlFieldsetFormProvider : IFormComponentProvider
+{
+ public string View => "Forms/FieldSetElement";
+
+ public void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
+ {
+ typeToComponentProvider.Add("fieldset", this);
+ }
+
+ public string GetValue(Form form, Field field)
+ {
+ return null;
+ }
+
+ public void SetValue(Field field, JToken value)
+ {
+ //ignored
+ }
+
+ public void Validate(Form form, Field field)
+ {
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/HtmlInputFormProvider.cs b/BTCPayServer/Plugins/Forms/HtmlInputFormProvider.cs
new file mode 100644
index 0000000..472271d
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/HtmlInputFormProvider.cs
@@ -0,0 +1,47 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using BTCPayServer.Abstractions.Form;
+using BTCPayServer.Validation;
+
+namespace BTCPayServer.Forms;
+
+public class HtmlInputFormProvider : FormComponentProviderBase
+{
+ public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
+ {
+ foreach (var t in new[] {
+ "text",
+ "checkbox",
+ "password",
+ "hidden",
+ "color",
+ "date",
+ "datetime-local",
+ "month",
+ "week",
+ "time",
+ "email",
+ "number",
+ "url",
+ "tel"})
+ typeToComponentProvider.Add(t, this);
+ }
+ public override string View => "Forms/InputElement";
+
+ public override void Validate(Form form, Field field)
+ {
+ if (field.Required)
+ {
+ ValidateField<RequiredAttribute>(field);
+ if (field.ValidationErrors.Count != 0)
+ return;
+ }
+ else if (string.IsNullOrEmpty(field.Value))
+ return;
+
+ if (field.Type == "email")
+ {
+ ValidateField<MailboxAddressAttribute>(field);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/HtmlSelectFormProvider.cs b/BTCPayServer/Plugins/Forms/HtmlSelectFormProvider.cs
new file mode 100644
index 0000000..900e17e
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/HtmlSelectFormProvider.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using BTCPayServer.Abstractions.Form;
+using Microsoft.AspNetCore.Mvc.Rendering;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Forms;
+
+public class HtmlSelectFormProvider : FormComponentProviderBase
+{
+ public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
+ {
+ typeToComponentProvider.Add("select", this);
+ }
+
+ public override string View => "Forms/SelectElement";
+
+ public override void Validate(Form form, Field field)
+ {
+ if (field.Required)
+ {
+ ValidateField<RequiredAttribute>(field);
+ }
+
+ if (field.ValidationErrors.Count != 0 || string.IsNullOrEmpty(field.Value))
+ return;
+
+ var selectField = field as SelectField ?? JObject.FromObject(field).ToObject<SelectField>();
+ if (selectField?.Options != null &&
+ !selectField.Options.Any(o => string.Equals(o.Value, field.Value, System.StringComparison.Ordinal)))
+ {
+ field.ValidationErrors.Add($"{field.Label} contains an invalid option");
+ }
+ }
+}
+
+public class SelectField : Field
+{
+ public List<SelectListItem> Options { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Forms/HtmlTextareaFormProvider.cs b/BTCPayServer/Plugins/Forms/HtmlTextareaFormProvider.cs
new file mode 100644
index 0000000..c209af1
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/HtmlTextareaFormProvider.cs
@@ -0,0 +1,23 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using BTCPayServer.Abstractions.Form;
+
+namespace BTCPayServer.Forms;
+
+public class HtmlTextareaFormProvider : FormComponentProviderBase
+{
+ public override void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider)
+ {
+ typeToComponentProvider.Add("textarea", this);
+ }
+
+ public override string View => "Forms/TextareaElement";
+
+ public override void Validate(Form form, Field field)
+ {
+ if (field.Required)
+ {
+ ValidateField<RequiredAttribute>(field);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/IFormComponentProvider.cs b/BTCPayServer/Plugins/Forms/IFormComponentProvider.cs
new file mode 100644
index 0000000..d59a46d
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/IFormComponentProvider.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using BTCPayServer.Abstractions.Form;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Forms;
+
+public interface IFormComponentProvider
+{
+ string View { get; }
+ void Validate(Form form, Field field);
+ void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider);
+ string GetValue(Form form, Field field);
+ void SetValue(Field field, JToken value);
+}
+
+public abstract class FormComponentProviderBase : IFormComponentProvider
+{
+ public abstract string View { get; }
+ public abstract void Register(Dictionary<string, IFormComponentProvider> typeToComponentProvider);
+ public virtual string GetValue(Form form, Field field)
+ {
+ return field.Value;
+ }
+
+ public void SetValue(Field field, JToken value)
+ {
+ field.Value = value.ToString();
+ }
+
+ public abstract void Validate(Form form, Field field);
+
+ public void ValidateField<T>(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/Plugins/Forms/ModifyForm.cs b/BTCPayServer/Plugins/Forms/ModifyForm.cs
new file mode 100644
index 0000000..1531650
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/ModifyForm.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel;
+
+namespace BTCPayServer.Forms;
+
+public class ModifyForm
+{
+ [DisplayName("Name")]
+ public string Name { get; set; }
+
+ [DisplayName("Form configuration (JSON)")]
+ public string FormConfig { get; set; }
+
+ [DisplayName("Allow form for public use")]
+ public bool Public { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Forms/UIFormsController.cs b/BTCPayServer/Plugins/Forms/UIFormsController.cs
new file mode 100644
index 0000000..1dcecf4
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/UIFormsController.cs
@@ -0,0 +1,223 @@
+#nullable enable
+using System;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Abstractions.Form;
+using BTCPayServer.Abstractions.Models;
+using BTCPayServer.Client;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Controllers;
+using BTCPayServer.Data;
+using BTCPayServer.Filters;
+using BTCPayServer.Forms.Models;
+using BTCPayServer.Models;
+using BTCPayServer.Plugins.Forms;
+using BTCPayServer.Services;
+using BTCPayServer.Services.Stores;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Localization;
+
+namespace BTCPayServer.Forms;
+
+[Area(FormsPlugin.Area)]
+[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+public class UIFormsController(
+ FormDataService formDataService,
+ UIInvoiceController invoiceController,
+ UriResolver uriResolver,
+ IStringLocalizer stringLocalizer,
+ StoreRepository storeRepository,
+ IAuthorizationService authorizationService)
+ : Controller
+{
+ private IStringLocalizer StringLocalizer { get; } = stringLocalizer;
+
+ [HttpGet("~/stores/{storeId}/forms")]
+ public async Task<IActionResult> FormsList(string storeId)
+ {
+ var forms = await formDataService.GetForms(storeId);
+
+ return View(forms);
+ }
+
+ [HttpGet("~/stores/{storeId}/forms/new")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public IActionResult Create(string storeId)
+ {
+ var vm = new ModifyForm { FormConfig = new Form().ToString() };
+ return View("Modify", vm);
+ }
+
+ [HttpGet("~/stores/{storeId}/forms/modify/{id}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> Modify(string storeId, string id)
+ {
+ var form = await formDataService.GetForm(storeId, id);
+ if (form is null)
+ return NotFound();
+
+ var config = Form.Parse(form.Config);
+ return View(new ModifyForm { Name = form.Name, FormConfig = config.ToString(), Public = form.Public });
+ }
+
+ [HttpPost("~/stores/{storeId}/forms/modify/{id?}")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> Modify(string storeId, string? id, ModifyForm modifyForm)
+ {
+ if (id is not null)
+ {
+ if (await formDataService.GetForm(storeId, id) is null)
+ {
+ return NotFound();
+ }
+ }
+
+ if (!formDataService.IsFormSchemaValid(modifyForm.FormConfig, out var form, out var error))
+ {
+ ModelState.AddModelError(nameof(modifyForm.FormConfig), StringLocalizer["Form config was invalid: {0}", error!]);
+ }
+ else
+ {
+ modifyForm.FormConfig = form.ToString();
+ }
+
+ if (!ModelState.IsValid)
+ {
+ return View(modifyForm);
+ }
+
+ try
+ {
+ var formData = new FormData
+ {
+ Id = id,
+ StoreId = storeId,
+ Name = modifyForm.Name,
+ Config = modifyForm.FormConfig,
+ Public = modifyForm.Public
+ };
+ var isNew = id is null;
+ await formDataService.AddOrUpdateForm(formData);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Success,
+ Message = isNew
+ ? StringLocalizer["Form created successfully."].Value
+ : StringLocalizer["Form updated successfully."].Value
+ });
+ if (isNew)
+ {
+ return RedirectToAction("Modify", new { storeId, id = formData.Id });
+ }
+ }
+ catch (Exception e)
+ {
+ ModelState.AddModelError("", StringLocalizer["An error occurred while saving: {0}", e.Message]);
+ }
+
+ return View(modifyForm);
+ }
+
+ [HttpPost("~/stores/{storeId}/forms/{id}/remove")]
+ [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
+ public async Task<IActionResult> Remove(string storeId, string id)
+ {
+ await formDataService.RemoveForm(id, storeId);
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Success,
+ Message = StringLocalizer["Form removed"].Value
+ });
+ return RedirectToAction("FormsList", new { storeId });
+ }
+
+ [AllowAnonymous]
+ [HttpGet("~/forms/{formId}")]
+ [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
+ public async Task<IActionResult> ViewPublicForm(string? formId)
+ {
+ FormData? formData = await formDataService.GetForm(formId);
+ if (formData?.Config is null)
+ {
+ return NotFound();
+ }
+
+ if (!formData.Public &&
+ !(await authorizationService.AuthorizeAsync(User, formData.StoreId, Policies.CanViewStoreSettings)).Succeeded)
+ {
+ return NotFound();
+ }
+
+ return await GetFormView(formData);
+ }
+
+ async Task<ViewResult> GetFormView(FormData formData, Form? form = null)
+ {
+ form ??= Form.Parse(formData.Config);
+ form.ApplyValuesFromForm(Request.Query);
+ var store = formData.Store ?? await storeRepository.FindStore(formData.StoreId);
+ var storeBlob = store?.GetStoreBlob();
+
+ return View("View", new FormViewModel
+ {
+ FormName = formData.Name,
+ Form = form,
+ StoreName = store?.StoreName,
+ StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, storeBlob)
+ });
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/forms/{formId}")]
+ [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
+ public async Task<IActionResult> SubmitForm(string formId)
+ {
+ var formData = await formDataService.GetForm(formId);
+ if (formData?.Config is null)
+ {
+ return NotFound();
+ }
+
+ if (!formData.Public &&
+ !(await authorizationService.AuthorizeAsync(User, formData.StoreId, Policies.CanViewStoreSettings)).Succeeded)
+ {
+ return NotFound();
+ }
+
+ if (!Request.HasFormContentType)
+ return await GetFormView(formData);
+
+ var form = Form.Parse(formData.Config);
+ form.ApplyValuesFromForm(Request.Form);
+
+ if (!formDataService.Validate(form, ModelState))
+ return await GetFormView(formData, form);
+
+ // Create invoice after public form has been filled
+ var store = await storeRepository.FindStore(formData.StoreId);
+ if (store is null)
+ return NotFound();
+
+ try
+ {
+ var request = formDataService.GenerateInvoiceParametersFromForm(form);
+ var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot());
+ if (inv.Price == 0 && inv.Type == InvoiceType.Standard && inv.ReceiptOptions?.Enabled is not false)
+ {
+ return RedirectToAction("InvoiceReceipt", "UIInvoice", new { invoiceId = inv.Id });
+ }
+ return RedirectToAction("Checkout", "UIInvoice", new { invoiceId = inv.Id });
+ }
+ catch (Exception e)
+ {
+ TempData.SetStatusMessageModel(new StatusMessageModel
+ {
+ Severity = StatusMessageModel.StatusSeverity.Error,
+ Message = StringLocalizer["Could not generate invoice: {0}", e.Message].Value
+ });
+ return await GetFormView(formData, form);
+ }
+ }
+}
diff --git a/BTCPayServer/Plugins/Forms/Views/FormViewModel.cs b/BTCPayServer/Plugins/Forms/Views/FormViewModel.cs
new file mode 100644
index 0000000..c258f74
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/Views/FormViewModel.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using BTCPayServer.Abstractions.Form;
+using BTCPayServer.Models;
+
+namespace BTCPayServer.Forms.Models;
+
+public class FormViewModel
+{
+ public string StoreName { get; set; }
+ public string FormName { get; set; }
+ public Form Form { get; set; }
+ public string AspController { get; set; }
+ public string AspAction { get; set; }
+ public Dictionary<string, string> RouteParameters { get; set; } = new();
+ public MultiValueDictionary<string, string> FormParameters { get; set; } = new();
+ public StoreBrandingViewModel StoreBranding { get; set; }
+ public string FormParameterPrefix { get; set; }
+}
diff --git a/BTCPayServer/Plugins/Forms/Views/FormsList.cshtml b/BTCPayServer/Plugins/Forms/Views/FormsList.cshtml
new file mode 100644
index 0000000..bcf8049
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/Views/FormsList.cshtml
@@ -0,0 +1,61 @@
+@using BTCPayServer.Client
+@model List<BTCPayServer.Data.FormData>
+@{
+ ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Forms), StringLocalizer["Forms"]).SetCategory(WellKnownCategories.Store));
+ var storeId = Context.GetCurrentStoreId();
+ Layout = "_Layout";
+}
+
+<div class="sticky-header">
+ <h2>
+ <span>@ViewData["Title"]</span>
+ <a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
+ <vc:icon symbol="info" />
+ </a>
+ </h2>
+ <a id="page-primary" asp-action="Create" asp-route-storeId="@storeId" class="btn btn-primary mt-3 mt-sm-0" role="button" permission="@Policies.CanModifyStoreSettings" text-translate="true">
+ Create Form
+ </a>
+</div>
+<partial name="_StatusMessage" />
+
+<div class="row">
+ <div class="col-xxl-constrain col-xl-10">
+ @if (Model.Any())
+ {
+ <div class="table-responsive-md mt-0">
+ <table class="table table-hover">
+ <thead>
+ <tr>
+ <th text-translate="true">Name</th>
+ <th text-translate="true" class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var item in Model)
+ {
+ <tr>
+ <td>
+ <a asp-action="Modify" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Edit-@item.Name" permission="@Policies.CanModifyStoreSettings">@item.Name</a>
+ <a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" not-permission="@Policies.CanModifyStoreSettings">@item.Name</a>
+ </td>
+ <td class="actions-col" permission="@Policies.CanModifyStoreSettings">
+ <a asp-action="Remove" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Remove-@item.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a> -
+ <a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" text-translate="true">View</a>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ }
+ else
+ {
+ <p class="text-secondary" text-translate="true">
+ There are no forms yet.
+ </p>
+ }
+ </div>
+</div>
+
+<partial name="_Confirm" model="@(new ConfirmModel("Delete form", "This form will be removed from this store.", StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
diff --git a/BTCPayServer/Plugins/Forms/Views/Modify.cshtml b/BTCPayServer/Plugins/Forms/Views/Modify.cshtml
new file mode 100644
index 0000000..3a87400
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/Views/Modify.cshtml
@@ -0,0 +1,295 @@
+@using BTCPayServer.Forms
+@inject BTCPayServer.Security.ContentSecurityPolicies Csp
+@model BTCPayServer.Forms.ModifyForm
+@{
+ Csp.UnsafeEval();
+ var storeId = Context.GetCurrentStoreId();
+ var formId = Context.GetRouteValue("id");
+ var isNew = formId is null;
+ ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Forms), isNew ? StringLocalizer["Create Form"] : StringLocalizer["Edit Form"]).SetCategory(WellKnownCategories.Store));
+ Layout = "_Layout";
+}
+
+@section PageHeadContent {
+ <link href="~/main/editor.css" rel="stylesheet" asp-append-version="true" />
+}
+
+@section PageFootContent {
+ <datalist id="special-field-names">
+ <option text-translate="true" value="invoice_amount">Determine the generated invoice amount</option>
+ <option text-translate="true" value="invoice_currency">Determine the generated invoice currency</option>
+ <option text-translate="true" value="invoice_amount_adjustment">Adjusts the generated invoice amount — use as a prefix to have multiple adjustment fields</option>
+ <option text-translate="true" value="invoice_amount_multiply_adjustment">Adjusts the generated invoice amount by multiplying with this value — use as a prefix to have multiple adjustment fields</option>
+ </datalist>
+ <template id="form-template-email">
+ @FormDataService.StaticFormEmail
+ </template>
+ <template id="form-template-address">
+ @FormDataService.StaticFormAddress
+ </template>
+ <template id="field-editor">
+ <div class="field" v-if="field">
+ <div class="form-group">
+ <label for="field-editor-field-type" class="form-label" data-required text-translate="true">Type</label>
+ <select id="field-editor-field-type" class="form-select" required v-model="field.type">
+ <option v-for="option in fieldTypeOptions" :key="option" :value="option" v-text="option.charAt(0).toUpperCase() + option.slice(1)"></option>
+ </select>
+ </div>
+ <div class="form-group">
+ <label for="field-editor-field-label" class="form-label" data-required text-translate="true">Label</label>
+ <input id="field-editor-field-label" class="form-control" required v-model="field.label" />
+ </div>
+ <div class="form-group">
+ <label for="field-editor-field-name" class="form-label" data-required text-translate="true">Name</label>
+ <input id="field-editor-field-name" class="form-control" list="special-field-names" required v-model="field.name" />
+ <div text-translate="true" class="form-text">The name of the field in the invoice's metadata.</div>
+ <div text-translate="true" class="form-text text-info" v-if="field.name === 'invoice_currency'">The configured name means the value of this field will determine the invoice currency for public forms.</div>
+ <div text-translate="true" class="form-text text-info" v-if="field.name === 'invoice_amount'">The configured name means the value of this field will determine the invoice amount for public forms.</div>
+ <div text-translate="true" class="form-text text-info" v-if="field.name && field.name.startsWith('invoice_amount_adjustment')">The configured name means the value of this field will adjust the invoice amount for public forms and the point of sale app.</div>
+ <div text-translate="true" class="form-text text-info" v-if="field.name && field.name.startsWith('invoice_amount_multiply_adjustment')">The configured name means the value of this field will adjust the invoice amount by multiplying it for public forms and the point of sale app.</div>
+ </div>
+ <div class="form-group" v-if="field.type === 'select'">
+ <h5 class="mt-2" text-translate="true">Options</h5>
+ <div class="options" v-sortable="{ handle: '.drag', onUpdate: sortOptions }">
+ <div v-for="(option, index) in field.options" :key="option.value" class="d-flex align-items-start gap-2 pt-3">
+ <button type="button" class="btn b-0 control drag">
+ <vc:icon symbol="drag" />
+ </button>
+ <div class="field flex-grow-1">
+ <label :for="`field-option-value-${index}`" class="form-label" text-translate="true">Value</label>
+ <input :id="`field-option-value-${index}`" class="form-control" v-model.lazy="option.value" />
+ </div>
+ <div class="field flex-grow-1">
+ <label :for="`field-option-text-${index}`" class="form-label" text-translate="true">Text</label>
+ <input :id="`field-option-text-${index}`" class="form-control" v-model="option.text" />
+ </div>
+ <button type="button" class="btn b-0 control remove" v-on:click="removeOption($event, index)">
+ <vc:icon symbol="remove" />
+ </button>
+ </div>
+ </div>
+ <button type="button" class="btn btn-link px-1 py-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="addOption($event)">
+ <vc:icon symbol="actions-add" />
+ <span text-translate="true">Add Option</span>
+ </button>
+ </div>
+ <div class="form-group" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
+ <label for="field-editor-field-value" class="form-label" text-translate="true">Default Value</label>
+ <input id="field-editor-field-value" class="form-control" v-model="field.value" />
+ </div>
+ <div class="form-group" v-if="field.type === 'mirror'">
+ <label for="field-editor-field-mirror" class="form-label" text-translate="true">Field to mirror</label>
+ <select id="field-editor-field-mirror" class="form-select" v-model="field.value">
+ <option v-for="option in $root.allFields" v-if="option.name && option.name !== field.name" :key="option.name" :value="option.name" :selected="option.name === field.value" v-text="option.label || option.name"></option>
+ </select>
+ <div class="form-text" text-translate="true">The chosen field's selected value will be copied to this field upon submission.</div>
+ </div>
+ <div class="form-group" v-if="field.type === 'mirror'">
+ <h5 class="mt-2" text-translate="true">Value Mapper</h5>
+ <div class="form-text" text-translate="true">The values being mirrored from another field will be mapped to another value if configured.</div>
+ <div class="options">
+ <div v-if="field.valuemap" v-for="(v, k, index) in field.valuemap" :key="k" class="d-flex align-items-start gap-2 pt-3">
+ <div class="field flex-grow-1">
+ <label :for="`field-valuemap-value-${index}`" class="form-label" text-translate="true">Original Value</label>
+ <select v-if="mirroredField && mirroredField.type === 'select'" :id="`field-valuemap-value-${index}`" class="form-select" v-on:change="updateValueMap(k, $event.target.value, v)">
+ <option v-for="option in mirroredField.options" v-if="option.text && option.value" :key="option.value" :value="option.value" :selected="k === option.value" v-text="`${option.value} (${option.text})`"></option>
+ </select>
+ <input v-else :id="`field-valuemap-value-${index}`" class="form-control" placeholder="@StringLocalizer["Value to match"]" :value="k" v-on:change="updateValueMap(k, $event.target.value, v)" />
+ </div>
+ <div class="field flex-grow-1">
+ <label :for="`field-valuemap-mapped-${index}`" class="form-label" text-translate="true">Mapped Value</label>
+ <input :id="`field-valuemap-mapped-${index}`" class="form-control" placeholder="@StringLocalizer["Value to set"]" :value="v" v-on:change="updateValueMap(k, k, $event.target.value)" />
+ </div>
+ <button type="button" class="btn b-0 control remove" v-on:click="removeValueMap($event, k)">
+ <vc:icon symbol="remove" />
+ </button>
+ </div>
+ </div>
+ <button type="button" class="btn btn-link px-1 py-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="addValueMap($event)">
+ <vc:icon symbol="actions-add" />
+ <span text-translate="true">Add mapped value</span>
+ </button>
+ </div>
+ <div class="form-group" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
+ <label for="field-editor-field-helpText" class="form-label" text-translate="true">Helper Text</label>
+ <input id="field-editor-field-helpText" class="form-control" v-model="field.helpText" />
+ <div class="form-text" text-translate="true">Additional text to provide an explanation for the field</div>
+ </div>
+ <div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
+ <input id="field-editor-field-required" type="checkbox" class="form-check-input" v-model="field.required" />
+ <label for="field-editor-field-required" class="form-check-label" text-translate="true">Required Field</label>
+ </div>
+ <div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'select' && field.type !== 'mirror'">
+ <input id="field-editor-field-constant" type="checkbox" class="form-check-input" v-model="field.constant" />
+ <label for="field-editor-field-constant" class="form-check-label" text-translate="true">Constant</label>
+ <div class="form-text" text-translate="true">The user will not be able to change the field's value</div>
+ </div>
+ </div>
+ <div v-else text-translate="true">Select a field to edit</div>
+ </template>
+ <template id="fields-editor">
+ <div>
+ <div class="fields list-group" :class="{ 'list-group-flush': path.length }" :data-path="path.join(',')" v-sortable="{ handle: '.drag', onUpdate (event) { const { path } = this.el.dataset; $emit('sort-fields', event, (path.indexOf(',') !== -1 ? path.split(',') : [])) } }">
+ <div v-for="(field, index) in fields" :key="field.name" class="d-flex align-items-start gap-2 list-group-item" :class="{ active: field === selectedField }" v-on:click.stop="$emit('select-field', $event, path, index)">
+ <button type="button" class="btn b-0 control drag" :disabled="fields.length === 1">
+ <vc:icon symbol="actions-drag" />
+ </button>
+ <div class="field flex-grow-1">
+ <component :is="getFieldComponent(field.type)" v-bind="field" :path="path.concat(field.name)" :selected-field="selectedField" v-on="$listeners" />
+ </div>
+ <button type="button" class="btn b-0 control remove" v-on:click="$emit('remove-field', $event, path, index)">
+ <vc:icon symbol="actions-remove" />
+ </button>
+ </div>
+ </div>
+ <button type="button" class="btn btn-link py-0 px-2 mt-2 mb-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="$emit('add-field', $event, path)">
+ <vc:icon symbol="actions-add" />
+ <span text-translate="true">Add Form Field</span>
+ </button>
+ </div>
+ </template>
+ <template id="field-type-input">
+ <div class="form-check mb-0" v-if="type === 'checkbox'">
+ <input class="form-check-input" :id="name" :name="name" :type="type" v-model="value" />
+ <label class="form-check-label" :for="name" :data-required="required" v-sanitize="label"></label>
+ <div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-sanitize="helpText"></div>
+ </div>
+ <div class="form-groupcheck mb-0" v-else>
+ <label class="form-label" :for="name" :data-required="required" v-sanitize="label"></label>
+ <input class="form-control" :id="name" :name="name" :type="type" v-model="value" />
+ <div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-sanitize="helpText"></div>
+ </div>
+ </template>
+ <template id="field-type-textarea">
+ <div class="form-group mb-0">
+ <label class="form-label" :for="name" :data-required="required" v-sanitize="label"></label>
+ <textarea class="form-control" :id="name" :name="name" v-model="value"></textarea>
+ <div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-sanitize="helpText"></div>
+ </div>
+ </template>
+ <template id="field-type-select">
+ <div class="form-group mb-0">
+ <label class="form-label" :for="name" :data-required="required" v-sanitize="label"></label>
+ <select class="form-select" :id="name" :name="name">
+ <option v-for="option in options" :key="option.value" :value="option.value" :selected="option.value === value" v-text="option.text"></option>
+ </select>
+ <div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-sanitize="helpText"></div>
+ </div>
+ </template>
+ <template id="field-type-mirror">
+ <div class="form-group mb-0">
+ <label class="form-label" v-text="label" v-if="label"></label>
+ <div class="form-text"><span text-translate="true">Mirror of</span> {{value}}</div>
+ </div>
+ </template>
+ <template id="field-type-fieldset">
+ <fieldset>
+ <legend class="h5 mt-1 mb-2" v-text="label"></legend>
+ <fields-editor :path="path" :fields="fields" :selected-field="selectedField" v-on="$listeners" class="nested-fields" />
+ </fieldset>
+ </template>
+ <script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
+ <script src="~/vendor/vue-sanitize-directive/vue-sanitize-directive.umd.min.js" asp-append-version="true"></script>
+ <script src="~/vendor/vue-sortable/sortable.min.js" asp-append-version="true"></script>
+ <script src="~/vendor/vue-sortable/vue-sortable.js" asp-append-version="true"></script>
+ <script src="~/js/form-editor.js" asp-append-version="true"></script>
+ <partial name="_ValidationScriptsPartial" />
+}
+
+<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
+ <div class="sticky-header">
+ <nav aria-label="breadcrumb">
+ <ol class="breadcrumb">
+ <li class="breadcrumb-item">
+ <a asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@storeId" text-translate="true">Forms</a>
+ </li>
+ <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
+ </ol>
+ <h2>
+ @ViewData["Title"]
+ <a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
+ <vc:icon symbol="info" />
+ </a>
+ </h2>
+ </nav>
+ <div>
+ <button id="page-primary" type="submit" class="btn btn-primary order-sm-1" text-translate="true">Save</button>
+ @if (!isNew)
+ {
+ <a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm" text-translate="true">View</a>
+ }
+ </div>
+ </div>
+ <partial name="_StatusMessage" />
+ <div class="row mb-4">
+ <div class="col-12">
+ @if (!ViewContext.ModelState.IsValid)
+ {
+ <div asp-validation-summary="All"></div>
+ }
+ <div class="form-group" style="max-width: 27rem;">
+ <label asp-for="Name" class="form-label" data-required></label>
+ <input asp-for="Name" class="form-control" required />
+ <span asp-validation-for="Name" class="text-danger"></span>
+ </div>
+ <div class="d-flex align-items-center mb-4 gap-3">
+ <input asp-for="Public" type="checkbox" class="btcpay-toggle" />
+ <div>
+ <label asp-for="Public"></label>
+ <div class="form-text" text-translate="true">Standalone mode, which can be used to generate invoices independent of payment requests or apps.</div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div id="FormEditor" class="editor col-xxl-constrain">
+ <div class="d-flex flex-wrap align-items-end justify-content-between gap-3 mb-3">
+ <ul class="nav nav-pills gap-4" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button class="nav-link active" id="EditorTabButton" data-bs-toggle="pill" data-bs-target="#EditorTabPane" type="button" role="tab" aria-controls="EditorTabPane" aria-selected="true" text-translate="true">Editor</button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="CodeTabButton" data-bs-toggle="pill" data-bs-target="#CodeTabPane" type="button" role="tab" aria-controls="CodeTabPane" aria-selected="false" text-translate="true">Code</button>
+ </li>
+ </ul>
+ <div class="d-flex align-items-center gap-2 mb-1">
+ <span class="fw-semibold" text-translate="true">Templates</span>
+ <button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('email')" id="ApplyEmailTemplate" text-translate="true">Email</button>
+ <button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('address')" id="ApplyAddressTemplate" text-translate="true">Address</button>
+ </div>
+ </div>
+ <div class="tab-content">
+ <div class="tab-pane fade show active" id="EditorTabPane" role="tabpanel" aria-labelledby="EditorTabButton" tabindex="0">
+ <div class="row align-items-start">
+ <div class="col-12">
+ <fields-editor :path="[]"
+ :fields="fields"
+ :selected-field="selectedField"
+ v-on:add-field="addField"
+ v-on:sort-fields="sortFields"
+ v-on:select-field="selectField"
+ v-on:remove-field="removeField"
+ :class="{ 'pt-2': (!fields || fields.length === 0) }"
+ class="bg-tile pb-2 rounded" />
+ </div>
+ <div class="offcanvas offcanvas-end" tabindex="-1" ref="editorOffcanvas">
+ <div class="offcanvas-header justify-content-between p-3">
+ <h5 class="offcanvas-title" text-translate="true">Edit Field</h5>
+ <button type="button" class="btn-close" aria-label="@StringLocalizer["Close"]" v-on:click="hideOffcanvas">
+ <vc:icon symbol="close" />
+ </button>
+ </div>
+ <div class="offcanvas-body p-3">
+ <field-editor :field="selectedField" class="bg-tile w-100 rounded" />
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="tab-pane fade" id="CodeTabPane" role="tabpanel" aria-labelledby="CodeTabButton" tabindex="0">
+ <label asp-for="FormConfig" class="form-label" data-required text-translate="true">Form JSON</label>
+ <textarea asp-for="FormConfig" class="form-control font-monospace" style="font-size:.85rem" rows="21" cols="21" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
+ <span asp-validation-for="FormConfig" class="text-danger"></span>
+ </div>
+ </div>
+ </div>
+</form>
diff --git a/BTCPayServer/Plugins/Forms/Views/NavExtension.cshtml b/BTCPayServer/Plugins/Forms/Views/NavExtension.cshtml
new file mode 100644
index 0000000..5f460f5
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/Views/NavExtension.cshtml
@@ -0,0 +1,6 @@
+@using BTCPayServer.Client
+@model BTCPayServer.Components.MainNav.MainNavViewModel
+
+<li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
+ <a layout-menu-item="@(nameof(StoreNavPages.Forms))" asp-area="@FormsPlugin.Area" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@Model.Store.Id" text-translate="true">Forms</a>
+</li>
diff --git a/BTCPayServer/Plugins/Forms/Views/View.cshtml b/BTCPayServer/Plugins/Forms/Views/View.cshtml
new file mode 100644
index 0000000..b2ae9c3
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/Views/View.cshtml
@@ -0,0 +1,58 @@
+@inject BTCPayServer.Services.BTCPayServerEnvironment Env
+@model BTCPayServer.Forms.Models.FormViewModel
+@{
+ Layout = null;
+ ViewData["Title"] = Model.FormName;
+ ViewData["StoreBranding"] = Model.StoreBranding;
+}
+<!DOCTYPE html>
+<html lang="en" @(Env.IsDeveloping ? " data-devenv" : "")>
+<head>
+ <partial name="LayoutHead" />
+ <meta name="robots" content="noindex,nofollow">
+ <style>#FormView { --wrap-max-width: 576px; }</style>
+</head>
+<body class="min-vh-100">
+ <div id="FormView" class="public-page-wrap">
+ <partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
+ @if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.StoreBranding.LogoUrl))
+ {
+ <partial name="_StoreHeader" model="(Model.StoreName, Model.StoreBranding)" />
+ }
+ else
+ {
+ <h1 class="h3 text-center mt-3">@ViewData["Title"]</h1>
+ }
+ <main class="flex-grow-1">
+ @if (!ViewContext.ModelState.IsValid)
+ {
+ <div asp-validation-summary="ModelOnly"></div>
+ }
+ <partial name="_FormTopMessages" model="@Model.Form" />
+ <div class="d-flex flex-column justify-content-center gap-4">
+ <div class="tile">
+ @if (string.IsNullOrEmpty(Model.AspAction))
+ {
+ <form method="post" novalidate="novalidate">
+ <partial name="_FormWrap" model="@Model" />
+ </form>
+ }
+ else
+ {
+ <form method="post" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters">
+ <partial name="_FormWrap" model="@Model" />
+ </form>
+ }
+ </div>
+ </div>
+ </main>
+ <footer class="store-footer">
+ <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
+ <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
+ </a>
+ </footer>
+ </div>
+ <partial name="LayoutFoot" />
+ <partial name="_ValidationScriptsPartial"/>
+</body>
+</html>
diff --git a/BTCPayServer/Plugins/Forms/Views/_ViewImports.cshtml b/BTCPayServer/Plugins/Forms/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..7439efa
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/Views/_ViewImports.cshtml
@@ -0,0 +1,4 @@
+@using BTCPayServer.Abstractions.Extensions
+@using BTCPayServer.Views.Stores
+@using BTCPayServer.Models.StoreViewModels
+@using BTCPayServer.Plugins.Forms
diff --git a/BTCPayServer/Plugins/Forms/Views/_ViewStart.cshtml b/BTCPayServer/Plugins/Forms/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..f1d4cfa
--- /dev/null
+++ b/BTCPayServer/Plugins/Forms/Views/_ViewStart.cshtml
@@ -0,0 +1,7 @@
+@using BTCPayServer.Abstractions.Extensions
+@using BTCPayServer.Views
+@using BTCPayServer.Views.Stores
+
+@{
+ ViewData.SetActiveCategory(typeof(StoreNavPages));
+}
diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
index b87652b..02759ca 100644
--- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
@@ -485,7 +485,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
vm.RouteParameters.Add("viewType", viewType.Value.ToString());
}
- return View("Views/UIForms/View", vm);
+ return View("/Plugins/Forms/Views/View.cshtml", vm);
}
[HttpPost("/apps/{appId}/pos/form/submit/{viewType?}")]
@@ -535,7 +535,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
viewModel.Form = form;
viewModel.FormParameters = formParameters;
viewModel.StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob);
- return View("Views/UIForms/View", viewModel);
+ return View("/Plugins/Forms/Views/View.cshtml", viewModel);
}
[Authorize(Policy = Policies.CanViewInvoices, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
diff --git a/BTCPayServer/Views/UIForms/FormsList.cshtml b/BTCPayServer/Views/UIForms/FormsList.cshtml
deleted file mode 100644
index a05f9e3..0000000
--- a/BTCPayServer/Views/UIForms/FormsList.cshtml
+++ /dev/null
@@ -1,60 +0,0 @@
-@using BTCPayServer.Client
-@model List<BTCPayServer.Data.FormData>
-@{
- ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Forms), StringLocalizer["Forms"]).SetCategory(WellKnownCategories.Store));
- var storeId = Context.GetCurrentStoreId();
-}
-
-<div class="sticky-header">
- <h2>
- <span>@ViewData["Title"]</span>
- <a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
- <vc:icon symbol="info" />
- </a>
- </h2>
- <a id="page-primary" asp-action="Create" asp-route-storeId="@storeId" class="btn btn-primary mt-3 mt-sm-0" role="button" permission="@Policies.CanModifyStoreSettings" text-translate="true">
- Create Form
- </a>
-</div>
-<partial name="_StatusMessage" />
-
-<div class="row">
- <div class="col-xxl-constrain col-xl-10">
- @if (Model.Any())
- {
- <div class="table-responsive-md mt-0">
- <table class="table table-hover">
- <thead>
- <tr>
- <th text-translate="true">Name</th>
- <th text-translate="true" class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
- </tr>
- </thead>
- <tbody>
- @foreach (var item in Model)
- {
- <tr>
- <td>
- <a asp-action="Modify" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Edit-@item.Name" permission="@Policies.CanModifyStoreSettings">@item.Name</a>
- <a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" not-permission="@Policies.CanModifyStoreSettings">@item.Name</a>
- </td>
- <td class="actions-col" permission="@Policies.CanModifyStoreSettings">
- <a asp-action="Remove" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Remove-@item.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a> -
- <a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" text-translate="true">View</a>
- </td>
- </tr>
- }
- </tbody>
- </table>
- </div>
- }
- else
- {
- <p class="text-secondary" text-translate="true">
- There are no forms yet.
- </p>
- }
- </div>
-</div>
-
-<partial name="_Confirm" model="@(new ConfirmModel("Delete form", "This form will be removed from this store.", StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
diff --git a/BTCPayServer/Views/UIForms/Modify.cshtml b/BTCPayServer/Views/UIForms/Modify.cshtml
deleted file mode 100644
index 9fd466c..0000000
--- a/BTCPayServer/Views/UIForms/Modify.cshtml
+++ /dev/null
@@ -1,294 +0,0 @@
-@using BTCPayServer.Forms
-@inject BTCPayServer.Security.ContentSecurityPolicies Csp
-@model BTCPayServer.Forms.ModifyForm
-@{
- Csp.UnsafeEval();
- var storeId = Context.GetCurrentStoreId();
- var formId = Context.GetRouteValue("id");
- var isNew = formId is null;
- ViewData.SetLayoutModel(new LayoutModel(nameof(StoreNavPages.Forms), isNew ? StringLocalizer["Create Form"] : StringLocalizer["Edit Form"]).SetCategory(WellKnownCategories.Store));
-}
-
-@section PageHeadContent {
- <link href="~/main/editor.css" rel="stylesheet" asp-append-version="true" />
-}
-
-@section PageFootContent {
- <datalist id="special-field-names">
- <option text-translate="true" value="invoice_amount">Determine the generated invoice amount</option>
- <option text-translate="true" value="invoice_currency">Determine the generated invoice currency</option>
- <option text-translate="true" value="invoice_amount_adjustment">Adjusts the generated invoice amount — use as a prefix to have multiple adjustment fields</option>
- <option text-translate="true" value="invoice_amount_multiply_adjustment">Adjusts the generated invoice amount by multiplying with this value — use as a prefix to have multiple adjustment fields</option>
- </datalist>
- <template id="form-template-email">
- @FormDataService.StaticFormEmail
- </template>
- <template id="form-template-address">
- @FormDataService.StaticFormAddress
- </template>
- <template id="field-editor">
- <div class="field" v-if="field">
- <div class="form-group">
- <label for="field-editor-field-type" class="form-label" data-required text-translate="true">Type</label>
- <select id="field-editor-field-type" class="form-select" required v-model="field.type">
- <option v-for="option in fieldTypeOptions" :key="option" :value="option" v-text="option.charAt(0).toUpperCase() + option.slice(1)"></option>
- </select>
- </div>
- <div class="form-group">
- <label for="field-editor-field-label" class="form-label" data-required text-translate="true">Label</label>
- <input id="field-editor-field-label" class="form-control" required v-model="field.label" />
- </div>
- <div class="form-group">
- <label for="field-editor-field-name" class="form-label" data-required text-translate="true">Name</label>
- <input id="field-editor-field-name" class="form-control" list="special-field-names" required v-model="field.name" />
- <div text-translate="true" class="form-text">The name of the field in the invoice's metadata.</div>
- <div text-translate="true" class="form-text text-info" v-if="field.name === 'invoice_currency'">The configured name means the value of this field will determine the invoice currency for public forms.</div>
- <div text-translate="true" class="form-text text-info" v-if="field.name === 'invoice_amount'">The configured name means the value of this field will determine the invoice amount for public forms.</div>
- <div text-translate="true" class="form-text text-info" v-if="field.name && field.name.startsWith('invoice_amount_adjustment')">The configured name means the value of this field will adjust the invoice amount for public forms and the point of sale app.</div>
- <div text-translate="true" class="form-text text-info" v-if="field.name && field.name.startsWith('invoice_amount_multiply_adjustment')">The configured name means the value of this field will adjust the invoice amount by multiplying it for public forms and the point of sale app.</div>
- </div>
- <div class="form-group" v-if="field.type === 'select'">
- <h5 class="mt-2" text-translate="true">Options</h5>
- <div class="options" v-sortable="{ handle: '.drag', onUpdate: sortOptions }">
- <div v-for="(option, index) in field.options" :key="option.value" class="d-flex align-items-start gap-2 pt-3">
- <button type="button" class="btn b-0 control drag">
- <vc:icon symbol="drag" />
- </button>
- <div class="field flex-grow-1">
- <label :for="`field-option-value-${index}`" class="form-label" text-translate="true">Value</label>
- <input :id="`field-option-value-${index}`" class="form-control" v-model.lazy="option.value" />
- </div>
- <div class="field flex-grow-1">
- <label :for="`field-option-text-${index}`" class="form-label" text-translate="true">Text</label>
- <input :id="`field-option-text-${index}`" class="form-control" v-model="option.text" />
- </div>
- <button type="button" class="btn b-0 control remove" v-on:click="removeOption($event, index)">
- <vc:icon symbol="remove" />
- </button>
- </div>
- </div>
- <button type="button" class="btn btn-link px-1 py-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="addOption($event)">
- <vc:icon symbol="actions-add" />
- <span text-translate="true">Add Option</span>
- </button>
- </div>
- <div class="form-group" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
- <label for="field-editor-field-value" class="form-label" text-translate="true">Default Value</label>
- <input id="field-editor-field-value" class="form-control" v-model="field.value" />
- </div>
- <div class="form-group" v-if="field.type === 'mirror'">
- <label for="field-editor-field-mirror" class="form-label" text-translate="true">Field to mirror</label>
- <select id="field-editor-field-mirror" class="form-select" v-model="field.value">
- <option v-for="option in $root.allFields" v-if="option.name && option.name !== field.name" :key="option.name" :value="option.name" :selected="option.name === field.value" v-text="option.label || option.name"></option>
- </select>
- <div class="form-text" text-translate="true">The chosen field's selected value will be copied to this field upon submission.</div>
- </div>
- <div class="form-group" v-if="field.type === 'mirror'">
- <h5 class="mt-2" text-translate="true">Value Mapper</h5>
- <div class="form-text" text-translate="true">The values being mirrored from another field will be mapped to another value if configured.</div>
- <div class="options">
- <div v-if="field.valuemap" v-for="(v, k, index) in field.valuemap" :key="k" class="d-flex align-items-start gap-2 pt-3">
- <div class="field flex-grow-1">
- <label :for="`field-valuemap-value-${index}`" class="form-label" text-translate="true">Original Value</label>
- <select v-if="mirroredField && mirroredField.type === 'select'" :id="`field-valuemap-value-${index}`" class="form-select" v-on:change="updateValueMap(k, $event.target.value, v)">
- <option v-for="option in mirroredField.options" v-if="option.text && option.value" :key="option.value" :value="option.value" :selected="k === option.value" v-text="`${option.value} (${option.text})`"></option>
- </select>
- <input v-else :id="`field-valuemap-value-${index}`" class="form-control" placeholder="@StringLocalizer["Value to match"]" :value="k" v-on:change="updateValueMap(k, $event.target.value, v)" />
- </div>
- <div class="field flex-grow-1">
- <label :for="`field-valuemap-mapped-${index}`" class="form-label" text-translate="true">Mapped Value</label>
- <input :id="`field-valuemap-mapped-${index}`" class="form-control" placeholder="@StringLocalizer["Value to set"]" :value="v" v-on:change="updateValueMap(k, k, $event.target.value)" />
- </div>
- <button type="button" class="btn b-0 control remove" v-on:click="removeValueMap($event, k)">
- <vc:icon symbol="remove" />
- </button>
- </div>
- </div>
- <button type="button" class="btn btn-link px-1 py-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="addValueMap($event)">
- <vc:icon symbol="actions-add" />
- <span text-translate="true">Add mapped value</span>
- </button>
- </div>
- <div class="form-group" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
- <label for="field-editor-field-helpText" class="form-label" text-translate="true">Helper Text</label>
- <input id="field-editor-field-helpText" class="form-control" v-model="field.helpText" />
- <div class="form-text" text-translate="true">Additional text to provide an explanation for the field</div>
- </div>
- <div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
- <input id="field-editor-field-required" type="checkbox" class="form-check-input" v-model="field.required" />
- <label for="field-editor-field-required" class="form-check-label" text-translate="true">Required Field</label>
- </div>
- <div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'select' && field.type !== 'mirror'">
- <input id="field-editor-field-constant" type="checkbox" class="form-check-input" v-model="field.constant" />
- <label for="field-editor-field-constant" class="form-check-label" text-translate="true">Constant</label>
- <div class="form-text" text-translate="true">The user will not be able to change the field's value</div>
- </div>
- </div>
- <div v-else text-translate="true">Select a field to edit</div>
- </template>
- <template id="fields-editor">
- <div>
- <div class="fields list-group" :class="{ 'list-group-flush': path.length }" :data-path="path.join(',')" v-sortable="{ handle: '.drag', onUpdate (event) { const { path } = this.el.dataset; $emit('sort-fields', event, (path.indexOf(',') !== -1 ? path.split(',') : [])) } }">
- <div v-for="(field, index) in fields" :key="field.name" class="d-flex align-items-start gap-2 list-group-item" :class="{ active: field === selectedField }" v-on:click.stop="$emit('select-field', $event, path, index)">
- <button type="button" class="btn b-0 control drag" :disabled="fields.length === 1">
- <vc:icon symbol="actions-drag" />
- </button>
- <div class="field flex-grow-1">
- <component :is="getFieldComponent(field.type)" v-bind="field" :path="path.concat(field.name)" :selected-field="selectedField" v-on="$listeners" />
- </div>
- <button type="button" class="btn b-0 control remove" v-on:click="$emit('remove-field', $event, path, index)">
- <vc:icon symbol="actions-remove" />
- </button>
- </div>
- </div>
- <button type="button" class="btn btn-link py-0 px-2 mt-2 mb-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="$emit('add-field', $event, path)">
- <vc:icon symbol="actions-add" />
- <span text-translate="true">Add Form Field</span>
- </button>
- </div>
- </template>
- <template id="field-type-input">
- <div class="form-check mb-0" v-if="type === 'checkbox'">
- <input class="form-check-input" :id="name" :name="name" :type="type" v-model="value" />
- <label class="form-check-label" :for="name" :data-required="required" v-sanitize="label"></label>
- <div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-sanitize="helpText"></div>
- </div>
- <div class="form-groupcheck mb-0" v-else>
- <label class="form-label" :for="name" :data-required="required" v-sanitize="label"></label>
- <input class="form-control" :id="name" :name="name" :type="type" v-model="value" />
- <div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-sanitize="helpText"></div>
- </div>
- </template>
- <template id="field-type-textarea">
- <div class="form-group mb-0">
- <label class="form-label" :for="name" :data-required="required" v-sanitize="label"></label>
- <textarea class="form-control" :id="name" :name="name" v-model="value"></textarea>
- <div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-sanitize="helpText"></div>
- </div>
- </template>
- <template id="field-type-select">
- <div class="form-group mb-0">
- <label class="form-label" :for="name" :data-required="required" v-sanitize="label"></label>
- <select class="form-select" :id="name" :name="name">
- <option v-for="option in options" :key="option.value" :value="option.value" :selected="option.value === value" v-text="option.text"></option>
- </select>
- <div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-sanitize="helpText"></div>
- </div>
- </template>
- <template id="field-type-mirror">
- <div class="form-group mb-0">
- <label class="form-label" v-text="label" v-if="label"></label>
- <div class="form-text"><span text-translate="true">Mirror of</span> {{value}}</div>
- </div>
- </template>
- <template id="field-type-fieldset">
- <fieldset>
- <legend class="h5 mt-1 mb-2" v-text="label"></legend>
- <fields-editor :path="path" :fields="fields" :selected-field="selectedField" v-on="$listeners" class="nested-fields" />
- </fieldset>
- </template>
- <script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
- <script src="~/vendor/vue-sanitize-directive/vue-sanitize-directive.umd.min.js" asp-append-version="true"></script>
- <script src="~/vendor/vue-sortable/sortable.min.js" asp-append-version="true"></script>
- <script src="~/vendor/vue-sortable/vue-sortable.js" asp-append-version="true"></script>
- <script src="~/js/form-editor.js" asp-append-version="true"></script>
- <partial name="_ValidationScriptsPartial" />
-}
-
-<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
- <div class="sticky-header">
- <nav aria-label="breadcrumb">
- <ol class="breadcrumb">
- <li class="breadcrumb-item">
- <a asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@storeId" text-translate="true">Forms</a>
- </li>
- <li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
- </ol>
- <h2>
- @ViewData["Title"]
- <a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
- <vc:icon symbol="info" />
- </a>
- </h2>
- </nav>
- <div>
- <button id="page-primary" type="submit" class="btn btn-primary order-sm-1" text-translate="true">Save</button>
- @if (!isNew)
- {
- <a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm" text-translate="true">View</a>
- }
- </div>
- </div>
- <partial name="_StatusMessage" />
- <div class="row mb-4">
- <div class="col-12">
- @if (!ViewContext.ModelState.IsValid)
- {
- <div asp-validation-summary="All"></div>
- }
- <div class="form-group" style="max-width: 27rem;">
- <label asp-for="Name" class="form-label" data-required></label>
- <input asp-for="Name" class="form-control" required />
- <span asp-validation-for="Name" class="text-danger"></span>
- </div>
- <div class="d-flex align-items-center mb-4 gap-3">
- <input asp-for="Public" type="checkbox" class="btcpay-toggle" />
- <div>
- <label asp-for="Public"></label>
- <div class="form-text" text-translate="true">Standalone mode, which can be used to generate invoices independent of payment requests or apps.</div>
- </div>
- </div>
- </div>
- </div>
-
- <div id="FormEditor" class="editor col-xxl-constrain">
- <div class="d-flex flex-wrap align-items-end justify-content-between gap-3 mb-3">
- <ul class="nav nav-pills gap-4" role="tablist">
- <li class="nav-item" role="presentation">
- <button class="nav-link active" id="EditorTabButton" data-bs-toggle="pill" data-bs-target="#EditorTabPane" type="button" role="tab" aria-controls="EditorTabPane" aria-selected="true" text-translate="true">Editor</button>
- </li>
- <li class="nav-item" role="presentation">
- <button class="nav-link" id="CodeTabButton" data-bs-toggle="pill" data-bs-target="#CodeTabPane" type="button" role="tab" aria-controls="CodeTabPane" aria-selected="false" text-translate="true">Code</button>
- </li>
- </ul>
- <div class="d-flex align-items-center gap-2 mb-1">
- <span class="fw-semibold" text-translate="true">Templates</span>
- <button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('email')" id="ApplyEmailTemplate" text-translate="true">Email</button>
- <button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('address')" id="ApplyAddressTemplate" text-translate="true">Address</button>
- </div>
- </div>
- <div class="tab-content">
- <div class="tab-pane fade show active" id="EditorTabPane" role="tabpanel" aria-labelledby="EditorTabButton" tabindex="0">
- <div class="row align-items-start">
- <div class="col-12">
- <fields-editor :path="[]"
- :fields="fields"
- :selected-field="selectedField"
- v-on:add-field="addField"
- v-on:sort-fields="sortFields"
- v-on:select-field="selectField"
- v-on:remove-field="removeField"
- :class="{ 'pt-2': (!fields || fields.length === 0) }"
- class="bg-tile pb-2 rounded" />
- </div>
- <div class="offcanvas offcanvas-end" tabindex="-1" ref="editorOffcanvas">
- <div class="offcanvas-header justify-content-between p-3">
- <h5 class="offcanvas-title" text-translate="true">Edit Field</h5>
- <button type="button" class="btn-close" aria-label="@StringLocalizer["Close"]" v-on:click="hideOffcanvas">
- <vc:icon symbol="close" />
- </button>
- </div>
- <div class="offcanvas-body p-3">
- <field-editor :field="selectedField" class="bg-tile w-100 rounded" />
- </div>
- </div>
- </div>
- </div>
- <div class="tab-pane fade" id="CodeTabPane" role="tabpanel" aria-labelledby="CodeTabButton" tabindex="0">
- <label asp-for="FormConfig" class="form-label" data-required text-translate="true">Form JSON</label>
- <textarea asp-for="FormConfig" class="form-control font-monospace" style="font-size:.85rem" rows="21" cols="21" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
- <span asp-validation-for="FormConfig" class="text-danger"></span>
- </div>
- </div>
- </div>
-</form>
diff --git a/BTCPayServer/Views/UIForms/View.cshtml b/BTCPayServer/Views/UIForms/View.cshtml
deleted file mode 100644
index b2ae9c3..0000000
--- a/BTCPayServer/Views/UIForms/View.cshtml
+++ /dev/null
@@ -1,58 +0,0 @@
-@inject BTCPayServer.Services.BTCPayServerEnvironment Env
-@model BTCPayServer.Forms.Models.FormViewModel
-@{
- Layout = null;
- ViewData["Title"] = Model.FormName;
- ViewData["StoreBranding"] = Model.StoreBranding;
-}
-<!DOCTYPE html>
-<html lang="en" @(Env.IsDeveloping ? " data-devenv" : "")>
-<head>
- <partial name="LayoutHead" />
- <meta name="robots" content="noindex,nofollow">
- <style>#FormView { --wrap-max-width: 576px; }</style>
-</head>
-<body class="min-vh-100">
- <div id="FormView" class="public-page-wrap">
- <partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
- @if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.StoreBranding.LogoUrl))
- {
- <partial name="_StoreHeader" model="(Model.StoreName, Model.StoreBranding)" />
- }
- else
- {
- <h1 class="h3 text-center mt-3">@ViewData["Title"]</h1>
- }
- <main class="flex-grow-1">
- @if (!ViewContext.ModelState.IsValid)
- {
- <div asp-validation-summary="ModelOnly"></div>
- }
- <partial name="_FormTopMessages" model="@Model.Form" />
- <div class="d-flex flex-column justify-content-center gap-4">
- <div class="tile">
- @if (string.IsNullOrEmpty(Model.AspAction))
- {
- <form method="post" novalidate="novalidate">
- <partial name="_FormWrap" model="@Model" />
- </form>
- }
- else
- {
- <form method="post" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters">
- <partial name="_FormWrap" model="@Model" />
- </form>
- }
- </div>
- </div>
- </main>
- <footer class="store-footer">
- <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
- <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
- </a>
- </footer>
- </div>
- <partial name="LayoutFoot" />
- <partial name="_ValidationScriptsPartial"/>
-</body>
-</html>
diff --git a/BTCPayServer/Views/UIForms/_ViewImports.cshtml b/BTCPayServer/Views/UIForms/_ViewImports.cshtml
deleted file mode 100644
index 4a24df5..0000000
--- a/BTCPayServer/Views/UIForms/_ViewImports.cshtml
+++ /dev/null
@@ -1,3 +0,0 @@
-@using BTCPayServer.Abstractions.Extensions
-@using BTCPayServer.Views.Stores
-@using BTCPayServer.Models.StoreViewModels
diff --git a/BTCPayServer/Views/UIForms/_ViewStart.cshtml b/BTCPayServer/Views/UIForms/_ViewStart.cshtml
deleted file mode 100644
index f1d4cfa..0000000
--- a/BTCPayServer/Views/UIForms/_ViewStart.cshtml
+++ /dev/null
@@ -1,7 +0,0 @@
-@using BTCPayServer.Abstractions.Extensions
-@using BTCPayServer.Views
-@using BTCPayServer.Views.Stores
-
-@{
- ViewData.SetActiveCategory(typeof(StoreNavPages));
-}
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.