mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 01:43:50 +01:00
Onboarding: Invite new users on store level (#5719)
* Onboarding: Invite new users - Separates the user self-registration and invite cases - Adds invitation email for users created by the admin - Adds invitation tokens to verify user was invited - Adds handler action for invite links - Refactors `UserEventHostedService` - Fixes #5726. * Add permissioned form tag helper * Better way of changing a user's role * Test fixes
This commit is contained in:
parent
b7ce6b7400
commit
09dbe44bca
@ -0,0 +1,35 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Abstractions.TagHelpers;
|
||||||
|
|
||||||
|
[HtmlTargetElement("form", Attributes = "[permissioned]")]
|
||||||
|
public partial class PermissionedFormTagHelper(
|
||||||
|
IAuthorizationService authorizationService,
|
||||||
|
IHttpContextAccessor httpContextAccessor)
|
||||||
|
: TagHelper
|
||||||
|
{
|
||||||
|
public string Permissioned { get; set; }
|
||||||
|
public string PermissionResource { get; set; }
|
||||||
|
|
||||||
|
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
|
||||||
|
{
|
||||||
|
if (httpContextAccessor.HttpContext is null || string.IsNullOrEmpty(Permissioned))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var res = await authorizationService.AuthorizeAsync(httpContextAccessor.HttpContext.User,
|
||||||
|
PermissionResource, Permissioned);
|
||||||
|
if (!res.Succeeded)
|
||||||
|
{
|
||||||
|
var content = await output.GetChildContentAsync();
|
||||||
|
var html = SubmitButtonRegex().Replace(content.GetContent(), "");
|
||||||
|
output.Content.SetHtmlContent($"<fieldset disabled>{html}</fieldset>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex("<(button|input).*?type=\"submit\".*?>.*?</\\1>")]
|
||||||
|
private static partial Regex SubmitButtonRegex();
|
||||||
|
}
|
@ -0,0 +1,98 @@
|
|||||||
|
using BTCPayServer.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BTCPayServer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20240229092905_AddManagerAndEmployeeToStoreRoles")]
|
||||||
|
public partial class AddManagerAndEmployeeToStoreRoles : Migration
|
||||||
|
{
|
||||||
|
object GetPermissionsData(MigrationBuilder migrationBuilder, string[] permissions)
|
||||||
|
{
|
||||||
|
return migrationBuilder.IsNpgsql()
|
||||||
|
? permissions
|
||||||
|
: JsonConvert.SerializeObject(permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
"StoreRoles",
|
||||||
|
columns: new[] { "Id", "Role", "Permissions" },
|
||||||
|
columnTypes: new[] { "TEXT", "TEXT", permissionsType },
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{
|
||||||
|
"Manager", "Manager", GetPermissionsData(migrationBuilder, new[]
|
||||||
|
{
|
||||||
|
"btcpay.store.canviewstoresettings",
|
||||||
|
"btcpay.store.canmodifyinvoices",
|
||||||
|
"btcpay.store.webhooks.canmodifywebhooks",
|
||||||
|
"btcpay.store.canmodifypaymentrequests",
|
||||||
|
"btcpay.store.canmanagepullpayments",
|
||||||
|
"btcpay.store.canmanagepayouts"
|
||||||
|
})
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Employee", "Employee", GetPermissionsData(migrationBuilder, new[]
|
||||||
|
{
|
||||||
|
"btcpay.store.canmodifyinvoices",
|
||||||
|
"btcpay.store.canmodifypaymentrequests",
|
||||||
|
"btcpay.store.cancreatenonapprovedpullpayments",
|
||||||
|
"btcpay.store.canviewpayouts",
|
||||||
|
"btcpay.store.canviewpullpayments"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
"StoreRoles",
|
||||||
|
keyColumns: new[] { "Id" },
|
||||||
|
keyColumnTypes: new[] { "TEXT" },
|
||||||
|
keyValues: new[] { "Guest" },
|
||||||
|
columns: new[] { "Permissions" },
|
||||||
|
columnTypes: new[] { permissionsType },
|
||||||
|
values: new object[]
|
||||||
|
{
|
||||||
|
GetPermissionsData(migrationBuilder, new[]
|
||||||
|
{
|
||||||
|
"btcpay.store.canmodifyinvoices",
|
||||||
|
"btcpay.store.canviewpaymentrequests",
|
||||||
|
"btcpay.store.canviewpullpayments",
|
||||||
|
"btcpay.store.canviewpayouts"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData("StoreRoles", "Id", "Manager");
|
||||||
|
migrationBuilder.DeleteData("StoreRoles", "Id", "Employee");
|
||||||
|
|
||||||
|
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
"StoreRoles",
|
||||||
|
keyColumns: new[] { "Id" },
|
||||||
|
keyColumnTypes: new[] { "TEXT" },
|
||||||
|
keyValues: new[] { "Guest" },
|
||||||
|
columns: new[] { "Permissions" },
|
||||||
|
columnTypes: new[] { permissionsType },
|
||||||
|
values: new object[]
|
||||||
|
{
|
||||||
|
GetPermissionsData(migrationBuilder, new[]
|
||||||
|
{
|
||||||
|
"btcpay.store.canviewstoresettings",
|
||||||
|
"btcpay.store.canmodifyinvoices",
|
||||||
|
"btcpay.store.canviewcustodianaccounts",
|
||||||
|
"btcpay.store.candeposittocustodianaccount"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -16,7 +16,7 @@ namespace BTCPayServer.Migrations
|
|||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
|
||||||
{
|
{
|
||||||
|
@ -3490,7 +3490,6 @@ namespace BTCPayServer.Tests
|
|||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task StoreUsersAPITest()
|
public async Task StoreUsersAPITest()
|
||||||
{
|
{
|
||||||
|
|
||||||
using var tester = CreateServerTester();
|
using var tester = CreateServerTester();
|
||||||
await tester.StartAsync();
|
await tester.StartAsync();
|
||||||
|
|
||||||
@ -3500,52 +3499,83 @@ namespace BTCPayServer.Tests
|
|||||||
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
|
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
|
||||||
|
|
||||||
var roles = await client.GetServerRoles();
|
var roles = await client.GetServerRoles();
|
||||||
Assert.Equal(2,roles.Count);
|
Assert.Equal(4, roles.Count);
|
||||||
#pragma warning disable CS0618
|
#pragma warning disable CS0618
|
||||||
var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner);
|
var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner);
|
||||||
|
var managerRole = roles.Single(data => data.Role == StoreRoles.Manager);
|
||||||
|
var employeeRole = roles.Single(data => data.Role == StoreRoles.Employee);
|
||||||
var guestRole = roles.Single(data => data.Role == StoreRoles.Guest);
|
var guestRole = roles.Single(data => data.Role == StoreRoles.Guest);
|
||||||
#pragma warning restore CS0618
|
#pragma warning restore CS0618
|
||||||
var users = await client.GetStoreUsers(user.StoreId);
|
var users = await client.GetStoreUsers(user.StoreId);
|
||||||
var storeuser = Assert.Single(users);
|
var storeUser = Assert.Single(users);
|
||||||
Assert.Equal(user.UserId, storeuser.UserId);
|
Assert.Equal(user.UserId, storeUser.UserId);
|
||||||
Assert.Equal(ownerRole.Id, storeuser.Role);
|
Assert.Equal(ownerRole.Id, storeUser.Role);
|
||||||
var user2 = tester.NewAccount();
|
var manager = tester.NewAccount();
|
||||||
await user2.GrantAccessAsync(false);
|
await manager.GrantAccessAsync();
|
||||||
|
var employee = tester.NewAccount();
|
||||||
|
await employee.GrantAccessAsync();
|
||||||
|
var guest = tester.NewAccount();
|
||||||
|
await guest.GrantAccessAsync();
|
||||||
|
|
||||||
var user2Client = await user2.CreateClient(Policies.CanModifyStoreSettings);
|
var managerClient = await manager.CreateClient(Policies.CanModifyStoreSettings);
|
||||||
|
var employeeClient = await employee.CreateClient(Policies.CanModifyStoreSettings);
|
||||||
|
var guestClient = await guest.CreateClient(Policies.CanModifyStoreSettings);
|
||||||
|
|
||||||
//test no access to api when unrelated to store at all
|
//test no access to api when unrelated to store at all
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStore(user.StoreId));
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStoreUsers(user.StoreId));
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
|
||||||
|
|
||||||
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
|
||||||
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
|
||||||
|
|
||||||
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
|
||||||
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
|
||||||
|
|
||||||
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = guestRole.Id, UserId = user2.UserId });
|
// add users to store
|
||||||
|
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = managerRole.Id, UserId = manager.UserId });
|
||||||
|
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = employeeRole.Id, UserId = employee.UserId });
|
||||||
|
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = guestRole.Id, UserId = guest.UserId });
|
||||||
|
|
||||||
//test no access to api when only a guest
|
//test no access to api for employee
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
|
||||||
|
|
||||||
|
//test no access to api for guest
|
||||||
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
|
||||||
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
|
||||||
|
|
||||||
|
//test access to api for manager
|
||||||
|
await managerClient.GetStore(user.StoreId);
|
||||||
|
await managerClient.GetStoreUsers(user.StoreId);
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||||
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
|
||||||
|
|
||||||
await user2Client.GetStore(user.StoreId);
|
// updates
|
||||||
|
await client.RemoveStoreUser(user.StoreId, employee.UserId);
|
||||||
|
await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId));
|
||||||
|
|
||||||
await client.RemoveStoreUser(user.StoreId, user2.UserId);
|
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId });
|
||||||
await AssertHttpError(403, async () =>
|
|
||||||
await user2Client.GetStore(user.StoreId));
|
|
||||||
|
|
||||||
|
|
||||||
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId });
|
|
||||||
await AssertAPIError("duplicate-store-user-role", async () =>
|
await AssertAPIError("duplicate-store-user-role", async () =>
|
||||||
await client.AddStoreUser(user.StoreId,
|
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId }));
|
||||||
new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId }));
|
await employeeClient.RemoveStoreUser(user.StoreId, user.UserId);
|
||||||
await user2Client.RemoveStoreUser(user.StoreId, user.UserId);
|
|
||||||
|
|
||||||
|
|
||||||
//test no access to api when unrelated to store at all
|
//test no access to api when unrelated to store at all
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.GetStoreUsers(user.StoreId));
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStore(user.StoreId));
|
||||||
|
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStoreUsers(user.StoreId));
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.AddStoreUser(user.StoreId, new StoreUserData()));
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.AddStoreUser(user.StoreId, new StoreUserData()));
|
||||||
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
|
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
|
||||||
|
|
||||||
await AssertAPIError("store-user-role-orphaned", async () => await user2Client.RemoveStoreUser(user.StoreId, user2.UserId));
|
await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Timeout = 60 * 2 * 1000)]
|
[Fact(Timeout = 60 * 2 * 1000)]
|
||||||
|
@ -407,15 +407,12 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
public void Logout()
|
public void Logout()
|
||||||
{
|
{
|
||||||
if (!Driver.PageSource.Contains("id=\"Nav-Logout\""))
|
if (!Driver.PageSource.Contains("id=\"Nav-Logout\"")) GoToUrl("/account");
|
||||||
{
|
|
||||||
Driver.Navigate().GoToUrl(ServerUri);
|
|
||||||
}
|
|
||||||
Driver.FindElement(By.Id("Nav-Account")).Click();
|
Driver.FindElement(By.Id("Nav-Account")).Click();
|
||||||
Driver.FindElement(By.Id("Nav-Logout")).Click();
|
Driver.FindElement(By.Id("Nav-Logout")).Click();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void LogIn(string user, string password)
|
public void LogIn(string user, string password = "123456")
|
||||||
{
|
{
|
||||||
Driver.FindElement(By.Id("Email")).SendKeys(user);
|
Driver.FindElement(By.Id("Email")).SendKeys(user);
|
||||||
Driver.FindElement(By.Id("Password")).SendKeys(password);
|
Driver.FindElement(By.Id("Password")).SendKeys(password);
|
||||||
@ -656,7 +653,7 @@ retry:
|
|||||||
Driver.FindElement(By.Id("AddUser")).Click();
|
Driver.FindElement(By.Id("AddUser")).Click();
|
||||||
Assert.Contains("User added successfully", FindAlertMessage().Text);
|
Assert.Contains("User added successfully", FindAlertMessage().Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void AssertPageAccess(bool shouldHaveAccess, string url)
|
public void AssertPageAccess(bool shouldHaveAccess, string url)
|
||||||
{
|
{
|
||||||
GoToUrl(url);
|
GoToUrl(url);
|
||||||
|
@ -380,13 +380,13 @@ namespace BTCPayServer.Tests
|
|||||||
s.Driver.Navigate().GoToUrl(url);
|
s.Driver.Navigate().GoToUrl(url);
|
||||||
Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
|
Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
|
||||||
Assert.Equal(usr, s.Driver.FindElement(By.Id("Email")).GetAttribute("value"));
|
Assert.Equal(usr, s.Driver.FindElement(By.Id("Email")).GetAttribute("value"));
|
||||||
Assert.Equal("Set your password", s.Driver.FindElement(By.CssSelector("h4")).Text);
|
Assert.Equal("Create Account", s.Driver.FindElement(By.CssSelector("h4")).Text);
|
||||||
Assert.Contains("Invitation accepted. Please set your password.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info).Text);
|
Assert.Contains("Invitation accepted. Please set your password.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info).Text);
|
||||||
|
|
||||||
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
||||||
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
|
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
|
||||||
s.Driver.FindElement(By.Id("SetPassword")).Click();
|
s.Driver.FindElement(By.Id("SetPassword")).Click();
|
||||||
Assert.Contains("Password successfully set.", s.FindAlertMessage().Text);
|
Assert.Contains("Account successfully created.", s.FindAlertMessage().Text);
|
||||||
|
|
||||||
s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
|
s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
|
||||||
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
||||||
@ -928,11 +928,9 @@ namespace BTCPayServer.Tests
|
|||||||
s.GoToHome();
|
s.GoToHome();
|
||||||
s.Logout();
|
s.Logout();
|
||||||
|
|
||||||
// Let's add Bob as a guest to alice's store
|
// Let's add Bob as an employee to alice's store
|
||||||
s.LogIn(alice);
|
s.LogIn(alice);
|
||||||
s.GoToUrl(storeUrl + "/users");
|
s.AddUserToStore(storeId, bob, "Employee");
|
||||||
s.Driver.FindElement(By.Id("Email")).SendKeys(bob + Keys.Enter);
|
|
||||||
Assert.Contains("User added successfully", s.Driver.PageSource);
|
|
||||||
s.Logout();
|
s.Logout();
|
||||||
|
|
||||||
// Bob should not have access to store, but should have access to invoice
|
// Bob should not have access to store, but should have access to invoice
|
||||||
@ -1063,7 +1061,8 @@ namespace BTCPayServer.Tests
|
|||||||
Policies.CanViewInvoices,
|
Policies.CanViewInvoices,
|
||||||
Policies.CanModifyInvoices,
|
Policies.CanModifyInvoices,
|
||||||
Policies.CanViewPaymentRequests,
|
Policies.CanViewPaymentRequests,
|
||||||
Policies.CanViewStoreSettings,
|
Policies.CanViewPullPayments,
|
||||||
|
Policies.CanViewPayouts,
|
||||||
Policies.CanModifyStoreSettingsUnscoped,
|
Policies.CanModifyStoreSettingsUnscoped,
|
||||||
Policies.CanDeleteUser
|
Policies.CanDeleteUser
|
||||||
});
|
});
|
||||||
@ -1148,13 +1147,8 @@ namespace BTCPayServer.Tests
|
|||||||
using var s = CreateSeleniumTester(newDb: true);
|
using var s = CreateSeleniumTester(newDb: true);
|
||||||
await s.StartAsync();
|
await s.StartAsync();
|
||||||
var userId = s.RegisterNewUser(true);
|
var userId = s.RegisterNewUser(true);
|
||||||
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}";
|
|
||||||
s.CreateNewStore();
|
s.CreateNewStore();
|
||||||
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
|
(_, string appId) = s.CreateApp("PointOfSale");
|
||||||
s.Driver.FindElement(By.Name("AppName")).SendKeys(appName);
|
|
||||||
s.Driver.FindElement(By.Id("Create")).Click();
|
|
||||||
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
|
|
||||||
Assert.Equal(appName, s.Driver.FindElement(By.Id("Title")).GetAttribute("value"));
|
|
||||||
s.Driver.FindElement(By.Id("Title")).Clear();
|
s.Driver.FindElement(By.Id("Title")).Clear();
|
||||||
s.Driver.FindElement(By.Id("Title")).SendKeys("Tea shop");
|
s.Driver.FindElement(By.Id("Title")).SendKeys("Tea shop");
|
||||||
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
|
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
|
||||||
@ -1169,7 +1163,6 @@ namespace BTCPayServer.Tests
|
|||||||
|
|
||||||
s.Driver.FindElement(By.Id("SaveSettings")).Click();
|
s.Driver.FindElement(By.Id("SaveSettings")).Click();
|
||||||
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
||||||
var appId = s.Driver.Url.Split('/')[4];
|
|
||||||
|
|
||||||
s.Driver.FindElement(By.Id("ViewApp")).Click();
|
s.Driver.FindElement(By.Id("ViewApp")).Click();
|
||||||
var windows = s.Driver.WindowHandles;
|
var windows = s.Driver.WindowHandles;
|
||||||
@ -1268,12 +1261,7 @@ namespace BTCPayServer.Tests
|
|||||||
s.CreateNewStore();
|
s.CreateNewStore();
|
||||||
s.AddDerivationScheme();
|
s.AddDerivationScheme();
|
||||||
|
|
||||||
var appName = $"CF-{Guid.NewGuid().ToString()[..21]}";
|
(_, string appId) = s.CreateApp("Crowdfund");
|
||||||
s.Driver.FindElement(By.Id("StoreNav-CreateCrowdfund")).Click();
|
|
||||||
s.Driver.FindElement(By.Name("AppName")).SendKeys(appName);
|
|
||||||
s.Driver.FindElement(By.Id("Create")).Click();
|
|
||||||
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
|
|
||||||
Assert.Equal(appName, s.Driver.FindElement(By.Id("Title")).GetAttribute("value"));
|
|
||||||
s.Driver.FindElement(By.Id("Title")).Clear();
|
s.Driver.FindElement(By.Id("Title")).Clear();
|
||||||
s.Driver.FindElement(By.Id("Title")).SendKeys("Kukkstarter");
|
s.Driver.FindElement(By.Id("Title")).SendKeys("Kukkstarter");
|
||||||
s.Driver.FindElement(By.CssSelector("div.note-editable.card-block")).SendKeys("1BTC = 1BTC");
|
s.Driver.FindElement(By.CssSelector("div.note-editable.card-block")).SendKeys("1BTC = 1BTC");
|
||||||
@ -1293,7 +1281,6 @@ namespace BTCPayServer.Tests
|
|||||||
s.Driver.FindElement(By.Id("SaveSettings")).Click();
|
s.Driver.FindElement(By.Id("SaveSettings")).Click();
|
||||||
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
||||||
var editUrl = s.Driver.Url;
|
var editUrl = s.Driver.Url;
|
||||||
var appId = editUrl.Split('/')[4];
|
|
||||||
|
|
||||||
// Check public page
|
// Check public page
|
||||||
s.Driver.FindElement(By.Id("ViewApp")).Click();
|
s.Driver.FindElement(By.Id("ViewApp")).Click();
|
||||||
@ -3333,6 +3320,7 @@ retry:
|
|||||||
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url);
|
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Selenium", "Selenium")]
|
[Trait("Selenium", "Selenium")]
|
||||||
public async Task CanUseRoleManager()
|
public async Task CanUseRoleManager()
|
||||||
@ -3343,8 +3331,10 @@ retry:
|
|||||||
s.GoToHome();
|
s.GoToHome();
|
||||||
s.GoToServer(ServerNavPages.Roles);
|
s.GoToServer(ServerNavPages.Roles);
|
||||||
var existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
var existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||||
Assert.Equal(3, existingServerRoles.Count);
|
Assert.Equal(5, existingServerRoles.Count);
|
||||||
IWebElement ownerRow = null;
|
IWebElement ownerRow = null;
|
||||||
|
IWebElement managerRow = null;
|
||||||
|
IWebElement employeeRow = null;
|
||||||
IWebElement guestRow = null;
|
IWebElement guestRow = null;
|
||||||
foreach (var roleItem in existingServerRoles)
|
foreach (var roleItem in existingServerRoles)
|
||||||
{
|
{
|
||||||
@ -3352,6 +3342,14 @@ retry:
|
|||||||
{
|
{
|
||||||
ownerRow = roleItem;
|
ownerRow = roleItem;
|
||||||
}
|
}
|
||||||
|
else if (roleItem.Text.Contains("manager", StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
managerRow = roleItem;
|
||||||
|
}
|
||||||
|
else if (roleItem.Text.Contains("employee", StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
employeeRow = roleItem;
|
||||||
|
}
|
||||||
else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
|
else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
guestRow = roleItem;
|
guestRow = roleItem;
|
||||||
@ -3359,11 +3357,21 @@ retry:
|
|||||||
}
|
}
|
||||||
|
|
||||||
Assert.NotNull(ownerRow);
|
Assert.NotNull(ownerRow);
|
||||||
|
Assert.NotNull(managerRow);
|
||||||
|
Assert.NotNull(employeeRow);
|
||||||
Assert.NotNull(guestRow);
|
Assert.NotNull(guestRow);
|
||||||
|
|
||||||
var ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
|
var ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
|
||||||
Assert.Contains(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
Assert.Contains(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||||
Assert.Contains(ownerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
|
Assert.Contains(ownerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
|
||||||
|
var managerBadges = managerRow.FindElements(By.CssSelector(".badge"));
|
||||||
|
Assert.DoesNotContain(managerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
Assert.Contains(managerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
|
||||||
|
var employeeBadges = employeeRow.FindElements(By.CssSelector(".badge"));
|
||||||
|
Assert.DoesNotContain(employeeBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
Assert.Contains(employeeBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
|
||||||
var guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
|
var guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
|
||||||
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
|
||||||
@ -3391,13 +3399,11 @@ retry:
|
|||||||
ownerRow.FindElement(By.Id("SetDefault")).Click();
|
ownerRow.FindElement(By.Id("SetDefault")).Click();
|
||||||
s.FindAlertMessage();
|
s.FindAlertMessage();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
s.CreateNewStore();
|
s.CreateNewStore();
|
||||||
s.GoToStore(StoreNavPages.Roles);
|
s.GoToStore(StoreNavPages.Roles);
|
||||||
var existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
var existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||||
Assert.Equal(3, existingStoreRoles.Count);
|
Assert.Equal(5, existingStoreRoles.Count);
|
||||||
Assert.Equal(2, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
|
Assert.Equal(4, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
|
||||||
|
|
||||||
foreach (var roleItem in existingStoreRoles)
|
foreach (var roleItem in existingStoreRoles)
|
||||||
{
|
{
|
||||||
@ -3448,20 +3454,19 @@ retry:
|
|||||||
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase));
|
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase));
|
||||||
s.GoToStore(StoreNavPages.Users);
|
s.GoToStore(StoreNavPages.Users);
|
||||||
var options = s.Driver.FindElements(By.CssSelector("#Role option"));
|
var options = s.Driver.FindElements(By.CssSelector("#Role option"));
|
||||||
Assert.Equal(2, options.Count);
|
Assert.Equal(4, options.Count);
|
||||||
Assert.Contains(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
|
Assert.Contains(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
|
||||||
s.CreateNewStore();
|
s.CreateNewStore();
|
||||||
s.GoToStore(StoreNavPages.Roles);
|
s.GoToStore(StoreNavPages.Roles);
|
||||||
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
|
||||||
Assert.Equal(2, existingStoreRoles.Count);
|
Assert.Equal(4, existingStoreRoles.Count);
|
||||||
Assert.Equal(1, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
|
Assert.Equal(3, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
|
||||||
Assert.Equal(0, existingStoreRoles.Count(element => element.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase)));
|
Assert.Equal(0, existingStoreRoles.Count(element => element.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase)));
|
||||||
s.GoToStore(StoreNavPages.Users);
|
s.GoToStore(StoreNavPages.Users);
|
||||||
options = s.Driver.FindElements(By.CssSelector("#Role option"));
|
options = s.Driver.FindElements(By.CssSelector("#Role option"));
|
||||||
Assert.Single(options);
|
Assert.Equal(3, options.Count);
|
||||||
Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
|
Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
|
||||||
|
|
||||||
|
|
||||||
s.GoToStore(StoreNavPages.Roles);
|
s.GoToStore(StoreNavPages.Roles);
|
||||||
s.Driver.FindElement(By.Id("CreateRole")).Click();
|
s.Driver.FindElement(By.Id("CreateRole")).Click();
|
||||||
s.Driver.FindElement(By.Id("Role")).SendKeys("Malice");
|
s.Driver.FindElement(By.Id("Role")).SendKeys("Malice");
|
||||||
@ -3502,7 +3507,7 @@ retry:
|
|||||||
s.RegisterNewUser(true);
|
s.RegisterNewUser(true);
|
||||||
string GetStorePath(string subPath) => $"/stores/{storeId}/{subPath}";
|
string GetStorePath(string subPath) => $"/stores/{storeId}/{subPath}";
|
||||||
|
|
||||||
// Owner access
|
// Admin access
|
||||||
s.AssertPageAccess(false, GetStorePath(""));
|
s.AssertPageAccess(false, GetStorePath(""));
|
||||||
s.AssertPageAccess(true, GetStorePath("reports"));
|
s.AssertPageAccess(true, GetStorePath("reports"));
|
||||||
s.AssertPageAccess(true, GetStorePath("invoices"));
|
s.AssertPageAccess(true, GetStorePath("invoices"));
|
||||||
@ -3518,9 +3523,214 @@ retry:
|
|||||||
s.AssertPageAccess(false, GetStorePath("apps/create"));
|
s.AssertPageAccess(false, GetStorePath("apps/create"));
|
||||||
foreach (var path in storeSettingsPaths)
|
foreach (var path in storeSettingsPaths)
|
||||||
{ // should have view access to settings, but no submit buttons or create links
|
{ // should have view access to settings, but no submit buttons or create links
|
||||||
|
TestLogs.LogInformation($"Checking access to store page {path} as admin");
|
||||||
|
s.AssertPageAccess(true, $"stores/{storeId}/{path}");
|
||||||
|
if (path != "payout-processors")
|
||||||
|
{
|
||||||
|
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .btn-primary"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Selenium", "Selenium")]
|
||||||
|
[Trait("Lightning", "Lightning")]
|
||||||
|
public async Task CanUsePredefinedRoles()
|
||||||
|
{
|
||||||
|
using var s = CreateSeleniumTester(newDb: true);
|
||||||
|
s.Server.ActivateLightning();
|
||||||
|
await s.StartAsync();
|
||||||
|
await s.Server.EnsureChannelsSetup();
|
||||||
|
var storeSettingsPaths = new [] {"settings", "rates", "checkout", "tokens", "users", "roles", "webhooks", "payout-processors", "payout-processors/onchain-automated/BTC", "payout-processors/lightning-automated/BTC", "emails", "email-settings", "forms"};
|
||||||
|
|
||||||
|
// Setup users
|
||||||
|
var manager = s.RegisterNewUser();
|
||||||
|
s.Logout();
|
||||||
|
s.GoToRegister();
|
||||||
|
var employee = s.RegisterNewUser();
|
||||||
|
s.Logout();
|
||||||
|
s.GoToRegister();
|
||||||
|
var guest = s.RegisterNewUser();
|
||||||
|
s.Logout();
|
||||||
|
s.GoToRegister();
|
||||||
|
|
||||||
|
// Setup store, wallets and add users
|
||||||
|
s.RegisterNewUser(true);
|
||||||
|
(_, string storeId) = s.CreateNewStore();
|
||||||
|
s.GoToStore();
|
||||||
|
s.GenerateWallet(isHotWallet: true);
|
||||||
|
s.AddLightningNode(LightningConnectionType.CLightning, false);
|
||||||
|
s.AddUserToStore(storeId, manager, "Manager");
|
||||||
|
s.AddUserToStore(storeId, employee, "Employee");
|
||||||
|
s.AddUserToStore(storeId, guest, "Guest");
|
||||||
|
|
||||||
|
// Add apps
|
||||||
|
(_, string posId) = s.CreateApp("PointOfSale");
|
||||||
|
(_, string crowdfundId) = s.CreateApp("Crowdfund");
|
||||||
|
|
||||||
|
string GetStorePath(string subPath) => $"/stores/{storeId}/{subPath}";
|
||||||
|
|
||||||
|
// Owner access
|
||||||
|
s.AssertPageAccess(true, GetStorePath(""));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("reports"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices/create"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payment-requests"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payment-requests/edit"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("pull-payments"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payouts"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("onchain/BTC"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("onchain/BTC/settings"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("lightning/BTC"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("lightning/BTC/settings"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("apps/create"));
|
||||||
|
s.AssertPageAccess(true, $"/apps/{posId}/settings/pos");
|
||||||
|
s.AssertPageAccess(true, $"/apps/{crowdfundId}/settings/crowdfund");
|
||||||
|
foreach (var path in storeSettingsPaths)
|
||||||
|
{ // should have manage access to settings, hence should see submit buttons or create links
|
||||||
|
TestLogs.LogInformation($"Checking access to store page {path} as owner");
|
||||||
|
s.AssertPageAccess(true, $"stores/{storeId}/{path}");
|
||||||
|
if (path != "payout-processors")
|
||||||
|
{
|
||||||
|
s.Driver.FindElement(By.CssSelector("#mainContent .btn-primary"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Logout();
|
||||||
|
|
||||||
|
// Manager access
|
||||||
|
s.LogIn(manager);
|
||||||
|
s.AssertPageAccess(false, GetStorePath(""));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("reports"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices/create"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payment-requests"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payment-requests/edit"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("pull-payments"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payouts"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("onchain/BTC"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("onchain/BTC/settings"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("lightning/BTC"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("lightning/BTC/settings"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("apps/create"));
|
||||||
|
s.AssertPageAccess(true, $"/apps/{posId}/settings/pos");
|
||||||
|
s.AssertPageAccess(true, $"/apps/{crowdfundId}/settings/crowdfund");
|
||||||
|
foreach (var path in storeSettingsPaths)
|
||||||
|
{ // should have view access to settings, but no submit buttons or create links
|
||||||
|
TestLogs.LogInformation($"Checking access to store page {path} as manager");
|
||||||
s.AssertPageAccess(true, $"stores/{storeId}/{path}");
|
s.AssertPageAccess(true, $"stores/{storeId}/{path}");
|
||||||
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .btn-primary"));
|
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .btn-primary"));
|
||||||
}
|
}
|
||||||
|
s.Logout();
|
||||||
|
|
||||||
|
// Employee access
|
||||||
|
s.LogIn(employee);
|
||||||
|
s.AssertPageAccess(false, GetStorePath(""));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("reports"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices/create"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payment-requests"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payment-requests/edit"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("pull-payments"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payouts"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("onchain/BTC"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("onchain/BTC/settings"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("lightning/BTC"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("lightning/BTC/settings"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("apps/create"));
|
||||||
|
s.AssertPageAccess(false, $"/apps/{posId}/settings/pos");
|
||||||
|
s.AssertPageAccess(false, $"/apps/{crowdfundId}/settings/crowdfund");
|
||||||
|
foreach (var path in storeSettingsPaths)
|
||||||
|
{ // should not have access to settings
|
||||||
|
TestLogs.LogInformation($"Checking access to store page {path} as employee");
|
||||||
|
s.AssertPageAccess(false, $"stores/{storeId}/{path}");
|
||||||
|
}
|
||||||
|
s.Logout();
|
||||||
|
|
||||||
|
// Guest access
|
||||||
|
s.LogIn(guest);
|
||||||
|
s.AssertPageAccess(false, GetStorePath(""));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("reports"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("invoices/create"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payment-requests"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("payment-requests/edit"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("pull-payments"));
|
||||||
|
s.AssertPageAccess(true, GetStorePath("payouts"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("onchain/BTC"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("onchain/BTC/settings"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("lightning/BTC"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("lightning/BTC/settings"));
|
||||||
|
s.AssertPageAccess(false, GetStorePath("apps/create"));
|
||||||
|
s.AssertPageAccess(false, $"/apps/{posId}/settings/pos");
|
||||||
|
s.AssertPageAccess(false, $"/apps/{crowdfundId}/settings/crowdfund");
|
||||||
|
foreach (var path in storeSettingsPaths)
|
||||||
|
{ // should not have access to settings
|
||||||
|
TestLogs.LogInformation($"Checking access to store page {path} as guest");
|
||||||
|
s.AssertPageAccess(false, $"stores/{storeId}/{path}");
|
||||||
|
}
|
||||||
|
s.Logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Selenium", "Selenium")]
|
||||||
|
public async Task CanChangeUserRoles()
|
||||||
|
{
|
||||||
|
using var s = CreateSeleniumTester(newDb: true);
|
||||||
|
await s.StartAsync();
|
||||||
|
|
||||||
|
// Setup users and store
|
||||||
|
var employee = s.RegisterNewUser();
|
||||||
|
s.Logout();
|
||||||
|
s.GoToRegister();
|
||||||
|
var owner = s.RegisterNewUser(true);
|
||||||
|
(_, string storeId) = s.CreateNewStore();
|
||||||
|
s.GoToStore();
|
||||||
|
s.AddUserToStore(storeId, employee, "Employee");
|
||||||
|
|
||||||
|
// Should successfully change the role
|
||||||
|
var userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr"));
|
||||||
|
Assert.Equal(2, userRows.Count);
|
||||||
|
IWebElement employeeRow = null;
|
||||||
|
foreach (var row in userRows)
|
||||||
|
{
|
||||||
|
if (row.Text.Contains(employee, StringComparison.InvariantCultureIgnoreCase)) employeeRow = row;
|
||||||
|
}
|
||||||
|
Assert.NotNull(employeeRow);
|
||||||
|
employeeRow.FindElement(By.CssSelector("a[data-bs-target=\"#EditModal\"]")).Click();
|
||||||
|
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, employee);
|
||||||
|
new SelectElement(s.Driver.FindElement(By.Id("EditUserRole"))).SelectByValue("Manager");
|
||||||
|
s.Driver.FindElement(By.Id("EditContinue")).Click();
|
||||||
|
Assert.Contains($"The role of {employee} has been changed to Manager.", s.FindAlertMessage().Text);
|
||||||
|
|
||||||
|
// Should not see a message when not changing role
|
||||||
|
userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr"));
|
||||||
|
Assert.Equal(2, userRows.Count);
|
||||||
|
employeeRow = null;
|
||||||
|
foreach (var row in userRows)
|
||||||
|
{
|
||||||
|
if (row.Text.Contains(employee, StringComparison.InvariantCultureIgnoreCase)) employeeRow = row;
|
||||||
|
}
|
||||||
|
Assert.NotNull(employeeRow);
|
||||||
|
employeeRow.FindElement(By.CssSelector("a[data-bs-target=\"#EditModal\"]")).Click();
|
||||||
|
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, employee);
|
||||||
|
// no change, no alert message
|
||||||
|
s.Driver.FindElement(By.Id("EditContinue")).Click();
|
||||||
|
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .alert"));
|
||||||
|
|
||||||
|
// Should not change last owner
|
||||||
|
userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr"));
|
||||||
|
Assert.Equal(2, userRows.Count);
|
||||||
|
IWebElement ownerRow = null;
|
||||||
|
foreach (var row in userRows)
|
||||||
|
{
|
||||||
|
if (row.Text.Contains(owner, StringComparison.InvariantCultureIgnoreCase)) ownerRow = row;
|
||||||
|
}
|
||||||
|
Assert.NotNull(ownerRow);
|
||||||
|
ownerRow.FindElement(By.CssSelector("a[data-bs-target=\"#EditModal\"]")).Click();
|
||||||
|
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, owner);
|
||||||
|
new SelectElement(s.Driver.FindElement(By.Id("EditUserRole"))).SelectByValue("Employee");
|
||||||
|
s.Driver.FindElement(By.Id("EditContinue")).Click();
|
||||||
|
Assert.Contains($"User {owner} is the last owner. Their role cannot be changed.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error).Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CanBrowseContent(SeleniumTester s)
|
private static void CanBrowseContent(SeleniumTester s)
|
||||||
|
@ -554,13 +554,23 @@ retry:
|
|||||||
|
|
||||||
public async Task AddGuest(string userId)
|
public async Task AddGuest(string userId)
|
||||||
{
|
{
|
||||||
var repo = this.parent.PayTester.GetService<StoreRepository>();
|
var repo = parent.PayTester.GetService<StoreRepository>();
|
||||||
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Guest);
|
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Guest);
|
||||||
}
|
}
|
||||||
public async Task AddOwner(string userId)
|
public async Task AddOwner(string userId)
|
||||||
{
|
{
|
||||||
var repo = this.parent.PayTester.GetService<StoreRepository>();
|
var repo = parent.PayTester.GetService<StoreRepository>();
|
||||||
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
|
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Owner);
|
||||||
|
}
|
||||||
|
public async Task AddManager(string userId)
|
||||||
|
{
|
||||||
|
var repo = parent.PayTester.GetService<StoreRepository>();
|
||||||
|
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Manager);
|
||||||
|
}
|
||||||
|
public async Task AddEmployee(string userId)
|
||||||
|
{
|
||||||
|
var repo = parent.PayTester.GetService<StoreRepository>();
|
||||||
|
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Employee);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<uint256> PayOnChain(string invoiceId)
|
public async Task<uint256> PayOnChain(string invoiceId)
|
||||||
|
@ -2782,7 +2782,7 @@ namespace BTCPayServer.Tests
|
|||||||
await tester.StartAsync();
|
await tester.StartAsync();
|
||||||
|
|
||||||
var acc = tester.NewAccount();
|
var acc = tester.NewAccount();
|
||||||
acc.GrantAccess(true);
|
await acc.GrantAccessAsync(true);
|
||||||
|
|
||||||
var settings = tester.PayTester.GetService<SettingsRepository>();
|
var settings = tester.PayTester.GetService<SettingsRepository>();
|
||||||
var emailSenderFactory = tester.PayTester.GetService<EmailSenderFactory>();
|
var emailSenderFactory = tester.PayTester.GetService<EmailSenderFactory>();
|
||||||
@ -2807,14 +2807,14 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
|
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
|
||||||
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
|
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
|
||||||
|
|
||||||
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings()
|
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
|
||||||
{
|
{
|
||||||
From = "store@store.com",
|
From = "store@store.com",
|
||||||
Login = "store@store.com",
|
Login = "store@store.com",
|
||||||
Password = "store@store.com",
|
Password = "store@store.com",
|
||||||
Port = 1234,
|
Port = 1234,
|
||||||
Server = "store.com"
|
Server = "store.com"
|
||||||
}), ""));
|
}), "", true));
|
||||||
|
|
||||||
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
|
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
_storeRepository = storeRepository;
|
_storeRepository = storeRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
[HttpGet("~/api/v1/stores/{storeId}/roles")]
|
[HttpGet("~/api/v1/stores/{storeId}/roles")]
|
||||||
public async Task<IActionResult> GetStoreRoles(string storeId)
|
public async Task<IActionResult> GetStoreRoles(string storeId)
|
||||||
{
|
{
|
||||||
@ -34,7 +34,6 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
|
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
|
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();
|
return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = r.IsServerRole}).ToList();
|
||||||
|
@ -27,14 +27,15 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
_storeRepository = storeRepository;
|
_storeRepository = storeRepository;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
}
|
}
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
|
||||||
|
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
[HttpGet("~/api/v1/stores/{storeId}/users")]
|
[HttpGet("~/api/v1/stores/{storeId}/users")]
|
||||||
public IActionResult GetStoreUsers()
|
public IActionResult GetStoreUsers()
|
||||||
{
|
{
|
||||||
|
|
||||||
var store = HttpContext.GetStoreData();
|
var store = HttpContext.GetStoreData();
|
||||||
return store == null ? StoreNotFound() : Ok(FromModel(store));
|
return store == null ? StoreNotFound() : Ok(FromModel(store));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
[HttpDelete("~/api/v1/stores/{storeId}/users/{idOrEmail}")]
|
[HttpDelete("~/api/v1/stores/{storeId}/users/{idOrEmail}")]
|
||||||
public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail)
|
public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail)
|
||||||
|
@ -147,7 +147,7 @@ namespace BTCPayServer.Controllers
|
|||||||
return await Login(returnUrl);
|
return await Login(returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("User with ID {UserId} logged in with a login code", user!.Id);
|
_logger.LogInformation("User {Email} logged in with a login code", user!.Email);
|
||||||
await _signInManager.SignInAsync(user, false, "LoginCode");
|
await _signInManager.SignInAsync(user, false, "LoginCode");
|
||||||
return RedirectToLocal(returnUrl);
|
return RedirectToLocal(returnUrl);
|
||||||
}
|
}
|
||||||
@ -215,7 +215,7 @@ namespace BTCPayServer.Controllers
|
|||||||
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
|
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("User {UserId} logged in", user.Id);
|
_logger.LogInformation("User {Email} logged in", user.Email);
|
||||||
return RedirectToLocal(returnUrl);
|
return RedirectToLocal(returnUrl);
|
||||||
}
|
}
|
||||||
if (result.RequiresTwoFactor)
|
if (result.RequiresTwoFactor)
|
||||||
@ -230,7 +230,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
if (result.IsLockedOut)
|
if (result.IsLockedOut)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("User {UserId} account locked out", user.Id);
|
_logger.LogWarning("User {Email} tried to log in, but is locked out", user.Email);
|
||||||
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
|
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -368,7 +368,7 @@ namespace BTCPayServer.Controllers
|
|||||||
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
|
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
|
||||||
{
|
{
|
||||||
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
|
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
|
||||||
_logger.LogInformation("User logged in");
|
_logger.LogInformation("User {Email} logged in with FIDO2", user.Email);
|
||||||
return RedirectToLocal(returnUrl);
|
return RedirectToLocal(returnUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -455,11 +455,11 @@ namespace BTCPayServer.Controllers
|
|||||||
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
|
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("User with ID {UserId} logged in with 2fa", user.Id);
|
_logger.LogInformation("User {Email} logged in with 2FA", user.Email);
|
||||||
return RedirectToLocal(returnUrl);
|
return RedirectToLocal(returnUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}", user.Id);
|
_logger.LogWarning("User {Email} entered invalid authenticator code", user.Email);
|
||||||
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
|
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
|
||||||
return View("SecondaryLogin", new SecondaryLoginViewModel
|
return View("SecondaryLogin", new SecondaryLoginViewModel
|
||||||
{
|
{
|
||||||
@ -524,17 +524,17 @@ namespace BTCPayServer.Controllers
|
|||||||
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("User with ID {UserId} logged in with a recovery code", user.Id);
|
_logger.LogInformation("User {Email} logged in with a recovery code", user.Email);
|
||||||
return RedirectToLocal(returnUrl);
|
return RedirectToLocal(returnUrl);
|
||||||
}
|
}
|
||||||
if (result.IsLockedOut)
|
if (result.IsLockedOut)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("User with ID {UserId} account locked out", user.Id);
|
_logger.LogWarning("User {Email} account locked out", user.Email);
|
||||||
|
|
||||||
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
|
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
|
_logger.LogWarning("User {Email} entered invalid recovery code", user.Email);
|
||||||
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
|
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
@ -650,9 +650,11 @@ namespace BTCPayServer.Controllers
|
|||||||
[HttpGet("/logout")]
|
[HttpGet("/logout")]
|
||||||
public async Task<IActionResult> Logout()
|
public async Task<IActionResult> Logout()
|
||||||
{
|
{
|
||||||
|
var userId = _signInManager.UserManager.GetUserId(HttpContext.User);
|
||||||
|
var user = await _userManager.FindByIdAsync(userId);
|
||||||
await _signInManager.SignOutAsync();
|
await _signInManager.SignOutAsync();
|
||||||
HttpContext.DeleteUserPrefsCookie();
|
HttpContext.DeleteUserPrefsCookie();
|
||||||
_logger.LogInformation("User logged out");
|
_logger.LogInformation("User {Email} logged out", user!.Email);
|
||||||
return RedirectToAction(nameof(Login));
|
return RedirectToAction(nameof(Login));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -747,7 +749,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
if (code == null)
|
if (code == null)
|
||||||
{
|
{
|
||||||
throw new ApplicationException("A code must be supplied for password reset.");
|
throw new ApplicationException("A code must be supplied for this action.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId);
|
var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId);
|
||||||
@ -777,6 +779,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(model.Email);
|
var user = await _userManager.FindByEmailAsync(model.Email);
|
||||||
|
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
|
||||||
if (!UserService.TryCanLogin(user, out _))
|
if (!UserService.TryCanLogin(user, out _))
|
||||||
{
|
{
|
||||||
// Don't reveal that the user does not exist
|
// Don't reveal that the user does not exist
|
||||||
@ -789,7 +792,7 @@ namespace BTCPayServer.Controllers
|
|||||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
{
|
{
|
||||||
Severity = StatusMessageModel.StatusSeverity.Success,
|
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||||
Message = "Password successfully set."
|
Message = hasPassword ? "Password successfully set." : "Account successfully created."
|
||||||
});
|
});
|
||||||
return RedirectToAction(nameof(Login));
|
return RedirectToAction(nameof(Login));
|
||||||
}
|
}
|
||||||
@ -817,6 +820,12 @@ namespace BTCPayServer.Controllers
|
|||||||
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
|
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
|
||||||
var requiresSetPassword = !await _userManager.HasPasswordAsync(user);
|
var requiresSetPassword = !await _userManager.HasPasswordAsync(user);
|
||||||
|
|
||||||
|
_eventAggregator.Publish(new UserInviteAcceptedEvent
|
||||||
|
{
|
||||||
|
User = user,
|
||||||
|
RequestUri = Request.GetAbsoluteRootUri()
|
||||||
|
});
|
||||||
|
|
||||||
if (requiresEmailConfirmation)
|
if (requiresEmailConfirmation)
|
||||||
{
|
{
|
||||||
return await RedirectToConfirmEmail(user);
|
return await RedirectToConfirmEmail(user);
|
||||||
|
@ -29,7 +29,6 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
private readonly ThemeSettings _theme;
|
private readonly ThemeSettings _theme;
|
||||||
private readonly StoreRepository _storeRepository;
|
private readonly StoreRepository _storeRepository;
|
||||||
private readonly BTCPayNetworkProvider _networkProvider;
|
|
||||||
private IHttpClientFactory HttpClientFactory { get; }
|
private IHttpClientFactory HttpClientFactory { get; }
|
||||||
private SignInManager<ApplicationUser> SignInManager { get; }
|
private SignInManager<ApplicationUser> SignInManager { get; }
|
||||||
|
|
||||||
@ -41,14 +40,12 @@ namespace BTCPayServer.Controllers
|
|||||||
ThemeSettings theme,
|
ThemeSettings theme,
|
||||||
LanguageService languageService,
|
LanguageService languageService,
|
||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
BTCPayNetworkProvider networkProvider,
|
|
||||||
IWebHostEnvironment environment,
|
IWebHostEnvironment environment,
|
||||||
SignInManager<ApplicationUser> signInManager)
|
SignInManager<ApplicationUser> signInManager)
|
||||||
{
|
{
|
||||||
_theme = theme;
|
_theme = theme;
|
||||||
HttpClientFactory = httpClientFactory;
|
HttpClientFactory = httpClientFactory;
|
||||||
LanguageService = languageService;
|
LanguageService = languageService;
|
||||||
_networkProvider = networkProvider;
|
|
||||||
_storeRepository = storeRepository;
|
_storeRepository = storeRepository;
|
||||||
SignInManager = signInManager;
|
SignInManager = signInManager;
|
||||||
_WebRootFileProvider = environment.WebRootFileProvider;
|
_WebRootFileProvider = environment.WebRootFileProvider;
|
||||||
@ -79,14 +76,14 @@ namespace BTCPayServer.Controllers
|
|||||||
var store = await _storeRepository.FindStore(storeId);
|
var store = await _storeRepository.FindStore(storeId);
|
||||||
if (store != null)
|
if (store != null)
|
||||||
{
|
{
|
||||||
return RedirectToStore(userId, storeId);
|
return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var stores = await _storeRepository.GetStoresByUserId(userId);
|
var stores = await _storeRepository.GetStoresByUserId(userId!);
|
||||||
var activeStore = stores.FirstOrDefault(s => !s.Archived);
|
var activeStore = stores.FirstOrDefault(s => !s.Archived);
|
||||||
return activeStore != null
|
return activeStore != null
|
||||||
? RedirectToStore(userId, activeStore.Id)
|
? RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId = activeStore.Id })
|
||||||
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
|
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,9 +195,5 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
|
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
|
||||||
}
|
}
|
||||||
public static RedirectToActionResult RedirectToStore(string userId, string storeId)
|
|
||||||
{
|
|
||||||
return new RedirectToActionResult("Index", "UIStores", new {storeId});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ namespace BTCPayServer.Controllers
|
|||||||
$"Unexpected error occurred disabling 2FA for user with ID '{user.Id}'.");
|
$"Unexpected error occurred disabling 2FA for user with ID '{user.Id}'.");
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("User with ID {UserId} has disabled 2fa.", user.Id);
|
_logger.LogInformation("User {Email} has disabled 2fa", user.Email);
|
||||||
return RedirectToAction(nameof(TwoFactorAuthentication));
|
return RedirectToAction(nameof(TwoFactorAuthentication));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +100,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _userManager.SetTwoFactorEnabledAsync(user, true);
|
await _userManager.SetTwoFactorEnabledAsync(user, true);
|
||||||
_logger.LogInformation("User with ID {UserId} has enabled 2FA with an authenticator app.", user.Id);
|
_logger.LogInformation("User {Email} has enabled 2FA with an authenticator app", user.Email);
|
||||||
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
||||||
TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
|
TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
|
||||||
|
|
||||||
@ -117,7 +117,7 @@ namespace BTCPayServer.Controllers
|
|||||||
|
|
||||||
await _userManager.SetTwoFactorEnabledAsync(user, false);
|
await _userManager.SetTwoFactorEnabledAsync(user, false);
|
||||||
await _userManager.ResetAuthenticatorKeyAsync(user);
|
await _userManager.ResetAuthenticatorKeyAsync(user);
|
||||||
_logger.LogInformation("User with id '{UserId}' has reset their authentication app key.", user.Id);
|
_logger.LogInformation("User {Email} has reset their authentication app key", user.Email);
|
||||||
|
|
||||||
return RedirectToAction(nameof(EnableAuthenticator));
|
return RedirectToAction(nameof(EnableAuthenticator));
|
||||||
}
|
}
|
||||||
|
@ -21,24 +21,21 @@ namespace BTCPayServer.Controllers
|
|||||||
string sortOrder = null
|
string sortOrder = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var roles = await storeRepository.GetStoreRoles(null, true);
|
||||||
|
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
|
||||||
model ??= new RolesViewModel();
|
model ??= new RolesViewModel();
|
||||||
|
model.DefaultRole = defaultRole;
|
||||||
|
|
||||||
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
|
switch (sortOrder)
|
||||||
var roles = await storeRepository.GetStoreRoles(null);
|
|
||||||
|
|
||||||
if (sortOrder != null)
|
|
||||||
{
|
{
|
||||||
switch (sortOrder)
|
case "desc":
|
||||||
{
|
ViewData["NextRoleSortOrder"] = "asc";
|
||||||
case "desc":
|
roles = roles.OrderByDescending(user => user.Role).ToArray();
|
||||||
ViewData["NextRoleSortOrder"] = "asc";
|
break;
|
||||||
roles = roles.OrderByDescending(user => user.Role).ToArray();
|
case "asc":
|
||||||
break;
|
roles = roles.OrderBy(user => user.Role).ToArray();
|
||||||
case "asc":
|
ViewData["NextRoleSortOrder"] = "desc";
|
||||||
roles = roles.OrderBy(user => user.Role).ToArray();
|
break;
|
||||||
ViewData["NextRoleSortOrder"] = "desc";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
|
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
|
||||||
|
@ -187,8 +187,8 @@ namespace BTCPayServer.Controllers
|
|||||||
|
|
||||||
_eventAggregator.Publish(new UserRegisteredEvent
|
_eventAggregator.Publish(new UserRegisteredEvent
|
||||||
{
|
{
|
||||||
Kind = UserRegisteredEventKind.Invite,
|
|
||||||
RequestUri = Request.GetAbsoluteRootUri(),
|
RequestUri = Request.GetAbsoluteRootUri(),
|
||||||
|
Kind = UserRegisteredEventKind.Invite,
|
||||||
User = user,
|
User = user,
|
||||||
InvitedByUser = currentUser,
|
InvitedByUser = currentUser,
|
||||||
Admin = model.IsAdmin,
|
Admin = model.IsAdmin,
|
||||||
|
@ -1204,8 +1204,10 @@ namespace BTCPayServer.Controllers
|
|||||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return View(model);
|
return View(model);
|
||||||
|
var serverSettings = await _SettingsRepository.GetSettingAsync<ServerSettings>();
|
||||||
|
var serverName = string.IsNullOrEmpty(serverSettings?.ServerName) ? "BTCPay Server" : serverSettings.ServerName;
|
||||||
using (var client = await model.Settings.CreateSmtpClient())
|
using (var client = await model.Settings.CreateSmtpClient())
|
||||||
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false))
|
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{serverName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false))
|
||||||
{
|
{
|
||||||
await client.SendAsync(message);
|
await client.SendAsync(message);
|
||||||
await client.DisconnectAsync(true);
|
await client.DisconnectAsync(true);
|
||||||
|
@ -121,7 +121,7 @@ namespace BTCPayServer.Controllers
|
|||||||
.Where(o => o != null)
|
.Where(o => o != null)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
emailSender.SendEmail(recipients.ToArray(), null, null, $"({store.StoreName} test) {rule.Subject}", rule.Body);
|
emailSender.SendEmail(recipients.ToArray(), null, null, $"[TEST] {rule.Subject}", rule.Body);
|
||||||
message += "Test email sent — please verify you received it.";
|
message += "Test email sent — please verify you received it.";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@ -189,32 +189,39 @@ namespace BTCPayServer.Controllers
|
|||||||
|
|
||||||
[HttpPost("{storeId}/email-settings")]
|
[HttpPost("{storeId}/email-settings")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command)
|
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command, [FromForm] bool useCustomSMTP = false)
|
||||||
{
|
{
|
||||||
var store = HttpContext.GetStoreData();
|
var store = HttpContext.GetStoreData();
|
||||||
if (store == null)
|
if (store == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
|
ViewBag.UseCustomSMTP = useCustomSMTP;
|
||||||
|
model.FallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
|
||||||
? await storeSender.FallbackSender.GetEmailSettings()
|
? await storeSender.FallbackSender.GetEmailSettings()
|
||||||
: null;
|
: null;
|
||||||
model.FallbackSettings = fallbackSettings;
|
if (useCustomSMTP)
|
||||||
|
{
|
||||||
|
model.Settings.Validate("Settings.", ModelState);
|
||||||
|
}
|
||||||
if (command == "Test")
|
if (command == "Test")
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (model.PasswordSet)
|
if (useCustomSMTP)
|
||||||
{
|
{
|
||||||
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
|
if (model.PasswordSet)
|
||||||
|
{
|
||||||
|
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
model.Settings.Validate("Settings.", ModelState);
|
|
||||||
if (string.IsNullOrEmpty(model.TestEmail))
|
if (string.IsNullOrEmpty(model.TestEmail))
|
||||||
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return View(model);
|
return View(model);
|
||||||
using var client = await model.Settings.CreateSmtpClient();
|
var settings = useCustomSMTP ? model.Settings : model.FallbackSettings;
|
||||||
var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false);
|
using var client = await settings.CreateSmtpClient();
|
||||||
|
var message = settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false);
|
||||||
await client.SendAsync(message);
|
await client.SendAsync(message);
|
||||||
await client.DisconnectAsync(true);
|
await client.DisconnectAsync(true);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
|
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
|
||||||
@ -232,17 +239,17 @@ namespace BTCPayServer.Controllers
|
|||||||
store.SetStoreBlob(storeBlob);
|
store.SetStoreBlob(storeBlob);
|
||||||
await _Repo.UpdateStore(store);
|
await _Repo.UpdateStore(store);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
|
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
|
||||||
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
|
|
||||||
}
|
}
|
||||||
else // if (command == "Save")
|
if (useCustomSMTP)
|
||||||
{
|
{
|
||||||
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
|
||||||
{
|
{
|
||||||
ModelState.AddModelError("Settings.From", "Invalid email");
|
ModelState.AddModelError("Settings.From", "Invalid email");
|
||||||
return View(model);
|
|
||||||
}
|
}
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return View(model);
|
||||||
var storeBlob = store.GetStoreBlob();
|
var storeBlob = store.GetStoreBlob();
|
||||||
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, fallbackSettings).PasswordSet)
|
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, model.FallbackSettings).PasswordSet)
|
||||||
{
|
{
|
||||||
model.Settings.Password = storeBlob.EmailSettings.Password;
|
model.Settings.Password = storeBlob.EmailSettings.Password;
|
||||||
}
|
}
|
||||||
@ -250,8 +257,8 @@ namespace BTCPayServer.Controllers
|
|||||||
store.SetStoreBlob(storeBlob);
|
store.SetStoreBlob(storeBlob);
|
||||||
await _Repo.UpdateStore(store);
|
await _Repo.UpdateStore(store);
|
||||||
TempData[WellKnownTempData.SuccessMessage] = "Email settings modified";
|
TempData[WellKnownTempData.SuccessMessage] = "Email settings modified";
|
||||||
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
|
|
||||||
}
|
}
|
||||||
|
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<bool> IsSetupComplete(IEmailSender emailSender)
|
private static async Task<bool> IsSetupComplete(IEmailSender emailSender)
|
||||||
|
@ -13,7 +13,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
public partial class UIStoresController
|
public partial class UIStoresController
|
||||||
{
|
{
|
||||||
[Route("{storeId}/roles")]
|
[HttpGet("{storeId}/roles")]
|
||||||
public async Task<IActionResult> ListRoles(
|
public async Task<IActionResult> ListRoles(
|
||||||
string storeId,
|
string storeId,
|
||||||
[FromServices] StoreRepository storeRepository,
|
[FromServices] StoreRepository storeRepository,
|
||||||
@ -21,24 +21,21 @@ namespace BTCPayServer.Controllers
|
|||||||
string sortOrder = null
|
string sortOrder = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
var roles = await storeRepository.GetStoreRoles(storeId, true);
|
||||||
|
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
|
||||||
model ??= new RolesViewModel();
|
model ??= new RolesViewModel();
|
||||||
|
model.DefaultRole = defaultRole;
|
||||||
|
|
||||||
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
|
switch (sortOrder)
|
||||||
var roles = await storeRepository.GetStoreRoles(storeId, false, false);
|
|
||||||
|
|
||||||
if (sortOrder != null)
|
|
||||||
{
|
{
|
||||||
switch (sortOrder)
|
case "desc":
|
||||||
{
|
ViewData["NextRoleSortOrder"] = "asc";
|
||||||
case "desc":
|
roles = roles.OrderByDescending(user => user.Role).ToArray();
|
||||||
ViewData["NextRoleSortOrder"] = "asc";
|
break;
|
||||||
roles = roles.OrderByDescending(user => user.Role).ToArray();
|
case "asc":
|
||||||
break;
|
roles = roles.OrderBy(user => user.Role).ToArray();
|
||||||
case "asc":
|
ViewData["NextRoleSortOrder"] = "desc";
|
||||||
roles = roles.OrderBy(user => user.Role).ToArray();
|
break;
|
||||||
ViewData["NextRoleSortOrder"] = "desc";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
|
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
|
||||||
@ -47,6 +44,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{storeId}/roles/{role}")]
|
[HttpGet("{storeId}/roles/{role}")]
|
||||||
|
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> CreateOrEditRole(
|
public async Task<IActionResult> CreateOrEditRole(
|
||||||
string storeId,
|
string storeId,
|
||||||
[FromServices] StoreRepository storeRepository,
|
[FromServices] StoreRepository storeRepository,
|
||||||
|
172
BTCPayServer/Controllers/UIStoresController.Users.cs
Normal file
172
BTCPayServer/Controllers/UIStoresController.Users.cs
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Configuration;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
using BTCPayServer.HostedServices.Webhooks;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
|
using BTCPayServer.Payments;
|
||||||
|
using BTCPayServer.Payments.Lightning;
|
||||||
|
using BTCPayServer.Rating;
|
||||||
|
using BTCPayServer.Security.Bitpay;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using BTCPayServer.Services.Rates;
|
||||||
|
using BTCPayServer.Services.Stores;
|
||||||
|
using BTCPayServer.Services.Wallets;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NBitcoin;
|
||||||
|
using NBitcoin.DataEncoders;
|
||||||
|
using StoreData = BTCPayServer.Data.StoreData;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Controllers
|
||||||
|
{
|
||||||
|
public partial class UIStoresController
|
||||||
|
{
|
||||||
|
[HttpGet("{storeId}/users")]
|
||||||
|
public async Task<IActionResult> StoreUsers()
|
||||||
|
{
|
||||||
|
var vm = new StoreUsersViewModel { Role = StoreRoleId.Guest.Role };
|
||||||
|
await FillUsers(vm);
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{storeId}/users")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
|
||||||
|
{
|
||||||
|
await FillUsers(vm);
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await _UserManager.FindByEmailAsync(vm.Email);
|
||||||
|
var isExistingUser = user is not null;
|
||||||
|
var isExistingStoreUser = isExistingUser && await _Repo.GetStoreUser(storeId, user!.Id) is not null;
|
||||||
|
var successInfo = string.Empty;
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
user = new ApplicationUser
|
||||||
|
{
|
||||||
|
UserName = vm.Email,
|
||||||
|
Email = vm.Email,
|
||||||
|
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
|
||||||
|
RequiresApproval = _policiesSettings.RequiresUserApproval,
|
||||||
|
Created = DateTimeOffset.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await _UserManager.CreateAsync(user);
|
||||||
|
if (result.Succeeded)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<Uri>();
|
||||||
|
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
|
||||||
|
|
||||||
|
_eventAggregator.Publish(new UserRegisteredEvent
|
||||||
|
{
|
||||||
|
RequestUri = Request.GetAbsoluteRootUri(),
|
||||||
|
Kind = UserRegisteredEventKind.Invite,
|
||||||
|
User = user,
|
||||||
|
InvitedByUser = currentUser,
|
||||||
|
CallbackUrlGenerated = tcs
|
||||||
|
});
|
||||||
|
|
||||||
|
var callbackUrl = await tcs.Task;
|
||||||
|
var settings = await _settingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
|
||||||
|
var info = settings.IsComplete()
|
||||||
|
? "An invitation email has been sent.<br/>You may alternatively"
|
||||||
|
: "An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to";
|
||||||
|
successInfo = $"{info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(vm.Email), "User could not be invited");
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
|
||||||
|
var action = isExistingUser
|
||||||
|
? isExistingStoreUser ? "updated" : "added"
|
||||||
|
: "invited";
|
||||||
|
if (await _Repo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId))
|
||||||
|
{
|
||||||
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
|
{
|
||||||
|
Severity = StatusMessageModel.StatusSeverity.Success,
|
||||||
|
AllowDismiss = false,
|
||||||
|
Html = $"User {action} successfully." + (string.IsNullOrEmpty(successInfo) ? "" : $" {successInfo}")
|
||||||
|
});
|
||||||
|
return RedirectToAction(nameof(StoreUsers));
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelState.AddModelError(nameof(vm.Email), $"The user could not be {action}");
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{storeId}/users/{userId}")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> UpdateStoreUser(string storeId, string userId, StoreUsersViewModel.StoreUserViewModel vm)
|
||||||
|
{
|
||||||
|
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
|
||||||
|
var storeUsers = await _Repo.GetStoreUsers(storeId);
|
||||||
|
var user = storeUsers.First(user => user.Id == userId);
|
||||||
|
var isOwner = user.StoreRole.Id == StoreRoleId.Owner.Id;
|
||||||
|
var isLastOwner = isOwner && storeUsers.Count(u => u.StoreRole.Id == StoreRoleId.Owner.Id) == 1;
|
||||||
|
if (isLastOwner && roleId != StoreRoleId.Owner)
|
||||||
|
TempData[WellKnownTempData.ErrorMessage] = $"User {user.Email} is the last owner. Their role cannot be changed.";
|
||||||
|
else if (await _Repo.AddOrUpdateStoreUser(storeId, userId, roleId))
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = $"The role of {user.Email} has been changed to {vm.Role}.";
|
||||||
|
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{storeId}/users/{userId}/delete")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> DeleteStoreUser(string storeId, string userId)
|
||||||
|
{
|
||||||
|
if (await _Repo.RemoveStoreUser(storeId, userId))
|
||||||
|
TempData[WellKnownTempData.SuccessMessage] = "User removed successfully.";
|
||||||
|
else
|
||||||
|
TempData[WellKnownTempData.ErrorMessage] = "Removing this user would result in the store having no owner.";
|
||||||
|
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FillUsers(StoreUsersViewModel vm)
|
||||||
|
{
|
||||||
|
var users = await _Repo.GetStoreUsers(CurrentStore.Id);
|
||||||
|
vm.StoreId = CurrentStore.Id;
|
||||||
|
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel()
|
||||||
|
{
|
||||||
|
Email = u.Email,
|
||||||
|
Id = u.Id,
|
||||||
|
Role = u.StoreRole.Role
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,10 +8,12 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Abstractions.Contracts;
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Configuration;
|
using BTCPayServer.Configuration;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.HostedServices.Webhooks;
|
using BTCPayServer.HostedServices.Webhooks;
|
||||||
using BTCPayServer.Models;
|
using BTCPayServer.Models;
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
@ -60,7 +62,6 @@ namespace BTCPayServer.Controllers
|
|||||||
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
|
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
|
||||||
PoliciesSettings policiesSettings,
|
PoliciesSettings policiesSettings,
|
||||||
IAuthorizationService authorizationService,
|
IAuthorizationService authorizationService,
|
||||||
EventAggregator eventAggregator,
|
|
||||||
AppService appService,
|
AppService appService,
|
||||||
IFileService fileService,
|
IFileService fileService,
|
||||||
WebhookSender webhookNotificationManager,
|
WebhookSender webhookNotificationManager,
|
||||||
@ -70,7 +71,9 @@ namespace BTCPayServer.Controllers
|
|||||||
IHtmlHelper html,
|
IHtmlHelper html,
|
||||||
LightningClientFactoryService lightningClientFactoryService,
|
LightningClientFactoryService lightningClientFactoryService,
|
||||||
EmailSenderFactory emailSenderFactory,
|
EmailSenderFactory emailSenderFactory,
|
||||||
WalletFileParsers onChainWalletParsers)
|
WalletFileParsers onChainWalletParsers,
|
||||||
|
SettingsRepository settingsRepository,
|
||||||
|
EventAggregator eventAggregator)
|
||||||
{
|
{
|
||||||
_RateFactory = rateFactory;
|
_RateFactory = rateFactory;
|
||||||
_Repo = repo;
|
_Repo = repo;
|
||||||
@ -97,6 +100,8 @@ namespace BTCPayServer.Controllers
|
|||||||
_lightningClientFactoryService = lightningClientFactoryService;
|
_lightningClientFactoryService = lightningClientFactoryService;
|
||||||
_emailSenderFactory = emailSenderFactory;
|
_emailSenderFactory = emailSenderFactory;
|
||||||
_onChainWalletParsers = onChainWalletParsers;
|
_onChainWalletParsers = onChainWalletParsers;
|
||||||
|
_settingsRepository = settingsRepository;
|
||||||
|
_eventAggregator = eventAggregator;
|
||||||
Html = html;
|
Html = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,6 +115,7 @@ namespace BTCPayServer.Controllers
|
|||||||
readonly TokenRepository _TokenRepository;
|
readonly TokenRepository _TokenRepository;
|
||||||
readonly UserManager<ApplicationUser> _UserManager;
|
readonly UserManager<ApplicationUser> _UserManager;
|
||||||
readonly RateFetcher _RateFactory;
|
readonly RateFetcher _RateFactory;
|
||||||
|
readonly SettingsRepository _settingsRepository;
|
||||||
private readonly ExplorerClientProvider _ExplorerProvider;
|
private readonly ExplorerClientProvider _ExplorerProvider;
|
||||||
private readonly LanguageService _LangService;
|
private readonly LanguageService _LangService;
|
||||||
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
|
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
|
||||||
@ -122,6 +128,7 @@ namespace BTCPayServer.Controllers
|
|||||||
private readonly LightningClientFactoryService _lightningClientFactoryService;
|
private readonly LightningClientFactoryService _lightningClientFactoryService;
|
||||||
private readonly EmailSenderFactory _emailSenderFactory;
|
private readonly EmailSenderFactory _emailSenderFactory;
|
||||||
private readonly WalletFileParsers _onChainWalletParsers;
|
private readonly WalletFileParsers _onChainWalletParsers;
|
||||||
|
private readonly EventAggregator _eventAggregator;
|
||||||
|
|
||||||
public string? GeneratedPairingCode { get; set; }
|
public string? GeneratedPairingCode { get; set; }
|
||||||
public WebhookSender WebhookNotificationManager { get; }
|
public WebhookSender WebhookNotificationManager { get; }
|
||||||
@ -157,85 +164,9 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
return Forbid();
|
return Forbid();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{storeId}/users")]
|
|
||||||
public async Task<IActionResult> StoreUsers()
|
|
||||||
{
|
|
||||||
var vm = new StoreUsersViewModel { Role = StoreRoleId.Guest.Role };
|
|
||||||
await FillUsers(vm);
|
|
||||||
return View(vm);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task FillUsers(StoreUsersViewModel vm)
|
|
||||||
{
|
|
||||||
var users = await _Repo.GetStoreUsers(CurrentStore.Id);
|
|
||||||
vm.StoreId = CurrentStore.Id;
|
|
||||||
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel()
|
|
||||||
{
|
|
||||||
Email = u.Email,
|
|
||||||
Id = u.Id,
|
|
||||||
Role = u.StoreRole.Role
|
|
||||||
}).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public StoreData? CurrentStore => HttpContext.GetStoreData();
|
public StoreData? CurrentStore => HttpContext.GetStoreData();
|
||||||
|
|
||||||
[HttpPost("{storeId}/users")]
|
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
||||||
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
|
|
||||||
{
|
|
||||||
await FillUsers(vm);
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return View(vm);
|
|
||||||
}
|
|
||||||
var user = await _UserManager.FindByEmailAsync(vm.Email);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(nameof(vm.Email), "User not found");
|
|
||||||
return View(vm);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = "User added successfully.";
|
|
||||||
return RedirectToAction(nameof(StoreUsers));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{storeId}/users/{userId}/delete")]
|
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
||||||
public async Task<IActionResult> DeleteStoreUser(string userId)
|
|
||||||
{
|
|
||||||
var user = await _UserManager.FindByIdAsync(userId);
|
|
||||||
if (user == null)
|
|
||||||
return NotFound();
|
|
||||||
return View("Confirm", new ConfirmModel("Remove store user", $"This action will prevent <strong>{Html.Encode(user.Email)}</strong> from accessing this store and its settings. Are you sure?", "Remove"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("{storeId}/users/{userId}/delete")]
|
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
|
||||||
public async Task<IActionResult> DeleteStoreUserPost(string storeId, string userId)
|
|
||||||
{
|
|
||||||
if (await _Repo.RemoveStoreUser(storeId, userId))
|
|
||||||
TempData[WellKnownTempData.SuccessMessage] = "User removed successfully.";
|
|
||||||
else
|
|
||||||
{
|
|
||||||
TempData[WellKnownTempData.ErrorMessage] = "Removing this user would result in the store having no owner.";
|
|
||||||
}
|
|
||||||
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("{storeId}/rates")]
|
[HttpGet("{storeId}/rates")]
|
||||||
public IActionResult Rates()
|
public IActionResult Rates()
|
||||||
{
|
{
|
||||||
@ -930,6 +861,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{storeId}/tokens")]
|
[HttpGet("{storeId}/tokens")]
|
||||||
|
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> ListTokens()
|
public async Task<IActionResult> ListTokens()
|
||||||
{
|
{
|
||||||
var model = new TokensViewModel();
|
var model = new TokensViewModel();
|
||||||
|
@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Models;
|
|||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
using BTCPayServer.Rating;
|
|
||||||
using BTCPayServer.Services.Rates;
|
using BTCPayServer.Services.Rates;
|
||||||
using BTCPayServer.Services.Stores;
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
10
BTCPayServer/Events/UserInviteAcceptedEvent.cs
Normal file
10
BTCPayServer/Events/UserInviteAcceptedEvent.cs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
using System;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Events;
|
||||||
|
|
||||||
|
public class UserInviteAcceptedEvent
|
||||||
|
{
|
||||||
|
public ApplicationUser User { get; set; }
|
||||||
|
public Uri RequestUri { get; set; }
|
||||||
|
}
|
@ -12,39 +12,49 @@ namespace BTCPayServer.Services
|
|||||||
|
|
||||||
private static string CallToAction(string actionName, string actionLink)
|
private static string CallToAction(string actionName, string actionLink)
|
||||||
{
|
{
|
||||||
string button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
|
var button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
|
||||||
button = button.Replace("{button_link}", actionLink, System.StringComparison.InvariantCulture);
|
return button.Replace("{button_link}", HtmlEncoder.Default.Encode(actionLink), System.StringComparison.InvariantCulture);
|
||||||
return button;
|
}
|
||||||
|
|
||||||
|
private static string CreateEmailBody(string body)
|
||||||
|
{
|
||||||
|
return $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
public static void SendEmailConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||||
{
|
{
|
||||||
emailSender.SendEmail(address, "BTCPay Server: Confirm your email",
|
emailSender.SendEmail(address, "Confirm your email", CreateEmailBody(
|
||||||
$"Please confirm your account by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
|
$"Please confirm your account.<br/><br/>{CallToAction("Confirm Email", link)}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||||
{
|
{
|
||||||
emailSender.SendEmail(address, "BTCPay Server: Your account has been approved",
|
emailSender.SendEmail(address, "Your account has been approved", CreateEmailBody(
|
||||||
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>.");
|
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>."));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SendResetPassword(this IEmailSender emailSender, MailboxAddress address, string link)
|
public static void SendResetPassword(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||||
{
|
{
|
||||||
var body = $"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", HtmlEncoder.Default.Encode(link))}";
|
emailSender.SendEmail(address, "Update Password", CreateEmailBody(
|
||||||
emailSender.SendEmail(address, "BTCPay Server: Update Password", $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
|
$"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.<br/><br/>{CallToAction("Update Password", link)}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link)
|
public static void SendInvitation(this IEmailSender emailSender, MailboxAddress address, string link)
|
||||||
{
|
{
|
||||||
emailSender.SendEmail(address, "BTCPay Server: Invitation",
|
emailSender.SendEmail(address, "Invitation", CreateEmailBody(
|
||||||
$"Please complete your account setup by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
|
$"Please complete your account setup by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>."));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SendNewUserInfo(this IEmailSender emailSender, MailboxAddress address, string newUserInfo, string link)
|
public static void SendNewUserInfo(this IEmailSender emailSender, MailboxAddress address, string newUserInfo, string link)
|
||||||
{
|
{
|
||||||
emailSender.SendEmail(address, $"BTCPay Server: {newUserInfo}",
|
emailSender.SendEmail(address, newUserInfo, CreateEmailBody(
|
||||||
$"{newUserInfo}. You can verify and approve the account here: <a href='{HtmlEncoder.Default.Encode(link)}'>User details</a>");
|
$"{newUserInfo}. You can verify and approve the account here: <a href='{HtmlEncoder.Default.Encode(link)}'>User details</a>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SendUserInviteAcceptedInfo(this IEmailSender emailSender, MailboxAddress address, string userInfo, string link)
|
||||||
|
{
|
||||||
|
emailSender.SendEmail(address, userInfo, CreateEmailBody(
|
||||||
|
$"{userInfo}. You can view the store users here: <a href='{HtmlEncoder.Default.Encode(link)}'>Store users</a>"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,12 @@ namespace Microsoft.AspNetCore.Mvc
|
|||||||
return urlHelper.GetUriByAction(nameof(UIServerController.User), "UIServer",
|
return urlHelper.GetUriByAction(nameof(UIServerController.User), "UIServer",
|
||||||
new { userId }, scheme, host, pathbase);
|
new { userId }, scheme, host, pathbase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static string StoreUsersLink(this LinkGenerator urlHelper, string storeId, string scheme, HostString host, string pathbase)
|
||||||
|
{
|
||||||
|
return urlHelper.GetUriByAction(nameof(UIStoresController.StoreUsers), "UIStores",
|
||||||
|
new { storeId }, scheme, host, pathbase);
|
||||||
|
}
|
||||||
|
|
||||||
public static string InvitationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
public static string InvitationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
|
||||||
{
|
{
|
||||||
|
@ -135,7 +135,7 @@ namespace BTCPayServer.HostedServices
|
|||||||
|
|
||||||
(await _EmailSenderFactory.GetEmailSender(invoice.StoreId)).SendEmail(
|
(await _EmailSenderFactory.GetEmailSender(invoice.StoreId)).SendEmail(
|
||||||
notificationEmail,
|
notificationEmail,
|
||||||
$"{storeName} Invoice Notification - ${invoice.StoreId}",
|
$"Invoice Notification - ${invoice.StoreId}",
|
||||||
emailBody);
|
emailBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ using BTCPayServer.Services;
|
|||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
using BTCPayServer.Services.Notifications;
|
using BTCPayServer.Services.Notifications;
|
||||||
using BTCPayServer.Services.Notifications.Blobs;
|
using BTCPayServer.Services.Notifications.Blobs;
|
||||||
|
using BTCPayServer.Services.Stores;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@ -21,6 +22,7 @@ public class UserEventHostedService(
|
|||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
EmailSenderFactory emailSenderFactory,
|
EmailSenderFactory emailSenderFactory,
|
||||||
NotificationSender notificationSender,
|
NotificationSender notificationSender,
|
||||||
|
StoreRepository storeRepository,
|
||||||
LinkGenerator generator,
|
LinkGenerator generator,
|
||||||
Logs logs)
|
Logs logs)
|
||||||
: EventHostedServiceBase(eventAggregator, logs)
|
: EventHostedServiceBase(eventAggregator, logs)
|
||||||
@ -31,6 +33,7 @@ public class UserEventHostedService(
|
|||||||
Subscribe<UserApprovedEvent>();
|
Subscribe<UserApprovedEvent>();
|
||||||
Subscribe<UserConfirmedEmailEvent>();
|
Subscribe<UserConfirmedEmailEvent>();
|
||||||
Subscribe<UserPasswordResetRequestedEvent>();
|
Subscribe<UserPasswordResetRequestedEvent>();
|
||||||
|
Subscribe<UserInviteAcceptedEvent>();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||||
@ -121,14 +124,22 @@ public class UserEventHostedService(
|
|||||||
if (!user.RequiresApproval || user.Approved) return;
|
if (!user.RequiresApproval || user.Approved) return;
|
||||||
await NotifyAdminsAboutUserRequiringApproval(user, uri, confirmedUserInfo);
|
await NotifyAdminsAboutUserRequiringApproval(user, uri, confirmedUserInfo);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case UserInviteAcceptedEvent inviteAcceptedEvent:
|
||||||
|
user = inviteAcceptedEvent.User;
|
||||||
|
uri = inviteAcceptedEvent.RequestUri;
|
||||||
|
Logs.PayServer.LogInformation("User {Email} accepted the invite", user.Email);
|
||||||
|
await NotifyAboutUserAcceptingInvite(user, uri);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, Uri uri, string newUserInfo)
|
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, Uri uri, string newUserInfo)
|
||||||
{
|
{
|
||||||
if (!user.RequiresApproval || user.Approved) return;
|
if (!user.RequiresApproval || user.Approved) return;
|
||||||
|
// notification
|
||||||
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
|
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
|
||||||
|
// email
|
||||||
var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
|
||||||
var host = new HostString(uri.Host, uri.Port);
|
var host = new HostString(uri.Host, uri.Port);
|
||||||
var approvalLink = generator.UserDetailsLink(user.Id, uri.Scheme, host, uri.PathAndQuery);
|
var approvalLink = generator.UserDetailsLink(user.Id, uri.Scheme, host, uri.PathAndQuery);
|
||||||
@ -138,4 +149,27 @@ public class UserEventHostedService(
|
|||||||
emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink);
|
emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task NotifyAboutUserAcceptingInvite(ApplicationUser user, Uri uri)
|
||||||
|
{
|
||||||
|
var stores = await storeRepository.GetStoresByUserId(user.Id);
|
||||||
|
var notifyRoles = new[] { StoreRoleId.Owner, StoreRoleId.Manager };
|
||||||
|
foreach (var store in stores)
|
||||||
|
{
|
||||||
|
// notification
|
||||||
|
await notificationSender.SendNotification(new StoreScope(store.Id, notifyRoles), new InviteAcceptedNotification(user, store));
|
||||||
|
// email
|
||||||
|
var notifyUsers = await storeRepository.GetStoreUsers(store.Id, notifyRoles);
|
||||||
|
var host = new HostString(uri.Host, uri.Port);
|
||||||
|
var storeUsersLink = generator.StoreUsersLink(store.Id, uri.Scheme, host, uri.PathAndQuery);
|
||||||
|
var emailSender = await emailSenderFactory.GetEmailSender(store.Id);
|
||||||
|
foreach (var storeUser in notifyUsers)
|
||||||
|
{
|
||||||
|
if (storeUser.Id == user.Id) continue; // do not notify the user itself (if they were added as owner or manager)
|
||||||
|
var notifyUser = await userManager.FindByIdOrEmail(storeUser.Id);
|
||||||
|
var info = $"User {user.Email} accepted the invite to {store.StoreName}";
|
||||||
|
emailSender.SendUserInviteAcceptedInfo(notifyUser.GetMailboxAddress(), info, storeUsersLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Configuration.Provider;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using BTCPayServer.Abstractions.Contracts;
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
using BTCPayServer.Abstractions.Custodians;
|
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Abstractions.Models;
|
using BTCPayServer.Abstractions.Models;
|
||||||
using BTCPayServer.Abstractions.Services;
|
using BTCPayServer.Abstractions.Services;
|
||||||
@ -66,11 +63,9 @@ using Microsoft.Extensions.Hosting;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.RPC;
|
|
||||||
using NBitpayClient;
|
using NBitpayClient;
|
||||||
using NBXplorer.DerivationStrategy;
|
using NBXplorer.DerivationStrategy;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using NicolasDorier.RateLimits;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using BTCPayServer.Services.Reporting;
|
using BTCPayServer.Services.Reporting;
|
||||||
using BTCPayServer.Services.WalletFileParsing;
|
using BTCPayServer.Services.WalletFileParsing;
|
||||||
@ -437,6 +432,7 @@ namespace BTCPayServer.Hosting
|
|||||||
|
|
||||||
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
|
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
|
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
|
||||||
|
services.AddSingleton<INotificationHandler, InviteAcceptedNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
|
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Logging;
|
using BTCPayServer.Logging;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@ -26,7 +25,7 @@ namespace BTCPayServer.Services.Mails
|
|||||||
|
|
||||||
public void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message)
|
public void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message)
|
||||||
{
|
{
|
||||||
_JobClient.Schedule(async (cancellationToken) =>
|
_JobClient.Schedule(async cancellationToken =>
|
||||||
{
|
{
|
||||||
var emailSettings = await GetEmailSettings();
|
var emailSettings = await GetEmailSettings();
|
||||||
if (emailSettings?.IsComplete() != true)
|
if (emailSettings?.IsComplete() != true)
|
||||||
@ -36,12 +35,14 @@ namespace BTCPayServer.Services.Mails
|
|||||||
}
|
}
|
||||||
|
|
||||||
using var smtp = await emailSettings.CreateSmtpClient();
|
using var smtp = await emailSettings.CreateSmtpClient();
|
||||||
var mail = emailSettings.CreateMailMessage(email, cc, bcc, subject, message, true);
|
var prefixedSubject = await GetPrefixedSubject(subject);
|
||||||
|
var mail = emailSettings.CreateMailMessage(email, cc, bcc, prefixedSubject, message, true);
|
||||||
await smtp.SendAsync(mail, cancellationToken);
|
await smtp.SendAsync(mail, cancellationToken);
|
||||||
await smtp.DisconnectAsync(true, cancellationToken);
|
await smtp.DisconnectAsync(true, cancellationToken);
|
||||||
}, TimeSpan.Zero);
|
}, TimeSpan.Zero);
|
||||||
}
|
}
|
||||||
|
|
||||||
public abstract Task<EmailSettings> GetEmailSettings();
|
public abstract Task<EmailSettings> GetEmailSettings();
|
||||||
|
public abstract Task<string> GetPrefixedSubject(string subject);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -20,5 +20,12 @@ namespace BTCPayServer.Services.Mails
|
|||||||
{
|
{
|
||||||
return SettingsRepository.GetSettingAsync<EmailSettings>();
|
return SettingsRepository.GetSettingAsync<EmailSettings>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task<string> GetPrefixedSubject(string subject)
|
||||||
|
{
|
||||||
|
var settings = await SettingsRepository.GetSettingAsync<ServerSettings>();
|
||||||
|
var prefix = string.IsNullOrEmpty(settings?.ServerName) ? "BTCPay Server" : settings.ServerName;
|
||||||
|
return $"{prefix}: {subject}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -36,5 +36,11 @@ namespace BTCPayServer.Services.Mails
|
|||||||
return await FallbackSender?.GetEmailSettings();
|
return await FallbackSender?.GetEmailSettings();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task<string> GetPrefixedSubject(string subject)
|
||||||
|
{
|
||||||
|
var store = await StoreRepository.FindStore(StoreId);
|
||||||
|
return string.IsNullOrEmpty(store?.StoreName) ? subject : $"{store.StoreName}: {subject}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,53 @@
|
|||||||
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
|
using BTCPayServer.Configuration;
|
||||||
|
using BTCPayServer.Controllers;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Services.Notifications.Blobs;
|
||||||
|
|
||||||
|
internal class InviteAcceptedNotification : BaseNotification
|
||||||
|
{
|
||||||
|
private const string TYPE = "inviteaccepted";
|
||||||
|
public string UserId { get; set; }
|
||||||
|
public string UserEmail { get; set; }
|
||||||
|
public string StoreId { get; set; }
|
||||||
|
public string StoreName { get; set; }
|
||||||
|
public override string Identifier => TYPE;
|
||||||
|
public override string NotificationType => TYPE;
|
||||||
|
|
||||||
|
public InviteAcceptedNotification()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public InviteAcceptedNotification(ApplicationUser user, StoreData store)
|
||||||
|
{
|
||||||
|
UserId = user.Id;
|
||||||
|
UserEmail = user.Email;
|
||||||
|
StoreId = store.Id;
|
||||||
|
StoreName = store.StoreName;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options)
|
||||||
|
: NotificationHandler<InviteAcceptedNotification>
|
||||||
|
{
|
||||||
|
public override string NotificationType => TYPE;
|
||||||
|
public override (string identifier, string name)[] Meta
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
return [(TYPE, "User accepted invitation")];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void FillViewModel(InviteAcceptedNotification notification, NotificationViewModel vm)
|
||||||
|
{
|
||||||
|
vm.Identifier = notification.Identifier;
|
||||||
|
vm.Type = notification.NotificationType;
|
||||||
|
vm.Body = $"User {notification.UserEmail} accepted the invite to {notification.StoreName}.";
|
||||||
|
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers),
|
||||||
|
"UIStores",
|
||||||
|
new { storeId = notification.StoreId }, options.RootPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,33 +1,30 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using BTCPayServer.Services.Stores;
|
||||||
|
|
||||||
namespace BTCPayServer.Services.Notifications
|
namespace BTCPayServer.Services.Notifications;
|
||||||
|
|
||||||
|
public class AdminScope : INotificationScope;
|
||||||
|
public class StoreScope : INotificationScope
|
||||||
{
|
{
|
||||||
public class AdminScope : NotificationScope
|
public StoreScope(string storeId, IEnumerable<StoreRoleId> roles = null)
|
||||||
{
|
|
||||||
public AdminScope()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
public class StoreScope : NotificationScope
|
|
||||||
{
|
|
||||||
public StoreScope(string storeId)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(storeId);
|
|
||||||
StoreId = storeId;
|
|
||||||
}
|
|
||||||
public string StoreId { get; }
|
|
||||||
}
|
|
||||||
public class UserScope : NotificationScope
|
|
||||||
{
|
|
||||||
public UserScope(string userId)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(userId);
|
|
||||||
UserId = userId;
|
|
||||||
}
|
|
||||||
public string UserId { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface NotificationScope
|
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(storeId);
|
||||||
|
StoreId = storeId;
|
||||||
|
Roles = roles;
|
||||||
}
|
}
|
||||||
|
public string StoreId { get; }
|
||||||
|
public IEnumerable<StoreRoleId> Roles { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class UserScope : INotificationScope
|
||||||
|
{
|
||||||
|
public UserScope(string userId)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(userId);
|
||||||
|
UserId = userId;
|
||||||
|
}
|
||||||
|
public string UserId { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface INotificationScope;
|
||||||
|
@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Contracts;
|
|||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Services.Notifications
|
namespace BTCPayServer.Services.Notifications
|
||||||
{
|
{
|
||||||
@ -31,7 +30,7 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
_notificationManager = notificationManager;
|
_notificationManager = notificationManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendNotification(NotificationScope scope, BaseNotification notification)
|
public async Task SendNotification(INotificationScope scope, BaseNotification notification)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(scope);
|
ArgumentNullException.ThrowIfNull(scope);
|
||||||
ArgumentNullException.ThrowIfNull(notification);
|
ArgumentNullException.ThrowIfNull(notification);
|
||||||
@ -59,7 +58,7 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string[]> GetUsers(NotificationScope scope, string notificationIdentifier)
|
private async Task<string[]> GetUsers(INotificationScope scope, string notificationIdentifier)
|
||||||
{
|
{
|
||||||
await using var ctx = _contextFactory.CreateContext();
|
await using var ctx = _contextFactory.CreateContext();
|
||||||
|
|
||||||
@ -79,9 +78,10 @@ namespace BTCPayServer.Services.Notifications
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case StoreScope s:
|
case StoreScope s:
|
||||||
|
var roles = s.Roles?.Select(role => role.Id);
|
||||||
query = ctx.UserStore
|
query = ctx.UserStore
|
||||||
.Include(store => store.ApplicationUser)
|
.Include(store => store.ApplicationUser)
|
||||||
.Where(u => u.StoreDataId == s.StoreId)
|
.Where(u => u.StoreDataId == s.StoreId && (roles == null || roles.Contains(u.StoreRoleId)))
|
||||||
.Select(u => u.ApplicationUser);
|
.Select(u => u.ApplicationUser);
|
||||||
break;
|
break;
|
||||||
case UserScope userScope:
|
case UserScope userScope:
|
||||||
|
@ -3,7 +3,6 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Amazon.S3;
|
|
||||||
using BTCPayServer.Abstractions.Contracts;
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
@ -87,7 +86,17 @@ namespace BTCPayServer.Services.Stores
|
|||||||
{
|
{
|
||||||
query = query.Include(u => u.Users);
|
query = query.Include(u => u.Users);
|
||||||
}
|
}
|
||||||
return (await query.ToArrayAsync()).Select(role => ToStoreRole(role)).ToArray();
|
|
||||||
|
var roles = await query.ToArrayAsync();
|
||||||
|
// return ordered: default role comes first, then server-wide roles in specified order, followed by store roles
|
||||||
|
var defaultRole = await GetDefaultRole();
|
||||||
|
var defaultOrder = StoreRoleId.DefaultOrder.Select(r => r.Role).ToArray();
|
||||||
|
return roles.OrderBy(role =>
|
||||||
|
{
|
||||||
|
if (role.Role == defaultRole.Role) return -1;
|
||||||
|
int index = Array.IndexOf(defaultOrder, role.Role);
|
||||||
|
return index == -1 ? int.MaxValue : index;
|
||||||
|
}).Select(ToStoreRole).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<StoreRoleId> GetDefaultRole()
|
public async Task<StoreRoleId> GetDefaultRole()
|
||||||
@ -166,11 +175,11 @@ namespace BTCPayServer.Services.Stores
|
|||||||
return ToStoreRole(match);
|
return ToStoreRole(match);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<StoreUser[]> GetStoreUsers(string storeId, IEnumerable<StoreRoleId>? filterRoles = null)
|
||||||
public async Task<StoreUser[]> GetStoreUsers(string storeId)
|
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(storeId);
|
ArgumentNullException.ThrowIfNull(storeId);
|
||||||
await using var ctx = _ContextFactory.CreateContext();
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
|
var roles = filterRoles?.Select(role => role.Id);
|
||||||
return (await
|
return (await
|
||||||
ctx
|
ctx
|
||||||
.UserStore
|
.UserStore
|
||||||
@ -181,7 +190,9 @@ namespace BTCPayServer.Services.Stores
|
|||||||
Id = u.ApplicationUserId,
|
Id = u.ApplicationUserId,
|
||||||
u.ApplicationUser.Email,
|
u.ApplicationUser.Email,
|
||||||
u.StoreRole
|
u.StoreRole
|
||||||
}).ToArrayAsync()).Select(arg => new StoreUser()
|
})
|
||||||
|
.Where(u => roles == null || roles.Contains(u.StoreRole.Id))
|
||||||
|
.ToArrayAsync()).Select(arg => new StoreUser
|
||||||
{
|
{
|
||||||
StoreRole = ToStoreRole(arg.StoreRole),
|
StoreRole = ToStoreRole(arg.StoreRole),
|
||||||
Id = arg.Id,
|
Id = arg.Id,
|
||||||
@ -191,7 +202,7 @@ namespace BTCPayServer.Services.Stores
|
|||||||
|
|
||||||
public static StoreRole ToStoreRole(Data.StoreRole storeRole)
|
public static StoreRole ToStoreRole(Data.StoreRole storeRole)
|
||||||
{
|
{
|
||||||
return new StoreRole()
|
return new StoreRole
|
||||||
{
|
{
|
||||||
Id = storeRole.Id,
|
Id = storeRole.Id,
|
||||||
Role = storeRole.Role,
|
Role = storeRole.Role,
|
||||||
@ -262,13 +273,19 @@ namespace BTCPayServer.Services.Stores
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserStore?> GetStoreUser(string storeId, string userId)
|
||||||
|
{
|
||||||
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
|
return await ctx.UserStore.FindAsync(userId, storeId);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> AddStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
|
public async Task<bool> AddStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(storeId);
|
ArgumentNullException.ThrowIfNull(storeId);
|
||||||
AssertStoreRoleIfNeeded(storeId, roleId);
|
AssertStoreRoleIfNeeded(storeId, roleId);
|
||||||
roleId ??= await GetDefaultRole();
|
roleId ??= await GetDefaultRole();
|
||||||
await using var ctx = _ContextFactory.CreateContext();
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id };
|
var userStore = new UserStore { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id };
|
||||||
ctx.UserStore.Add(userStore);
|
ctx.UserStore.Add(userStore);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@ -281,6 +298,34 @@ namespace BTCPayServer.Services.Stores
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<bool> AddOrUpdateStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(storeId);
|
||||||
|
AssertStoreRoleIfNeeded(storeId, roleId);
|
||||||
|
roleId ??= await GetDefaultRole();
|
||||||
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
|
var userStore = await ctx.UserStore.FindAsync(userId, storeId);
|
||||||
|
if (userStore is null)
|
||||||
|
{
|
||||||
|
userStore = new UserStore { StoreDataId = storeId, ApplicationUserId = userId };
|
||||||
|
ctx.UserStore.Add(userStore);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userStore.StoreRoleId == roleId.Id)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
userStore.StoreRoleId = roleId.Id;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void AssertStoreRoleIfNeeded(string storeId, StoreRoleId? roleId)
|
static void AssertStoreRoleIfNeeded(string storeId, StoreRoleId? roleId)
|
||||||
{
|
{
|
||||||
if (roleId?.StoreId != null && storeId != roleId.StoreId)
|
if (roleId?.StoreId != null && storeId != roleId.StoreId)
|
||||||
@ -645,8 +690,12 @@ retry:
|
|||||||
Role = role;
|
Role = role;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static StoreRoleId Owner { get; } = new StoreRoleId("Owner");
|
public static StoreRoleId Owner { get; } = new ("Owner");
|
||||||
public static StoreRoleId Guest { get; } = new StoreRoleId("Guest");
|
public static StoreRoleId Manager { get; } = new ("Manager");
|
||||||
|
public static StoreRoleId Employee { get; } = new ("Employee");
|
||||||
|
public static StoreRoleId Guest { get; } = new ("Guest");
|
||||||
|
|
||||||
|
public static readonly StoreRoleId[] DefaultOrder = [Owner, Manager, Employee, Guest];
|
||||||
public string? StoreId { get; }
|
public string? StoreId { get; }
|
||||||
public string Role { get; }
|
public string Role { get; }
|
||||||
public string Id
|
public string Id
|
||||||
|
@ -8,6 +8,10 @@ namespace BTCPayServer
|
|||||||
[Obsolete("You should check authorization policies instead of roles")]
|
[Obsolete("You should check authorization policies instead of roles")]
|
||||||
public const string Owner = "Owner";
|
public const string Owner = "Owner";
|
||||||
[Obsolete("You should check authorization policies instead of roles")]
|
[Obsolete("You should check authorization policies instead of roles")]
|
||||||
|
public const string Manager = "Manager";
|
||||||
|
[Obsolete("You should check authorization policies instead of roles")]
|
||||||
|
public const string Employee = "Employee";
|
||||||
|
[Obsolete("You should check authorization policies instead of roles")]
|
||||||
public const string Guest = "Guest";
|
public const string Guest = "Guest";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,14 +26,14 @@
|
|||||||
<script src="~/crowdfund/admin.js" asp-append-version="true"></script>
|
<script src="~/crowdfund/admin.js" asp-append-version="true"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
||||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||||
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button>
|
||||||
@if (Model.Archived)
|
@if (Model.Archived)
|
||||||
{
|
{
|
||||||
<button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" permission="@Policies.CanModifyStoreSettings">Unarchive</button>
|
<button type="submit" class="btn btn-outline-secondary" name="Archived" value="False">Unarchive</button>
|
||||||
}
|
}
|
||||||
else if (Model.ModelWithMinimumData)
|
else if (Model.ModelWithMinimumData)
|
||||||
{
|
{
|
||||||
|
@ -61,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<input asp-for="PasswordSet" type="hidden"/>
|
<input asp-for="PasswordSet" type="hidden"/>
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton" data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings">
|
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton" data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings">
|
||||||
<vc:icon symbol="caret-down"/>
|
<vc:icon symbol="caret-down"/>
|
||||||
<span class="ms-1">Advanced settings</span>
|
<span class="ms-1">Advanced settings</span>
|
||||||
@ -75,7 +75,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary mt-2" name="command" value="Save" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button type="submit" class="btn btn-primary mt-2" name="command" value="Save" id="Save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -33,14 +33,14 @@
|
|||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form method="post">
|
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
||||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||||
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button>
|
||||||
@if (Model.Archived)
|
@if (Model.Archived)
|
||||||
{
|
{
|
||||||
<button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" permission="@Policies.CanModifyStoreSettings">Unarchive</button>
|
<button type="submit" class="btn btn-outline-secondary" name="Archived" value="False">Unarchive</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -4,51 +4,53 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const modal = document.getElementById('ConfirmModal')
|
(function () {
|
||||||
modal.addEventListener('show.bs.modal', event => {
|
const modal = document.getElementById('ConfirmModal')
|
||||||
const $target = event.relatedTarget
|
modal.addEventListener('show.bs.modal', event => {
|
||||||
const $form = document.getElementById('ConfirmForm')
|
const $target = event.relatedTarget
|
||||||
const $text = document.getElementById('ConfirmText')
|
const $form = document.getElementById('ConfirmForm')
|
||||||
const $title = document.getElementById('ConfirmTitle')
|
const $text = document.getElementById('ConfirmText')
|
||||||
const $description = document.getElementById('ConfirmDescription')
|
const $title = document.getElementById('ConfirmTitle')
|
||||||
const $input = document.getElementById('ConfirmInput')
|
const $description = document.getElementById('ConfirmDescription')
|
||||||
const $inputText = document.getElementById('ConfirmInputText')
|
const $input = document.getElementById('ConfirmInput')
|
||||||
const $continue = document.getElementById('ConfirmContinue')
|
const $inputText = document.getElementById('ConfirmInputText')
|
||||||
const { title, description, confirm, confirmInput } = $target.dataset
|
const $continue = document.getElementById('ConfirmContinue')
|
||||||
const action = $target.dataset.action || ($target.nodeName === 'A'
|
const { title, description, confirm, confirmInput } = $target.dataset
|
||||||
? $target.getAttribute('href')
|
const action = $target.dataset.action || ($target.nodeName === 'A'
|
||||||
: $target.form.getAttribute('action'))
|
? $target.getAttribute('href')
|
||||||
|
: $target.form.getAttribute('action'))
|
||||||
if ($form && !$form.hasAttribute('action')) $form.setAttribute('action', action)
|
|
||||||
if (title) $title.textContent = title
|
if ($form && !$form.hasAttribute('action')) $form.setAttribute('action', action)
|
||||||
if (description) $description.innerHTML = description
|
if (title) $title.textContent = title
|
||||||
if (confirm) $continue.textContent = confirm
|
if (description) $description.innerHTML = description
|
||||||
if (confirmInput) {
|
if (confirm) $continue.textContent = confirm
|
||||||
$text.removeAttribute('hidden')
|
if (confirmInput) {
|
||||||
$continue.setAttribute('disabled', 'disabled')
|
$text.removeAttribute('hidden')
|
||||||
$inputText.textContent = confirmInput
|
$continue.setAttribute('disabled', 'disabled')
|
||||||
$input.setAttribute("autocomplete", "off")
|
$inputText.textContent = confirmInput
|
||||||
$input.addEventListener('input', event => {
|
$input.setAttribute("autocomplete", "off")
|
||||||
event.target.value.trim() === confirmInput
|
$input.addEventListener('input', event => {
|
||||||
? $continue.removeAttribute('disabled')
|
event.target.value.trim() === confirmInput
|
||||||
: $continue.setAttribute('disabled', 'disabled')
|
? $continue.removeAttribute('disabled')
|
||||||
})
|
: $continue.setAttribute('disabled', 'disabled')
|
||||||
$form.addEventListener('submit', event => {
|
})
|
||||||
if ($input.value.trim() !== confirmInput) {
|
$form.addEventListener('submit', event => {
|
||||||
event.preventDefault()
|
if ($input.value.trim() !== confirmInput) {
|
||||||
}
|
event.preventDefault()
|
||||||
})
|
}
|
||||||
} else {
|
})
|
||||||
$text.setAttribute('hidden', 'hidden')
|
} else {
|
||||||
$continue.removeAttribute('disabled')
|
$text.setAttribute('hidden', 'hidden')
|
||||||
}
|
$continue.removeAttribute('disabled')
|
||||||
});
|
}
|
||||||
modal.addEventListener('shown.bs.modal', event => {
|
})
|
||||||
const $target = event.relatedTarget
|
modal.addEventListener('shown.bs.modal', event => {
|
||||||
const $input = document.getElementById('ConfirmInput')
|
const $target = event.relatedTarget
|
||||||
const { confirmInput } = $target.dataset
|
const $input = document.getElementById('ConfirmInput')
|
||||||
if (confirmInput) {
|
const { confirmInput } = $target.dataset
|
||||||
$input.focus();
|
if (confirmInput) {
|
||||||
}
|
$input.focus()
|
||||||
});
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
|
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = $"{(Model.HasPassword ? "Reset" : "Set")} your password";
|
var cta = Model.HasPassword ? "Reset your password" : "Create Account";
|
||||||
|
ViewData["Title"] = cta;
|
||||||
Layout = "_LayoutSignedOut";
|
Layout = "_LayoutSignedOut";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,5 +36,5 @@
|
|||||||
<input asp-for="ConfirmPassword" class="form-control"/>
|
<input asp-for="ConfirmPassword" class="form-control"/>
|
||||||
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
|
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary w-100 btn-lg" id="SetPassword">Set Password</button>
|
<button type="submit" class="btn btn-primary w-100 btn-lg" id="SetPassword">@cta</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a asp-action="Modify" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Edit-@item.Name" permission="@Policies.CanModifyStoreSettings">@item.Name</a>
|
<a asp-action="Modify" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Edit-@item.Name" permission="@Policies.CanModifyStoreSettings">@item.Name</a>
|
||||||
<a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" not-permission="@Policies.CanModifyStoreSettings">View</a>
|
<a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" not-permission="@Policies.CanModifyStoreSettings">@item.Name</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
||||||
<a asp-action="Remove" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Remove-@item.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Remove</a> -
|
<a asp-action="Remove" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Remove-@item.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Remove</a> -
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
{
|
{
|
||||||
<div asp-validation-summary="All"></div>
|
<div asp-validation-summary="All"></div>
|
||||||
}
|
}
|
||||||
<form method="post">
|
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" />
|
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" />
|
||||||
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
|
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
|
||||||
@ -41,7 +41,7 @@
|
|||||||
<div class="form-text">If a payout fails this many times, it will be cancelled.</div>
|
<div class="form-text">If a payout fails this many times, it will be cancelled.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
{
|
{
|
||||||
<div asp-validation-summary="All"></div>
|
<div asp-validation-summary="All"></div>
|
||||||
}
|
}
|
||||||
<form method="post">
|
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
<div class="d-flex my-3">
|
<div class="d-flex my-3">
|
||||||
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" />
|
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" />
|
||||||
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
|
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
|
||||||
@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-text">Only process payouts when this payout sum is reached.</div>
|
<div class="form-text">Only process payouts when this payout sum is reached.</div>
|
||||||
</div>
|
</div>
|
||||||
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -61,7 +61,7 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xxl-constrain col-xl-8">
|
<div class="col-xxl-constrain col-xl-8">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
@if (!ViewContext.ModelState.IsValid)
|
@if (!ViewContext.ModelState.IsValid)
|
||||||
{
|
{
|
||||||
<div asp-validation-summary="All"></div>
|
<div asp-validation-summary="All"></div>
|
||||||
@ -275,7 +275,7 @@
|
|||||||
<input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="btcpay-toggle me-3" />
|
<input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="btcpay-toggle me-3" />
|
||||||
<label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label>
|
<label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary mt-4" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button type="submit" class="btn btn-primary mt-4" id="Save">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
{
|
{
|
||||||
<div asp-validation-summary="All"></div>
|
<div asp-validation-summary="All"></div>
|
||||||
}
|
}
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
<h3 class="mb-3">General</h3>
|
<h3 class="mb-3">General</h3>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="Id" class="form-label"></label>
|
<label asp-for="Id" class="form-label"></label>
|
||||||
@ -159,7 +159,7 @@
|
|||||||
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
|
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary mt-2" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button type="submit" class="btn btn-primary mt-2" id="Save">Save</button>
|
||||||
</form>
|
</form>
|
||||||
<div permission="@Policies.CanModifyStoreSettings">
|
<div permission="@Policies.CanModifyStoreSettings">
|
||||||
<h3 class="mt-5 mb-3">Additional Actions</h3>
|
<h3 class="mt-5 mb-3">Additional Actions</h3>
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
{
|
{
|
||||||
<div asp-validation-summary="All"></div>
|
<div asp-validation-summary="All"></div>
|
||||||
}
|
}
|
||||||
<form method="post">
|
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
<input type="hidden" asp-for="ShowScripting" />
|
<input type="hidden" asp-for="ShowScripting" />
|
||||||
@if (Model.ShowScripting)
|
@if (Model.ShowScripting)
|
||||||
{
|
{
|
||||||
@ -178,7 +178,7 @@ X_X = kraken(X_X);</code></pre>
|
|||||||
<input asp-for="DefaultCurrencyPairs" class="form-control" placeholder="BTC_USD, BTC_CAD" />
|
<input asp-for="DefaultCurrencyPairs" class="form-control" placeholder="BTC_USD, BTC_CAD" />
|
||||||
<span asp-validation-for="DefaultCurrencyPairs" class="text-danger"></span>
|
<span asp-validation-for="DefaultCurrencyPairs" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" permission="@Policies.CanModifyStoreSettings">Save</button>
|
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save">Save</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
@{
|
@{
|
||||||
Layout = "../Shared/_NavLayout.cshtml";
|
Layout = "../Shared/_NavLayout.cshtml";
|
||||||
ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id);
|
ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id);
|
||||||
var hasCustomSettings = Model.IsSetup() && !Model.UsesFallback();
|
var hasCustomSettings = (Model.IsSetup() && !Model.UsesFallback()) || ViewBag.UseCustomSMTP ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
@ -15,27 +15,27 @@
|
|||||||
Configure
|
Configure
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p class="mb-0">
|
||||||
<a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id">Email rules</a>
|
<a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id">Email rules</a>
|
||||||
allow BTCPay Server to send customized emails from your store based on events.
|
allow BTCPay Server to send customized emails from your store based on events.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-4">Email Server</h3>
|
<h3 class="mt-5 mb-4">Email Server</h3>
|
||||||
|
|
||||||
<form method="post" autocomplete="off">
|
<form method="post" autocomplete="off" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
@if (Model.IsFallbackSetup())
|
@if (Model.IsFallbackSetup())
|
||||||
{
|
{
|
||||||
<label class="d-flex align-items-center mb-4">
|
<label class="d-flex align-items-center mb-4">
|
||||||
<input type="checkbox" id="UseCustomSMTP" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" />
|
<input type="checkbox" id="UseCustomSMTP" name="UseCustomSMTP" value="true" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" />
|
||||||
<div>
|
<div>
|
||||||
<span>Use custom SMTP settings for this store</span>
|
<span>Use custom SMTP settings for this store</span>
|
||||||
<div class="form-text">Otherwise, the server's SMTP settings will be used to send emails.</div>
|
<div class="form-text">Otherwise, the server's SMTP settings will be used to send emails.</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="checkout-settings collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
|
<div class="collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
|
||||||
<partial name="EmailsBody" model="Model" />
|
<partial name="EmailsBody" model="Model" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
||||||
}
|
}
|
||||||
|
|
||||||
<form class="row" asp-action="StoreEmails" method="post" asp-route-storeId="@Context.GetStoreData().Id">
|
<form class="row" asp-action="StoreEmails" method="post" asp-route-storeId="@Context.GetStoreData().Id" permissioned="@Policies.CanModifyStoreSettings">
|
||||||
<div class="col-xxl-constrain">
|
<div class="col-xxl-constrain">
|
||||||
<div class="d-flex align-items-center justify-content-between mt-n1 mb-3">
|
<div class="d-flex align-items-center justify-content-between mt-n1 mb-3">
|
||||||
<h3 class="mb-0">@ViewData["Title"]</h3>
|
<h3 class="mb-0">@ViewData["Title"]</h3>
|
||||||
|
@ -9,11 +9,21 @@
|
|||||||
@{
|
@{
|
||||||
Layout = "../Shared/_NavLayout.cshtml";
|
Layout = "../Shared/_NavLayout.cshtml";
|
||||||
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id);
|
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);
|
var roles = new SelectList(
|
||||||
|
await StoreRepository.GetStoreRoles(ScopeProvider.GetCurrentStoreId()),
|
||||||
|
nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role),
|
||||||
|
Model.Role);
|
||||||
|
}
|
||||||
|
@section PageHeadContent {
|
||||||
|
<style>
|
||||||
|
@@media (min-width: 576px) {
|
||||||
|
#Role { width: auto !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xxl-constrain col-xl-8">
|
<div class="col-xxl-constrain">
|
||||||
<h3 class="mb-3">@ViewData["Title"]</h3>
|
<h3 class="mb-3">@ViewData["Title"]</h3>
|
||||||
<p>
|
<p>
|
||||||
Give other registered BTCPay Server users access to your store.<br />
|
Give other registered BTCPay Server users access to your store.<br />
|
||||||
@ -27,7 +37,7 @@
|
|||||||
|
|
||||||
<form method="post" class="d-flex flex-wrap align-items-center gap-3" permission="@Policies.CanModifyStoreSettings">
|
<form method="post" class="d-flex flex-wrap align-items-center gap-3" permission="@Policies.CanModifyStoreSettings">
|
||||||
<input asp-for="Email" type="text" class="form-control" placeholder="user@example.com" style="flex: 1 1 14rem">
|
<input asp-for="Email" type="text" class="form-control" placeholder="user@example.com" style="flex: 1 1 14rem">
|
||||||
<select asp-for="Role" class="form-select w-auto" asp-items="roles"></select>
|
<select asp-for="Role" class="form-select" asp-items="roles"></select>
|
||||||
<button type="submit" role="button" class="btn btn-primary text-nowrap flex-grow-1 flex-sm-grow-0" id="AddUser">Add User</button>
|
<button type="submit" role="button" class="btn btn-primary text-nowrap flex-grow-1 flex-sm-grow-0" id="AddUser">Add User</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -39,14 +49,17 @@
|
|||||||
<th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
|
<th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="StoreUsersList">
|
||||||
@foreach (var user in Model.Users)
|
@foreach (var user in Model.Users)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td>@user.Email</td>
|
<td>@user.Email</td>
|
||||||
<td>@user.Role</td>
|
<td>@user.Role</td>
|
||||||
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
||||||
<a asp-action="DeleteStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="This action will prevent <strong>@Html.Encode(user.Email)</strong> from accessing this store and its settings." data-confirm-input="REMOVE">Remove</a>
|
<div class="d-inline-flex align-items-center gap-3">
|
||||||
|
<a asp-action="UpdateStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#EditModal" data-user-email="@user.Email" data-user-role="@user.Role">Change Role</a>
|
||||||
|
<a asp-action="DeleteStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="This action will prevent <strong>@Html.Encode(user.Email)</strong> from accessing this store and its settings." data-confirm-input="REMOVE">Remove</a>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@ -57,6 +70,50 @@
|
|||||||
|
|
||||||
<partial name="_Confirm" model="@(new ConfirmModel("Remove store user", "This action will prevent the user from accessing this store and its settings. Are you sure?", "Delete"))" permission="@Policies.CanModifyStoreSettings" />
|
<partial name="_Confirm" model="@(new ConfirmModel("Remove store user", "This action will prevent the user from accessing this store and its settings. Are you sure?", "Delete"))" permission="@Policies.CanModifyStoreSettings" />
|
||||||
|
|
||||||
|
<div class="modal fade" id="EditModal" tabindex="-1" aria-labelledby="EditTitle" aria-hidden="true" permission="@Policies.CanModifyStoreSettings">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="EditTitle">Edit <span id="EditUserEmail">store user</span></h4>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||||
|
<vc:icon symbol="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="EditForm" method="post" rel="noreferrer noopener">
|
||||||
|
<div class="modal-body">
|
||||||
|
<label asp-for="Role" for="EditUserRole" class="form-label">New role</label>
|
||||||
|
<select asp-for="Role" id="EditUserRole" class="form-select w-auto" asp-items="roles"></select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary only-for-js" data-bs-dismiss="modal" id="EditCancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" id="EditContinue">Change Role</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const modal = document.getElementById('EditModal')
|
||||||
|
modal.addEventListener('show.bs.modal', event => {
|
||||||
|
const $target = event.relatedTarget
|
||||||
|
const $form = document.getElementById('EditForm')
|
||||||
|
const $role = document.getElementById('EditUserRole')
|
||||||
|
const $email = document.getElementById('EditUserEmail')
|
||||||
|
const { userEmail, userRole } = $target.dataset
|
||||||
|
const action = $target.dataset.action || ($target.nodeName === 'A'
|
||||||
|
? $target.getAttribute('href')
|
||||||
|
: $target.form.getAttribute('action'))
|
||||||
|
|
||||||
|
if ($form && !$form.hasAttribute('action')) $form.setAttribute('action', action)
|
||||||
|
if (userEmail) $email.textContent = userEmail
|
||||||
|
if (userRole) $role.value = userRole
|
||||||
|
});
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@section PageFootContent {
|
@section PageFootContent {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Url</th>
|
<th>Url</th>
|
||||||
<th class="text-end" permission="@Policies.CanModifyStoreSettings">Actions</th>
|
<th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -48,7 +48,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="d-block text-break">@wh.Url</td>
|
<td class="d-block text-break">@wh.Url</td>
|
||||||
<td class="text-end text-md-nowrap" permission="@Policies.CanModifyStoreSettings">
|
<td class="actions-col text-md-nowrap" permission="@Policies.CanModifyStoreSettings">
|
||||||
<a asp-action="TestWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Test</a> -
|
<a asp-action="TestWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Test</a> -
|
||||||
<a asp-action="ModifyWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Modify</a> -
|
<a asp-action="ModifyWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Modify</a> -
|
||||||
<a asp-action="DeleteWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Delete</a>
|
<a asp-action="DeleteWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Delete</a>
|
||||||
|
@ -74,3 +74,16 @@
|
|||||||
.editor .nested-fields .list-group-item {
|
.editor .nested-fields .list-group-item {
|
||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:disabled .editor {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:disabled .editor .bg-tile {
|
||||||
|
background-color: var(--btcpay-form-bg-disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
:disabled .editor .control,
|
||||||
|
:disabled .editor button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
@ -78,6 +78,12 @@ hr.primary {
|
|||||||
background-color: var(--btcpay-form-bg);
|
background-color: var(--btcpay-form-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:disabled .note-editable {
|
||||||
|
border-color: var(--btcpay-form-border-disabled);
|
||||||
|
background-color: var(--btcpay-form-bg-disabled);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.text-md-nowrap {
|
.text-md-nowrap {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@ -648,6 +654,11 @@ label.btcpay-list-select-item:hover {
|
|||||||
border-color: var(--btcpay-form-border-hover);
|
border-color: var(--btcpay-form-border-hover);
|
||||||
background-color: var(--btcpay-form-bg-hover);
|
background-color: var(--btcpay-form-bg-hover);
|
||||||
}
|
}
|
||||||
|
:disabled label.btcpay-list-select-item {
|
||||||
|
border-color: var(--btcpay-form-border-disabled);
|
||||||
|
background-color: var(--btcpay-form-bg-disabled);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@media (max-width: 575px) {
|
@media (max-width: 575px) {
|
||||||
.btcpay-list-select-item {
|
.btcpay-list-select-item {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
|
Loading…
Reference in New Issue
Block a user