mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 01:43:50 +01:00
Store Custom Roles (#4940)
This commit is contained in:
parent
6b7fb55658
commit
783e4ccb35
@ -6,7 +6,8 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BTCPayServer.Abstractions.TagHelpers;
|
||||
|
||||
[HtmlTargetElement(Attributes = nameof(Permission))]
|
||||
[HtmlTargetElement(Attributes = "[permission]")]
|
||||
[HtmlTargetElement(Attributes = "[not-permission]" )]
|
||||
public class PermissionTagHelper : TagHelper
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
@ -21,16 +22,19 @@ public class PermissionTagHelper : TagHelper
|
||||
}
|
||||
|
||||
public string Permission { get; set; }
|
||||
public string NotPermission { get; set; }
|
||||
public string PermissionResource { get; set; }
|
||||
|
||||
|
||||
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
if (string.IsNullOrEmpty(Permission))
|
||||
if (string.IsNullOrEmpty(Permission) && string.IsNullOrEmpty(NotPermission))
|
||||
return;
|
||||
if (_httpContextAccessor.HttpContext is null)
|
||||
return;
|
||||
|
||||
var key = $"{Permission}_{PermissionResource}";
|
||||
var expectedResult = !string.IsNullOrEmpty(Permission);
|
||||
var key = $"{Permission??NotPermission}_{PermissionResource}";
|
||||
if (!_httpContextAccessor.HttpContext.Items.TryGetValue(key, out var o) ||
|
||||
o is not AuthorizationResult res)
|
||||
{
|
||||
@ -39,7 +43,7 @@ public class PermissionTagHelper : TagHelper
|
||||
Permission);
|
||||
_httpContextAccessor.HttpContext.Items.Add(key, res);
|
||||
}
|
||||
if (!res.Succeeded)
|
||||
if (expectedResult != res.Succeeded)
|
||||
{
|
||||
output.SuppressOutput();
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
@ -11,5 +12,11 @@ namespace BTCPayServer.Client
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/server/info"), token);
|
||||
return await HandleResponse<ServerInfoData>(response);
|
||||
}
|
||||
|
||||
public virtual async Task<List<RoleData>> GetServerRoles(CancellationToken token = default)
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/server/roles"), token);
|
||||
return await HandleResponse<List<RoleData>>(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,13 @@ namespace BTCPayServer.Client
|
||||
{
|
||||
public partial class BTCPayServerClient
|
||||
{
|
||||
public virtual async Task<List<RoleData>> GetStoreRoles(string storeId,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/roles"), token);
|
||||
return await HandleResponse<List<RoleData>>(response);
|
||||
}
|
||||
|
||||
public virtual async Task<IEnumerable<StoreUserData>> GetStoreUsers(string storeId,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
|
@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class StoreData : StoreBaseData
|
||||
@ -17,4 +19,12 @@ namespace BTCPayServer.Client.Models
|
||||
|
||||
public string Role { get; set; }
|
||||
}
|
||||
|
||||
public class RoleData
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public List<string> Permissions { get; set; }
|
||||
public string Role { get; set; }
|
||||
public bool IsServerRole { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
@ -134,7 +136,7 @@ namespace BTCPayServer.Client
|
||||
{
|
||||
static Permission()
|
||||
{
|
||||
Init();
|
||||
PolicyMap = Init();
|
||||
}
|
||||
|
||||
public static Permission Create(string policy, string scope = null)
|
||||
@ -235,11 +237,13 @@ namespace BTCPayServer.Client
|
||||
return subPolicies.Contains(subpolicy) || subPolicies.Any(s => ContainsPolicy(s, subpolicy));
|
||||
}
|
||||
|
||||
private static Dictionary<string, HashSet<string>> PolicyMap = new();
|
||||
public static ReadOnlyDictionary<string, HashSet<string>> PolicyMap { get; private set; }
|
||||
|
||||
|
||||
private static void Init()
|
||||
private static ReadOnlyDictionary<string, HashSet<string>> Init()
|
||||
{
|
||||
PolicyHasChild(Policies.CanModifyStoreSettings,
|
||||
var policyMap = new Dictionary<string, HashSet<string>>();
|
||||
PolicyHasChild(policyMap, Policies.CanModifyStoreSettings,
|
||||
Policies.CanManageCustodianAccounts,
|
||||
Policies.CanManagePullPayments,
|
||||
Policies.CanModifyInvoices,
|
||||
@ -248,25 +252,42 @@ namespace BTCPayServer.Client
|
||||
Policies.CanModifyPaymentRequests,
|
||||
Policies.CanUseLightningNodeInStore);
|
||||
|
||||
PolicyHasChild(Policies.CanManageUsers, Policies.CanCreateUser);
|
||||
PolicyHasChild(Policies.CanManagePullPayments, Policies.CanCreatePullPayments);
|
||||
PolicyHasChild(Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments);
|
||||
PolicyHasChild(Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
|
||||
PolicyHasChild(Policies.CanModifyProfile, Policies.CanViewProfile);
|
||||
PolicyHasChild(Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
|
||||
PolicyHasChild(Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
|
||||
PolicyHasChild(Policies.CanModifyServerSettings,
|
||||
PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser);
|
||||
PolicyHasChild(policyMap,Policies.CanManagePullPayments, Policies.CanCreatePullPayments);
|
||||
PolicyHasChild(policyMap,Policies.CanCreatePullPayments, Policies.CanCreateNonApprovedPullPayments);
|
||||
PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
|
||||
PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
|
||||
PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
|
||||
PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
|
||||
PolicyHasChild(policyMap,Policies.CanModifyServerSettings,
|
||||
Policies.CanUseInternalLightningNode,
|
||||
Policies.CanManageUsers);
|
||||
PolicyHasChild(Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode, Policies.CanViewLightningInvoiceInternalNode);
|
||||
PolicyHasChild(Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts);
|
||||
PolicyHasChild(Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice, Policies.CanCreateLightningInvoiceInStore);
|
||||
PolicyHasChild(Policies.CanViewStoreSettings, Policies.CanViewInvoices, Policies.CanViewPaymentRequests);
|
||||
PolicyHasChild(policyMap, Policies.CanUseInternalLightningNode, Policies.CanCreateLightningInvoiceInternalNode, Policies.CanViewLightningInvoiceInternalNode);
|
||||
PolicyHasChild(policyMap, Policies.CanManageCustodianAccounts, Policies.CanViewCustodianAccounts);
|
||||
PolicyHasChild(policyMap, Policies.CanModifyInvoices, Policies.CanViewInvoices, Policies.CanCreateInvoice, Policies.CanCreateLightningInvoiceInStore);
|
||||
PolicyHasChild(policyMap, Policies.CanViewStoreSettings, Policies.CanViewInvoices, Policies.CanViewPaymentRequests);
|
||||
|
||||
var missingPolicies = Policies.AllPolicies.ToHashSet();
|
||||
//recurse through the tree to see which policies are not included in the tree
|
||||
foreach (var policy in policyMap)
|
||||
{
|
||||
missingPolicies.Remove(policy.Key);
|
||||
foreach (var subPolicy in policy.Value)
|
||||
{
|
||||
missingPolicies.Remove(subPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var missingPolicy in missingPolicies)
|
||||
{
|
||||
policyMap.Add(missingPolicy, new HashSet<string>());
|
||||
}
|
||||
return new ReadOnlyDictionary<string, HashSet<string>>(policyMap);
|
||||
}
|
||||
|
||||
private static void PolicyHasChild(string policy, params string[] subPolicies)
|
||||
private static void PolicyHasChild(Dictionary<string, HashSet<string>>policyMap, string policy, params string[] subPolicies)
|
||||
{
|
||||
if (PolicyMap.TryGetValue(policy, out var existingSubPolicies))
|
||||
if (policyMap.TryGetValue(policy, out var existingSubPolicies))
|
||||
{
|
||||
foreach (string subPolicy in subPolicies)
|
||||
{
|
||||
@ -275,7 +296,7 @@ namespace BTCPayServer.Client
|
||||
}
|
||||
else
|
||||
{
|
||||
PolicyMap.Add(policy, subPolicies.ToHashSet());
|
||||
policyMap.Add(policy, subPolicies.ToHashSet());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -64,6 +64,7 @@ namespace BTCPayServer.Data
|
||||
public DbSet<U2FDevice> U2FDevices { get; set; }
|
||||
public DbSet<Fido2Credential> Fido2Credentials { get; set; }
|
||||
public DbSet<UserStore> UserStore { get; set; }
|
||||
public DbSet<StoreRole> StoreRoles { get; set; }
|
||||
[Obsolete]
|
||||
public DbSet<WalletData> Wallets { get; set; }
|
||||
public DbSet<WalletObjectData> WalletObjects { get; set; }
|
||||
@ -129,6 +130,7 @@ namespace BTCPayServer.Data
|
||||
PayoutProcessorData.OnModelCreating(builder, Database);
|
||||
WebhookData.OnModelCreating(builder, Database);
|
||||
FormData.OnModelCreating(builder, Database);
|
||||
StoreRole.OnModelCreating(builder, Database);
|
||||
|
||||
|
||||
if (Database.IsSqlite() && !_designTime)
|
||||
|
@ -1,13 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
@ -37,8 +34,6 @@ namespace BTCPayServer.Data
|
||||
|
||||
public byte[] StoreCertificate { get; set; }
|
||||
|
||||
[NotMapped] public string Role { get; set; }
|
||||
|
||||
public string StoreBlob { get; set; }
|
||||
|
||||
[Obsolete("Use GetDefaultPaymentId instead")]
|
||||
@ -52,6 +47,7 @@ namespace BTCPayServer.Data
|
||||
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
|
||||
public IEnumerable<StoreSettingData> Settings { get; set; }
|
||||
public IEnumerable<FormData> Forms { get; set; }
|
||||
public IEnumerable<StoreRole> StoreRoles { get; set; }
|
||||
|
||||
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||
{
|
||||
|
50
BTCPayServer.Data/Data/StoreRole.cs
Normal file
50
BTCPayServer.Data/Data/StoreRole.cs
Normal file
@ -0,0 +1,50 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Linq;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BTCPayServer.Data;
|
||||
|
||||
public class StoreRole
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string StoreDataId { get; set; }
|
||||
public string Role { get; set; }
|
||||
public List<string> Permissions { get; set; }
|
||||
public List<UserStore> Users { get; set; }
|
||||
public StoreData StoreData { get; set; }
|
||||
|
||||
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||
{
|
||||
builder.Entity<StoreRole>(entity =>
|
||||
{
|
||||
entity.HasOne(e => e.StoreData)
|
||||
.WithMany(s => s.StoreRoles)
|
||||
.HasForeignKey(e => e.StoreDataId)
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired(false);
|
||||
|
||||
entity.HasIndex(entity => new {entity.StoreDataId, entity.Role}).IsUnique();
|
||||
});
|
||||
|
||||
|
||||
|
||||
if (!databaseFacade.IsNpgsql())
|
||||
{
|
||||
builder.Entity<StoreRole>()
|
||||
.Property(o => o.Permissions)
|
||||
.HasConversion(
|
||||
v => JsonConvert.SerializeObject(v),
|
||||
v => JsonConvert.DeserializeObject<List<string>>(v)?? new List<string>(),
|
||||
new ValueComparer<List<string>>(
|
||||
(c1, c2) => c1 ==c2 || c1 != null && c2 != null && c1.SequenceEqual(c2),
|
||||
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||
c => c.ToList()));
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
@ -9,7 +10,10 @@ namespace BTCPayServer.Data
|
||||
|
||||
public string StoreDataId { get; set; }
|
||||
public StoreData StoreData { get; set; }
|
||||
public string Role { get; set; }
|
||||
[Column("Role")]
|
||||
public string StoreRoleId { get; set; }
|
||||
public StoreRole StoreRole { get; set; }
|
||||
|
||||
|
||||
|
||||
internal static void OnModelCreating(ModelBuilder builder)
|
||||
@ -32,6 +36,10 @@ namespace BTCPayServer.Data
|
||||
.HasOne(pt => pt.StoreData)
|
||||
.WithMany(t => t.UserStores)
|
||||
.HasForeignKey(pt => pt.StoreDataId);
|
||||
|
||||
builder.Entity<UserStore>().HasOne(e => e.StoreRole)
|
||||
.WithMany(role => role.Users)
|
||||
.HasForeignKey(e => e.StoreRoleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
106
BTCPayServer.Data/Migrations/20230504125505_StoreRoles.cs
Normal file
106
BTCPayServer.Data/Migrations/20230504125505_StoreRoles.cs
Normal file
@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
#nullable disable
|
||||
|
||||
namespace BTCPayServer.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20230504125505_StoreRoles")]
|
||||
public partial class StoreRoles : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
|
||||
migrationBuilder.CreateTable(
|
||||
name: "StoreRoles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
StoreDataId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Role = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Permissions = table.Column<string>(type: permissionsType, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_StoreRoles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_StoreRoles_Stores_StoreDataId",
|
||||
column: x => x.StoreDataId,
|
||||
principalTable: "Stores",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_StoreRoles_StoreDataId_Role",
|
||||
table: "StoreRoles",
|
||||
columns: new[] { "StoreDataId", "Role" },
|
||||
unique: true);
|
||||
|
||||
object GetPermissionsData(string[] permissions)
|
||||
{
|
||||
if (migrationBuilder.IsNpgsql())
|
||||
return permissions;
|
||||
return JsonConvert.SerializeObject(permissions);
|
||||
}
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
"StoreRoles",
|
||||
columns: new[] { "Id", "Role", "Permissions" },
|
||||
columnTypes: new[] { "TEXT", "TEXT", permissionsType },
|
||||
values: new object[,]
|
||||
{
|
||||
{
|
||||
"Owner", "Owner", GetPermissionsData(new[]
|
||||
{
|
||||
"btcpay.store.canmodifystoresettings",
|
||||
"btcpay.store.cantradecustodianaccount",
|
||||
"btcpay.store.canwithdrawfromcustodianaccount",
|
||||
"btcpay.store.candeposittocustodianaccount"
|
||||
})
|
||||
},
|
||||
{
|
||||
"Guest", "Guest", GetPermissionsData(new[]
|
||||
{
|
||||
"btcpay.store.canviewstoresettings",
|
||||
"btcpay.store.canmodifyinvoices",
|
||||
"btcpay.store.canviewcustodianaccounts",
|
||||
"btcpay.store.candeposittocustodianaccount"
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
if (this.SupportAddForeignKey(migrationBuilder.ActiveProvider))
|
||||
{
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_UserStore_StoreRoles_Role",
|
||||
table: "UserStore",
|
||||
column: "Role",
|
||||
principalTable: "StoreRoles",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
if (this.SupportDropForeignKey(migrationBuilder.ActiveProvider))
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_UserStore_StoreRoles_Role",
|
||||
table: "UserStore");
|
||||
}
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "StoreRoles");
|
||||
}
|
||||
}
|
||||
}
|
@ -214,56 +214,6 @@ namespace BTCPayServer.Migrations
|
||||
b.ToTable("CustodianAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.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.PayoutProcessorData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Blob")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("PaymentMethod")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Processor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StoreId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StoreId");
|
||||
|
||||
b.ToTable("PayoutProcessors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@ -292,6 +242,31 @@ namespace BTCPayServer.Migrations
|
||||
b.ToTable("Fido2Credentials");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.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.InvoiceData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@ -655,6 +630,34 @@ namespace BTCPayServer.Migrations
|
||||
b.ToTable("Payouts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PayoutProcessorData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Blob")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Blob2")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PaymentMethod")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Processor")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StoreId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StoreId");
|
||||
|
||||
b.ToTable("PayoutProcessors");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@ -802,6 +805,28 @@ namespace BTCPayServer.Migrations
|
||||
b.ToTable("Files");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Permissions")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StoreDataId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StoreDataId", "Role")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("StoreRoles");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
|
||||
{
|
||||
b.Property<string>("StoreId")
|
||||
@ -878,13 +903,16 @@ namespace BTCPayServer.Migrations
|
||||
b.Property<string>("StoreDataId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.HasColumnType("TEXT");
|
||||
b.Property<string>("StoreRoleId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("Role");
|
||||
|
||||
b.HasKey("ApplicationUserId", "StoreDataId");
|
||||
|
||||
b.HasIndex("StoreDataId");
|
||||
|
||||
b.HasIndex("StoreRoleId");
|
||||
|
||||
b.ToTable("UserStore");
|
||||
});
|
||||
|
||||
@ -1188,26 +1216,6 @@ namespace BTCPayServer.Migrations
|
||||
b.Navigation("StoreData");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.FormData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||
.WithMany("Forms")
|
||||
.HasForeignKey("StoreId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PayoutProcessorData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||
.WithMany("PayoutProcessors")
|
||||
.HasForeignKey("StoreId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.Fido2Credential", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
||||
@ -1218,6 +1226,16 @@ namespace BTCPayServer.Migrations
|
||||
b.Navigation("ApplicationUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.FormData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||
.WithMany("Forms")
|
||||
.HasForeignKey("StoreId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
||||
@ -1343,6 +1361,16 @@ namespace BTCPayServer.Migrations
|
||||
b.Navigation("StoreData");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PayoutProcessorData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||
.WithMany("PayoutProcessors")
|
||||
.HasForeignKey("StoreId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
|
||||
@ -1392,6 +1420,16 @@ namespace BTCPayServer.Migrations
|
||||
b.Navigation("ApplicationUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "StoreData")
|
||||
.WithMany("StoreRoles")
|
||||
.HasForeignKey("StoreDataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.Navigation("StoreData");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
|
||||
{
|
||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||
@ -1446,9 +1484,15 @@ namespace BTCPayServer.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("BTCPayServer.Data.StoreRole", "StoreRole")
|
||||
.WithMany("Users")
|
||||
.HasForeignKey("StoreRoleId");
|
||||
|
||||
b.Navigation("ApplicationUser");
|
||||
|
||||
b.Navigation("StoreData");
|
||||
|
||||
b.Navigation("StoreRole");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.WalletObjectLinkData", b =>
|
||||
@ -1606,9 +1650,16 @@ namespace BTCPayServer.Migrations
|
||||
|
||||
b.Navigation("Settings");
|
||||
|
||||
b.Navigation("StoreRoles");
|
||||
|
||||
b.Navigation("UserStores");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
|
||||
{
|
||||
b.Navigation("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
|
||||
{
|
||||
b.Navigation("WalletTransactions");
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
@ -55,7 +56,7 @@ namespace BTCPayServer.Tests
|
||||
Assert.Empty(appList2.Apps);
|
||||
Assert.Equal("test", appList.Apps[0].AppName);
|
||||
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id);
|
||||
Assert.True(appList.Apps[0].IsOwner);
|
||||
Assert.True(appList.Apps[0].Role.ToPermissionSet(appList.Apps[0].StoreId).Contains(Policies.CanModifyStoreSettings, appList.Apps[0].StoreId));
|
||||
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
|
||||
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
|
||||
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));
|
||||
|
@ -21,6 +21,7 @@ using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Custodian.Client.MockCustodian;
|
||||
using BTCPayServer.Services.Notifications;
|
||||
using BTCPayServer.Services.Notifications.Blobs;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@ -228,7 +229,7 @@ namespace BTCPayServer.Tests
|
||||
await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetInvoices(store.Id));
|
||||
|
||||
// if user is a guest or owner, then it should be ok
|
||||
await unrestricted.AddStoreUser(store.Id, new StoreUserData() { UserId = newUser.Id, Role = "Guest" });
|
||||
await unrestricted.AddStoreUser(store.Id, new StoreUserData() { UserId = newUser.Id});
|
||||
await newUserClient.GetInvoices(store.Id);
|
||||
}
|
||||
|
||||
@ -1319,7 +1320,8 @@ namespace BTCPayServer.Tests
|
||||
// We strip the user's Owner right, so the key should not work
|
||||
using var ctx = tester.PayTester.GetService<Data.ApplicationDbContextFactory>().CreateContext();
|
||||
var storeEntity = await ctx.UserStore.SingleAsync(u => u.ApplicationUserId == user.UserId && u.StoreDataId == newStore.Id);
|
||||
storeEntity.Role = "Guest";
|
||||
var roleId = (await tester.PayTester.GetService<StoreRepository>().GetStoreRoles(null)).Single(r => r.Role == "Guest").Id;
|
||||
storeEntity.StoreRoleId = roleId;
|
||||
await ctx.SaveChangesAsync();
|
||||
await AssertHttpError(403, async () => await client.UpdateStore(newStore.Id, new UpdateStoreRequest() { Name = "B" }));
|
||||
|
||||
@ -3365,11 +3367,16 @@ namespace BTCPayServer.Tests
|
||||
|
||||
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
|
||||
|
||||
var roles = await client.GetServerRoles();
|
||||
Assert.Equal(2,roles.Count);
|
||||
#pragma warning disable CS0618
|
||||
var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner);
|
||||
var guestRole = roles.Single(data => data.Role == StoreRoles.Guest);
|
||||
#pragma warning restore CS0618
|
||||
var users = await client.GetStoreUsers(user.StoreId);
|
||||
var storeuser = Assert.Single(users);
|
||||
Assert.Equal(user.UserId, storeuser.UserId);
|
||||
Assert.Equal(StoreRoles.Owner, storeuser.Role);
|
||||
|
||||
Assert.Equal(ownerRole.Id, storeuser.Role);
|
||||
var user2 = tester.NewAccount();
|
||||
await user2.GrantAccessAsync(false);
|
||||
|
||||
@ -3380,7 +3387,7 @@ namespace BTCPayServer.Tests
|
||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
|
||||
|
||||
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = StoreRoles.Guest, UserId = user2.UserId });
|
||||
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = guestRole.Id, UserId = user2.UserId });
|
||||
|
||||
//test no access to api when only a guest
|
||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
|
||||
@ -3394,10 +3401,10 @@ namespace BTCPayServer.Tests
|
||||
await user2Client.GetStore(user.StoreId));
|
||||
|
||||
|
||||
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = StoreRoles.Owner, UserId = user2.UserId });
|
||||
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId });
|
||||
await AssertAPIError("duplicate-store-user-role", async () =>
|
||||
await client.AddStoreUser(user.StoreId,
|
||||
new StoreUserData() { Role = StoreRoles.Owner, UserId = user2.UserId }));
|
||||
new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId }));
|
||||
await user2Client.RemoveStoreUser(user.StoreId, user.UserId);
|
||||
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
@ -1741,11 +1742,12 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
s.Driver.Navigate().Refresh();
|
||||
Assert.Contains("transaction-label", s.Driver.PageSource);
|
||||
var labels = s.Driver.FindElements(By.CssSelector("#WalletTransactionsList tr:first-child div.transaction-label"));
|
||||
Assert.Equal(2, labels.Count);
|
||||
Assert.Contains(labels, element => element.Text == "payout");
|
||||
Assert.Contains(labels, element => element.Text == "pull-payment");
|
||||
});
|
||||
var labels = s.Driver.FindElements(By.CssSelector("#WalletTransactionsList tr:first-child div.transaction-label"));
|
||||
Assert.Equal(2, labels.Count);
|
||||
Assert.Contains(labels, element => element.Text == "payout");
|
||||
Assert.Contains(labels, element => element.Text == "pull-payment");
|
||||
|
||||
|
||||
s.GoToStore(s.StoreId, StoreNavPages.Payouts);
|
||||
s.Driver.FindElement(By.Id($"{PayoutState.InProgress}-view")).Click();
|
||||
@ -2468,6 +2470,145 @@ retry:
|
||||
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url);
|
||||
});
|
||||
}
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
public async Task CanUseRoleManager()
|
||||
{
|
||||
using var s = CreateSeleniumTester(newDb: true);
|
||||
await s.StartAsync();
|
||||
var user = s.RegisterNewUser(true);
|
||||
s.GoToServer(ServerNavPages.Roles);
|
||||
var existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||
Assert.Equal(3, existingServerRoles.Count);
|
||||
IWebElement ownerRow = null;
|
||||
IWebElement guestRow = null;
|
||||
foreach (var roleItem in existingServerRoles)
|
||||
{
|
||||
if (roleItem.Text.Contains("owner", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
ownerRow = roleItem;
|
||||
}
|
||||
else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
guestRow = roleItem;
|
||||
}
|
||||
}
|
||||
|
||||
Assert.NotNull(ownerRow);
|
||||
Assert.NotNull(guestRow);
|
||||
|
||||
var ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
|
||||
Assert.Contains(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||
Assert.Contains(ownerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
var guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
|
||||
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||
Assert.Contains(guestBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
|
||||
guestRow.FindElement(By.Id("SetDefault")).Click();
|
||||
s.FindAlertMessage();
|
||||
|
||||
existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||
foreach (var roleItem in existingServerRoles)
|
||||
{
|
||||
if (roleItem.Text.Contains("owner", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
ownerRow = roleItem;
|
||||
}
|
||||
else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
guestRow = roleItem;
|
||||
}
|
||||
}
|
||||
guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
|
||||
Assert.Contains(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
|
||||
Assert.DoesNotContain(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||
ownerRow.FindElement(By.Id("SetDefault")).Click();
|
||||
s.FindAlertMessage();
|
||||
|
||||
|
||||
|
||||
s.CreateNewStore();
|
||||
s.GoToStore(StoreNavPages.Roles);
|
||||
var existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||
Assert.Equal(3, existingStoreRoles.Count);
|
||||
Assert.Equal(2, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
|
||||
|
||||
foreach (var roleItem in existingStoreRoles)
|
||||
{
|
||||
if (roleItem.Text.Contains("owner", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
ownerRow = roleItem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ownerRow.FindElement(By.LinkText("Remove")).Click();
|
||||
Assert.DoesNotContain("ConfirmContinue", s.Driver.PageSource);
|
||||
s.Driver.Navigate().Back();
|
||||
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||
foreach (var roleItem in existingStoreRoles)
|
||||
{
|
||||
if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
guestRow = roleItem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
guestRow.FindElement(By.LinkText("Remove")).Click();
|
||||
s.Driver.FindElement(By.Id("ConfirmContinue")).Click();
|
||||
s.FindAlertMessage();
|
||||
|
||||
s.GoToStore(StoreNavPages.Roles);
|
||||
s.Driver.FindElement(By.Id("CreateRole")).Click();
|
||||
|
||||
Assert.Contains("Create role", s.Driver.PageSource);
|
||||
s.Driver.FindElement(By.Id("Save")).Click();
|
||||
s.Driver.FindElement(By.Id("Role")).SendKeys("store role");
|
||||
s.Driver.FindElement(By.Id("Save")).Click();
|
||||
s.FindAlertMessage();
|
||||
|
||||
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||
foreach (var roleItem in existingStoreRoles)
|
||||
{
|
||||
if (roleItem.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase))
|
||||
{
|
||||
guestRow = roleItem;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
|
||||
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase));
|
||||
s.GoToStore(StoreNavPages.Users);
|
||||
var options = s.Driver.FindElements(By.CssSelector("#Role option"));
|
||||
Assert.Equal(2, options.Count);
|
||||
Assert.Contains(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
|
||||
s.CreateNewStore();
|
||||
s.GoToStore(StoreNavPages.Roles);
|
||||
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||
Assert.Equal(2, existingStoreRoles.Count);
|
||||
Assert.Equal(1, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
|
||||
Assert.Equal(0, existingStoreRoles.Count(element => element.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase)));
|
||||
s.GoToStore(StoreNavPages.Users);
|
||||
options = s.Driver.FindElements(By.CssSelector("#Role option"));
|
||||
Assert.Single(options);
|
||||
Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
|
||||
s.GoToStore(StoreNavPages.Roles);
|
||||
s.Driver.FindElement(By.Id("CreateRole")).Click();
|
||||
s.Driver.FindElement(By.Id("Role")).SendKeys("Malice");
|
||||
|
||||
s.Driver.ExecuteJavaScript($"document.getElementById('Policies')['{Policies.CanModifyServerSettings}']=new Option('{Policies.CanModifyServerSettings}', '{Policies.CanModifyServerSettings}', true,true);");
|
||||
|
||||
s.Driver.FindElement(By.Id("Save")).Click();
|
||||
s.FindAlertMessage();
|
||||
Assert.Contains("Malice",s.Driver.PageSource);
|
||||
Assert.DoesNotContain(Policies.CanModifyServerSettings,s.Driver.PageSource);
|
||||
}
|
||||
|
||||
private static void CanBrowseContent(SeleniumTester s)
|
||||
{
|
||||
|
@ -470,7 +470,10 @@ namespace BTCPayServer.Tests
|
||||
var req = await _server.GetNextRequest(cancellation);
|
||||
var bytes = await req.Request.Body.ReadBytesAsync((int)req.Request.Headers.ContentLength);
|
||||
var callback = Encoding.UTF8.GetString(bytes);
|
||||
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback));
|
||||
lock (_webhookEvents)
|
||||
{
|
||||
_webhookEvents.Add(JsonConvert.DeserializeObject<WebhookInvoiceEvent>(callback));
|
||||
}
|
||||
req.Response.StatusCode = 200;
|
||||
_server.Done();
|
||||
}
|
||||
@ -487,18 +490,21 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
int retry = 0;
|
||||
retry:
|
||||
foreach (var evt in WebhookEvents)
|
||||
lock (WebhookEvents)
|
||||
{
|
||||
if (evt.Type == eventType)
|
||||
foreach (var evt in WebhookEvents)
|
||||
{
|
||||
var typedEvt = evt.ReadAs<TEvent>();
|
||||
try
|
||||
{
|
||||
assert(typedEvt);
|
||||
return typedEvt;
|
||||
}
|
||||
catch (XunitException)
|
||||
if (evt.Type == eventType)
|
||||
{
|
||||
var typedEvt = evt.ReadAs<TEvent>();
|
||||
try
|
||||
{
|
||||
assert(typedEvt);
|
||||
return typedEvt;
|
||||
}
|
||||
catch (XunitException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -540,12 +546,12 @@ retry:
|
||||
public async Task AddGuest(string userId)
|
||||
{
|
||||
var repo = this.parent.PayTester.GetService<StoreRepository>();
|
||||
await repo.AddStoreUser(StoreId, userId, "Guest");
|
||||
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Guest);
|
||||
}
|
||||
public async Task AddOwner(string userId)
|
||||
{
|
||||
var repo = this.parent.PayTester.GetService<StoreRepository>();
|
||||
await repo.AddStoreUser(StoreId, userId, "Owner");
|
||||
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1962,7 +1962,8 @@ namespace BTCPayServer.Tests
|
||||
Assert.Empty(appList2.Apps);
|
||||
Assert.Equal("test", appList.Apps[0].AppName);
|
||||
Assert.Equal(apps.CreatedAppId, appList.Apps[0].Id);
|
||||
Assert.True(app.IsOwner);
|
||||
|
||||
Assert.True(app.Role.ToPermissionSet(app.StoreId).Contains(Policies.CanModifyStoreSettings, app.StoreId));
|
||||
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
|
||||
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
|
||||
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));
|
||||
|
@ -72,7 +72,6 @@ namespace BTCPayServer.Components.MainNav
|
||||
vm.Apps = apps.Select(a => new StoreApp
|
||||
{
|
||||
Id = a.Id,
|
||||
IsOwner = a.IsOwner,
|
||||
AppName = a.AppName,
|
||||
AppType = a.AppType
|
||||
}).ToList();
|
||||
|
@ -20,6 +20,5 @@ namespace BTCPayServer.Components.MainNav
|
||||
public string Id { get; set; }
|
||||
public string AppName { get; set; }
|
||||
public string AppType { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Services
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject BTCPayServerEnvironment Env
|
||||
@ -29,13 +30,11 @@
|
||||
{
|
||||
<a asp-controller="UIHome" asp-action="Index" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
|
||||
}
|
||||
else if (Model.CurrentStoreIsOwner)
|
||||
{
|
||||
<a asp-controller="UIStores" asp-action="Dashboard" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
|
||||
<a asp-controller="UIStores" asp-action="Dashboard" permission="@Policies.CanModifyStoreSettings" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
|
||||
|
||||
<a asp-controller="UIInvoice" asp-action="ListInvoices" not-permission="@Policies.CanModifyStoreSettings" asp-route-storeId="@Model.CurrentStoreId" id="StoreSelectorHome" class="navbar-brand py-2">@{await LogoContent();}</a>
|
||||
}
|
||||
@if (Model.Options.Any())
|
||||
{
|
||||
|
@ -43,7 +43,6 @@ namespace BTCPayServer.Components.StoreSelector
|
||||
Text = store.StoreName,
|
||||
Value = store.Id,
|
||||
Selected = store.Id == currentStore?.Id,
|
||||
IsOwner = store.Role == StoreRoles.Owner,
|
||||
WalletId = walletId
|
||||
};
|
||||
})
|
||||
@ -57,7 +56,6 @@ namespace BTCPayServer.Components.StoreSelector
|
||||
Options = options,
|
||||
CurrentStoreId = currentStore?.Id,
|
||||
CurrentDisplayName = currentStore?.StoreName,
|
||||
CurrentStoreIsOwner = currentStore?.Role == StoreRoles.Owner,
|
||||
CurrentStoreLogoFileId = blob?.LogoFileId
|
||||
};
|
||||
|
||||
|
@ -8,7 +8,6 @@ namespace BTCPayServer.Components.StoreSelector
|
||||
public string CurrentStoreId { get; set; }
|
||||
public string CurrentStoreLogoFileId { get; set; }
|
||||
public string CurrentDisplayName { get; set; }
|
||||
public bool CurrentStoreIsOwner { get; set; }
|
||||
}
|
||||
|
||||
public class StoreSelectorOption
|
||||
|
@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BTCPayServer.Controllers.Greenfield;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[EnableCors(CorsPolicies.All)]
|
||||
public class GreenfieldServerRolesController : ControllerBase
|
||||
{
|
||||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
public GreenfieldServerRolesController(StoreRepository storeRepository)
|
||||
{
|
||||
_storeRepository = storeRepository;
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/server/roles")]
|
||||
public async Task<IActionResult> GetServerRoles()
|
||||
{
|
||||
return Ok(FromModel(await _storeRepository.GetStoreRoles(null, false, false)));
|
||||
}
|
||||
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
|
||||
{
|
||||
return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = true}).ToList();
|
||||
}
|
||||
|
||||
private IActionResult StoreNotFound()
|
||||
{
|
||||
return this.CreateAPIError(404, "store-not-found", "The store was not found");
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[EnableCors(CorsPolicies.All)]
|
||||
public class GreenfieldStoreRolesController : ControllerBase
|
||||
{
|
||||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
public GreenfieldStoreRolesController(StoreRepository storeRepository)
|
||||
{
|
||||
_storeRepository = storeRepository;
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/stores/{storeId}/roles")]
|
||||
public async Task<IActionResult> GetStoreRoles(string storeId)
|
||||
{
|
||||
var store = HttpContext.GetStoreData();
|
||||
return store == null
|
||||
? StoreNotFound()
|
||||
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
|
||||
}
|
||||
|
||||
|
||||
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
|
||||
{
|
||||
return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = r.IsServerRole}).ToList();
|
||||
}
|
||||
|
||||
private IActionResult StoreNotFound()
|
||||
{
|
||||
return this.CreateAPIError(404, "store-not-found", "The store was not found");
|
||||
}
|
||||
}
|
||||
}
|
@ -63,8 +63,19 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
return StoreNotFound();
|
||||
}
|
||||
//we do not need to validate the role string as any value other than `StoreRoles.Owner` is currently treated like a guest
|
||||
if (await _storeRepository.AddStoreUser(storeId, request.UserId, request.Role))
|
||||
StoreRoleId roleId = null;
|
||||
|
||||
if (request.Role is not null)
|
||||
{
|
||||
roleId = await _storeRepository.ResolveStoreRoleId(storeId, request.Role);
|
||||
if (roleId is null)
|
||||
ModelState.AddModelError(nameof(request.Role), "The role id provided does not exist");
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
return this.CreateValidationError(ModelState);
|
||||
|
||||
if (await _storeRepository.AddStoreUser(storeId, request.UserId, roleId))
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
@ -74,7 +85,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
|
||||
private IEnumerable<StoreUserData> FromModel(Data.StoreData data)
|
||||
{
|
||||
return data.UserStores.Select(store => new StoreUserData() { UserId = store.ApplicationUserId, Role = store.Role });
|
||||
return data.UserStores.Select(store => new StoreUserData() { UserId = store.ApplicationUserId, Role = store.StoreRoleId });
|
||||
}
|
||||
private IActionResult StoreNotFound()
|
||||
{
|
||||
|
@ -1319,5 +1319,27 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
return GetFromActionResult<CrowdfundAppData>(await GetController<GreenfieldAppsController>().GetCrowdfundApp(appId));
|
||||
}
|
||||
public override async Task<PullPaymentData> RefundInvoice(string storeId, string invoiceId, RefundInvoiceRequest request, CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<PullPaymentData>(await GetController<GreenfieldInvoiceController>().RefundInvoice(storeId, invoiceId, request, token));
|
||||
}
|
||||
public override async Task RevokeAPIKey(string userId, string apikey, CancellationToken token = default)
|
||||
{
|
||||
HandleActionResult(await GetController<GreenfieldApiKeysController>().RevokeAPIKey(userId, apikey));
|
||||
}
|
||||
|
||||
public override async Task<ApiKeyData> CreateAPIKey(string userId, CreateApiKeyRequest request, CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<ApiKeyData>(await GetController<GreenfieldApiKeysController>().CreateUserAPIKey(userId, request));
|
||||
}
|
||||
|
||||
public override async Task<List<RoleData>> GetServerRoles(CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<List<RoleData>>(await GetController<GreenfieldServerRolesController>().GetServerRoles());
|
||||
}
|
||||
public override async Task<List<RoleData>> GetStoreRoles(string storeId, CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<List<RoleData>>(await GetController<GreenfieldStoreRolesController>().GetStoreRoles(storeId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
@ -9,30 +8,18 @@ using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Components.StoreSelector;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.StoreViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using ExchangeSharp;
|
||||
using Google.Apis.Auth.OAuth2;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Payment;
|
||||
using NBitpayClient;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
@ -92,13 +79,13 @@ namespace BTCPayServer.Controllers
|
||||
var store = await _storeRepository.FindStore(storeId, userId);
|
||||
if (store != null)
|
||||
{
|
||||
return RedirectToStore(store);
|
||||
return RedirectToStore(userId, store);
|
||||
}
|
||||
}
|
||||
|
||||
var stores = await _storeRepository.GetStoresByUserId(userId);
|
||||
return stores.Any()
|
||||
? RedirectToStore(stores.First())
|
||||
? RedirectToStore(userId, stores.First())
|
||||
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
|
||||
}
|
||||
|
||||
@ -211,9 +198,9 @@ namespace BTCPayServer.Controllers
|
||||
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
|
||||
}
|
||||
|
||||
public RedirectToActionResult RedirectToStore(StoreData store)
|
||||
public RedirectToActionResult RedirectToStore(string userId, StoreData store)
|
||||
{
|
||||
return store.HasPermission(Policies.CanModifyStoreSettings)
|
||||
return store.HasPermission(userId, Policies.CanModifyStoreSettings)
|
||||
? RedirectToAction("Dashboard", "UIStores", new { storeId = store.Id })
|
||||
: RedirectToAction("ListInvoices", "UIInvoice", new { storeId = store.Id });
|
||||
}
|
||||
|
@ -638,7 +638,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
if (explorer is null)
|
||||
return NotSupported("This feature is only available to BTC wallets");
|
||||
if (this.GetCurrentStore().Role != StoreRoles.Owner)
|
||||
if (!GetCurrentStore().HasPermission(GetUserId(), Policies.CanModifyStoreSettings))
|
||||
return Forbid();
|
||||
|
||||
var derivationScheme = (this.GetCurrentStore().GetDerivationSchemeSettings(_NetworkProvider, network.CryptoCode))?.AccountDerivation;
|
||||
|
185
BTCPayServer/Controllers/UIServerController.Roles.cs
Normal file
185
BTCPayServer/Controllers/UIServerController.Roles.cs
Normal file
@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.S3.Transfer;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Models.ServerViewModels;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
public partial class UIServerController
|
||||
{
|
||||
[Route("server/roles")]
|
||||
public async Task<IActionResult> ListRoles(
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
RolesViewModel model,
|
||||
string sortOrder = null
|
||||
)
|
||||
{
|
||||
model ??= new RolesViewModel();
|
||||
|
||||
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
|
||||
var roles = await storeRepository.GetStoreRoles(null);
|
||||
|
||||
if (sortOrder != null)
|
||||
{
|
||||
switch (sortOrder)
|
||||
{
|
||||
case "desc":
|
||||
ViewData["NextRoleSortOrder"] = "asc";
|
||||
roles = roles.OrderByDescending(user => user.Role).ToArray();
|
||||
break;
|
||||
case "asc":
|
||||
roles = roles.OrderBy(user => user.Role).ToArray();
|
||||
ViewData["NextRoleSortOrder"] = "desc";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpGet("server/roles/{role}")]
|
||||
public async Task<IActionResult> CreateOrEditRole(
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
string role)
|
||||
{
|
||||
if (role == "create")
|
||||
{
|
||||
ModelState.Remove(nameof(role));
|
||||
return View(new UpdateRoleViewModel());
|
||||
}
|
||||
else
|
||||
{
|
||||
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role));
|
||||
if (roleData == null)
|
||||
return NotFound();
|
||||
return View(new UpdateRoleViewModel()
|
||||
{
|
||||
Policies = roleData.Permissions,
|
||||
Role = roleData.Role
|
||||
});
|
||||
}
|
||||
}
|
||||
[HttpPost("server/roles/{role}")]
|
||||
public async Task<IActionResult> CreateOrEditRole(
|
||||
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
[FromRoute] string role, UpdateRoleViewModel viewModel)
|
||||
{
|
||||
string successMessage = null;
|
||||
if (role == "create")
|
||||
{
|
||||
successMessage = "Role created";
|
||||
role = viewModel.Role;
|
||||
}
|
||||
else
|
||||
{
|
||||
successMessage = "Role updated";
|
||||
var storeRole = await storeRepository.GetStoreRole(new StoreRoleId(role));
|
||||
if (storeRole == null)
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
var r = await storeRepository.AddOrUpdateStoreRole(new StoreRoleId(role), viewModel.Policies);
|
||||
if (r is null)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
Message = "Role could not be updated"
|
||||
});
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Message = successMessage
|
||||
});
|
||||
|
||||
return RedirectToAction(nameof(ListRoles));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpGet("server/roles/{role}/delete")]
|
||||
public async Task<IActionResult> DeleteRole(
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
string role)
|
||||
{
|
||||
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(role), true);
|
||||
if (roleData == null)
|
||||
return NotFound();
|
||||
|
||||
return View("Confirm",
|
||||
roleData.IsUsed is true
|
||||
? new ConfirmModel("Delete role",
|
||||
$"Unable to proceed: The role <strong>{Html.Encode(roleData.Role)}</strong> is currently assigned to one or more users, it cannot be removed.")
|
||||
: new ConfirmModel("Delete role",
|
||||
$"The role <strong>{Html.Encode(roleData.Role)}</strong> will be permanently deleted. Are you sure?",
|
||||
"Delete"));
|
||||
}
|
||||
|
||||
[HttpPost("server/roles/{role}/delete")]
|
||||
public async Task<IActionResult> DeleteRolePost(
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
string role)
|
||||
{
|
||||
var roleId = new StoreRoleId(role);
|
||||
var roleData = await storeRepository.GetStoreRole(roleId, true);
|
||||
if (roleData == null)
|
||||
return NotFound();
|
||||
if (roleData.IsUsed is true)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var errorMessage = await storeRepository.RemoveStoreRole(roleId);
|
||||
if (errorMessage is null)
|
||||
{
|
||||
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Role deleted";
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = errorMessage;
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(ListRoles));
|
||||
}
|
||||
|
||||
[HttpGet("server/roles/{role}/default")]
|
||||
public async Task<IActionResult> SetDefaultRole(
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
string role)
|
||||
{
|
||||
await storeRepository.SetDefaultRole(role);
|
||||
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Role set default";
|
||||
|
||||
return RedirectToAction(nameof(ListRoles));
|
||||
}
|
||||
}
|
||||
}
|
||||
public class UpdateRoleViewModel
|
||||
{
|
||||
[Required]
|
||||
[Display(Name = "Role")]
|
||||
public string Role { get; set; }
|
||||
|
||||
[Display(Name = "Policies")] public List<string> Policies { get; set; } = new();
|
||||
}
|
164
BTCPayServer/Controllers/UIStoresController.Roles.cs
Normal file
164
BTCPayServer/Controllers/UIStoresController.Roles.cs
Normal file
@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Amazon.S3.Transfer;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Models.ServerViewModels;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
public partial class UIStoresController
|
||||
{
|
||||
[Route("{storeId}/roles")]
|
||||
public async Task<IActionResult> ListRoles(
|
||||
string storeId,
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
RolesViewModel model,
|
||||
string sortOrder = null
|
||||
)
|
||||
{
|
||||
model ??= new RolesViewModel();
|
||||
|
||||
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
|
||||
var roles = await storeRepository.GetStoreRoles(storeId, false, false);
|
||||
|
||||
if (sortOrder != null)
|
||||
{
|
||||
switch (sortOrder)
|
||||
{
|
||||
case "desc":
|
||||
ViewData["NextRoleSortOrder"] = "asc";
|
||||
roles = roles.OrderByDescending(user => user.Role).ToArray();
|
||||
break;
|
||||
case "asc":
|
||||
roles = roles.OrderBy(user => user.Role).ToArray();
|
||||
ViewData["NextRoleSortOrder"] = "desc";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
|
||||
|
||||
return View(model);
|
||||
}
|
||||
|
||||
[HttpGet("{storeId}/roles/{role}")]
|
||||
public async Task<IActionResult> CreateOrEditRole(
|
||||
string storeId,
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
string role)
|
||||
{
|
||||
if (role == "create")
|
||||
{
|
||||
ModelState.Remove(nameof(role));
|
||||
return View(new UpdateRoleViewModel());
|
||||
}
|
||||
else
|
||||
{
|
||||
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(storeId, role));
|
||||
if (roleData == null)
|
||||
return NotFound();
|
||||
return View(new UpdateRoleViewModel()
|
||||
{
|
||||
Policies = roleData.Permissions,
|
||||
Role = roleData.Role
|
||||
});
|
||||
}
|
||||
}
|
||||
[HttpPost("{storeId}/roles/{role}")]
|
||||
public async Task<IActionResult> CreateOrEditRole(
|
||||
string storeId,
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
[FromRoute] string role, UpdateRoleViewModel viewModel)
|
||||
{
|
||||
string successMessage = null;
|
||||
StoreRoleId roleId;
|
||||
if (role == "create")
|
||||
{
|
||||
successMessage = "Role created";
|
||||
role = viewModel.Role;
|
||||
roleId = new StoreRoleId(storeId, role);
|
||||
}
|
||||
else
|
||||
{
|
||||
successMessage = "Role updated";
|
||||
roleId = new StoreRoleId(storeId, role);
|
||||
var storeRole = await storeRepository.GetStoreRole(roleId);
|
||||
if (storeRole == null)
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
var r = await storeRepository.AddOrUpdateStoreRole(roleId, viewModel.Policies);
|
||||
if (r is null)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
Message = "Role could not be updated"
|
||||
});
|
||||
return View(viewModel);
|
||||
}
|
||||
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||
Message = successMessage
|
||||
});
|
||||
|
||||
return RedirectToAction(nameof(ListRoles), new { storeId });
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpGet("{storeId}/roles/{role}/delete")]
|
||||
public async Task<IActionResult> DeleteRole(
|
||||
string storeId,
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
string role)
|
||||
{
|
||||
var roleData = await storeRepository.GetStoreRole(new StoreRoleId(storeId, role), true);;
|
||||
if (roleData == null)
|
||||
return NotFound();
|
||||
|
||||
return View("Confirm",
|
||||
roleData.IsUsed is true
|
||||
? new ConfirmModel("Delete role",
|
||||
$"Unable to proceed: The role <strong>{Html.Encode(roleData.Role)}</strong> is currently assigned to one or more users, it cannot be removed.")
|
||||
: new ConfirmModel("Delete role",
|
||||
$"The role <strong>{Html.Encode(roleData.Role)}</strong> will be permanently deleted. Are you sure?",
|
||||
"Delete"));
|
||||
}
|
||||
|
||||
[HttpPost("{storeId}/roles/{roleId}/delete")]
|
||||
public async Task<IActionResult> DeleteRolePost(
|
||||
string storeId,
|
||||
[FromServices] StoreRepository storeRepository,
|
||||
string role)
|
||||
{
|
||||
var roleId = new StoreRoleId(storeId, role);
|
||||
var roleData = await storeRepository.GetStoreRole(roleId, true);
|
||||
if (roleData == null)
|
||||
return NotFound();
|
||||
if (roleData.IsUsed is true)
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
await storeRepository.RemoveStoreRole(roleId);
|
||||
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Role deleted";
|
||||
return RedirectToAction(nameof(ListRoles), new { storeId });
|
||||
}
|
||||
}
|
||||
}
|
@ -130,6 +130,7 @@ namespace BTCPayServer.Controllers
|
||||
public async Task<IActionResult> StoreUsers()
|
||||
{
|
||||
StoreUsersViewModel vm = new StoreUsersViewModel();
|
||||
vm.Role = StoreRoleId.Guest.Role;
|
||||
await FillUsers(vm);
|
||||
return View(vm);
|
||||
}
|
||||
@ -142,7 +143,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
Email = u.Email,
|
||||
Id = u.Id,
|
||||
Role = u.Role
|
||||
Role = u.StoreRole.Role
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@ -150,7 +151,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
[HttpPost]
|
||||
[Route("{storeId}/users")]
|
||||
public async Task<IActionResult> StoreUsers(StoreUsersViewModel vm)
|
||||
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
|
||||
{
|
||||
await FillUsers(vm);
|
||||
if (!ModelState.IsValid)
|
||||
@ -163,12 +164,16 @@ namespace BTCPayServer.Controllers
|
||||
ModelState.AddModelError(nameof(vm.Email), "User not found");
|
||||
return View(vm);
|
||||
}
|
||||
if (!StoreRoles.AllRoles.Contains(vm.Role))
|
||||
|
||||
var roles = await _Repo.GetStoreRoles(CurrentStore.Id);
|
||||
if (roles.All(role => role.Id != vm.Role))
|
||||
{
|
||||
ModelState.AddModelError(nameof(vm.Role), "Invalid role");
|
||||
return View(vm);
|
||||
}
|
||||
if (!await _Repo.AddStoreUser(CurrentStore.Id, user.Id, vm.Role))
|
||||
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
|
||||
|
||||
if (!await _Repo.AddStoreUser(CurrentStore.Id, user.Id, roleId))
|
||||
{
|
||||
ModelState.AddModelError(nameof(vm.Email), "The user already has access to this store");
|
||||
return View(vm);
|
||||
@ -938,8 +943,9 @@ namespace BTCPayServer.Controllers
|
||||
ViewBag.HidePublicKey = true;
|
||||
ViewBag.ShowStores = true;
|
||||
ViewBag.ShowMenu = false;
|
||||
var stores = await _Repo.GetStoresByUserId(userId);
|
||||
model.Stores = new SelectList(stores.Where(s => s.Role == StoreRoles.Owner), nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
|
||||
var stores = (await _Repo.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
|
||||
|
||||
model.Stores = new SelectList(stores, nameof(CurrentStore.Id), nameof(CurrentStore.StoreName));
|
||||
if (!model.Stores.Any())
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "You need to be owner of at least one store before pairing";
|
||||
@ -1004,14 +1010,14 @@ namespace BTCPayServer.Controllers
|
||||
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
|
||||
}
|
||||
|
||||
var stores = await _Repo.GetStoresByUserId(userId);
|
||||
var stores = (await _Repo.GetStoresByUserId(userId)).Where(data => data.HasPermission(userId, Policies.CanModifyStoreSettings)).ToArray();
|
||||
return View(new PairingModel
|
||||
{
|
||||
Id = pairing.Id,
|
||||
Label = pairing.Label,
|
||||
SIN = pairing.SIN ?? "Server-Initiated Pairing",
|
||||
StoreId = selectedStore ?? stores.FirstOrDefault()?.Id,
|
||||
Stores = stores.Where(u => u.Role == StoreRoles.Owner).Select(s => new PairingModel.StoreViewModel
|
||||
Stores = stores.Select(s => new PairingModel.StoreViewModel
|
||||
{
|
||||
Id = s.Id,
|
||||
Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName
|
||||
|
@ -189,11 +189,7 @@ namespace BTCPayServer.Controllers
|
||||
ListWalletsViewModel.WalletViewModel walletVm = new ListWalletsViewModel.WalletViewModel();
|
||||
wallets.Wallets.Add(walletVm);
|
||||
walletVm.Balance = await wallet.Balance + " " + wallet.Wallet.Network.CryptoCode;
|
||||
walletVm.IsOwner = wallet.Store.Role == StoreRoles.Owner;
|
||||
if (!walletVm.IsOwner)
|
||||
{
|
||||
walletVm.Balance = "";
|
||||
}
|
||||
|
||||
|
||||
walletVm.CryptoCode = wallet.Network.CryptoCode;
|
||||
walletVm.StoreId = wallet.Store.Id;
|
||||
|
@ -74,6 +74,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
.Include(data => data.PullPaymentData)
|
||||
.ThenInclude(data => data.StoreData)
|
||||
.ThenInclude(data => data.UserStores)
|
||||
.ThenInclude(data => data.StoreRole)
|
||||
.Where(data =>
|
||||
payoutIds.Contains(data.Id) &&
|
||||
data.State == PayoutState.AwaitingPayment &&
|
||||
@ -84,7 +85,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
if (approvedStores.TryGetValue(payout.PullPaymentData.StoreId, out var value))
|
||||
return value;
|
||||
value = payout.PullPaymentData.StoreData.UserStores
|
||||
.Any(store => store.Role == StoreRoles.Owner && store.ApplicationUserId == userId);
|
||||
.Any(store => store.ApplicationUserId == userId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings));
|
||||
approvedStores.Add(payout.PullPaymentData.StoreId, value);
|
||||
return value;
|
||||
}).ToList();
|
||||
|
@ -16,32 +16,6 @@ namespace BTCPayServer.Data
|
||||
{
|
||||
public static class StoreDataExtensions
|
||||
{
|
||||
public static PermissionSet GetPermissionSet(this StoreData store)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
if (store.Role is null)
|
||||
return new PermissionSet();
|
||||
return new PermissionSet(store.Role == StoreRoles.Owner
|
||||
? new[]
|
||||
{
|
||||
Permission.Create(Policies.CanModifyStoreSettings, store.Id),
|
||||
Permission.Create(Policies.CanTradeCustodianAccount, store.Id),
|
||||
Permission.Create(Policies.CanWithdrawFromCustodianAccounts, store.Id),
|
||||
Permission.Create(Policies.CanDepositToCustodianAccounts, store.Id)
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
Permission.Create(Policies.CanViewStoreSettings, store.Id),
|
||||
Permission.Create(Policies.CanModifyInvoices, store.Id),
|
||||
Permission.Create(Policies.CanViewCustodianAccounts, store.Id),
|
||||
Permission.Create(Policies.CanDepositToCustodianAccounts, store.Id)
|
||||
});
|
||||
}
|
||||
public static bool HasPermission(this StoreData store, string permission)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
return store.GetPermissionSet().Contains(permission, store.Id);
|
||||
}
|
||||
|
||||
#pragma warning disable CS0618
|
||||
public static PaymentMethodId? GetDefaultPaymentId(this StoreData storeData)
|
||||
|
@ -1,11 +1,40 @@
|
||||
#nullable enable
|
||||
using System.Linq;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
public static class StoreExtensions
|
||||
{
|
||||
public static StoreRole? GetStoreRoleOfUser(this StoreData store, string userId)
|
||||
{
|
||||
return store.UserStores.FirstOrDefault(r => r.ApplicationUserId == userId)?.StoreRole;
|
||||
}
|
||||
|
||||
public static PermissionSet GetPermissionSet(this StoreRole storeRole, string storeId)
|
||||
{
|
||||
return new PermissionSet(storeRole.Permissions
|
||||
.Select(s => Permission.TryCreatePermission(s, storeId, out var permission) ? permission : null)
|
||||
.Where(s => s != null).ToArray());
|
||||
}
|
||||
|
||||
|
||||
public static PermissionSet GetPermissionSet(this StoreData store, string userId)
|
||||
{
|
||||
return store.GetStoreRoleOfUser(userId)?.GetPermissionSet(store.Id)?? new PermissionSet();
|
||||
}
|
||||
|
||||
public static bool HasPermission(this StoreData store, string userId, string permission)
|
||||
{
|
||||
return GetPermissionSet(store, userId).HasPermission(permission, store.Id);
|
||||
}
|
||||
|
||||
public static bool HasPermission(this PermissionSet permissionSet, string permission, string storeId)
|
||||
{
|
||||
return permissionSet.Contains(permission, storeId);
|
||||
}
|
||||
|
||||
public static DerivationSchemeSettings? GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider, string cryptoCode)
|
||||
{
|
||||
var paymentMethod = store
|
||||
|
@ -6,12 +6,12 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Fido2;
|
||||
using BTCPayServer.Fido2.Models;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Plugins.Crowdfund;
|
||||
@ -40,7 +40,6 @@ namespace BTCPayServer.Hosting
|
||||
{
|
||||
public class MigrationStartupTask : IStartupTask
|
||||
{
|
||||
public Logs Logs { get; }
|
||||
|
||||
private readonly ApplicationDbContextFactory _DBContextFactory;
|
||||
private readonly StoreRepository _StoreRepository;
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Stores;
|
||||
|
||||
|
||||
namespace BTCPayServer.Models.AppViewModels
|
||||
@ -14,12 +15,12 @@ namespace BTCPayServer.Models.AppViewModels
|
||||
public string AppName { get; set; }
|
||||
public string AppType { get; set; }
|
||||
public string ViewStyle { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
|
||||
public string UpdateAction { get { return "Update" + AppType; } }
|
||||
public string ViewAction { get { return "View" + AppType; } }
|
||||
public DateTimeOffset Created { get; set; }
|
||||
public AppData App { get; set; }
|
||||
public StoreRepository.StoreRole Role { get; set; }
|
||||
}
|
||||
|
||||
public ListAppViewModel[] Apps { get; set; }
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Services.Stores;
|
||||
|
||||
namespace BTCPayServer.Models.ServerViewModels
|
||||
{
|
||||
@ -20,5 +21,11 @@ namespace BTCPayServer.Models.ServerViewModels
|
||||
public override int CurrentPageCount => Users.Count;
|
||||
public Dictionary<string, string> Roles { get; set; }
|
||||
}
|
||||
public class RolesViewModel : BasePagingViewModel
|
||||
{
|
||||
public List<StoreRepository.StoreRole> Roles { get; set; } = new List<StoreRepository.StoreRole>();
|
||||
public string DefaultRole { get; set; }
|
||||
public override int CurrentPageCount => Roles.Count;
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -11,10 +11,6 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
public string Role { get; set; }
|
||||
public string Id { get; set; }
|
||||
}
|
||||
public StoreUsersViewModel()
|
||||
{
|
||||
Role = StoreRoles.Guest;
|
||||
}
|
||||
[Required]
|
||||
[EmailAddress]
|
||||
public string Email { get; set; }
|
||||
|
@ -12,7 +12,6 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public string StoreId { get; set; }
|
||||
public string CryptoCode { get; set; }
|
||||
public string Balance { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
public WalletId Id { get; set; }
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
@ -57,7 +58,7 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Au
|
||||
var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod;
|
||||
if (lightningSupportedPaymentMethod.IsInternalNode &&
|
||||
!(await Task.WhenAll((await _storeRepository.GetStoreUsers(_PayoutProcesserSettings.StoreId))
|
||||
.Where(user => user.Role == StoreRoles.Owner).Select(user => user.Id)
|
||||
.Where(user => user.StoreRole.ToPermissionSet( _PayoutProcesserSettings.StoreId).Contains(Policies.CanModifyStoreSettings, _PayoutProcesserSettings.StoreId)).Select(user => user.Id)
|
||||
.Select(s => _userService.IsAdminUser(s)))).Any(b => b))
|
||||
{
|
||||
return;
|
||||
|
@ -141,7 +141,7 @@ namespace BTCPayServer.Security
|
||||
{
|
||||
if (store is not null)
|
||||
{
|
||||
if (store.HasPermission(policy))
|
||||
if (store.HasPermission(userId,policy))
|
||||
{
|
||||
success = true;
|
||||
}
|
||||
|
@ -98,7 +98,7 @@ namespace BTCPayServer.Security.Greenfield
|
||||
var store = await _storeRepository.FindStore(storeId, userid);
|
||||
if (store == null)
|
||||
break;
|
||||
if (!store.HasPermission(policy))
|
||||
if (!store.HasPermission(userid, policy))
|
||||
break;
|
||||
success = true;
|
||||
_httpContext.SetStoreData(store);
|
||||
|
@ -5,6 +5,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Models.AppViewModels;
|
||||
using BTCPayServer.Plugins.Crowdfund;
|
||||
@ -236,15 +237,6 @@ namespace BTCPayServer.Services.Apps
|
||||
return invoices;
|
||||
}
|
||||
|
||||
public async Task<StoreData[]> GetOwnedStores(string userId)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
return await ctx.UserStore
|
||||
.Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner)
|
||||
.Select(u => u.StoreData)
|
||||
.ToArrayAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteApp(AppData appData)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
@ -256,25 +248,25 @@ namespace BTCPayServer.Services.Apps
|
||||
public async Task<ListAppsViewModel.ListAppViewModel[]> GetAllApps(string? userId, bool allowNoUser = false, string? storeId = null)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var listApps = await ctx.UserStore
|
||||
var listApps = (await ctx.UserStore
|
||||
.Where(us =>
|
||||
(allowNoUser && string.IsNullOrEmpty(userId) || us.ApplicationUserId == userId) &&
|
||||
(storeId == null || us.StoreDataId == storeId))
|
||||
.Join(ctx.Apps, us => us.StoreDataId, app => app.StoreDataId,
|
||||
(us, app) =>
|
||||
new ListAppsViewModel.ListAppViewModel
|
||||
{
|
||||
IsOwner = us.Role == StoreRoles.Owner,
|
||||
StoreId = us.StoreDataId,
|
||||
StoreName = us.StoreData.StoreName,
|
||||
AppName = app.Name,
|
||||
AppType = app.AppType,
|
||||
Id = app.Id,
|
||||
Created = app.Created,
|
||||
App = app
|
||||
})
|
||||
.OrderBy(b => b.Created)
|
||||
.ToArrayAsync();
|
||||
.Include(store => store.StoreRole)
|
||||
.Include(store => store.StoreData)
|
||||
.Join(ctx.Apps, us => us.StoreDataId, app => app.StoreDataId, (us, app) => new { us, app })
|
||||
.OrderBy(b => b.app.Created)
|
||||
.ToArrayAsync()).Select(arg => new ListAppsViewModel.ListAppViewModel
|
||||
{
|
||||
Role = StoreRepository.ToStoreRole(arg.us.StoreRole),
|
||||
StoreId = arg.us.StoreDataId,
|
||||
StoreName = arg.us.StoreData.StoreName,
|
||||
AppName = arg.app.Name,
|
||||
AppType = arg.app.AppType,
|
||||
Id = arg.app.Id,
|
||||
Created = arg.app.Created,
|
||||
App = arg.app
|
||||
}).ToArray();
|
||||
|
||||
// allowNoUser can lead to apps being included twice, unify them with distinct
|
||||
if (allowNoUser)
|
||||
@ -368,7 +360,8 @@ namespace BTCPayServer.Services.Apps
|
||||
return null;
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var app = await ctx.UserStore
|
||||
.Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner)
|
||||
.Include(store => store.StoreRole)
|
||||
.Where(us => us.ApplicationUserId == userId && us.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings))
|
||||
.SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId))
|
||||
.FirstOrDefaultAsync();
|
||||
if (app == null)
|
||||
|
@ -52,6 +52,9 @@ namespace BTCPayServer.Services
|
||||
public List<DomainToAppMappingItem> DomainToAppMapping { get; set; } = new List<DomainToAppMappingItem>();
|
||||
[Display(Name = "Enable experimental features")]
|
||||
public bool Experimental { get; set; }
|
||||
|
||||
[Display(Name = "Default role for users on a new store")]
|
||||
public string DefaultRole { get; set; }
|
||||
|
||||
public class BlockExplorerOverrideItem
|
||||
{
|
||||
|
@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Migrations;
|
||||
@ -18,6 +19,7 @@ namespace BTCPayServer.Services.Stores
|
||||
{
|
||||
private readonly ApplicationDbContextFactory _ContextFactory;
|
||||
private readonly EventAggregator _eventAggregator;
|
||||
private readonly SettingsRepository _settingsRepository;
|
||||
|
||||
public JsonSerializerSettings SerializerSettings { get; }
|
||||
|
||||
@ -25,10 +27,11 @@ namespace BTCPayServer.Services.Stores
|
||||
{
|
||||
return _ContextFactory.CreateContext();
|
||||
}
|
||||
public StoreRepository(ApplicationDbContextFactory contextFactory, JsonSerializerSettings serializerSettings, EventAggregator eventAggregator)
|
||||
public StoreRepository(ApplicationDbContextFactory contextFactory, JsonSerializerSettings serializerSettings, EventAggregator eventAggregator, SettingsRepository settingsRepository)
|
||||
{
|
||||
_ContextFactory = contextFactory ?? throw new ArgumentNullException(nameof(contextFactory));
|
||||
_eventAggregator = eventAggregator;
|
||||
_settingsRepository = settingsRepository;
|
||||
SerializerSettings = serializerSettings;
|
||||
}
|
||||
|
||||
@ -45,56 +48,168 @@ namespace BTCPayServer.Services.Stores
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(userId);
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
return (await ctx
|
||||
.UserStore
|
||||
.Where(us => us.ApplicationUserId == userId && us.StoreDataId == storeId)
|
||||
.Include(store => store.StoreData.UserStores)
|
||||
.Select(us => new
|
||||
{
|
||||
Store = us.StoreData,
|
||||
Role = us.Role
|
||||
}).ToArrayAsync())
|
||||
.Select(us =>
|
||||
{
|
||||
us.Store.Role = us.Role;
|
||||
return us.Store;
|
||||
}).FirstOrDefault();
|
||||
return await ctx
|
||||
.UserStore
|
||||
.Where(us => us.ApplicationUserId == userId && us.StoreDataId == storeId)
|
||||
.Include(store => store.StoreData.UserStores)
|
||||
.ThenInclude(store => store.StoreRole)
|
||||
.Select(us => us.StoreData).FirstOrDefaultAsync();
|
||||
}
|
||||
#nullable disable
|
||||
public class StoreUser
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Email { get; set; }
|
||||
public StoreRole StoreRole { get; set; }
|
||||
}
|
||||
|
||||
public class StoreRole
|
||||
{
|
||||
public PermissionSet ToPermissionSet(string storeId)
|
||||
{
|
||||
return new PermissionSet(Permissions
|
||||
.Select(s => Permission.TryCreatePermission(s, storeId, out var permission) ? permission : null)
|
||||
.Where(s => s != null).ToArray());
|
||||
}
|
||||
public string Role { get; set; }
|
||||
public List<string> Permissions { get; set; }
|
||||
public bool IsServerRole { get; set; }
|
||||
public string Id { get; set; }
|
||||
public bool? IsUsed { get; set; }
|
||||
}
|
||||
#nullable enable
|
||||
public async Task<StoreRole[]> GetStoreRoles(string? storeId, bool includeUsers = false, bool storeOnly = false)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var query = ctx.StoreRoles.Where(u => (storeOnly && u.StoreDataId == storeId) || (!storeOnly && (u.StoreDataId == null || u.StoreDataId == storeId)));
|
||||
if (includeUsers)
|
||||
{
|
||||
query = query.Include(u => u.Users);
|
||||
}
|
||||
return (await query.ToArrayAsync()).Select(role => ToStoreRole(role)).ToArray();
|
||||
}
|
||||
|
||||
public async Task<StoreRoleId> GetDefaultRole()
|
||||
{
|
||||
var r = (await _settingsRepository.GetSettingAsync<PoliciesSettings>())?.DefaultRole;
|
||||
if (r is not null)
|
||||
return new StoreRoleId(r);
|
||||
return StoreRoleId.Owner;
|
||||
}
|
||||
public async Task SetDefaultRole(string role)
|
||||
{
|
||||
var s = (await _settingsRepository.GetSettingAsync<PoliciesSettings>()) ?? new PoliciesSettings();
|
||||
s.DefaultRole = role;
|
||||
await _settingsRepository.UpdateSetting(s);
|
||||
}
|
||||
|
||||
public async Task<StoreRole?> GetStoreRole(StoreRoleId role, bool includeUsers = false)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var query = ctx.StoreRoles.AsQueryable();
|
||||
if (includeUsers)
|
||||
{
|
||||
query = query.Include(u => u.Users);
|
||||
}
|
||||
var match = await query.SingleOrDefaultAsync(r => r.Id == role.Id);
|
||||
if (match == null)
|
||||
return null;
|
||||
if (match.StoreDataId != role.StoreId)
|
||||
// Should never happen because the roleId include the storeId
|
||||
throw new InvalidOperationException("Bug 03991: This should never happen");
|
||||
return ToStoreRole(match);
|
||||
}
|
||||
|
||||
public async Task<string?> RemoveStoreRole(StoreRoleId role)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
if (await GetDefaultRole() == role)
|
||||
{
|
||||
return "Cannot remove the default role";
|
||||
}
|
||||
|
||||
var match = await ctx.StoreRoles.FindAsync(role.Id);
|
||||
if (match != null && match.StoreDataId == role.StoreId)
|
||||
{
|
||||
if (role.StoreId is null && match.Permissions.Contains(Policies.CanModifyStoreSettings) &&
|
||||
await ctx.StoreRoles.CountAsync(role =>
|
||||
role.StoreDataId == null && role.Permissions.Contains(Policies.CanModifyStoreSettings)) == 1)
|
||||
return "This is the last role that allows to modify store settings, you cannot remove it";
|
||||
ctx.StoreRoles.Remove(match);
|
||||
await ctx.SaveChangesAsync();
|
||||
return null;
|
||||
}
|
||||
|
||||
return "Role not found";
|
||||
}
|
||||
|
||||
public async Task<StoreRole?> AddOrUpdateStoreRole(StoreRoleId role, List<string> policies)
|
||||
{
|
||||
policies = policies.Where(s => Policies.IsValidPolicy(s) && Policies.IsStorePolicy(s)).ToList();
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
Data.StoreRole? match = await ctx.StoreRoles.FindAsync(role.Id);
|
||||
if (match is null)
|
||||
{
|
||||
match = new Data.StoreRole() { Id = role.Id, StoreDataId = role.StoreId, Role = role.Role };
|
||||
ctx.StoreRoles.Add(match);
|
||||
}
|
||||
match.Permissions = policies;
|
||||
try
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return ToStoreRole(match);
|
||||
}
|
||||
|
||||
|
||||
public async Task<StoreUser[]> GetStoreUsers(string storeId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storeId);
|
||||
using var ctx = _ContextFactory.CreateContext();
|
||||
return await ctx
|
||||
.UserStore
|
||||
.Where(u => u.StoreDataId == storeId)
|
||||
.Select(u => new StoreUser()
|
||||
{
|
||||
Id = u.ApplicationUserId,
|
||||
Email = u.ApplicationUser.Email,
|
||||
Role = u.Role
|
||||
}).ToArrayAsync();
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
return (await
|
||||
ctx
|
||||
.UserStore
|
||||
.Where(u => u.StoreDataId == storeId)
|
||||
.Include(u => u.StoreRole)
|
||||
.Select(u => new
|
||||
{
|
||||
Id = u.ApplicationUserId,
|
||||
u.ApplicationUser.Email,
|
||||
u.StoreRole
|
||||
}).ToArrayAsync()).Select(arg => new StoreUser()
|
||||
{
|
||||
StoreRole = ToStoreRole(arg.StoreRole),
|
||||
Id = arg.Id,
|
||||
Email = arg.Email
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
public static StoreRole ToStoreRole(Data.StoreRole storeRole)
|
||||
{
|
||||
return new StoreRole()
|
||||
{
|
||||
Id = storeRole.Id,
|
||||
Role = storeRole.Role,
|
||||
Permissions = storeRole.Permissions,
|
||||
IsServerRole = storeRole.StoreDataId == null,
|
||||
IsUsed = storeRole.Users?.Any()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<StoreData[]> GetStoresByUserId(string userId, IEnumerable<string>? storeIds = null)
|
||||
{
|
||||
using var ctx = _ContextFactory.CreateContext();
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
return (await ctx.UserStore
|
||||
.Where(u => u.ApplicationUserId == userId && (storeIds == null || storeIds.Contains(u.StoreDataId)))
|
||||
.Select(u => new { u.StoreData, u.Role })
|
||||
.ToArrayAsync())
|
||||
.Select(u =>
|
||||
{
|
||||
u.StoreData.Role = u.Role;
|
||||
return u.StoreData;
|
||||
}).ToArray();
|
||||
.Include(store => store.StoreData)
|
||||
.ThenInclude(data => data.UserStores)
|
||||
.ThenInclude(data => data.StoreRole)
|
||||
.Select(store => store.StoreData)
|
||||
.ToArrayAsync());
|
||||
}
|
||||
|
||||
public async Task<StoreData?> GetStoreByInvoiceId(string invoiceId)
|
||||
@ -105,29 +220,68 @@ namespace BTCPayServer.Services.Stores
|
||||
return matched?.StoreData;
|
||||
}
|
||||
|
||||
public async Task<bool> AddStoreUser(string storeId, string userId, string role)
|
||||
/// <summary>
|
||||
/// `role` can be passed in two format:
|
||||
/// STOREID::ROLE or ROLE.
|
||||
/// If the first case, this method make sure the storeId is same as <paramref name="storeId"/>.
|
||||
/// In the second case, we interprete ROLE as a server level roleId first, then if it does not exist, check if there is a store level role.
|
||||
/// </summary>
|
||||
/// <param name="storeId"></param>
|
||||
/// <param name="role"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<StoreRoleId?> ResolveStoreRoleId(string storeId, string? role)
|
||||
{
|
||||
using var ctx = _ContextFactory.CreateContext();
|
||||
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, Role = role };
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
return null;
|
||||
if (role.Contains("::", StringComparison.OrdinalIgnoreCase) || storeId.Contains("::", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
var roleId = StoreRoleId.Parse(role);
|
||||
if (roleId.StoreId != null && roleId.StoreId != storeId)
|
||||
return null;
|
||||
if ((await GetStoreRole(roleId)) != null)
|
||||
return roleId;
|
||||
if (roleId.IsServerRole)
|
||||
roleId = new StoreRoleId(storeId, role);
|
||||
if ((await GetStoreRole(roleId)) != null)
|
||||
return roleId;
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<bool> AddStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storeId);
|
||||
AssertStoreRoleIfNeeded(storeId, roleId);
|
||||
roleId ??= await GetDefaultRole();
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id };
|
||||
ctx.UserStore.Add(userStore);
|
||||
try
|
||||
{
|
||||
await ctx.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Microsoft.EntityFrameworkCore.DbUpdateException)
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static void AssertStoreRoleIfNeeded(string storeId, StoreRoleId? roleId)
|
||||
{
|
||||
if (roleId?.StoreId != null && storeId != roleId.StoreId)
|
||||
throw new ArgumentException("The roleId doesn't belong to this storeId", nameof(roleId));
|
||||
}
|
||||
|
||||
public async Task CleanUnreachableStores()
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
if (!ctx.Database.SupportDropForeignKey())
|
||||
return;
|
||||
var events = new List<Events.StoreRemovedEvent>();
|
||||
foreach (var store in await ctx.Stores.Where(s => s.UserStores.All(u => u.Role != StoreRoles.Owner)).ToArrayAsync())
|
||||
foreach (var store in await ctx.Stores.Include(data => data.UserStores)
|
||||
.ThenInclude(store => store.StoreRole).Where(s =>
|
||||
s.UserStores.All(u => !u.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings)))
|
||||
.ToArrayAsync())
|
||||
{
|
||||
ctx.Stores.Remove(store);
|
||||
events.Add(new Events.StoreRemovedEvent(store.Id));
|
||||
@ -139,8 +293,8 @@ namespace BTCPayServer.Services.Stores
|
||||
public async Task<bool> RemoveStoreUser(string storeId, string userId)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
if (!await ctx.UserStore.AnyAsync(store =>
|
||||
store.StoreDataId == storeId && store.Role == StoreRoles.Owner &&
|
||||
if (!await ctx.UserStore.Include(store => store.StoreRole).AnyAsync(store =>
|
||||
store.StoreDataId == storeId && store.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings) &&
|
||||
userId != store.ApplicationUserId))
|
||||
return false;
|
||||
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId };
|
||||
@ -156,7 +310,7 @@ namespace BTCPayServer.Services.Stores
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
if (ctx.Database.SupportDropForeignKey())
|
||||
{
|
||||
if (!await ctx.UserStore.Where(u => u.StoreDataId == storeId && u.Role == StoreRoles.Owner).AnyAsync())
|
||||
if (!await ctx.UserStore.Where(u => u.StoreDataId == storeId && u.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings)).AnyAsync())
|
||||
{
|
||||
var store = await ctx.Stores.FindAsync(storeId);
|
||||
if (store != null)
|
||||
@ -168,20 +322,23 @@ namespace BTCPayServer.Services.Stores
|
||||
}
|
||||
}
|
||||
}
|
||||
public async Task CreateStore(string ownerId, StoreData storeData)
|
||||
public async Task CreateStore(string ownerId, StoreData storeData, StoreRoleId? roleId = null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(storeData.Id))
|
||||
throw new ArgumentException("id should be empty", nameof(storeData.StoreName));
|
||||
if (string.IsNullOrEmpty(storeData.StoreName))
|
||||
throw new ArgumentException("name should not be empty", nameof(storeData.StoreName));
|
||||
ArgumentNullException.ThrowIfNull(ownerId);
|
||||
using var ctx = _ContextFactory.CreateContext();
|
||||
AssertStoreRoleIfNeeded(storeData.Id, roleId);
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
storeData.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32));
|
||||
roleId ??= await GetDefaultRole();
|
||||
|
||||
var userStore = new UserStore
|
||||
{
|
||||
StoreDataId = storeData.Id,
|
||||
ApplicationUserId = ownerId,
|
||||
Role = StoreRoles.Owner,
|
||||
StoreRoleId = roleId.Id,
|
||||
};
|
||||
|
||||
ctx.Add(storeData);
|
||||
@ -234,7 +391,7 @@ namespace BTCPayServer.Services.Stores
|
||||
.Where(s => s.StoreId == storeId && s.WebhookId == webhookId)
|
||||
.SelectMany(s => s.Webhook.Deliveries)
|
||||
.OrderByDescending(s => s.Timestamp);
|
||||
if (count is int c)
|
||||
if (count is { } c)
|
||||
req = req.Take(c);
|
||||
return await req
|
||||
.ToArrayAsync();
|
||||
@ -431,4 +588,66 @@ retry:
|
||||
return ctx.Database.SupportDropForeignKey();
|
||||
}
|
||||
}
|
||||
|
||||
public record StoreRoleId
|
||||
{
|
||||
public static StoreRoleId Parse(string str)
|
||||
{
|
||||
var i = str.IndexOf("::");
|
||||
string? storeId = null;
|
||||
string role;
|
||||
if (i == -1)
|
||||
{
|
||||
role = str;
|
||||
return new StoreRoleId(role);
|
||||
}
|
||||
else
|
||||
{
|
||||
role = str[0..i];
|
||||
storeId = str[(i + 2)..];
|
||||
return new StoreRoleId(storeId, role);
|
||||
}
|
||||
}
|
||||
public StoreRoleId(string role)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
throw new ArgumentException("Role shouldn't be null or empty", nameof(role));
|
||||
if (role.Contains("::", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("Role shouldn't contains '::'", nameof(role));
|
||||
Role = role;
|
||||
}
|
||||
public StoreRoleId(string storeId, string role)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(role))
|
||||
throw new ArgumentException("Role shouldn't be null or empty", nameof(role));
|
||||
if (string.IsNullOrWhiteSpace(storeId))
|
||||
throw new ArgumentException("StoreId shouldn't be null or empty", nameof(storeId));
|
||||
if (role.Contains("::", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("Role shouldn't contains '::'", nameof(role));
|
||||
if (storeId.Contains("::", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException("StoreId shouldn't contains '::'", nameof(storeId));
|
||||
StoreId = storeId;
|
||||
Role = role;
|
||||
}
|
||||
|
||||
public static StoreRoleId Owner { get; } = new StoreRoleId("Owner");
|
||||
public static StoreRoleId Guest { get; } = new StoreRoleId("Guest");
|
||||
public string? StoreId { get; }
|
||||
public string Role { get; }
|
||||
public string Id
|
||||
{
|
||||
get
|
||||
{
|
||||
if (StoreId is null)
|
||||
return Role;
|
||||
return $"{StoreId}::{Role}";
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsServerRole => StoreId is null;
|
||||
public override string ToString()
|
||||
{
|
||||
return Id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,19 +1,13 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
public class StoreRoles
|
||||
{
|
||||
[Obsolete("You should check authorization policies instead of roles")]
|
||||
public const string Owner = "Owner";
|
||||
[Obsolete("You should check authorization policies instead of roles")]
|
||||
public const string Guest = "Guest";
|
||||
public static IEnumerable<String> AllRoles
|
||||
{
|
||||
get
|
||||
{
|
||||
yield return Owner;
|
||||
yield return Guest;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
119
BTCPayServer/Views/Shared/CreateOrEditRole.cshtml
Normal file
119
BTCPayServer/Views/Shared/CreateOrEditRole.cshtml
Normal file
@ -0,0 +1,119 @@
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Views.Server
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using BTCPayServer.Controllers
|
||||
@using BTCPayServer.Views.Stores
|
||||
@model UpdateRoleViewModel
|
||||
@{
|
||||
Layout = "_NavLayout.cshtml";
|
||||
var role = Context.GetRouteValue("role") as string;
|
||||
|
||||
if (role == "create")
|
||||
role = null;
|
||||
|
||||
var storeId = Context.GetRouteValue("storeId") as string;
|
||||
var controller = ViewContext.RouteData.Values["controller"].ToString().TrimEnd("Controller", StringComparison.InvariantCultureIgnoreCase);
|
||||
if (storeId is null)
|
||||
ViewData.SetActivePage(ServerNavPages.Roles, role is null ? "Create role" : "Update role");
|
||||
|
||||
else
|
||||
{
|
||||
ViewData.SetActivePage(StoreNavPages.Roles, role is null ? "Create role" : "Update role");
|
||||
}
|
||||
var storePolicies = Policies.AllPolicies.Where(Policies.IsStorePolicy).ToArray();
|
||||
|
||||
}
|
||||
|
||||
<h3 class="mb-4">@ViewData["Title"]</h3>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xxl-constrain">
|
||||
<form method="post">
|
||||
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||
|
||||
<div class="form-group" style="max-width:320px">
|
||||
<label asp-for="Role" class="form-label"></label>
|
||||
@if (role == null)
|
||||
{
|
||||
<input asp-for="Role" required="required" class="form-control" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<input asp-for="Role" required="required" class="form-control" readonly />
|
||||
}
|
||||
<span asp-validation-for="Role" class="text-danger"></span>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4 mb-3">Permissions</h4>
|
||||
<select multiple="multiple" asp-for="Policies" class="form-select hide-when-js">
|
||||
@foreach (var policy in storePolicies)
|
||||
{
|
||||
<option value="@policy" class="text-truncate" asp-selected="@(Model.Policies?.Contains(policy) ?? false)">@policy</option>
|
||||
}
|
||||
</select>
|
||||
<div class="list-group mb-2">
|
||||
@{
|
||||
var storePolicyMap = Permission.PolicyMap.Where(pair => Policies.IsStorePolicy(pair.Key)).ToArray();
|
||||
var topMostPolicies = storePolicyMap.Where(pair => !storePolicyMap.Any(valuePair => valuePair.Value.Contains(pair.Key)));
|
||||
@foreach (var policy in topMostPolicies)
|
||||
{
|
||||
RenderTree(policy, storePolicyMap, Model.Policies.Contains(policy.Key));
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<span asp-validation-for="Policies" class="text-danger"></span>
|
||||
<button id="Save" type="submit" class="btn btn-primary" name="command" value="Save">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@{
|
||||
void RenderTree(KeyValuePair<string, HashSet<string>> policy, KeyValuePair<string, HashSet<string>>[] storePolicyMap, bool isChecked)
|
||||
{
|
||||
<div class="form-check mb-0">
|
||||
<input type="checkbox" class="form-check-input policy-cb" checked="@isChecked" value="@policy.Key" id="Policy-@policy.Key.Replace(".", "_")" />
|
||||
<label class="h5 fw-semibold form-check-label mb-1" for="Policy-@policy.Key.Replace(".", "_")" data-bs-toggle="tooltip" title="@policy.Key">
|
||||
@UIManageController.AddApiKeyViewModel.PermissionValueItem.PermissionDescriptions[policy.Key].Title
|
||||
</label>
|
||||
<p class="text-muted">@UIManageController.AddApiKeyViewModel.PermissionValueItem.PermissionDescriptions[policy.Key].Description</p>
|
||||
@if (policy.Value?.Any() is true)
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (var subPolicy in policy.Value)
|
||||
{
|
||||
var match = storePolicyMap.SingleOrDefault(pair => pair.Key == subPolicy);
|
||||
RenderTree(match.Key is not null ? match : new KeyValuePair<string, HashSet<string>>(subPolicy, null), storePolicyMap, !isChecked && Model.Policies.Contains(subPolicy));
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<script>
|
||||
function handleCheckboxChange(element) {
|
||||
const { checked, value: policy } = element;
|
||||
const policySelect = document.getElementById('Policies');
|
||||
const subPolicies = element.parentElement.querySelectorAll(`.list-group .policy-cb:not([value="${policy}"])`);
|
||||
|
||||
policySelect.querySelector(`option[value="${policy}"]`).selected = checked;
|
||||
subPolicies.forEach(subPolicy => {
|
||||
subPolicy.checked = checked? false : subPolicy.checked;
|
||||
if (checked){
|
||||
subPolicy.setAttribute("disabled", "disabled");
|
||||
} else {
|
||||
subPolicy.removeAttribute("disabled");
|
||||
}
|
||||
policySelect.querySelector(`option[value="${subPolicy.value}"]`).selected = subPolicy.checked;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll(".policy-cb:checked").forEach(handleCheckboxChange);
|
||||
|
||||
delegate('change', '.policy-cb', event => {
|
||||
handleCheckboxChange(event.target);
|
||||
});
|
||||
});
|
||||
</script>
|
147
BTCPayServer/Views/Shared/ListRoles.cshtml
Normal file
147
BTCPayServer/Views/Shared/ListRoles.cshtml
Normal file
@ -0,0 +1,147 @@
|
||||
@using BTCPayServer.Components
|
||||
@using BTCPayServer.Views.Server
|
||||
@using BTCPayServer.Views.Stores
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Abstractions.Extensions
|
||||
@using BTCPayServer.Client
|
||||
@model BTCPayServer.Models.ServerViewModels.RolesViewModel
|
||||
@{
|
||||
Layout = "_NavLayout.cshtml";
|
||||
var storeId = Context.GetRouteValue("storeId") as string;
|
||||
var controller = ViewContext.RouteData.Values["controller"].ToString().TrimEnd("Controller", StringComparison.InvariantCultureIgnoreCase);
|
||||
if (storeId is null)
|
||||
ViewData.SetActivePage(ServerNavPages.Roles);
|
||||
|
||||
else
|
||||
{
|
||||
ViewData.SetActivePage(StoreNavPages.Roles);
|
||||
}
|
||||
var nextRoleSortOrder = (string) ViewData["NextRoleSortOrder"];
|
||||
String roleSortOrder = null;
|
||||
switch (nextRoleSortOrder)
|
||||
{
|
||||
case "asc":
|
||||
roleSortOrder = "desc";
|
||||
break;
|
||||
case "desc":
|
||||
roleSortOrder = "asc";
|
||||
break;
|
||||
}
|
||||
|
||||
var sortIconClass = "fa-sort";
|
||||
if (roleSortOrder != null)
|
||||
{
|
||||
sortIconClass = $"fa-sort-alpha-{roleSortOrder}";
|
||||
}
|
||||
|
||||
var sortByDesc = "Sort by descending...";
|
||||
var sortByAsc = "Sort by ascending...";
|
||||
|
||||
var showInUseColumn = !Model.Roles.Any(r => r.IsUsed is null);
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="mb-0">@ViewData["Title"]</h3>
|
||||
<a asp-action="CreateOrEditRole" asp-route-storeId="@storeId" class="btn btn-primary" role="button" id="CreateRole" asp-route-role="create"
|
||||
asp-controller="@controller">
|
||||
<span class="fa fa-plus"></span>
|
||||
Add Role
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<a
|
||||
asp-controller="@controller"
|
||||
asp-action="ListRoles"
|
||||
asp-route-storeId="@storeId"
|
||||
asp-route-sortOrder="@(nextRoleSortOrder ?? "asc")"
|
||||
class="text-nowrap"
|
||||
title="@(nextRoleSortOrder == "desc" ? sortByAsc : sortByDesc)">
|
||||
Role
|
||||
<span class="fa @(sortIconClass)" />
|
||||
</a>
|
||||
</th>
|
||||
<th >Permissions</th>
|
||||
@if (showInUseColumn)
|
||||
{
|
||||
<th>In use</th>
|
||||
}
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var role in Model.Roles)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-1">@role.Role</span>
|
||||
@if (role.IsServerRole)
|
||||
{
|
||||
<span class="badge bg-dark">
|
||||
Server-wide
|
||||
</span>
|
||||
@if (Model.DefaultRole == role.Id)
|
||||
{
|
||||
<span class="badge bg-info">
|
||||
Default
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (!role.Permissions.Any())
|
||||
{
|
||||
<span class="text-warning">No policies</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var policy in role.Permissions)
|
||||
{
|
||||
<code class="d-block text-break">@policy</code>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
@if (showInUseColumn)
|
||||
{
|
||||
<td class="text-center">
|
||||
@if (role.IsUsed is true)
|
||||
{
|
||||
<span class="text-success fa fa-check"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-danger fa fa-times"></span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="text-end">
|
||||
<a permission="@(role.IsServerRole ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)" asp-action="CreateOrEditRole" asp-route-storeId="@storeId" asp-route-role="@role.Role"
|
||||
asp-controller="@(role.IsServerRole ? "UIServer" : "UIStores")">
|
||||
Edit
|
||||
</a> -
|
||||
<a permission="@(role.IsServerRole ? Policies.CanModifyServerSettings : Policies.CanModifyStoreSettings)" asp-action="DeleteRole" asp-route-storeId="@storeId" asp-route-role="@role.Role"
|
||||
asp-controller="@(role.IsServerRole ? "UIServer" : "UIStores")">
|
||||
Remove
|
||||
</a>
|
||||
@if (role.IsServerRole && Model.DefaultRole != role.Id)
|
||||
{
|
||||
|
||||
<a permission="@Policies.CanModifyServerSettings" asp-action="SetDefaultRole" asp-route-role="@role.Role"
|
||||
asp-controller="UIServer" id="SetDefault">
|
||||
- Set as default
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<vc:pager view-model="Model"></vc:pager>
|
@ -1,5 +1,6 @@
|
||||
@using BTCPayServer.Services.Apps
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@using BTCPayServer.Client
|
||||
@model ListAppsViewModel
|
||||
@inject AppService AppService
|
||||
@{
|
||||
@ -79,14 +80,11 @@
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (app.IsOwner)
|
||||
{
|
||||
<span><a asp-controller="UIStores" asp-action="GeneralSettings" asp-route-storeId="@app.StoreId">@app.StoreName</a></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>@app.StoreName</span>
|
||||
}
|
||||
<span permission="@Policies.CanModifyStoreSettings">
|
||||
<a asp-controller="UIStores" asp-action="GeneralSettings" asp-route-storeId="@app.StoreId">@app.StoreName</a>
|
||||
</span>
|
||||
|
||||
<span not-permission="@Policies.CanModifyStoreSettings">@app.StoreName</span>
|
||||
</td>
|
||||
<td>@app.AppName</td>
|
||||
<td>
|
||||
@ -100,14 +98,15 @@
|
||||
<span>@viewStyle</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (app.IsOwner)
|
||||
{
|
||||
<td class="text-end" permission="@Policies.CanModifyStoreSettings">
|
||||
|
||||
<a asp-action="@app.UpdateAction" asp-controller="UIApps" asp-route-appId="@app.Id" asp-route-storeId="@app.StoreId">Settings</a>
|
||||
<span> - </span>
|
||||
}
|
||||
|
||||
<a asp-action="DeleteApp" asp-route-appId="@app.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The app <strong>@Html.Encode(app.AppName)</strong> and its settings will be permanently deleted from your store <strong>@Html.Encode(app.StoreName)</strong>." data-confirm-input="DELETE">Delete</a>
|
||||
</td>
|
||||
<td class="text-end" no-permission="@Policies.CanModifyStoreSettings">
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
|
@ -2,6 +2,7 @@ namespace BTCPayServer.Views.Server
|
||||
{
|
||||
public enum ServerNavPages
|
||||
{
|
||||
Index, Users, Emails, Policies, Theme, Services, Maintenance, Logs, Files, Plugins
|
||||
Index, Users, Emails, Policies, Theme, Services, Maintenance, Logs, Files, Plugins,
|
||||
Roles
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@
|
||||
<nav id="SectionNav">
|
||||
<div class="nav">
|
||||
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Users" class="nav-link @ViewData.IsActivePage(ServerNavPages.Users)" asp-action="ListUsers">Users</a>
|
||||
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Roles" class="nav-link @ViewData.IsActivePage(ServerNavPages.Roles)" asp-action="ListRoles">Roles</a>
|
||||
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Emails" class="nav-link @ViewData.IsActivePage(ServerNavPages.Emails)" asp-action="Emails">Email</a>
|
||||
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Policies" class="nav-link @ViewData.IsActivePage(ServerNavPages.Policies)" asp-action="Policies">Policies</a>
|
||||
<a asp-controller="UIServer" id="SectionNav-@ServerNavPages.Services" class="nav-link @ViewData.IsActivePage(ServerNavPages.Services)" asp-action="Services">Services</a>
|
||||
|
@ -24,6 +24,7 @@ namespace BTCPayServer.Views.Stores
|
||||
[Obsolete("Use StoreNavPages.Plugins instead")]
|
||||
Integrations,
|
||||
Emails,
|
||||
Forms
|
||||
Forms,
|
||||
Roles
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,13 @@
|
||||
@using BTCPayServer.Abstractions.Models
|
||||
@using BTCPayServer.Services.Stores
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@model StoreUsersViewModel
|
||||
@inject IScopeProvider ScopeProvider
|
||||
@inject StoreRepository StoreRepository
|
||||
@{
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id);
|
||||
var roles = new SelectList(await StoreRepository.GetStoreRoles(ScopeProvider.GetCurrentStoreId()), nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role), Model.Role);
|
||||
}
|
||||
|
||||
<div class="row">
|
||||
@ -24,9 +29,7 @@
|
||||
<input asp-for="Email" type="text" class="form-control" placeholder="user@example.com">
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
<select asp-for="Role" class="form-select">
|
||||
<option value="@StoreRoles.Owner">Owner</option>
|
||||
<option value="@StoreRoles.Guest">Guest</option>
|
||||
<select asp-for="Role" class="form-select" asp-items="roles">
|
||||
</select>
|
||||
</div>
|
||||
<div class="ms-3">
|
||||
|
@ -14,6 +14,7 @@
|
||||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.CheckoutAppearance))" class="nav-link @ViewData.IsActivePage(StoreNavPages.CheckoutAppearance)" asp-controller="UIStores" asp-action="CheckoutAppearance" asp-route-storeId="@storeId">Checkout Appearance</a>
|
||||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Tokens))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Tokens)" asp-controller="UIStores" asp-action="ListTokens" asp-route-storeId="@storeId">Access Tokens</a>
|
||||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Users))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Users)" asp-controller="UIStores" asp-action="StoreUsers" asp-route-storeId="@storeId">Users</a>
|
||||
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Roles))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Roles)" asp-controller="UIStores" asp-action="ListRoles" asp-route-storeId="@storeId">Roles</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.Emails))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@storeId">Emails</a>
|
||||
|
@ -1,4 +1,5 @@
|
||||
@model BTCPayServer.Models.WalletViewModels.ListWalletsViewModel
|
||||
@using BTCPayServer.Client
|
||||
@model BTCPayServer.Models.WalletViewModels.ListWalletsViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(WalletsNavPages.Index, "Wallets");
|
||||
}
|
||||
@ -42,16 +43,18 @@
|
||||
@foreach (var wallet in Model.Wallets)
|
||||
{
|
||||
<tr>
|
||||
@if (wallet.IsOwner)
|
||||
{
|
||||
<td><a asp-action="GeneralSettings" asp-controller="UIStores" asp-route-storeId="@wallet.StoreId">@wallet.StoreName</a></td>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td>@wallet.StoreName</td>
|
||||
}
|
||||
|
||||
<td>
|
||||
<a
|
||||
permission="@Policies.CanModifyStoreSettings"
|
||||
asp-action="GeneralSettings" asp-controller="UIStores" asp-route-storeId="@wallet.StoreId">
|
||||
@wallet.StoreName
|
||||
</a>
|
||||
<span not-permission="@Policies.CanModifyStoreSettings">@wallet.StoreName</span>
|
||||
</td>
|
||||
|
||||
<td>@wallet.CryptoCode</td>
|
||||
<td>@wallet.Balance</td>
|
||||
<td><span permission="@Policies.CanModifyStoreSettings">@wallet.Balance</span></td>
|
||||
<td class="text-end">
|
||||
<a asp-action="WalletTransactions" asp-route-walletId="@wallet.Id">Manage</a>
|
||||
</td>
|
||||
|
@ -47,6 +47,42 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/server/roles": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"ServerInfo"
|
||||
],
|
||||
"summary": "Get store's roles",
|
||||
"description": "View information about the store's roles at the server's scope",
|
||||
"operationId": "Server_GetStoreRoles",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user roles available at the server's scope",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RoleData"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "If you are authenticated but forbidden to get the store's roles"
|
||||
},
|
||||
"404": {
|
||||
"description": "Store not found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"API_Key": [
|
||||
"btcpay.server.canmodifyserversettings"
|
||||
],
|
||||
"Basic": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
|
@ -254,10 +254,92 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/stores/{storeId}/roles": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Stores"
|
||||
],
|
||||
"summary": "Get store's roles",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "storeId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "The store to fetch",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "View information about the specified store's roles",
|
||||
"operationId": "Stores_GetStoreRoles",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user roles available for this store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RoleData"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "If you are authenticated but forbidden to get the store's roles"
|
||||
},
|
||||
"404": {
|
||||
"description": "Store not found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"API_Key": [
|
||||
"btcpay.store.canmodifystoresettings"
|
||||
],
|
||||
"Basic": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"RoleData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"description": "The role's Id (Same as role if the role is created at server level, if the role is created at the store level the format is `STOREID::ROLE`)",
|
||||
"type": "string",
|
||||
"nullable": false,
|
||||
"example": "Owner"
|
||||
},
|
||||
"role": {
|
||||
"description": "The role's name",
|
||||
"type": "string",
|
||||
"nullable": false,
|
||||
"example": "Owner"
|
||||
},
|
||||
"permissions": {
|
||||
"description": "The permissions attached to this role",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": [
|
||||
"btcpay.store.canmodifystoresettings",
|
||||
"btcpay.store.cantradecustodianaccount",
|
||||
"btcpay.store.canwithdrawfromcustodianaccount",
|
||||
"btcpay.store.candeposittocustodianaccount"
|
||||
]
|
||||
},
|
||||
"isServerRole": {
|
||||
"description": "Whether this role is at the scope of the store or scope of the server",
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"StoreDataList": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@ -463,7 +545,7 @@
|
||||
"nullable": true,
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PaymentMethodCriteriaData"
|
||||
},
|
||||
},
|
||||
"description": "The criteria required to activate specific payment methods."
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user