Decouple User from Store

This commit is contained in:
NicolasDorier 2017-09-13 23:50:36 +09:00
parent 79200412fd
commit 467ecd0923
40 changed files with 919 additions and 336 deletions

View File

@ -1,6 +1,7 @@
using BTCPayServer.Controllers;
using BTCPayServer.Invoicing;
using BTCPayServer.Models.AccountViewModels;
using BTCPayServer.Models.StoreViewModels;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitpayClient;
@ -37,15 +38,18 @@ namespace BTCPayServer.Tests
Password = "Kitten0@",
});
UserId = account.RegisteredUserId;
StoreId = account.RegisteredStoreId;
var manage = parent.PayTester.GetController<ManageController>(account.RegisteredUserId);
await manage.Index(new Models.ManageViewModels.IndexViewModel()
var store = parent.PayTester.GetController<StoresController>(account.RegisteredUserId);
await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" });
StoreId = store.CreatedStoreId;
await store.UpdateStore(StoreId, new StoreViewModel()
{
ExtPubKey = extKey.Neuter().ToString(),
SpeedPolicy = SpeedPolicy.MediumSpeed
});
Assert.IsType<ViewResult>(await manage.AskPairing(pairingCode.ToString()));
await manage.Pairs(pairingCode.ToString());
Assert.IsType<ViewResult>(await store.RequestPairing(pairingCode.ToString()));
await store.Pair(pairingCode.ToString(), StoreId);
}
public Bitpay BitPay

View File

@ -148,9 +148,11 @@ namespace BTCPayServer.Authentication
}
public async Task DeleteToken(string sin, string tokenName)
public async Task<bool> DeleteToken(string sin, string tokenName, string storeId)
{
var token = await GetToken(sin, tokenName);
if(token == null || (token.PairedId != null && token.PairedId != storeId))
return false;
using(var tx = _Engine.GetTransaction())
{
tx.RemoveKey<string>($"T_{sin}", tokenName);
@ -158,6 +160,7 @@ namespace BTCPayServer.Authentication
tx.RemoveKey<string>($"TbP_" + token.PairedId, token.Value);
tx.Commit();
}
return true;
}
public Task<BitTokenEntity> GetToken(string sin, string tokenName)

View File

@ -234,8 +234,6 @@ namespace BTCPayServer.Controllers
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var callbackUrl = Url.EmailConfirmationLink(user.Id, code, Request.Scheme);
RegisteredUserId = user.Id;
var store = await storeRepository.CreateStore(user.Id);
RegisteredStoreId = store.Id;
await _emailSender.SendEmailConfirmationAsync(model.Email, callbackUrl);
await _signInManager.SignInAsync(user, isPersistent: false);
_logger.LogInformation("User created a new account with password.");
@ -256,14 +254,6 @@ namespace BTCPayServer.Controllers
get; set;
}
/// <summary>
/// Test property
/// </summary>
public string RegisteredStoreId
{
get; set;
}
[HttpGet]
public async Task<IActionResult> Logout()
{

View File

@ -2,6 +2,7 @@
using BTCPayServer.Filters;
using BTCPayServer.Invoicing;
using BTCPayServer.Models.InvoicingModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitpayClient;
@ -89,18 +90,18 @@ namespace BTCPayServer.Controllers
}
[HttpGet]
[Route("Invoices")]
[Route("invoices")]
[Authorize(AuthenticationSchemes = "Identity.Application")]
[BitpayAPIConstraint(false)]
public async Task<IActionResult> ListInvoices(string searchTerm = null, int skip = 0, int count = 20)
{
var store = await FindStore(User);
var model = new InvoicesModel();
foreach(var invoice in await _InvoiceRepository.GetInvoices(new InvoiceQuery()
{
TextSearch = searchTerm,
Count = count,
Skip = skip,
StoreId = store.Id
UserId = GetUserId()
}))
{
model.SearchTerm = searchTerm;
@ -119,7 +120,8 @@ namespace BTCPayServer.Controllers
}
[HttpGet]
[Route("Invoices/Create")]
[Route("invoices/create")]
[Authorize(AuthenticationSchemes = "Identity.Application")]
[BitpayAPIConstraint(false)]
public IActionResult CreateInvoice()
{
@ -127,7 +129,8 @@ namespace BTCPayServer.Controllers
}
[HttpPost]
[Route("Invoices/Create")]
[Route("invoices/create")]
[Authorize(AuthenticationSchemes = "Identity.Application")]
[BitpayAPIConstraint(false)]
public async Task<IActionResult> CreateInvoice(CreateInvoiceModel model)
{
@ -135,7 +138,7 @@ namespace BTCPayServer.Controllers
{
return View(model);
}
var store = await FindStore(User);
var store = await _StoreRepository.FindStore(model.StoreId, GetUserId());
var result = await CreateInvoiceCore(new Invoice()
{
Price = model.Amount.Value,
@ -154,6 +157,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost]
[Authorize(AuthenticationSchemes = "Identity.Application")]
[BitpayAPIConstraint(false)]
public IActionResult SearchInvoice(InvoicesModel invoices)
{
@ -172,14 +176,9 @@ namespace BTCPayServer.Controllers
set;
}
private async Task<StoreData> FindStore(ClaimsPrincipal user)
private string GetUserId()
{
var usr = await _UserManager.GetUserAsync(User);
if(user == null)
{
throw new ApplicationException($"Unable to load user with ID '{_UserManager.GetUserId(User)}'.");
}
return await _StoreRepository.GetStore(usr.Id);
return _UserManager.GetUserId(User);
}
}
}

View File

@ -88,7 +88,7 @@ namespace BTCPayServer.Controllers
var entity = new InvoiceEntity
{
InvoiceTime = DateTimeOffset.UtcNow,
DerivationStrategy = derivationStrategy ?? throw new BitpayHttpException(400, "This user has not configured his derivation strategy")
DerivationStrategy = derivationStrategy ?? throw new BitpayHttpException(400, "This store has not configured the derivation strategy")
};
EmailAddressAttribute emailValidator = new EmailAddressAttribute();
entity.ExpirationTime = entity.InvoiceTime + TimeSpan.FromMinutes(15.0);

View File

@ -34,7 +34,7 @@ namespace BTCPayServer.Controllers
TokenRepository _TokenRepository;
private readonly BTCPayWallet _Wallet;
IHostingEnvironment _Env;
IExternalUrlProvider _UrlProvider;
private readonly IExternalUrlProvider _UrlProvider;
StoreRepository _StoreRepository;
@ -78,7 +78,6 @@ namespace BTCPayServer.Controllers
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var store = await _StoreRepository.GetStore(user.Id);
var model = new IndexViewModel
{
@ -86,11 +85,7 @@ namespace BTCPayServer.Controllers
Email = user.Email,
PhoneNumber = user.PhoneNumber,
IsEmailConfirmed = user.EmailConfirmed,
StatusMessage = StatusMessage,
ExtPubKey = store.DerivationStrategy,
StoreWebsite = store.StoreWebsite,
StoreName = store.StoreName,
SpeedPolicy = store.SpeedPolicy
StatusMessage = StatusMessage
};
return View(model);
}
@ -111,33 +106,7 @@ namespace BTCPayServer.Controllers
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var store = await _StoreRepository.GetStore(user.Id);
if(model.ExtPubKey != store.DerivationStrategy)
{
store.DerivationStrategy = model.ExtPubKey;
await _Wallet.TrackAsync(store.DerivationStrategy);
needUpdate = true;
}
if(model.SpeedPolicy != store.SpeedPolicy)
{
store.SpeedPolicy = model.SpeedPolicy;
needUpdate = true;
}
if(model.StoreName != store.StoreName)
{
store.StoreName = model.StoreName;
needUpdate = true;
}
if(model.StoreWebsite != store.StoreWebsite)
{
store.StoreWebsite = model.StoreWebsite;
needUpdate = true;
}
var email = user.Email;
if(model.Email != email)
{
@ -161,7 +130,6 @@ namespace BTCPayServer.Controllers
if(needUpdate)
{
var result = await _userManager.UpdateAsync(user);
await _StoreRepository.UpdateStore(store);
if(!result.Succeeded)
{
throw new ApplicationException($"Unexpected error occurred updating user with ID '{user.Id}'.");
@ -352,45 +320,6 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ExternalLogins));
}
[HttpGet]
[Route("/api-access-request")]
public async Task<IActionResult> AskPairing(string pairingCode)
{
var pairing = await _TokenRepository.GetPairingAsync(pairingCode);
if(pairing == null)
{
StatusMessage = "Unknown pairing code";
return RedirectToAction(nameof(Pairs));
}
else
{
return View(new PairingModel()
{
Id = pairing.Id,
Facade = pairing.Facade,
Label = pairing.Label,
SIN = pairing.SIN
});
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Pairs(string pairingCode)
{
var store = await _StoreRepository.GetStore(_userManager.GetUserId(User));
if(pairingCode != null && await _TokenRepository.PairWithAsync(pairingCode, store.Id))
{
StatusMessage = "Pairing is successfull";
return RedirectToAction(nameof(ListTokens));
}
else
{
StatusMessage = "Pairing failed";
return RedirectToAction(nameof(ListTokens));
}
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveLogin(RemoveLoginViewModel model)
@ -524,74 +453,7 @@ namespace BTCPayServer.Controllers
_logger.LogInformation("User with ID {UserId} has enabled 2FA with an authenticator app.", user.Id);
return RedirectToAction(nameof(GenerateRecoveryCodes));
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CreateToken(CreateTokenViewModel model)
{
if(!ModelState.IsValid)
{
return View(model);
}
string storeId = await GetStoreId();
var url = new Uri(_UrlProvider.GetAbsolute(""));
var bitpay = new Bitpay(new NBitcoin.Key(), url);
var pairing = await bitpay.RequestClientAuthorizationAsync(model.Label, new Facade(model.Facade));
var link = pairing.CreateLink(url).ToString();
await _TokenRepository.PairWithAsync(pairing.ToString(), storeId);
StatusMessage = "New access token paired to this store";
return RedirectToAction(nameof(ListTokens));
}
private async Task<string> GetStoreId()
{
var user = await _userManager.GetUserAsync(User);
if(user == null)
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
return (await _StoreRepository.GetStore(user.Id)).Id;
}
[HttpGet]
public IActionResult CreateToken()
{
var model = new CreateTokenViewModel();
model.Facade = "merchant";
if(_Env.IsDevelopment())
{
model.PublicKey = new Key().PubKey.ToHex();
}
return View(model);
}
[HttpPost]
public async Task<IActionResult> DeleteToken(string name, string sin)
{
await _TokenRepository.DeleteToken(sin, name);
StatusMessage = "Token revoked";
return RedirectToAction(nameof(ListTokens));
}
[HttpGet]
public async Task<IActionResult> ListTokens()
{
var model = new TokensViewModel();
var tokens = await _TokenRepository.GetTokensByPairedIdAsync(await GetStoreId());
model.StatusMessage = StatusMessage;
model.Tokens = tokens.Select(t => new TokenViewModel()
{
Facade = t.Name,
Label = t.Label,
SIN = t.SIN,
Id = t.Value
}).ToArray();
return View(model);
}
[HttpGet]
public IActionResult ResetAuthenticatorWarning()
{

View File

@ -0,0 +1,298 @@
using BTCPayServer.Authentication;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Stores;
using BTCPayServer.Wallet;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitpayClient;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Controllers
{
[Route("stores")]
[Authorize(AuthenticationSchemes = "Identity.Application")]
[Authorize(Policy = "CanAccessStore")]
public class StoresController : Controller
{
public StoresController(
StoreRepository repo,
TokenRepository tokenRepo,
UserManager<ApplicationUser> userManager,
AccessTokenController tokenController,
BTCPayWallet wallet,
IHostingEnvironment env)
{
_Repo = repo;
_TokenRepository = tokenRepo;
_UserManager = userManager;
_TokenController = tokenController;
_Wallet = wallet;
_Env = env;
}
BTCPayWallet _Wallet;
AccessTokenController _TokenController;
StoreRepository _Repo;
TokenRepository _TokenRepository;
UserManager<ApplicationUser> _UserManager;
IHostingEnvironment _Env;
[TempData]
public string StatusMessage
{
get; set;
}
[HttpGet]
[Route("create")]
public IActionResult CreateStore()
{
return View();
}
[HttpPost]
[Route("create")]
public async Task<IActionResult> CreateStore(CreateStoreViewModel vm)
{
if(!ModelState.IsValid)
{
return View(vm);
}
var store = await _Repo.CreateStore(GetUserId(), vm.Name);
CreatedStoreId = store.Id;
StatusMessage = "Store successfully created";
return RedirectToAction(nameof(ListStores));
}
public string CreatedStoreId
{
get; set;
}
[HttpGet]
public async Task<IActionResult> ListStores()
{
StoresViewModel result = new StoresViewModel();
result.StatusMessage = StatusMessage;
var stores = await _Repo.GetStoresByUserId(GetUserId());
foreach(var store in stores)
{
result.Stores.Add(new StoresViewModel.StoreViewModel()
{
Id = store.Id,
Name = store.StoreName,
WebSite = store.StoreWebsite
});
}
return View(result);
}
[HttpGet]
[Route("{storeId}")]
public async Task<IActionResult> UpdateStore(string storeId)
{
var store = await _Repo.FindStore(storeId, GetUserId());
if(store == null)
return NotFound();
var vm = new StoreViewModel();
vm.StoreName = store.StoreName;
vm.StoreWebsite = store.StoreWebsite;
vm.SpeedPolicy = store.SpeedPolicy;
vm.ExtPubKey = store.DerivationStrategy;
vm.StatusMessage = StatusMessage;
return View(vm);
}
[HttpPost]
[ValidateAntiForgeryToken]
[Route("{storeId}")]
public async Task<IActionResult> UpdateStore(string storeId, StoreViewModel model)
{
if(!ModelState.IsValid)
{
return View(model);
}
var store = await _Repo.FindStore(storeId, GetUserId());
if(store == null)
return NotFound();
bool needUpdate = false;
if(store.SpeedPolicy != model.SpeedPolicy)
{
needUpdate = true;
store.SpeedPolicy = model.SpeedPolicy;
}
if(store.StoreName != model.StoreName)
{
needUpdate = true;
store.StoreName = model.StoreName;
}
if(store.StoreWebsite != model.StoreWebsite)
{
needUpdate = true;
store.StoreWebsite = model.StoreWebsite;
}
if(store.DerivationStrategy != model.ExtPubKey)
{
needUpdate = true;
try
{
await _Wallet.TrackAsync(model.ExtPubKey);
store.DerivationStrategy = model.ExtPubKey;
}
catch
{
ModelState.AddModelError(nameof(model.ExtPubKey), "Invalid Derivation Scheme");
return View(model);
}
}
if(needUpdate)
{
await _Repo.UpdateStore(store);
StatusMessage = "Store successfully updated";
}
return RedirectToAction(nameof(UpdateStore), new
{
storeId = storeId
});
}
[HttpGet]
[Route("{storeId}/Tokens")]
public async Task<IActionResult> ListTokens(string storeId)
{
var model = new TokensViewModel();
var tokens = await _TokenRepository.GetTokensByPairedIdAsync(storeId);
model.StatusMessage = StatusMessage;
model.Tokens = tokens.Select(t => new TokenViewModel()
{
Facade = t.Name,
Label = t.Label,
SIN = t.SIN,
Id = t.Value
}).ToArray();
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
[Route("{storeId}/Tokens/Create")]
public async Task<IActionResult> CreateToken(string storeId, CreateTokenViewModel model)
{
if(!ModelState.IsValid)
{
return View(model);
}
var pairingCode = await _TokenController.GetPairingCode(new PairingCodeRequest()
{
Facade = model.Facade,
Label = model.Label,
Id = NBitpayClient.Extensions.BitIdExtensions.GetBitIDSIN(new PubKey(model.PublicKey))
});
return RedirectToAction(nameof(RequestPairing), new
{
pairingCode = pairingCode.Data[0].PairingCode,
selectedStore = storeId
});
}
[HttpGet]
[Route("{storeId}/Tokens/Create")]
public IActionResult CreateToken()
{
var model = new CreateTokenViewModel();
model.Facade = "merchant";
if(_Env.IsDevelopment())
{
model.PublicKey = new Key().PubKey.ToHex();
}
return View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
[Route("{storeId}/Tokens/Delete")]
public async Task<IActionResult> DeleteToken(string storeId, string name, string sin)
{
if(await _TokenRepository.DeleteToken(sin, name, storeId))
StatusMessage = "Token revoked";
else
StatusMessage = "Failure to revoke this token";
return RedirectToAction(nameof(ListTokens));
}
[HttpGet]
[Route("/api-access-request")]
public async Task<IActionResult> RequestPairing(string pairingCode, string selectedStore = null)
{
var pairing = await _TokenRepository.GetPairingAsync(pairingCode);
if(pairing == null)
{
StatusMessage = "Unknown pairing code";
return RedirectToAction(nameof(ListStores));
}
else
{
var stores = await _Repo.GetStoresByUserId(GetUserId());
return View(new PairingModel()
{
Id = pairing.Id,
Facade = pairing.Facade,
Label = pairing.Label,
SIN = pairing.SIN,
SelectedStore = selectedStore ?? stores.FirstOrDefault()?.Id,
Stores = stores.Select(s => new PairingModel.StoreViewModel()
{
Id = s.Id,
Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName
}).ToArray()
});
}
}
[HttpPost]
[ValidateAntiForgeryToken]
[Route("api-access-request")]
public async Task<IActionResult> Pair(string pairingCode, string selectedStore)
{
var store = await _Repo.FindStore(selectedStore, GetUserId());
if(store == null)
return NotFound();
if(pairingCode != null && await _TokenRepository.PairWithAsync(pairingCode, store.Id))
{
StatusMessage = "Pairing is successfull";
return RedirectToAction(nameof(ListTokens), new
{
storeId = store.Id
});
}
else
{
StatusMessage = "Pairing failed";
return RedirectToAction(nameof(ListTokens), new
{
storeId = store.Id
});
}
}
private string GetUserId()
{
return _UserManager.GetUserId(User);
}
}
}

View File

@ -40,6 +40,11 @@ namespace BTCPayServer.Data
get; set;
}
public DbSet<UserStore> UserStore
{
get; set;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var options = optionsBuilder.Options.FindExtension<SqliteOptionsExtension>();

View File

@ -2,6 +2,7 @@
using BTCPayServer.Models;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
@ -44,5 +45,11 @@ namespace BTCPayServer.Data
{
get; set;
}
[NotMapped]
public string Role
{
get; set;
}
}
}

View File

@ -25,5 +25,10 @@ namespace BTCPayServer.Data
{
get; set;
}
public string Role
{
get;
set;
}
}
}

View File

@ -18,6 +18,10 @@ using Microsoft.AspNetCore.Identity;
using BTCPayServer.Data;
using Microsoft.Extensions.Logging;
using BTCPayServer.Logging;
using Microsoft.AspNetCore.Authorization;
using System.Threading.Tasks;
using BTCPayServer.Stores;
using BTCPayServer.Controllers;
namespace BTCPayServer.Hosting
{
@ -30,6 +34,20 @@ namespace BTCPayServer.Hosting
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
services.AddAuthorization(o =>
{
o.AddPolicy("CanAccessStore", builder =>
{
builder.AddRequirements(new OwnStoreAuthorizationRequirement());
});
o.AddPolicy("OwnStore", builder =>
{
builder.AddRequirements(new OwnStoreAuthorizationRequirement("Owner"));
});
});
services.AddSingleton<IAuthorizationHandler, OwnStoreHandler>();
services.AddTransient<AccessTokenController>();
// Add application services.
services.AddTransient<IEmailSender, EmailSender>();
@ -42,6 +60,7 @@ namespace BTCPayServer.Hosting
})
.AddJsonFormatters()
.AddFormatterMappings();
services.AddMvc();
}
public void Configure(
@ -67,4 +86,45 @@ namespace BTCPayServer.Hosting
});
}
}
public class OwnStoreAuthorizationRequirement : IAuthorizationRequirement
{
public OwnStoreAuthorizationRequirement()
{
}
public OwnStoreAuthorizationRequirement(string role)
{
Role = role;
}
public string Role
{
get; set;
}
}
public class OwnStoreHandler : AuthorizationHandler<OwnStoreAuthorizationRequirement>
{
StoreRepository _StoreRepository;
UserManager<ApplicationUser> _UserManager;
public OwnStoreHandler(StoreRepository storeRepository, UserManager<ApplicationUser> userManager)
{
_StoreRepository = storeRepository;
_UserManager = userManager;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, OwnStoreAuthorizationRequirement requirement)
{
object storeId = null;
if(!((Microsoft.AspNetCore.Mvc.ActionContext)context.Resource).RouteData.Values.TryGetValue("storeId", out storeId))
context.Succeed(requirement);
else
{
var store = await _StoreRepository.FindStore((string)storeId, _UserManager.GetUserId(((Microsoft.AspNetCore.Mvc.ActionContext)context.Resource).HttpContext.User));
if(store != null)
if(requirement.Role == null || requirement.Role == store.Role)
context.Succeed(requirement);
}
}
}
}

View File

@ -225,6 +225,11 @@ namespace BTCPayServer.Invoicing
query = query.Where(i => i.StoreDataId == queryObject.StoreId);
}
if(queryObject.UserId != null)
{
query = query.Where(i => i.StoreData.UserStores.Any(u => u.ApplicationUserId == queryObject.UserId));
}
if(!string.IsNullOrEmpty(queryObject.TextSearch))
{
var ids = new HashSet<string>(SearchInvoice(queryObject.TextSearch));
@ -340,6 +345,10 @@ namespace BTCPayServer.Invoicing
{
get; set;
}
public string UserId
{
get; set;
}
public string TextSearch
{
get; set;

View File

@ -12,7 +12,7 @@ using System;
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20170901023716_Init")]
[Migration("20170913143004_Init")]
partial class Init
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@ -107,6 +107,8 @@ namespace BTCPayServer.Migrations
b.Property<string>("StoreDataId");
b.Property<string>("Role");
b.HasKey("ApplicationUserId", "StoreDataId");
b.HasIndex("StoreDataId");

View File

@ -199,7 +199,8 @@ namespace BTCPayServer.Migrations
columns: table => new
{
ApplicationUserId = table.Column<string>(type: "TEXT", nullable: false),
StoreDataId = table.Column<string>(type: "TEXT", nullable: false)
StoreDataId = table.Column<string>(type: "TEXT", nullable: false),
Role = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{

View File

@ -106,6 +106,8 @@ namespace BTCPayServer.Migrations
b.Property<string>("StoreDataId");
b.Property<string>("Role");
b.HasKey("ApplicationUserId", "StoreDataId");
b.HasIndex("StoreDataId");

View File

@ -14,6 +14,12 @@ namespace BTCPayServer.Models.InvoicingModels
get; set;
}
[Required]
public string StoreId
{
get; set;
}
public string OrderId
{
get; set;

View File

@ -12,42 +12,26 @@ namespace BTCPayServer.Models.ManageViewModels
{
public string Username { get; set; }
public bool IsEmailConfirmed { get; set; }
[Required]
[EmailAddress]
[Required]
[EmailAddress]
[MaxLength(50)]
public string Email { get; set; }
[ExtPubKeyValidator]
public string ExtPubKey { get; set; }
[Display(Name = "Store Name")]
[MaxLength(50)]
public string StoreName
public string Email
{
get; set;
}
[Display(Name = "Consider the invoice confirmed when the payment transaction...")]
public SpeedPolicy SpeedPolicy
{
get; set;
}
public bool IsEmailConfirmed { get; set; }
[Phone]
[Display(Name = "Phone number")]
[MaxLength(50)]
public string PhoneNumber { get; set; }
public string StatusMessage { get; set; }
[Url]
[Display(Name = "Store Website")]
public string StoreWebsite
public string StatusMessage
{
get;
set;
get; set;
}
}
}

View File

@ -1,27 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.ManageViewModels
{
public class PairingModel
{
public string Id
{
get; set;
}
public string Label
{
get; set;
}
public string Facade
{
get; set;
}
public string SIN
{
get; set;
}
}
}

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.StoreViewModels
{
public class CreateStoreViewModel
{
[Required]
[MaxLength(50)]
[MinLength(1)]
public string Name
{
get; set;
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.StoreViewModels
{
public class PairingModel
{
public class StoreViewModel
{
public string Name
{
get; set;
}
public string Id
{
get; set;
}
}
public string Id
{
get; set;
}
public string Label
{
get; set;
}
public string Facade
{
get; set;
}
public string SIN
{
get; set;
}
public StoreViewModel[] Stores
{
get;
set;
}
[Display(Name = "Pair to")]
[Required]
public string SelectedStore
{
get; set;
}
}
}

View File

@ -0,0 +1,47 @@
using BTCPayServer.Invoicing;
using BTCPayServer.Validations;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.StoreViewModels
{
public class StoreViewModel
{
[Display(Name = "Store Name")]
[Required]
[MaxLength(50)]
[MinLength(1)]
public string StoreName
{
get; set;
}
[Url]
[Display(Name = "Store Website")]
public string StoreWebsite
{
get;
set;
}
[ExtPubKeyValidator]
public string ExtPubKey
{
get; set;
}
[Display(Name = "Consider the invoice confirmed when the payment transaction...")]
public SpeedPolicy SpeedPolicy
{
get; set;
}
public string StatusMessage
{
get; set;
}
}
}

View File

@ -0,0 +1,36 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.StoreViewModels
{
public class StoresViewModel
{
public string StatusMessage
{
get; set;
}
public List<StoreViewModel> Stores
{
get; set;
} = new List<StoreViewModel>();
public class StoreViewModel
{
public string Name
{
get; set;
}
public string WebSite
{
get; set;
}
public string Id
{
get; set;
}
}
}
}

View File

@ -5,7 +5,7 @@ using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
namespace BTCPayServer.Models.ManageViewModels
namespace BTCPayServer.Models.StoreViewModels
{
public class CreateTokenViewModel
{

View File

@ -26,18 +26,55 @@ namespace BTCPayServer.Stores
}
}
public async Task<StoreData> CreateStore(string userId)
public async Task<StoreData> FindStore(string storeId, string userId)
{
if(userId == null)
throw new ArgumentNullException(nameof(userId));
using(var ctx = _ContextFactory.CreateContext())
{
return (await ctx
.UserStore
.Where(us => us.ApplicationUserId == userId && us.StoreDataId == storeId)
.Select(us => new
{
Store = us.StoreData,
Role = us.Role
}).ToArrayAsync())
.Select(us =>
{
us.Store.Role = us.Role;
return us.Store;
}).FirstOrDefault();
}
}
public async Task<StoreData[]> GetStoresByUserId(string userId)
{
using(var ctx = _ContextFactory.CreateContext())
{
return await ctx.UserStore
.Where(u => u.ApplicationUserId == userId)
.Select(u => u.StoreData)
.ToArrayAsync();
}
}
public async Task<StoreData> CreateStore(string ownerId, string name)
{
if(string.IsNullOrEmpty(name))
throw new ArgumentException("name should not be empty", nameof(name));
using(var ctx = _ContextFactory.CreateContext())
{
StoreData store = new StoreData
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32))
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(32)),
StoreName = name
};
var userStore = new UserStore
{
StoreDataId = store.Id,
ApplicationUserId = userId
ApplicationUserId = ownerId,
Role = "Owner"
};
await ctx.AddAsync(store).ConfigureAwait(false);
await ctx.AddAsync(userStore).ConfigureAwait(false);
@ -46,17 +83,6 @@ namespace BTCPayServer.Stores
}
}
public async Task<StoreData> GetStore(string userId)
{
using(var ctx = _ContextFactory.CreateContext())
{
return await ctx
.Stores
.Where(s => s.UserStores.Any(us => us.ApplicationUserId == userId))
.FirstOrDefaultAsync().ConfigureAwait(false);
}
}
public async Task UpdateStore(StoreData store)
{
using(var ctx = _ContextFactory.CreateContext())

View File

@ -43,7 +43,7 @@
<input type="submit" value="Create" class="btn btn-default" />
</div>
</form>
<a asp-action="Index">Back to List</a>
<a asp-action="ListInvoices">Back to List</a>
</div>
</div>
</div>

View File

@ -1,28 +0,0 @@
@model PairingModel
@{
ViewData["Title"] = "Pairing permission";
}
<h4>Pairing permission:</h4>
<table class="table">
<tr>
<th>Label</th>
<td>@Model.Label</td>
</tr>
<tr>
<th>Facade</th>
<td>@Model.Facade</td>
</tr>
<tr>
<th>SIN</th>
<td>@Model.SIN</td>
</tr>
</table>
<form asp-action="Pairs" method="post">
<div>
<input type="hidden" name="pairingCode" value="@Model.Id" />
<button type="submit" class="btn btn-info" title="Approve this pairing demand">Approve</button>
</div>
</form>

View File

@ -19,25 +19,6 @@
<label asp-for="Username"></label>
<input asp-for="Username" class="form-control" disabled />
</div>
<div class="form-group">
<label asp-for="StoreName"></label>
<input asp-for="StoreName" class="form-control" />
<span asp-validation-for="StoreName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="StoreWebsite"></label>
<input asp-for="StoreWebsite" class="form-control" />
<span asp-validation-for="StoreWebsite" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="SpeedPolicy"></label>
<select asp-for="SpeedPolicy" class="form-control">
<option value="0">Is unconfirmed</option>
<option value="1">Has at least 1 confirmation</option>
<option value="2">Has at least 6 confirmations</option>
</select>
<span asp-validation-for="SpeedPolicy" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Email"></label>
@if(Model.IsEmailConfirmed)
@ -54,11 +35,6 @@
}
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ExtPubKey"></label>
<input asp-for="ExtPubKey" class="form-control" />
<span asp-validation-for="ExtPubKey" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="PhoneNumber"></label>
<input asp-for="PhoneNumber" class="form-control" />

View File

@ -1,41 +0,0 @@
@model TokensViewModel
@{
ViewData["Title"] = "Access Tokens";
ViewData.AddActivePage(ManageNavPages.Tokens);
}
<h4>@ViewData["Title"]</h4>
<p>You can allow a public key to access the API of this store</p>
@Html.Partial("_StatusMessage", Model.StatusMessage)
<div class="row">
<div class="col-md-9">
<a asp-action="CreateToken" class="btn btn-success" role="button"><span class="glyphicon glyphicon-plus"></span>Create a new token</a>
<table class="table">
<thead class="thead-inverse">
<tr>
<th>Label</th>
<th>SIN</th>
<th>Facade</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach(var token in Model.Tokens)
{
<tr>
<td>@token.Label</td>
<td>@token.SIN</td>
<td>@token.Facade</td>
<td>
<form asp-action="DeleteToken" method="post">
<input type="hidden" name="name" value="@token.Facade">
<input type="hidden" name="sin" value="@token.SIN">
<button type="submit" class="btn btn-danger" role="button">Revoke</button>
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>

View File

@ -50,7 +50,8 @@
<ul class="navbar-nav ml-auto">
@if(SignInManager.IsSignedIn(User))
{
<li class="nav-item"><a asp-area="" asp-controller="Invoices" asp-action="Index" class="nav-link js-scroll-trigger">Invoices</a></li>
<li class="nav-item"><a asp-area="" asp-controller="Stores" asp-action="ListStores" class="nav-link js-scroll-trigger">Stores</a></li>
<li class="nav-item"><a asp-area="" asp-controller="Invoice" asp-action="ListInvoices" class="nav-link js-scroll-trigger">Invoices</a></li>
<li class="nav-item">
<a asp-area="" asp-controller="Manage" asp-action="Index" title="Manage" class="nav-link js-scroll-trigger">My settings</a>
</li>

View File

@ -0,0 +1,31 @@
@model BTCPayServer.Models.StoreViewModels.CreateStoreViewModel
@{
Layout = "/Views/Shared/_Layout.cshtml";
ViewData["Title"] = "Create a new store";
}
<section>
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h2 class="section-heading">@ViewData["Title"]</h2>
<hr class="primary">
</div>
</div>
<div class="row">
<div class="col-lg-12">
<form asp-action="CreateStore">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group">
<label asp-for="Name" class="control-label"></label>*
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-default" />
</div>
</form>
<a asp-action="ListStores">Back to List</a>
</div>
</div>
</div>
</section>

View File

@ -1,7 +1,7 @@
@model CreateTokenViewModel
@{
ViewData["Title"] = "Create a new token";
ViewData.AddActivePage(ManageNavPages.Tokens);
ViewData.AddActivePage(StoreNavPages.Tokens);
}
<h4>@ViewData["Title"]</h4>
@ -28,7 +28,7 @@
<span asp-validation-for="Facade" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Add token" class="btn btn-default" />
<input type="submit" value="Request pairing" class="btn btn-default" />
</div>
</form>
</div>

View File

@ -0,0 +1,52 @@
@model StoresViewModel
@{
Layout = "/Views/Shared/_Layout.cshtml";
ViewData["Title"] = "Stores";
}
<section>
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
@Html.Partial("_StatusMessage", Model.StatusMessage)
</div>
</div>
<div class="row">
<div class="col-lg-12 text-center">
<h2 class="section-heading">@ViewData["Title"]</h2>
<hr class="primary">
<p>Create and manage store settings.</p>
</div>
</div>
<div class="row">
<a asp-action="CreateStore" class="btn btn-success" role="button"><span class="glyphicon glyphicon-plus"></span> Create a new store</a>
<table class="table">
<thead class="thead-inverse">
<tr>
<th>Name</th>
<th>Website</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach(var store in Model.Stores)
{
<tr>
<td>@store.Name</td>
<td>
@if(!string.IsNullOrEmpty(store.WebSite))
{
<a href="@store.WebSite">@store.WebSite</a>
}</td>
<td><a asp-action="UpdateStore" asp-route-storeId="@store.Id">Settings</a></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</section>

View File

@ -0,0 +1,37 @@
@model TokensViewModel
@{
ViewData["Title"] = "Access Tokens";
ViewData.AddActivePage(StoreNavPages.Tokens);
}
<h4>@ViewData["Title"]</h4>
<p>You can allow a public key to access the API of this store</p>
@Html.Partial("_StatusMessage", Model.StatusMessage)
<a asp-action="CreateToken" class="btn btn-success" role="button"><span class="glyphicon glyphicon-plus"></span>Create a new token</a>
<table class="table">
<thead class="thead-inverse">
<tr>
<th>Label</th>
<th>SIN</th>
<th>Facade</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach(var token in Model.Tokens)
{
<tr>
<td>@token.Label</td>
<td>@token.SIN</td>
<td>@token.Facade</td>
<td>
<form asp-action="DeleteToken" method="post">
<input type="hidden" name="name" value="@token.Facade">
<input type="hidden" name="sin" value="@token.SIN">
<button type="submit" class="btn btn-danger" role="button">Revoke</button>
</form>
</td>
</tr>
}
</tbody>
</table>

View File

@ -0,0 +1,44 @@
@model PairingModel
@{
Layout = "/Views/Shared/_Layout.cshtml";
ViewData["Title"] = "Pairing permission";
}
<section>
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h2 class="section-heading">@ViewData["Title"]</h2>
<hr class="primary">
<p>Create and manage store settings.</p>
</div>
</div>
<div class="row">
<table class="table">
<tr>
<th>Label</th>
<td>@Model.Label</td>
</tr>
<tr>
<th>Facade</th>
<td>@Model.Facade</td>
</tr>
<tr>
<th>SIN</th>
<td>@Model.SIN</td>
</tr>
</table>
</div>
<div class="row">
<form asp-action="Pair" method="post">
<div class="form-group">
<label asp-for="SelectedStore"></label>
<select asp-for="SelectedStore" asp-items="@(new SelectList(Model.Stores,"Id","Name"))" class="form-control"></select>
<span asp-validation-for="SelectedStore" class="text-danger"></span>
</div>
<input type="hidden" name="pairingCode" value="@Model.Id" />
<button type="submit" class="btn btn-info" title="Approve this pairing demand">Approve</button>
</form>
</div>
</div>
</section>

View File

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace BTCPayServer.Views.Stores
{
public static class StoreNavPages
{
public static string ActivePageKey => "ActivePage";
public static string Index => "Index";
public static string Tokens => "Tokens";
public static string TokensNavClass(ViewContext viewContext) => PageNavClass(viewContext, Tokens);
public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
public static string PageNavClass(ViewContext viewContext, string page)
{
var activePage = viewContext.ViewData["ActivePage"] as string;
return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null;
}
public static void AddActivePage(this ViewDataDictionary viewData, string activePage) => viewData[ActivePageKey] = activePage;
}
}

View File

@ -0,0 +1,49 @@
@model StoreViewModel
@{
ViewData["Title"] = "Profile";
ViewData.AddActivePage(BTCPayServer.Views.Stores.StoreNavPages.Index);
}
<h4>@ViewData["Title"]</h4>
@Html.Partial("_StatusMessage", Model.StatusMessage)
<div class="row">
<div class="col-md-6">
<div asp-validation-summary="All" class="text-danger"></div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<form method="post">
<div class="form-group">
<label asp-for="StoreName"></label>
<input asp-for="StoreName" class="form-control" />
<span asp-validation-for="StoreName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="StoreWebsite"></label>
<input asp-for="StoreWebsite" class="form-control" />
<span asp-validation-for="StoreWebsite" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="SpeedPolicy"></label>
<select asp-for="SpeedPolicy" class="form-control">
<option value="0">Is unconfirmed</option>
<option value="1">Has at least 1 confirmation</option>
<option value="2">Has at least 6 confirmations</option>
</select>
<span asp-validation-for="SpeedPolicy" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ExtPubKey"></label>
<input asp-for="ExtPubKey" class="form-control" />
<span asp-validation-for="ExtPubKey" class="text-danger"></span>
</div>
<button type="submit" class="btn btn-default">Save</button>
</form>
</div>
</div>
@section Scripts {
@await Html.PartialAsync("_ValidationScriptsPartial")
}

View File

@ -0,0 +1,30 @@
@{
Layout = "/Views/Shared/_Layout.cshtml";
}
<section>
<div class="container">
<div class="row">
<div class="col-lg-12 text-center">
<h2 class="section-heading">Manage your store</h2>
<hr class="primary">
</div>
</div>
<div>
<div class="row">
<div class="col-md-3">
@await Html.PartialAsync("_StoreNav")
</div>
<div class="col-md-9">
@RenderBody()
</div>
</div>
</div>
</div>
</section>
@section Scripts {
@RenderSection("Scripts", required: false)
}

View File

@ -0,0 +1,11 @@
@using BTCPayServer.Views.Stores
@inject SignInManager<ApplicationUser> SignInManager
@{
var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
}
<ul class="nav nav-pills nav-stacked">
<li class="@StoreNavPages.IndexNavClass(ViewContext)"><a asp-action="UpdateStore">Information</a></li>
<li class="@StoreNavPages.TokensNavClass(ViewContext)"><a asp-action="ListTokens">Access Tokens</a></li>
</ul>

View File

@ -0,0 +1 @@
@using BTCPayServer.Views.Stores

View File

@ -4,4 +4,5 @@
@using BTCPayServer.Models.AccountViewModels
@using BTCPayServer.Models.InvoicingModels
@using BTCPayServer.Models.ManageViewModels
@using BTCPayServer.Models.StoreViewModels
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers