Generic Forms (#4561)

* Custom Forms

* Update BTCPayServer.Data/Migrations/20230125085242_AddForms.cs

* Cleanups

* Explain public form

* Add store branding

* Add form name to POS form

* add tests

* fix migration

* Minor cleanups

* Code improvements

* Add form validation

Closes #4317.

* Adapt form validation for Bootstrap 5

* update logic for forms

* pr changes

* Minor code cleanup

* Remove unused parameters

* Refactor Form data handling to avoid O(n3) issues

* Rename Hidden to Constant

* Pre-populate FormView from the query string params

* Fix test

---------

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri
2023-02-20 11:35:54 +01:00
committed by GitHub
parent 60f84d5e30
commit bbbaacc350
30 changed files with 1174 additions and 354 deletions

View File

@@ -12,7 +12,7 @@ public class Field
{ {
public static Field Create(string label, string name, string value, bool required, string helpText, string type = "text") public static Field Create(string label, string name, string value, bool required, string helpText, string type = "text")
{ {
return new Field() return new Field
{ {
Label = label, Label = label,
Name = name, Name = name,
@@ -26,14 +26,14 @@ public class Field
// The name of the HTML5 node. Should be used as the key for the posted data. // The name of the HTML5 node. Should be used as the key for the posted data.
public string Name; public string Name;
public bool Hidden; public bool Constant;
// HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options). // HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options).
public string Type; public string Type;
public static Field CreateFieldset() public static Field CreateFieldset()
{ {
return new Field() { Type = "fieldset" }; return new Field { Type = "fieldset" };
} }
// The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors. // The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors.
@@ -52,10 +52,10 @@ public class Field
public string HelpText; public string HelpText;
[JsonExtensionData] public IDictionary<string, JToken> AdditionalData { get; set; } [JsonExtensionData] public IDictionary<string, JToken> AdditionalData { get; set; }
public List<Field> Fields { get; set; } = new(); public List<Field> Fields { get; set; } = new ();
// The field is considered "valid" if there are no validation errors // The field is considered "valid" if there are no validation errors
public List<string> ValidationErrors = new List<string>(); public List<string> ValidationErrors = new ();
public virtual bool IsValid() public virtual bool IsValid()
{ {

View File

@@ -1,9 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Npgsql.Internal.TypeHandlers.GeometricHandlers;
namespace BTCPayServer.Abstractions.Form; namespace BTCPayServer.Abstractions.Form;
@@ -20,6 +22,7 @@ public class Form
return JObject.FromObject(this, CamelCaseSerializerSettings.Serializer).ToString(Newtonsoft.Json.Formatting.Indented); return JObject.FromObject(this, CamelCaseSerializerSettings.Serializer).ToString(Newtonsoft.Json.Formatting.Indented);
} }
#nullable restore #nullable restore
// Messages to be shown at the top of the form indicating user feedback like "Saved successfully" or "Please change X because of Y." or a warning, etc... // Messages to be shown at the top of the form indicating user feedback like "Saved successfully" or "Please change X because of Y." or a warning, etc...
public List<AlertMessage> TopMessages { get; set; } = new(); public List<AlertMessage> TopMessages { get; set; } = new();
@@ -32,126 +35,125 @@ public class Form
return Fields.Select(f => f.IsValid()).All(o => o); return Fields.Select(f => f.IsValid()).All(o => o);
} }
public Field GetFieldByName(string name) public Field GetFieldByFullName(string fullName)
{ {
return GetFieldByName(name, Fields, null); foreach (var f in GetAllFields())
}
private static Field GetFieldByName(string name, List<Field> fields, string prefix)
{ {
prefix ??= string.Empty; if (f.FullName == fullName)
foreach (var field in fields) return f.Field;
{
var currentPrefix = prefix;
if (!string.IsNullOrEmpty(field.Name))
{
currentPrefix = $"{prefix}{field.Name}";
if (currentPrefix.Equals(name, StringComparison.InvariantCultureIgnoreCase))
{
return field;
}
currentPrefix += "_";
}
var subFieldResult = GetFieldByName(name, field.Fields, currentPrefix);
if (subFieldResult is not null)
{
return subFieldResult;
}
} }
return null; return null;
} }
public List<string> GetAllNames() public IEnumerable<(string FullName, List<string> Path, Field Field)> GetAllFields()
{ {
return GetAllNames(Fields); HashSet<string> nameReturned = new HashSet<string>();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{
var fullName = String.Join('_', f.Path);
if (!nameReturned.Add(fullName))
continue;
yield return (fullName, f.Path, f.Field);
}
} }
private static List<string> GetAllNames(List<Field> fields) public bool ValidateFieldNames(out List<string> errors)
{ {
var names = new List<string>(); errors = new List<string>();
HashSet<string> nameReturned = new HashSet<string>();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{
var fullName = String.Join('_', f.Path);
if (!nameReturned.Add(fullName))
{
errors.Add($"Form contains duplicate field names '{fullName}'");
continue;
}
}
return errors.Count == 0;
}
IEnumerable<(List<string> Path, Field Field)> GetAllFieldsCore(List<string> path, List<Field> fields)
{
foreach (var field in fields) foreach (var field in fields)
{ {
string prefix = string.Empty; List<string> thisPath = new List<string>(path.Count + 1);
thisPath.AddRange(path);
if (!string.IsNullOrEmpty(field.Name)) if (!string.IsNullOrEmpty(field.Name))
{ {
names.Add(field.Name); thisPath.Add(field.Name);
prefix = $"{field.Name}_"; yield return (thisPath, field);
} }
if (field.Fields.Any()) foreach (var child in field.Fields)
{ {
names.AddRange(GetAllNames(field.Fields).Select(s => $"{prefix}{s}")); if (field.Constant)
child.Constant = true;
foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields))
{
yield return descendant;
} }
} }
return names;
}
public void ApplyValuesFromOtherForm(Form form)
{
foreach (var fieldset in Fields)
{
foreach (var field in fieldset.Fields)
{
field.Value = form
.GetFieldByName(
$"{(string.IsNullOrEmpty(fieldset.Name) ? string.Empty : fieldset.Name + "_")}{field.Name}")
?.Value;
}
} }
} }
public void ApplyValuesFromForm(IFormCollection form) public void ApplyValuesFromForm(IEnumerable<KeyValuePair<string, StringValues>> form)
{ {
var names = GetAllNames(); var values = form.GroupBy(f => f.Key, f => f.Value).ToDictionary(g => g.Key, g => g.First());
foreach (var name in names) foreach (var f in GetAllFields())
{
var field = GetFieldByName(name);
if (field is null || !form.TryGetValue(name, out var val))
{ {
if (f.Field.Constant || !values.TryGetValue(f.FullName, out var val))
continue; continue;
}
field.Value = val; f.Field.Value = val;
} }
} }
public Dictionary<string, object> GetValues() public void SetValues(JObject values)
{ {
return GetValues(Fields); var fields = GetAllFields().ToDictionary(k => k.FullName, k => k.Field);
SetValues(fields, new List<string>(), values);
} }
private static Dictionary<string, object> GetValues(List<Field> fields) private void SetValues(Dictionary<string, Field> fields, List<string> path, JObject values)
{ {
var result = new Dictionary<string, object>(); foreach (var prop in values.Properties())
foreach (Field field in fields)
{ {
var name = field.Name ?? string.Empty; List<string> propPath = new List<string>(path.Count + 1);
if (field.Fields.Any()) propPath.AddRange(path);
propPath.Add(prop.Name);
if (prop.Value.Type == JTokenType.Object)
{ {
var values = GetValues(fields); SetValues(fields, propPath, (JObject)prop.Value);
values.Remove(string.Empty, out var keylessValue);
result.TryAdd(name, values);
if (keylessValue is not Dictionary<string, object> dict)
continue;
foreach (KeyValuePair<string, object> keyValuePair in dict)
{
result.TryAdd(keyValuePair.Key, keyValuePair.Value);
} }
} else if (prop.Value.Type == JTokenType.String)
else
{ {
result.TryAdd(name, field.Value); var fullname = String.Join('_', propPath);
if (fields.TryGetValue(fullname, out var f) && !f.Constant)
f.Value = prop.Value.Value<string>();
}
} }
} }
return result; public JObject GetValues()
{
var r = new JObject();
foreach (var f in 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] = f.Field.Value;
}
return r;
} }
} }

View File

@@ -75,6 +75,7 @@ namespace BTCPayServer.Data
public DbSet<WebhookData> Webhooks { get; set; } public DbSet<WebhookData> Webhooks { get; set; }
public DbSet<LightningAddressData> LightningAddresses { get; set; } public DbSet<LightningAddressData> LightningAddresses { get; set; }
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; } public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
public DbSet<FormData> Forms { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -128,6 +129,7 @@ namespace BTCPayServer.Data
LightningAddressData.OnModelCreating(builder); LightningAddressData.OnModelCreating(builder);
PayoutProcessorData.OnModelCreating(builder); PayoutProcessorData.OnModelCreating(builder);
//WebhookData.OnModelCreating(builder); //WebhookData.OnModelCreating(builder);
FormData.OnModelCreating(builder, Database);
if (Database.IsSqlite() && !_designTime) if (Database.IsSqlite() && !_designTime)

View File

@@ -1,4 +1,4 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -6,7 +6,26 @@ namespace BTCPayServer.Data.Data;
public class FormData public class FormData
{ {
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public string Id { get; set; } public string Id { get; set; }
public string Name { get; set; } public string Name { get; set; }
public string StoreId { get; set; }
public StoreData Store { get; set; }
public string Config { get; set; } public string Config { get; set; }
public bool Public { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
builder.Entity<FormData>()
.HasOne(o => o.Store)
.WithMany(o => o.Forms).OnDelete(DeleteBehavior.Cascade);
builder.Entity<FormData>().HasIndex(o => o.StoreId);
if (databaseFacade.IsNpgsql())
{
builder.Entity<FormData>()
.Property(o => o.Config)
.HasColumnType("JSONB");
}
}
} }

View File

@@ -51,6 +51,7 @@ namespace BTCPayServer.Data
public IEnumerable<PayoutData> Payouts { get; set; } public IEnumerable<PayoutData> Payouts { get; set; }
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; } public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
public IEnumerable<StoreSettingData> Settings { get; set; } public IEnumerable<StoreSettingData> Settings { get; set; }
public IEnumerable<FormData> Forms { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{ {

View File

@@ -0,0 +1,54 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20230125085242_AddForms")]
public partial class AddForms : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
int? maxlength = migrationBuilder.IsMySql() ? 255 : null;
migrationBuilder.CreateTable(
name: "Forms",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false, maxLength: maxlength),
Name = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxlength),
StoreId = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxlength),
Config = table.Column<string>(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true),
Public = table.Column<bool>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Forms", x => x.Id);
table.ForeignKey(
name: "FK_Forms_Stores_StoreId",
column: x => x.StoreId,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Forms_StoreId",
table: "Forms",
column: "StoreId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Forms");
}
}
}

View File

@@ -205,6 +205,31 @@ namespace BTCPayServer.Migrations
b.ToTable("CustodianAccount"); b.ToTable("CustodianAccount");
}); });
modelBuilder.Entity("BTCPayServer.Data.Data.FormData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("Public")
.HasColumnType("INTEGER");
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("Forms");
});
modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b => modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -705,8 +730,8 @@ namespace BTCPayServer.Migrations
b.Property<int>("SpeedPolicy") b.Property<int>("SpeedPolicy")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<byte[]>("StoreBlob") b.Property<string>("StoreBlob")
.HasColumnType("BLOB"); .HasColumnType("TEXT");
b.Property<byte[]>("StoreCertificate") b.Property<byte[]>("StoreCertificate")
.HasColumnType("BLOB"); .HasColumnType("BLOB");
@@ -1129,6 +1154,16 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData"); b.Navigation("StoreData");
}); });
modelBuilder.Entity("BTCPayServer.Data.Data.FormData", b =>
{
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany("Forms")
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b => modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b =>
{ {
b.HasOne("BTCPayServer.Data.StoreData", "Store") b.HasOne("BTCPayServer.Data.StoreData", "Store")
@@ -1519,6 +1554,8 @@ namespace BTCPayServer.Migrations
b.Navigation("CustodianAccounts"); b.Navigation("CustodianAccounts");
b.Navigation("Forms");
b.Navigation("Invoices"); b.Navigation("Invoices");
b.Navigation("LightningAddresses"); b.Navigation("LightningAddresses");

View File

@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Forms;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests;
[Trait("Fast", "Fast")]
public class FormTests : UnitTestBase
{
public FormTests(ITestOutputHelper helper) : base(helper)
{
}
[Fact]
public void CanParseForm()
{
var form = new Form()
{
Fields = new List<Field>
{
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
Field.Create("Name", "item2", 2.ToString(), true, null),
Field.Create("Name", "invoice_test", 2.ToString(), true, null),
new Field
{
Name = "invoice",
Type = "fieldset",
Fields = new List<Field>
{
Field.Create("Name", "test", 3.ToString(), true, null),
Field.Create("Name", "item4", 4.ToString(), true, null),
Field.Create("Name", "item5", 5.ToString(), true, null),
}
}
}
};
var service = new FormDataService(null, null);
Assert.False(service.IsFormSchemaValid(form.ToString(), out _, out _));
form = new Form()
{
Fields = new List<Field>
{
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
Field.Create("Name", "item2", 2.ToString(), true, null),
Field.Create("Name", "invoice_item3", 2.ToString(), true, null),
new Field
{
Name = "invoice",
Type = "fieldset",
Fields = new List<Field> {Field.Create("Name", "test", 3.ToString(), true, null),}
}
}
};
Assert.True(service.IsFormSchemaValid(form.ToString(), out _, out _));
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
{
{"item1", new StringValues("updated")},
{"item2", new StringValues("updated")},
{"invoice_item3", new StringValues("updated")},
{"invoice_test", new StringValues("updated")}
}));
foreach (var f in form.GetAllFields())
{
if (f.Field.Type == "fieldset")
continue;
Assert.Equal("updated", f.Field.Value);
}
form = new Form()
{
Fields = new List<Field>
{
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
Field.Create("Name", "item2", 2.ToString(), true, null),
Field.Create("Name", "invoice_item3", 2.ToString(), true, null),
new Field
{
Name = "invoice",
Type = "fieldset",
Fields = new List<Field>
{
new() {Name = "test", Type = "text", Constant = true, Value = "original"}
}
}
}
};
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
{
{"item1", new StringValues("updated")},
{"item2", new StringValues("updated")},
{"invoice_item3", new StringValues("updated")},
{"invoice_test", new StringValues("updated")}
}));
foreach (var f in form.GetAllFields())
{
var field = f.Field;
if (field.Type == "fieldset")
continue;
switch (f.FullName)
{
case "invoice_test":
Assert.Equal("original", field.Value);
break;
default:
Assert.Equal("updated", field.Value);
break;
}
}
form = new Form()
{
Fields = new List<Field>
{
Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"),
Field.Create("Name", "item2", 2.ToString(), true, null),
Field.Create("Name", "invoice_item3", 2.ToString(), true, null),
new Field
{
Name = "invoice",
Type = "fieldset",
Constant = true,
Fields = new List<Field>
{
new() {Name = "test", Type = "text", Value = "original"}
}
}
}
};
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
{
{"item1", new StringValues("updated")},
{"item2", new StringValues("updated")},
{"invoice_item3", new StringValues("updated")},
{"invoice_test", new StringValues("updated")}
}));
foreach (var f in form.GetAllFields())
{
var field = f.Field;
if (field.Type == "fieldset")
continue;
switch (f.FullName)
{
case "invoice_test":
Assert.Equal("original", field.Value);
break;
default:
Assert.Equal("updated", field.Value);
break;
}
}
var obj = form.GetValues();
Assert.Equal("original", obj["invoice"]["test"].Value<string>());
Assert.Equal("updated", obj["invoice_item3"].Value<string>());
Clear(form);
form.SetValues(obj);
obj = form.GetValues();
Assert.Equal("original", obj["invoice"]["test"].Value<string>());
Assert.Equal("updated", obj["invoice_item3"].Value<string>());
form = new Form()
{
Fields = new List<Field>(){
new Field
{
Type = "fieldset",
Fields = new List<Field>
{
new() {Name = "test", Type = "text"}
}
}
}
};
form.SetValues(obj);
obj = form.GetValues();
Assert.Null(obj["test"].Value<string>());
form.SetValues(new JObject{ ["test"] = "hello" });
obj = form.GetValues();
Assert.Equal("hello", obj["test"].Value<string>());
}
private void Clear(Form form)
{
foreach (var f in form.Fields.Where(f => !f.Constant))
f.Value = null;
}
}

View File

@@ -86,8 +86,14 @@ namespace BTCPayServer.Tests
Driver.AssertNoError(); Driver.AssertNoError();
} }
public void PayInvoice(bool mine = false) public void PayInvoice(bool mine = false, decimal? amount= null)
{ {
if (amount is not null)
{
Driver.FindElement(By.Id("test-payment-amount")).Clear();
Driver.FindElement(By.Id("test-payment-amount")).SendKeys(amount.ToString());
}
Driver.FindElement(By.Id("FakePayment")).Click(); Driver.FindElement(By.Id("FakePayment")).Click();
if (mine) if (mine)
{ {

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -117,6 +118,68 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click(); s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click();
s.Driver.Navigate().GoToUrl(editUrl); s.Driver.Navigate().GoToUrl(editUrl);
Assert.Contains("aa@aa.com", s.Driver.PageSource); Assert.Contains("aa@aa.com", s.Driver.PageSource);
//Custom Forms
s.GoToStore(StoreNavPages.Forms);
Assert.Contains("There are no forms yet.", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateForm")).Click();
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 1");
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
var emailtemplate = s.Driver.FindElement(By.Name("FormConfig")).GetAttribute("value");
Assert.Contains("buyerEmail", emailtemplate);
s.Driver.FindElement(By.Name("FormConfig")).Clear();
s.Driver.FindElement(By.Name("FormConfig"))
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest"));
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.Driver.FindElement(By.Id("ViewForm")).Click();
var formurl = s.Driver.Url;
Assert.Contains("CustomFormInputTest", s.Driver.PageSource);
s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com");
s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click();
s.PayInvoice(true);
var result = await s.Server.PayTester.HttpClient.GetAsync(formurl);
Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
s.GoToHome();
s.GoToStore(StoreNavPages.Forms);
Assert.Contains("Custom Form 1", s.Driver.PageSource);
s.Driver.FindElement(By.LinkText("Remove")).Click();
s.Driver.WaitForElement(By.Id("ConfirmInput")).SendKeys("DELETE");
s.Driver.FindElement(By.Id("ConfirmContinue")).Click();
Assert.DoesNotContain("Custom Form 1", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateForm")).Click();
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 2");
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
s.Driver.SetCheckbox(By.Name("Public"), true);
s.Driver.FindElement(By.Name("FormConfig")).Clear();
s.Driver.FindElement(By.Name("FormConfig"))
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest2"));
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.Driver.FindElement(By.Id("ViewForm")).Click();
formurl = s.Driver.Url;
result = await s.Server.PayTester.HttpClient.GetAsync(formurl);
Assert.NotEqual(HttpStatusCode.NotFound, result.StatusCode);
s.GoToHome();
s.GoToStore(StoreNavPages.Forms);
Assert.Contains("Custom Form 2", s.Driver.PageSource);
s.Driver.FindElement(By.LinkText("Custom Form 2")).Click();
s.Driver.FindElement(By.Name("Name")).Clear();
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 3");
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.GoToStore(StoreNavPages.Forms);
Assert.Contains("Custom Form 3", s.Driver.PageSource);
s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click();
s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click();
Assert.Equal(4, new SelectElement(s.Driver.FindElement(By.Id("FormId"))).Options.Count);
} }
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]

View File

@@ -370,7 +370,7 @@ namespace BTCPayServer.Controllers
storedKeys.Add(item.Key); storedKeys.Add(item.Key);
} }
var formKeys = form.GetAllNames(); var formKeys = form.GetAllFields().Select(f => f.FullName).ToHashSet();
foreach (var item in newData) foreach (var item in newData)
{ {

View File

@@ -10,6 +10,7 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.Forms; using BTCPayServer.Forms;
using BTCPayServer.Forms.Models;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.Models.PaymentRequestViewModels;
using BTCPayServer.PaymentRequest; using BTCPayServer.PaymentRequest;
@@ -40,6 +41,7 @@ namespace BTCPayServer.Controllers
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private FormComponentProviders FormProviders { get; } private FormComponentProviders FormProviders { get; }
public FormDataService FormDataService { get; }
public UIPaymentRequestController( public UIPaymentRequestController(
UIInvoiceController invoiceController, UIInvoiceController invoiceController,
@@ -50,7 +52,8 @@ namespace BTCPayServer.Controllers
CurrencyNameTable currencies, CurrencyNameTable currencies,
StoreRepository storeRepository, StoreRepository storeRepository,
InvoiceRepository invoiceRepository, InvoiceRepository invoiceRepository,
FormComponentProviders formProviders) FormComponentProviders formProviders,
FormDataService formDataService)
{ {
_InvoiceController = invoiceController; _InvoiceController = invoiceController;
_UserManager = userManager; _UserManager = userManager;
@@ -61,6 +64,7 @@ namespace BTCPayServer.Controllers
_storeRepository = storeRepository; _storeRepository = storeRepository;
_InvoiceRepository = invoiceRepository; _InvoiceRepository = invoiceRepository;
FormProviders = formProviders; FormProviders = formProviders;
FormDataService = formDataService;
} }
[BitpayAPIConstraint(false)] [BitpayAPIConstraint(false)]
@@ -204,7 +208,7 @@ namespace BTCPayServer.Controllers
[HttpGet("{payReqId}/form")] [HttpGet("{payReqId}/form")]
[HttpPost("{payReqId}/form")] [HttpPost("{payReqId}/form")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> ViewPaymentRequestForm(string payReqId) public async Task<IActionResult> ViewPaymentRequestForm(string payReqId, FormViewModel viewModel)
{ {
var result = await _PaymentRequestRepository.FindPaymentRequest(payReqId, GetUserId()); var result = await _PaymentRequestRepository.FindPaymentRequest(payReqId, GetUserId());
if (result == null) if (result == null)
@@ -213,42 +217,34 @@ namespace BTCPayServer.Controllers
} }
var prBlob = result.GetBlob(); var prBlob = result.GetBlob();
var prFormId = prBlob.FormId; if (prBlob.FormResponse is not null)
var formConfig = prFormId is null ? null : Forms.UIFormsController.GetFormData(prFormId)?.Config;
switch (formConfig)
{ {
case null: return RedirectToAction("PayPaymentRequest", new {payReqId});
case { } when !this.Request.HasFormContentType && prBlob.FormResponse is not null:
return RedirectToAction("ViewPaymentRequest", new { payReqId });
case { } when !this.Request.HasFormContentType && prBlob.FormResponse is null:
break;
default:
// POST case: Handle form submit
var formData = Form.Parse(formConfig);
formData.ApplyValuesFromForm(Request.Form);
if (FormProviders.Validate(formData, ModelState))
{
prBlob.FormResponse = JObject.FromObject(formData.GetValues());
result.SetBlob(prBlob);
await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
return RedirectToAction("PayPaymentRequest", new { payReqId });
} }
break; var prFormId = prBlob.FormId;
var formData = await FormDataService.GetForm(prFormId);
if (formData is null)
{
return RedirectToAction("PayPaymentRequest", new {payReqId});
} }
return View("PostRedirect", new PostRedirectViewModel var form = Form.Parse(formData.Config);
if (Request.Method == "POST" && Request.HasFormContentType)
{ {
AspController = "UIForms", form.ApplyValuesFromForm(Request.Form);
AspAction = "ViewPublicForm", if (FormDataService.Validate(form, ModelState))
RouteParameters =
{ {
{ "formId", prFormId } prBlob.FormResponse = form.GetValues();
}, result.SetBlob(prBlob);
FormParameters = await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result);
{ return RedirectToAction("PayPaymentRequest", new {payReqId});
{ "redirectUrl", Request.GetCurrentUrl() }
} }
}); }
viewModel.FormName = formData.Name;
viewModel.Form = form;
return View("Views/UIForms/View", viewModel);
} }
[HttpGet("{payReqId}/pay")] [HttpGet("{payReqId}/pay")]
@@ -277,6 +273,15 @@ namespace BTCPayServer.Controllers
return BadRequest("Payment Request cannot be paid as it has been archived"); return BadRequest("Payment Request cannot be paid as it has been archived");
} }
if (!result.FormSubmitted && !string.IsNullOrEmpty(result.FormId))
{
var formData = await FormDataService.GetForm(result.FormId);
if (formData is not null)
{
return RedirectToAction("ViewPaymentRequestForm", new {payReqId});
}
}
result.HubPath = PaymentRequestHub.GetHubPath(Request); result.HubPath = PaymentRequestHub.GetHubPath(Request);
if (result.AmountDue <= 0) if (result.AmountDue <= 0)
{ {

View File

@@ -1,3 +1,4 @@
using BTCPayServer.Abstractions.Form;
using BTCPayServer.Data.Data; using BTCPayServer.Data.Data;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -15,6 +16,11 @@ public static class FormDataExtensions
serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>(); serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
} }
public static JObject Deserialize(this FormData form)
{
return JsonConvert.DeserializeObject<JObject>(form.Config);
}
public static string Serialize(this JObject form) public static string Serialize(this JObject form)
{ {
return JsonConvert.SerializeObject(form); return JsonConvert.SerializeObject(form);

View File

@@ -1,26 +1,48 @@
#nullable enable #nullable enable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Form;
using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Data.Data; using BTCPayServer.Data.Data;
using BTCPayServer.Models;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms; namespace BTCPayServer.Forms;
public class FormDataService 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() public static readonly Form StaticFormEmail = new()
{ {
Fields = new List<Field>() { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") } Fields = new List<Field> { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") }
}; };
public static readonly Form StaticFormAddress = new() public static readonly Form StaticFormAddress = new()
{ {
Fields = new List<Field>() Fields = new List<Field>
{ {
Field.Create("Enter your email", "buyerEmail", null, true, null, "email"), Field.Create("Enter your email", "buyerEmail", null, true, null, "email"),
Field.Create("Name", "buyerName", null, true, null), Field.Create("Name", "buyerName", null, true, null),
@@ -32,4 +54,109 @@ public class FormDataService
Field.Create("Country", "buyerCountry", null, true, null) Field.Create("Country", "buyerCountry", null, true, null)
} }
}; };
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 amt = form.GetFieldByFullName($"{InvoiceParameterPrefix}amount")?.Value;
return new CreateInvoiceRequest
{
Currency = form.GetFieldByFullName($"{InvoiceParameterPrefix}currency")?.Value,
Amount = string.IsNullOrEmpty(amt) ? null : decimal.Parse(amt, CultureInfo.InvariantCulture),
Metadata = form.GetValues(),
};
}
} }

View File

@@ -1,13 +1,18 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Form;
using BTCPayServer.Data.Data;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms.Models; namespace BTCPayServer.Forms.Models;
public class FormViewModel public class FormViewModel
{ {
public string LogoFileId { get; set; }
public string CssFileId { get; set; }
public string BrandColor { get; set; }
public string StoreName { get; set; }
public string FormName { get; set; }
public string RedirectUrl { get; set; } public string RedirectUrl { get; set; }
public FormData FormData { get; set; } public Form Form { get; set; }
Form _Form; public string AspController { get; set; }
public Form Form { get => _Form ??= Form.Parse(FormData.Config); } public string AspAction { get; set; }
public Dictionary<string, string> RouteParameters { get; set; } = new();
} }

View File

@@ -8,4 +8,7 @@ public class ModifyForm
[DisplayName("Form configuration (JSON)")] [DisplayName("Form configuration (JSON)")]
public string FormConfig { get; set; } public string FormConfig { get; set; }
[DisplayName("Allow form for public use")]
public bool Public { get; set; }
} }

View File

@@ -1,109 +1,203 @@
#nullable enable #nullable enable
using System; using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Form;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Data.Data; using BTCPayServer.Data.Data;
using BTCPayServer.Forms.Models; using BTCPayServer.Forms.Models;
using BTCPayServer.Models;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms; namespace BTCPayServer.Forms;
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public class UIFormsController : Controller public class UIFormsController : Controller
{ {
private readonly FormDataService _formDataService;
private readonly IAuthorizationService _authorizationService;
private FormComponentProviders FormProviders { get; } private FormComponentProviders FormProviders { get; }
public UIFormsController(FormComponentProviders formProviders) public UIFormsController(FormComponentProviders formProviders, FormDataService formDataService,
IAuthorizationService authorizationService)
{ {
FormProviders = formProviders; FormProviders = formProviders;
_formDataService = formDataService;
_authorizationService = authorizationService;
}
[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")]
public IActionResult Create(string storeId)
{
var vm = new ModifyForm {FormConfig = new Form().ToString()};
return View("Modify", vm);
}
[HttpGet("~/stores/{storeId}/forms/modify/{id}")]
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?}")]
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),
$"Form config was invalid: {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 = $"Form {(isNew ? "created" : "updated")} successfully."
});
if (isNew)
{
return RedirectToAction("Modify", new {storeId, id = formData.Id});
}
}
catch (Exception e)
{
ModelState.AddModelError("", $"An error occurred while saving: {e.Message}");
}
return View(modifyForm);
}
[HttpPost("~/stores/{storeId}/forms/{id}/remove")]
public async Task<IActionResult> Remove(string storeId, string id)
{
await _formDataService.RemoveForm(id, storeId);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success, Message = "Form removed"
});
return RedirectToAction("FormsList", new {storeId});
} }
[AllowAnonymous] [AllowAnonymous]
[HttpGet("~/forms/{formId}")] [HttpGet("~/forms/{formId}")]
[HttpPost("~/forms")] public async Task<IActionResult> ViewPublicForm(string? formId)
public IActionResult ViewPublicForm(string? formId, string? redirectUrl)
{ {
if (!IsValidRedirectUri(redirectUrl)) FormData? formData = await _formDataService.GetForm(formId);
return BadRequest();
FormData? formData = string.IsNullOrEmpty(formId) ? null : GetFormData(formId);
if (formData == null)
{
return string.IsNullOrEmpty(redirectUrl)
? NotFound()
: Redirect(redirectUrl);
}
return GetFormView(formData, redirectUrl);
}
ViewResult GetFormView(FormData formData, string? redirectUrl)
{
return View("View", new FormViewModel { FormData = formData, RedirectUrl = redirectUrl });
}
[AllowAnonymous]
[HttpPost("~/forms/{formId}")]
public IActionResult SubmitForm(string formId, string? redirectUrl, string? command)
{
if (!IsValidRedirectUri(redirectUrl))
return BadRequest();
var formData = GetFormData(formId);
if (formData?.Config is null) if (formData?.Config is null)
{
return NotFound(); return NotFound();
}
if (!Request.HasFormContentType) if (!formData.Public &&
return GetFormView(formData, redirectUrl); !(await _authorizationService.AuthorizeAsync(User, Policies.CanViewStoreSettings)).Succeeded)
var conf = Form.Parse(formData.Config);
conf.ApplyValuesFromForm(Request.Form);
if (!FormProviders.Validate(conf, ModelState))
return GetFormView(formData, redirectUrl);
var form = new MultiValueDictionary<string, string>();
foreach (var kv in Request.Form)
form.Add(kv.Key, kv.Value);
// With redirect, the form comes from another entity that we need to send the data back to
if (!string.IsNullOrEmpty(redirectUrl))
{ {
return View("PostRedirect", new PostRedirectViewModel return NotFound();
}
return GetFormView(formData);
}
ViewResult GetFormView(FormData formData, Form? form = null)
{ {
FormUrl = redirectUrl, form ??= Form.Parse(formData.Config);
FormParameters = form form.ApplyValuesFromForm(Request.Query);
var store = formData.Store;
var storeBlob = store?.GetStoreBlob();
return View("View", new FormViewModel
{
FormName = formData.Name,
Form = form,
StoreName = store?.StoreName,
BrandColor = storeBlob?.BrandColor,
CssFileId = storeBlob?.CssFileId,
LogoFileId = storeBlob?.LogoFileId,
}); });
} }
[AllowAnonymous]
[HttpPost("~/forms/{formId}")]
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(); return NotFound();
} }
internal static FormData? GetFormData(string id) if (!formData.Public &&
!(await _authorizationService.AuthorizeAsync(User, Policies.CanViewStoreSettings)).Succeeded)
{ {
FormData? form = id switch return NotFound();
{
{ } formId when formId == GenericFormOption.Address.ToString() => new FormData
{
Config = FormDataService.StaticFormAddress.ToString(),
Id = GenericFormOption.Address.ToString(),
Name = "Provide your address",
},
{ } formId when formId == GenericFormOption.Email.ToString() => new FormData
{
Config = FormDataService.StaticFormEmail.ToString(),
Id = GenericFormOption.Email.ToString(),
Name = "Provide your email address",
},
_ => null
};
return form;
} }
private bool IsValidRedirectUri(string? redirectUrl) => if (!Request.HasFormContentType)
!string.IsNullOrEmpty(redirectUrl) && Uri.TryCreate(redirectUrl, UriKind.RelativeOrAbsolute, out var uri) && return GetFormView(formData);
(Url.IsLocalUrl(redirectUrl) || uri.Host.Equals(Request.Host.Host));
var form = Form.Parse(formData.Config);
form.ApplyValuesFromForm(Request.Form);
if (!_formDataService.Validate(form, ModelState))
return GetFormView(formData, form);
// Create invoice after public form has been filled
var store = await storeRepository.FindStore(formData.StoreId);
if (store is null)
return NotFound();
var request = _formDataService.GenerateInvoiceParametersFromForm(form);
var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot());
return RedirectToAction("Checkout", "UIInvoice", new {invoiceId = inv.Id});
}
} }

View File

@@ -16,6 +16,7 @@ using BTCPayServer.Controllers;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.Forms; using BTCPayServer.Forms;
using BTCPayServer.Forms.Models;
using BTCPayServer.ModelBinders; using BTCPayServer.ModelBinders;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Plugins.PointOfSale.Models;
@@ -42,21 +43,20 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
CurrencyNameTable currencies, CurrencyNameTable currencies,
StoreRepository storeRepository, StoreRepository storeRepository,
UIInvoiceController invoiceController, UIInvoiceController invoiceController,
FormComponentProviders formProviders) FormDataService formDataService)
{ {
_currencies = currencies; _currencies = currencies;
_appService = appService; _appService = appService;
_storeRepository = storeRepository; _storeRepository = storeRepository;
_invoiceController = invoiceController; _invoiceController = invoiceController;
FormProviders = formProviders; FormDataService = formDataService;
} }
private readonly CurrencyNameTable _currencies; private readonly CurrencyNameTable _currencies;
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private readonly AppService _appService; private readonly AppService _appService;
private readonly UIInvoiceController _invoiceController; private readonly UIInvoiceController _invoiceController;
public FormDataService FormDataService { get; }
public FormComponentProviders FormProviders { get; }
[HttpGet("/")] [HttpGet("/")]
[HttpGet("/apps/{appId}/pos/{viewType?}")] [HttpGet("/apps/{appId}/pos/{viewType?}")]
@@ -121,14 +121,15 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
[DomainMappingConstraint(AppType.PointOfSale)] [DomainMappingConstraint(AppType.PointOfSale)]
[RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)] [RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> ViewPointOfSale(string appId, public async Task<IActionResult> ViewPointOfSale(string appId,
PosViewType? viewType, PosViewType? viewType = null,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal? amount, [ModelBinder(typeof(InvariantDecimalModelBinder))] decimal? amount = null,
string email, string email = null,
string orderId, string orderId = null,
string notificationUrl, string notificationUrl = null,
string redirectUrl, string redirectUrl = null,
string choiceKey, string choiceKey = null,
string posData = null, string posData = null,
string formResponse = null,
RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore, RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -229,45 +230,38 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var store = await _appService.GetStore(app); var store = await _appService.GetStore(app);
var posFormId = settings.FormId; var posFormId = settings.FormId;
var formData = await FormDataService.GetForm(posFormId);
var formConfig = posFormId is null ? null : Forms.UIFormsController.GetFormData(posFormId)?.Config; JObject formResponseJObject = null;
JObject formResponse = null; switch (formData)
switch (formConfig)
{ {
case null: case null:
case { } when !this.Request.HasFormContentType:
break; break;
default: case not null:
var formData = Form.Parse(formConfig); if (formResponse is null)
formData.ApplyValuesFromForm(this.Request.Form);
if (FormProviders.Validate(formData, ModelState))
{ {
formResponse = JObject.FromObject(formData.GetValues());
break;
}
var query = new QueryBuilder(Request.Query);
foreach (var keyValuePair in Request.Form)
{
query.Add(keyValuePair.Key, keyValuePair.Value.ToArray());
}
// GET or empty form data case: Redirect to form
return View("PostRedirect", new PostRedirectViewModel return View("PostRedirect", new PostRedirectViewModel
{ {
AspController = "UIForms", AspAction = nameof(POSForm),
AspAction = "ViewPublicForm", RouteParameters = new Dictionary<string, string> { { "appId", appId } },
RouteParameters = AspController = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture),
{ FormParameters = new MultiValueDictionary<string, string>(Request.Form.Select(pair => new KeyValuePair<string, IReadOnlyCollection<string>>(pair.Key, pair.Value)))
{ "formId", posFormId }
},
FormParameters =
{
{ "redirectUrl", Request.GetCurrentUrl() + query }
}
}); });
} }
formResponseJObject = JObject.Parse(formResponse);
var form = Form.Parse(formData.Config);
form.SetValues(formResponseJObject);
if (!FormDataService.Validate(form, ModelState))
{
//someone tried to bypass validation
return RedirectToAction(nameof(ViewPointOfSale), new {appId});
}
formResponseJObject = form.GetValues();
break;
}
try try
{ {
var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest
@@ -293,14 +287,14 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
: requiresRefundEmail == RequiresRefundEmail.On, : requiresRefundEmail == RequiresRefundEmail.On,
}, store, HttpContext.Request.GetAbsoluteRoot(), }, store, HttpContext.Request.GetAbsoluteRoot(),
new List<string> { AppService.GetAppInternalTag(appId) }, new List<string> { AppService.GetAppInternalTag(appId) },
cancellationToken, (entity) => cancellationToken, entity =>
{ {
entity.Metadata.OrderUrl = Request.GetDisplayUrl(); entity.Metadata.OrderUrl = Request.GetDisplayUrl();
if (formResponse is not null) if (formResponseJObject is not null)
{ {
var meta = entity.Metadata.ToJObject(); var meta = entity.Metadata.ToJObject();
meta.Merge(formResponse); meta.Merge(formResponseJObject);
entity.Metadata = InvoiceMetadata.FromJObject(meta); entity.Metadata = InvoiceMetadata.FromJObject(meta);
} }
}); });
@@ -314,10 +308,85 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
Severity = StatusMessageModel.StatusSeverity.Error, Severity = StatusMessageModel.StatusSeverity.Error,
AllowDismiss = true AllowDismiss = true
}); });
return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId }); return RedirectToAction(nameof(ViewPointOfSale), new { appId });
} }
} }
[HttpPost("/apps/{appId}/pos/form")]
public async Task<IActionResult> POSForm(string appId)
{
var app = await _appService.GetApp(appId, AppType.PointOfSale);
if (app == null)
return NotFound();
var settings = app.GetSettings<PointOfSaleSettings>();
var formData = await FormDataService.GetForm(settings.FormId);
if (formData is null)
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
var myDictionary = Request.Form
.Where(pair => pair.Key != "__RequestVerificationToken")
.ToDictionary(p => p.Key, p => p.Value.ToString());
myDictionary.Add("appId", appId);
var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture);
var redirectUrl = Url.Action(nameof(ViewPointOfSale), controller, myDictionary);
var store = await _appService.GetStore(app);
var storeBlob = store.GetStoreBlob();
var form = Form.Parse(formData.Config);
return View("Views/UIForms/View", new FormViewModel
{
StoreName = store.StoreName,
BrandColor = storeBlob.BrandColor,
CssFileId = storeBlob.CssFileId,
LogoFileId = storeBlob.LogoFileId,
FormName = formData.Name,
Form = form,
RedirectUrl = redirectUrl,
AspController = controller,
AspAction = nameof(POSFormSubmit),
RouteParameters = new Dictionary<string, string> { { "appId", appId } },
});
}
[HttpPost("/apps/{appId}/pos/form/submit")]
public async Task<IActionResult> POSFormSubmit(string appId, FormViewModel viewModel)
{
var app = await _appService.GetApp(appId, AppType.PointOfSale);
if (app == null)
return NotFound();
var settings = app.GetSettings<PointOfSaleSettings>();
var formData = await FormDataService.GetForm(settings.FormId);
if (formData is null || viewModel.RedirectUrl is null)
{
return RedirectToAction(nameof(ViewPointOfSale), new {appId });
}
var form = Form.Parse(formData.Config);
if (Request.Method == "POST" && Request.HasFormContentType)
{
form.ApplyValuesFromForm(Request.Form);
if (FormDataService.Validate(form, ModelState))
{
return View("PostRedirect", new PostRedirectViewModel
{
FormUrl = viewModel.RedirectUrl,
FormParameters =
{
{ "formResponse", form.GetValues().ToString() }
}
});
}
}
viewModel.FormName = formData.Name;
viewModel.Form = form;
return View("Views/UIForms/View", viewModel);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("{appId}/settings/pos")] [HttpGet("{appId}/settings/pos")]
public async Task<IActionResult> UpdatePointOfSale(string appId) public async Task<IActionResult> UpdatePointOfSale(string appId)
@@ -326,7 +395,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (app == null) if (app == null)
return NotFound(); return NotFound();
var storeBlob = GetCurrentStore().GetStoreBlob();
var settings = app.GetSettings<PointOfSaleSettings>(); var settings = app.GetSettings<PointOfSaleSettings>();
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView; settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
settings.EnableShoppingCart = false; settings.EnableShoppingCart = false;
@@ -358,13 +426,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "", RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "",
FormId = settings.FormId FormId = settings.FormId
}; };
if (HttpContext?.Request != null) if (HttpContext.Request != null)
{ {
var appUrl = HttpContext.Request.GetAbsoluteUri($"/apps/{appId}/pos"); var appUrl = HttpContext.Request.GetAbsoluteUri($"/apps/{appId}/pos");
var encoder = HtmlEncoder.Default; var encoder = HtmlEncoder.Default;
if (settings.ShowCustomAmount) if (settings.ShowCustomAmount)
{ {
StringBuilder builder = new StringBuilder(); var builder = new StringBuilder();
builder.AppendLine(CultureInfo.InvariantCulture, $"<form method=\"POST\" action=\"{encoder.Encode(appUrl)}\">"); builder.AppendLine(CultureInfo.InvariantCulture, $"<form method=\"POST\" action=\"{encoder.Encode(appUrl)}\">");
builder.AppendLine($" <input type=\"hidden\" name=\"amount\" value=\"100\" />"); builder.AppendLine($" <input type=\"hidden\" name=\"amount\" value=\"100\" />");
builder.AppendLine($" <input type=\"hidden\" name=\"email\" value=\"customer@example.com\" />"); builder.AppendLine($" <input type=\"hidden\" name=\"email\" value=\"customer@example.com\" />");
@@ -443,7 +511,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
Description = vm.Description, Description = vm.Description,
EmbeddedCSS = vm.EmbeddedCSS, EmbeddedCSS = vm.EmbeddedCSS,
RedirectAutomatically = RedirectAutomatically =
string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically) string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically)
}; };
settings.FormId = vm.FormId; settings.FormId = vm.FormId;

View File

@@ -1,7 +1,6 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AngleSharp.Dom;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events; using BTCPayServer.Events;
using BTCPayServer.Logging; using BTCPayServer.Logging;

View File

@@ -1,40 +0,0 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Services.Stores;
public enum GenericFormOption
{
[Display(Name = "Do not request any information")]
None,
[Display(Name = "Request email address only")]
Email,
[Display(Name = "Request shipping address")]
Address
}
public static class CheckoutFormSelectList
{
public static SelectList WithSelected(string selectedFormId)
{
var choices = new List<SelectListItem>
{
GenericOptionItem(GenericFormOption.None),
GenericOptionItem(GenericFormOption.Email),
GenericOptionItem(GenericFormOption.Address)
};
var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId);
return new SelectList(choices, nameof(SelectListItem.Value), nameof(SelectListItem.Text), chosen?.Value);
}
private static string DisplayName(GenericFormOption opt) =>
typeof(GenericFormOption).DisplayName(opt.ToString());
private static SelectListItem GenericOptionItem(GenericFormOption opt) =>
new() { Text = DisplayName(opt), Value = opt == GenericFormOption.None ? null : opt.ToString() };
}

View File

@@ -1,10 +1,8 @@
@using BTCPayServer.Abstractions.Form
@using BTCPayServer.Forms @using BTCPayServer.Forms
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@inject FormComponentProviders FormComponentProviders @inject FormComponentProviders FormComponentProviders
@model BTCPayServer.Abstractions.Form.Field @model BTCPayServer.Abstractions.Form.Field
@if (!Model.Hidden) @if (!Model.Constant)
{ {
<fieldset> <fieldset>
<legend class="h3 mt-4 mb-3">@Model.Label</legend> <legend class="h3 mt-4 mb-3">@Model.Label</legend>

View File

@@ -1,34 +1,26 @@
@using BTCPayServer.Abstractions.Form
@using Newtonsoft.Json.Linq
@model BTCPayServer.Abstractions.Form.Field @model BTCPayServer.Abstractions.Form.Field
@{ @{
var isInvalid = this.ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid; var isInvalid = ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
var error = isInvalid ? this.ViewContext.ModelState[Model.Name].Errors[0].ErrorMessage : null; var errors = isInvalid ? ViewContext.ModelState[Model.Name].Errors : null;
} }
<div class="form-group"> <div class="form-group">
@if (Model.Required) <label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
{
<label class="form-label" for="@Model.Name" data-required>
@Model.Label @Model.Label
</label> </label>
} <input id="@Model.Name" type="@Model.Type" class="form-control @(errors is null ? "" : "is-invalid")"
else name="@Model.Name" value="@Model.Value" data-val="true"
{
<label class="form-label" for="@Model.Name">
@Model.Label
</label>
}
<input class="form-control @(Model.IsValid() ? "" : "is-invalid")" id="@Model.Name" type="@Model.Type" required="@Model.Required" name="@Model.Name" value="@Model.Value" aria-describedby="@("HelpText" + Model.Name)"/>
@if(isInvalid)
{
<span class="text-danger">@error</span>
}
@if (!string.IsNullOrEmpty(Model.HelpText)) @if (!string.IsNullOrEmpty(Model.HelpText))
{ {
<div id="@("HelpText" + Model.Name)" class="form-text">@Model.HelpText</div> @Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
}
@if (Model.Required)
{
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
}
/>
<span class="text-danger" data-valmsg-for="@Model.Name" data-valmsg-replace="true">@(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty)</span>
@if (!string.IsNullOrEmpty(Model.HelpText))
{
<div id="@($"HelpText-{Model.Name}")" class="form-text">@Model.HelpText</div>
} }
</div> </div>

View File

@@ -2,12 +2,14 @@
@using BTCPayServer.Abstractions.Models @using BTCPayServer.Abstractions.Models
@using BTCPayServer.Views.Apps @using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Forms
@using BTCPayServer.Services.Stores @using BTCPayServer.Services.Stores
@inject FormDataService FormDataService
@model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel @model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel
@{ @{
ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id); ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id);
var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId); var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
} }
<form method="post"> <form method="post">

View File

@@ -7,6 +7,6 @@
{ {
if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial)) if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial))
{ {
<partial name="@partial.View" for="@field"></partial> <partial name="@partial.View" for="@field" />
} }
} }

View File

@@ -1,4 +1,16 @@
<script src="~/vendor/jquery-validate/jquery.validate.js" asp-append-version="true"></script> <script src="~/vendor/jquery-validate/jquery.validate.js" asp-append-version="true"></script>
<script src="~/vendor/jquery-validate-unobtrusive/jquery.validate.unobtrusive.js" asp-append-version="true"></script> <script src="~/vendor/jquery-validate-unobtrusive/jquery.validate.unobtrusive.js" asp-append-version="true"></script>
<script>
$.validator.setDefaults({
errorClass: '',
validClass: '',
highlight: function (element) {
$(element).addClass('is-invalid').removeClass('is-valid');
$(element.form).find(`[data-valmsg-for="${element.id}"]`).addClass('invalid-feedback');
},
unhighlight: function (element) {
$(element).addClass("is-valid").removeClass("is-invalid");
$(element.form).find(`[data-valmsg-for="${element.id}"]`).removeClass('invalid-feedback');
}
});
</script>

View File

@@ -0,0 +1,55 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Models
@model List<BTCPayServer.Data.Data.FormData>
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["NavPartialName"] = "../UIStores/_Nav";
ViewData.SetActivePage(StoreNavPages.Forms, "Forms");
var storeId = Context.GetCurrentStoreId();
}
<div class="row">
<div class="col-xxl-constrain col-xl-10">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
<a asp-action="Create" asp-route-storeId="@storeId" class="btn btn-primary mt-3 mt-sm-0" role="button" id="CreateForm">
<span class="fa fa-plus"></span>
Create Form
</a>
</div>
@if (Model.Any())
{
<table class="table table-hover table-responsive-md">
<thead>
<tr>
<th>Name</th>
<th class="text-end">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">@item.Name</a>
</td>
<td class="text-end">
<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="DELETE">Remove</a> -
<a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name">View</a>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-secondary mt-3">
There are no forms yet.
</p>
}
</div>
</div>
<partial name="_Confirm" model="@(new ConfirmModel("Delete form", "This form will be removed from this store.", "Delete"))" />

View File

@@ -0,0 +1,87 @@
@using BTCPayServer.Forms
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using Newtonsoft.Json
@model BTCPayServer.Forms.ModifyForm
@{
var formId = Context.GetRouteValue("id");
var isNew = formId is null;
Layout = "../Shared/_NavLayout.cshtml";
ViewData["NavPartialName"] = "../UIStores/_Nav";
ViewData.SetActivePage(StoreNavPages.Forms, $"{(isNew ? "Create" : "Edit")} Form", Model.Name);
var storeId = Context.GetCurrentStoreId();
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
<script>
document.addEventListener("DOMContentLoaded", () => {
const $config = document.getElementById("FormConfig");
delegate("click", "[data-form-template]", e => {
const { formTemplate: id } = e.target.dataset
const $template = document.getElementById(`form-template-${id}`)
$config.value = $template.innerHTML.trim()
})
})
</script>
}
<template id="form-template-email">
@Json.Serialize(FormDataService.StaticFormEmail, new JsonSerializerSettings()
{
Formatting = Formatting.Indented
})
</template>
<template id="form-template-address">
@Json.Serialize(FormDataService.StaticFormAddress, new JsonSerializerSettings()
{
Formatting = Formatting.Indented
})
</template>
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
<div class="row">
<div class="col-xl-10 col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
@if (!isNew)
{
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm">View</a>
}
</div>
</div>
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<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" style="max-width:27rem">
Standalone mode, which can be used to generate invoices
independent of payment requests or apps.
</div>
</div>
</div>
<div class="form-group">
<div class="d-flex align-items-center justify-content-between gap-3">
<label asp-for="FormConfig" class="form-label" data-required></label>
<div class="d-flex align-items-center gap-2 mb-2">
<span>Templates:</span>
<button type="button" class="btn btn-link p-0" data-form-template="email">Email</button>
<button type="button" class="btn btn-link p-0" data-form-template="address">Address</button>
</div>
</div>
<textarea asp-for="FormConfig" class="form-control" rows="10" cols="21"></textarea>
<span asp-validation-for="FormConfig" class="text-danger"></span>
</div>
</div>
</div>
</form>

View File

@@ -1,39 +1,58 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@inject BTCPayServer.Services.BTCPayServerEnvironment env @inject BTCPayServer.Services.BTCPayServerEnvironment Env
@model BTCPayServer.Forms.Models.FormViewModel @model BTCPayServer.Forms.Models.FormViewModel
@{ @{
Layout = null; Layout = null;
ViewData["Title"] = Model.FormData.Name; ViewData["Title"] = Model.FormName;
} }
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" @(env.IsDeveloping ? " data-devenv" : "")> <html lang="en" @(Env.IsDeveloping ? " data-devenv" : "")>
<head> <head>
<partial name="LayoutHead"/> <partial name="LayoutHead" />
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, "", "")" />
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
</head> </head>
<body class="min-vh-100"> <body class="min-vh-100">
<div class="public-page-wrap flex-column"> <div class="public-page-wrap flex-column">
<main class="flex-grow-1"> <partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
<div class="container" style="max-width:720px;"> @if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.LogoFileId))
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) {{"Margin", "mb-4"}})"/> {
<partial name="_StoreHeader" model="(Model.StoreName, Model.LogoFileId)" />
}
else
{
<h1 class="h3 text-center mt-3">@ViewData["Title"]</h1>
}
<main class="flex-grow-1 container" style="max-width:576px">
@if (!ViewContext.ModelState.IsValid) @if (!ViewContext.ModelState.IsValid)
{ {
<div asp-validation-summary="ModelOnly" class="text-danger"></div> <div asp-validation-summary="ModelOnly" class="text-danger"></div>
} }
<partial name="_FormTopMessages" model="@Model.Form"/> <partial name="_FormTopMessages" model="@Model.Form" />
<div class="d-flex flex-column justify-content-center gap-4"> <div class="d-flex flex-column justify-content-center gap-4">
<h1 class="h3 text-center">@ViewData["Title"]</h1>
<div class="bg-tile p-3 p-sm-4 rounded"> <div class="bg-tile p-3 p-sm-4 rounded">
<form asp-action="SubmitForm" asp-route-formId="@Model.FormData.Id"> @if (string.IsNullOrEmpty(Model.AspAction))
{
<form method="post" novalidate="novalidate">
@if (!string.IsNullOrEmpty(Model.RedirectUrl)) @if (!string.IsNullOrEmpty(Model.RedirectUrl))
{ {
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/> <input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
} }
<partial name="_Form" model="@Model.Form"/> <partial name="_Form" model="@Model.Form" />
<input type="submit" class="btn btn-primary" name="command" value="Submit"/> <input type="submit" class="btn btn-primary" name="command" value="Submit" />
</form> </form>
</div> }
else
{
<form method="post" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters">
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
{
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
}
<partial name="_Form" model="@Model.Form" />
<input type="submit" class="btn btn-primary" name="command" value="Submit" />
</form>
}
</div> </div>
</div> </div>
</main> </main>
@@ -43,6 +62,7 @@
</a> </a>
</footer> </footer>
</div> </div>
<partial name="LayoutFoot"/> <partial name="LayoutFoot" />
<partial name="_ValidationScriptsPartial"/>
</body> </body>
</html> </html>

View File

@@ -1,11 +1,14 @@
@using BTCPayServer.Services.PaymentRequests @using BTCPayServer.Services.PaymentRequests
@using System.Globalization @using System.Globalization
@using BTCPayServer.Forms
@using BTCPayServer.Services.Stores @using BTCPayServer.Services.Stores
@using BTCPayServer.TagHelpers @using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@inject FormDataService FormDataService
@model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel @model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel
@{ @{
var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId);
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id); ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id);
} }

View File

@@ -18,6 +18,7 @@
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a> <a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.PayoutProcessors))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayoutProcessors)" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@storeId">Payout Processors</a> <a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.PayoutProcessors))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayoutProcessors)" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@storeId">Payout Processors</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@storeId">Emails</a> <a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@storeId">Emails</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Forms))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Forms)" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@storeId">Forms</a>
<vc:ui-extension-point location="store-nav" model="@Model"/> <vc:ui-extension-point location="store-nav" model="@Model"/>
</div> </div>
</nav> </nav>