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:
d11n 2024-03-19 14:58:33 +01:00 committed by GitHub
parent b7ce6b7400
commit 09dbe44bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1124 additions and 378 deletions

View File

@ -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();
}

View File

@ -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"
})
});
}
}
}

View File

@ -16,7 +16,7 @@ namespace BTCPayServer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
modelBuilder.HasAnnotation("ProductVersion", "8.0.1");
modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b =>
{

View File

@ -3490,7 +3490,6 @@ namespace BTCPayServer.Tests
[Trait("Integration", "Integration")]
public async Task StoreUsersAPITest()
{
using var tester = CreateServerTester();
await tester.StartAsync();
@ -3500,52 +3499,83 @@ namespace BTCPayServer.Tests
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
var roles = await client.GetServerRoles();
Assert.Equal(2,roles.Count);
Assert.Equal(4, roles.Count);
#pragma warning disable CS0618
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);
#pragma warning restore CS0618
var users = await client.GetStoreUsers(user.StoreId);
var storeuser = Assert.Single(users);
Assert.Equal(user.UserId, storeuser.UserId);
Assert.Equal(ownerRole.Id, storeuser.Role);
var user2 = tester.NewAccount();
await user2.GrantAccessAsync(false);
var storeUser = Assert.Single(users);
Assert.Equal(user.UserId, storeUser.UserId);
Assert.Equal(ownerRole.Id, storeUser.Role);
var manager = tester.NewAccount();
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
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => 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 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
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId));
//test no access to api for employee
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));
//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 AssertHttpError(403, async () =>
await user2Client.GetStore(user.StoreId));
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId });
await AssertAPIError("duplicate-store-user-role", async () =>
await client.AddStoreUser(user.StoreId,
new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId }));
await user2Client.RemoveStoreUser(user.StoreId, user.UserId);
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId }));
await employeeClient.RemoveStoreUser(user.StoreId, user.UserId);
//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.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)]

View File

@ -407,15 +407,12 @@ namespace BTCPayServer.Tests
public void Logout()
{
if (!Driver.PageSource.Contains("id=\"Nav-Logout\""))
{
Driver.Navigate().GoToUrl(ServerUri);
}
if (!Driver.PageSource.Contains("id=\"Nav-Logout\"")) GoToUrl("/account");
Driver.FindElement(By.Id("Nav-Account")).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("Password")).SendKeys(password);
@ -656,7 +653,7 @@ retry:
Driver.FindElement(By.Id("AddUser")).Click();
Assert.Contains("User added successfully", FindAlertMessage().Text);
}
public void AssertPageAccess(bool shouldHaveAccess, string url)
{
GoToUrl(url);

View File

@ -380,13 +380,13 @@ namespace BTCPayServer.Tests
s.Driver.Navigate().GoToUrl(url);
Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
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);
s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
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("Password")).SendKeys("123456");
@ -928,11 +928,9 @@ namespace BTCPayServer.Tests
s.GoToHome();
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.GoToUrl(storeUrl + "/users");
s.Driver.FindElement(By.Id("Email")).SendKeys(bob + Keys.Enter);
Assert.Contains("User added successfully", s.Driver.PageSource);
s.AddUserToStore(storeId, bob, "Employee");
s.Logout();
// Bob should not have access to store, but should have access to invoice
@ -1063,7 +1061,8 @@ namespace BTCPayServer.Tests
Policies.CanViewInvoices,
Policies.CanModifyInvoices,
Policies.CanViewPaymentRequests,
Policies.CanViewStoreSettings,
Policies.CanViewPullPayments,
Policies.CanViewPayouts,
Policies.CanModifyStoreSettingsUnscoped,
Policies.CanDeleteUser
});
@ -1148,13 +1147,8 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester(newDb: true);
await s.StartAsync();
var userId = s.RegisterNewUser(true);
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}";
s.CreateNewStore();
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).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"));
(_, string appId) = s.CreateApp("PointOfSale");
s.Driver.FindElement(By.Id("Title")).Clear();
s.Driver.FindElement(By.Id("Title")).SendKeys("Tea shop");
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
@ -1169,7 +1163,6 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
var appId = s.Driver.Url.Split('/')[4];
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
@ -1268,12 +1261,7 @@ namespace BTCPayServer.Tests
s.CreateNewStore();
s.AddDerivationScheme();
var appName = $"CF-{Guid.NewGuid().ToString()[..21]}";
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"));
(_, string appId) = s.CreateApp("Crowdfund");
s.Driver.FindElement(By.Id("Title")).Clear();
s.Driver.FindElement(By.Id("Title")).SendKeys("Kukkstarter");
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();
Assert.Contains("App updated", s.FindAlertMessage().Text);
var editUrl = s.Driver.Url;
var appId = editUrl.Split('/')[4];
// Check public page
s.Driver.FindElement(By.Id("ViewApp")).Click();
@ -3333,6 +3320,7 @@ retry:
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url);
});
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUseRoleManager()
@ -3343,8 +3331,10 @@ retry:
s.GoToHome();
s.GoToServer(ServerNavPages.Roles);
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 managerRow = null;
IWebElement employeeRow = null;
IWebElement guestRow = null;
foreach (var roleItem in existingServerRoles)
{
@ -3352,6 +3342,14 @@ retry:
{
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))
{
guestRow = roleItem;
@ -3359,11 +3357,21 @@ retry:
}
Assert.NotNull(ownerRow);
Assert.NotNull(managerRow);
Assert.NotNull(employeeRow);
Assert.NotNull(guestRow);
var ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
Assert.Contains(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
Assert.Contains(ownerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
var 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"));
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
@ -3391,13 +3399,11 @@ retry:
ownerRow.FindElement(By.Id("SetDefault")).Click();
s.FindAlertMessage();
s.CreateNewStore();
s.GoToStore(StoreNavPages.Roles);
var existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(3, existingStoreRoles.Count);
Assert.Equal(2, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
Assert.Equal(5, existingStoreRoles.Count);
Assert.Equal(4, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
foreach (var roleItem in existingStoreRoles)
{
@ -3448,20 +3454,19 @@ retry:
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase));
s.GoToStore(StoreNavPages.Users);
var options = s.Driver.FindElements(By.CssSelector("#Role option"));
Assert.Equal(2, options.Count);
Assert.Equal(4, options.Count);
Assert.Contains(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
s.CreateNewStore();
s.GoToStore(StoreNavPages.Roles);
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(2, existingStoreRoles.Count);
Assert.Equal(1, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
Assert.Equal(4, existingStoreRoles.Count);
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)));
s.GoToStore(StoreNavPages.Users);
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));
s.GoToStore(StoreNavPages.Roles);
s.Driver.FindElement(By.Id("CreateRole")).Click();
s.Driver.FindElement(By.Id("Role")).SendKeys("Malice");
@ -3502,7 +3507,7 @@ retry:
s.RegisterNewUser(true);
string GetStorePath(string subPath) => $"/stores/{storeId}/{subPath}";
// Owner access
// Admin access
s.AssertPageAccess(false, GetStorePath(""));
s.AssertPageAccess(true, GetStorePath("reports"));
s.AssertPageAccess(true, GetStorePath("invoices"));
@ -3518,9 +3523,214 @@ retry:
s.AssertPageAccess(false, GetStorePath("apps/create"));
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 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.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)

View File

@ -554,13 +554,23 @@ retry:
public async Task AddGuest(string userId)
{
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Guest);
var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Guest);
}
public async Task AddOwner(string userId)
{
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
var repo = parent.PayTester.GetService<StoreRepository>();
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)

View File

@ -2782,7 +2782,7 @@ namespace BTCPayServer.Tests
await tester.StartAsync();
var acc = tester.NewAccount();
acc.GrantAccess(true);
await acc.GrantAccessAsync(true);
var settings = tester.PayTester.GetService<SettingsRepository>();
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.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",
Login = "store@store.com",
Password = "store@store.com",
Port = 1234,
Server = "store.com"
}), ""));
}), "", true));
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
}

View File

@ -24,7 +24,7 @@ namespace BTCPayServer.Controllers.Greenfield
_storeRepository = storeRepository;
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/roles")]
public async Task<IActionResult> GetStoreRoles(string storeId)
{
@ -34,7 +34,6 @@ namespace BTCPayServer.Controllers.Greenfield
: Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false)));
}
private List<RoleData> FromModel(StoreRepository.StoreRole[] data)
{
return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = r.IsServerRole}).ToList();

View File

@ -27,14 +27,15 @@ namespace BTCPayServer.Controllers.Greenfield
_storeRepository = storeRepository;
_userManager = userManager;
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/users")]
public IActionResult GetStoreUsers()
{
var store = HttpContext.GetStoreData();
return store == null ? StoreNotFound() : Ok(FromModel(store));
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/stores/{storeId}/users/{idOrEmail}")]
public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail)

View File

@ -147,7 +147,7 @@ namespace BTCPayServer.Controllers
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");
return RedirectToLocal(returnUrl);
}
@ -215,7 +215,7 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
_logger.LogInformation("User {UserId} logged in", user.Id);
_logger.LogInformation("User {Email} logged in", user.Email);
return RedirectToLocal(returnUrl);
}
if (result.RequiresTwoFactor)
@ -230,7 +230,7 @@ namespace BTCPayServer.Controllers
}
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 });
}
@ -368,7 +368,7 @@ namespace BTCPayServer.Controllers
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
{
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);
}
}
@ -455,11 +455,11 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
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);
}
_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.");
return View("SecondaryLogin", new SecondaryLoginViewModel
{
@ -524,17 +524,17 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
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);
}
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 });
}
_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.");
return View();
}
@ -650,9 +650,11 @@ namespace BTCPayServer.Controllers
[HttpGet("/logout")]
public async Task<IActionResult> Logout()
{
var userId = _signInManager.UserManager.GetUserId(HttpContext.User);
var user = await _userManager.FindByIdAsync(userId);
await _signInManager.SignOutAsync();
HttpContext.DeleteUserPrefsCookie();
_logger.LogInformation("User logged out");
_logger.LogInformation("User {Email} logged out", user!.Email);
return RedirectToAction(nameof(Login));
}
@ -747,7 +749,7 @@ namespace BTCPayServer.Controllers
{
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);
@ -777,6 +779,7 @@ namespace BTCPayServer.Controllers
}
var user = await _userManager.FindByEmailAsync(model.Email);
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist
@ -789,7 +792,7 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Password successfully set."
Message = hasPassword ? "Password successfully set." : "Account successfully created."
});
return RedirectToAction(nameof(Login));
}
@ -817,6 +820,12 @@ namespace BTCPayServer.Controllers
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
var requiresSetPassword = !await _userManager.HasPasswordAsync(user);
_eventAggregator.Publish(new UserInviteAcceptedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
});
if (requiresEmailConfirmation)
{
return await RedirectToConfirmEmail(user);

View File

@ -29,7 +29,6 @@ namespace BTCPayServer.Controllers
{
private readonly ThemeSettings _theme;
private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _networkProvider;
private IHttpClientFactory HttpClientFactory { get; }
private SignInManager<ApplicationUser> SignInManager { get; }
@ -41,14 +40,12 @@ namespace BTCPayServer.Controllers
ThemeSettings theme,
LanguageService languageService,
StoreRepository storeRepository,
BTCPayNetworkProvider networkProvider,
IWebHostEnvironment environment,
SignInManager<ApplicationUser> signInManager)
{
_theme = theme;
HttpClientFactory = httpClientFactory;
LanguageService = languageService;
_networkProvider = networkProvider;
_storeRepository = storeRepository;
SignInManager = signInManager;
_WebRootFileProvider = environment.WebRootFileProvider;
@ -79,14 +76,14 @@ namespace BTCPayServer.Controllers
var store = await _storeRepository.FindStore(storeId);
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);
return activeStore != null
? RedirectToStore(userId, activeStore.Id)
? RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId = activeStore.Id })
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
}
@ -198,9 +195,5 @@ namespace BTCPayServer.Controllers
{
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});
}
}
}

View File

@ -50,7 +50,7 @@ namespace BTCPayServer.Controllers
$"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));
}
@ -100,7 +100,7 @@ namespace BTCPayServer.Controllers
}
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);
TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
@ -117,7 +117,7 @@ namespace BTCPayServer.Controllers
await _userManager.SetTwoFactorEnabledAsync(user, false);
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));
}

View File

@ -21,24 +21,21 @@ namespace BTCPayServer.Controllers
string sortOrder = null
)
{
var roles = await storeRepository.GetStoreRoles(null, true);
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
var roles = await storeRepository.GetStoreRoles(null);
if (sortOrder != null)
switch (sortOrder)
{
switch (sortOrder)
{
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();

View File

@ -187,8 +187,8 @@ namespace BTCPayServer.Controllers
_eventAggregator.Publish(new UserRegisteredEvent
{
Kind = UserRegisteredEventKind.Invite,
RequestUri = Request.GetAbsoluteRootUri(),
Kind = UserRegisteredEventKind.Invite,
User = user,
InvitedByUser = currentUser,
Admin = model.IsAdmin,

View File

@ -1204,8 +1204,10 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid)
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 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.DisconnectAsync(true);

View File

@ -121,7 +121,7 @@ namespace BTCPayServer.Controllers
.Where(o => o != null)
.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.";
}
else
@ -189,32 +189,39 @@ namespace BTCPayServer.Controllers
[HttpPost("{storeId}/email-settings")]
[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();
if (store == null)
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()
: null;
model.FallbackSettings = fallbackSettings;
if (useCustomSMTP)
{
model.Settings.Validate("Settings.", ModelState);
}
if (command == "Test")
{
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))
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid)
return View(model);
using var client = await model.Settings.CreateSmtpClient();
var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false);
var settings = useCustomSMTP ? model.Settings : model.FallbackSettings;
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.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
@ -232,17 +239,17 @@ namespace BTCPayServer.Controllers
store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store);
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))
{
ModelState.AddModelError("Settings.From", "Invalid email");
return View(model);
}
if (!ModelState.IsValid)
return View(model);
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;
}
@ -250,8 +257,8 @@ namespace BTCPayServer.Controllers
store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store);
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)

View File

@ -13,7 +13,7 @@ namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[Route("{storeId}/roles")]
[HttpGet("{storeId}/roles")]
public async Task<IActionResult> ListRoles(
string storeId,
[FromServices] StoreRepository storeRepository,
@ -21,24 +21,21 @@ namespace BTCPayServer.Controllers
string sortOrder = null
)
{
var roles = await storeRepository.GetStoreRoles(storeId, true);
var defaultRole = (await storeRepository.GetDefaultRole()).Role;
model ??= new RolesViewModel();
model.DefaultRole = defaultRole;
model.DefaultRole = (await storeRepository.GetDefaultRole()).Role;
var roles = await storeRepository.GetStoreRoles(storeId, false, false);
if (sortOrder != null)
switch (sortOrder)
{
switch (sortOrder)
{
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
case "desc":
ViewData["NextRoleSortOrder"] = "asc";
roles = roles.OrderByDescending(user => user.Role).ToArray();
break;
case "asc":
roles = roles.OrderBy(user => user.Role).ToArray();
ViewData["NextRoleSortOrder"] = "desc";
break;
}
model.Roles = roles.Skip(model.Skip).Take(model.Count).ToList();
@ -47,6 +44,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/roles/{role}")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> CreateOrEditRole(
string storeId,
[FromServices] StoreRepository storeRepository,

View 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();
}
}
}

View File

@ -8,10 +8,12 @@ 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;
@ -60,7 +62,6 @@ namespace BTCPayServer.Controllers
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
PoliciesSettings policiesSettings,
IAuthorizationService authorizationService,
EventAggregator eventAggregator,
AppService appService,
IFileService fileService,
WebhookSender webhookNotificationManager,
@ -70,7 +71,9 @@ namespace BTCPayServer.Controllers
IHtmlHelper html,
LightningClientFactoryService lightningClientFactoryService,
EmailSenderFactory emailSenderFactory,
WalletFileParsers onChainWalletParsers)
WalletFileParsers onChainWalletParsers,
SettingsRepository settingsRepository,
EventAggregator eventAggregator)
{
_RateFactory = rateFactory;
_Repo = repo;
@ -97,6 +100,8 @@ namespace BTCPayServer.Controllers
_lightningClientFactoryService = lightningClientFactoryService;
_emailSenderFactory = emailSenderFactory;
_onChainWalletParsers = onChainWalletParsers;
_settingsRepository = settingsRepository;
_eventAggregator = eventAggregator;
Html = html;
}
@ -110,6 +115,7 @@ namespace BTCPayServer.Controllers
readonly TokenRepository _TokenRepository;
readonly UserManager<ApplicationUser> _UserManager;
readonly RateFetcher _RateFactory;
readonly SettingsRepository _settingsRepository;
private readonly ExplorerClientProvider _ExplorerProvider;
private readonly LanguageService _LangService;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
@ -122,6 +128,7 @@ namespace BTCPayServer.Controllers
private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly WalletFileParsers _onChainWalletParsers;
private readonly EventAggregator _eventAggregator;
public string? GeneratedPairingCode { get; set; }
public WebhookSender WebhookNotificationManager { get; }
@ -157,85 +164,9 @@ namespace BTCPayServer.Controllers
}
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();
[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")]
public IActionResult Rates()
{
@ -930,6 +861,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/tokens")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ListTokens()
{
var model = new TokensViewModel();

View File

@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;

View 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; }
}

View File

@ -12,39 +12,49 @@ namespace BTCPayServer.Services
private static string CallToAction(string actionName, string actionLink)
{
string button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
button = button.Replace("{button_link}", actionLink, System.StringComparison.InvariantCulture);
return button;
var button = $"{BUTTON_HTML}".Replace("{button_description}", actionName, System.StringComparison.InvariantCulture);
return button.Replace("{button_link}", HtmlEncoder.Default.Encode(actionLink), System.StringComparison.InvariantCulture);
}
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)
{
emailSender.SendEmail(address, "BTCPay Server: Confirm your email",
$"Please confirm your account by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
emailSender.SendEmail(address, "Confirm your email", CreateEmailBody(
$"Please confirm your account.<br/><br/>{CallToAction("Confirm Email", link)}"));
}
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
{
emailSender.SendEmail(address, "BTCPay Server: Your account has been approved",
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>.");
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>."));
}
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, "BTCPay Server: Update Password", $"<html><body style='{BODY_STYLE}'>{HEADER_HTML}{body}</body></html>");
emailSender.SendEmail(address, "Update Password", CreateEmailBody(
$"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)
{
emailSender.SendEmail(address, "BTCPay Server: Invitation",
$"Please complete your account setup by clicking <a href='{HtmlEncoder.Default.Encode(link)}'>this link</a>.");
emailSender.SendEmail(address, "Invitation", CreateEmailBody(
$"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)
{
emailSender.SendEmail(address, $"BTCPay Server: {newUserInfo}",
$"{newUserInfo}. You can verify and approve the account here: <a href='{HtmlEncoder.Default.Encode(link)}'>User details</a>");
emailSender.SendEmail(address, newUserInfo, CreateEmailBody(
$"{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>"));
}
}
}

View File

@ -29,6 +29,12 @@ namespace Microsoft.AspNetCore.Mvc
return urlHelper.GetUriByAction(nameof(UIServerController.User), "UIServer",
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)
{

View File

@ -135,7 +135,7 @@ namespace BTCPayServer.HostedServices
(await _EmailSenderFactory.GetEmailSender(invoice.StoreId)).SendEmail(
notificationEmail,
$"{storeName} Invoice Notification - ${invoice.StoreId}",
$"Invoice Notification - ${invoice.StoreId}",
emailBody);
}

View File

@ -8,6 +8,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -21,6 +22,7 @@ public class UserEventHostedService(
UserManager<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory,
NotificationSender notificationSender,
StoreRepository storeRepository,
LinkGenerator generator,
Logs logs)
: EventHostedServiceBase(eventAggregator, logs)
@ -31,6 +33,7 @@ public class UserEventHostedService(
Subscribe<UserApprovedEvent>();
Subscribe<UserConfirmedEmailEvent>();
Subscribe<UserPasswordResetRequestedEvent>();
Subscribe<UserInviteAcceptedEvent>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
@ -121,14 +124,22 @@ public class UserEventHostedService(
if (!user.RequiresApproval || user.Approved) return;
await NotifyAdminsAboutUserRequiringApproval(user, uri, confirmedUserInfo);
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)
{
if (!user.RequiresApproval || user.Approved) return;
// notification
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
// email
var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var host = new HostString(uri.Host, uri.Port);
var approvalLink = generator.UserDetailsLink(user.Id, uri.Scheme, host, uri.PathAndQuery);
@ -138,4 +149,27 @@ public class UserEventHostedService(
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);
}
}
}
}

View File

@ -1,11 +1,8 @@
using System;
using System.Configuration.Provider;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Custodians;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
@ -66,11 +63,9 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.RPC;
using NBitpayClient;
using NBXplorer.DerivationStrategy;
using Newtonsoft.Json;
using NicolasDorier.RateLimits;
using Serilog;
using BTCPayServer.Services.Reporting;
using BTCPayServer.Services.WalletFileParsing;
@ -437,6 +432,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
services.AddSingleton<INotificationHandler, InviteAcceptedNotification.Handler>();
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();

View File

@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.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)
{
_JobClient.Schedule(async (cancellationToken) =>
_JobClient.Schedule(async cancellationToken =>
{
var emailSettings = await GetEmailSettings();
if (emailSettings?.IsComplete() != true)
@ -36,12 +35,14 @@ namespace BTCPayServer.Services.Mails
}
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.DisconnectAsync(true, cancellationToken);
}, TimeSpan.Zero);
}
public abstract Task<EmailSettings> GetEmailSettings();
public abstract Task<string> GetPrefixedSubject(string subject);
}
}

View File

@ -20,5 +20,12 @@ namespace BTCPayServer.Services.Mails
{
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}";
}
}
}

View File

@ -36,5 +36,11 @@ namespace BTCPayServer.Services.Mails
return await FallbackSender?.GetEmailSettings();
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}";
}
}
}

View File

@ -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);
}
}
}

View File

@ -1,33 +1,30 @@
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 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
public StoreScope(string storeId, IEnumerable<StoreRoleId> roles = null)
{
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;

View File

@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications
{
@ -31,7 +30,7 @@ namespace BTCPayServer.Services.Notifications
_notificationManager = notificationManager;
}
public async Task SendNotification(NotificationScope scope, BaseNotification notification)
public async Task SendNotification(INotificationScope scope, BaseNotification notification)
{
ArgumentNullException.ThrowIfNull(scope);
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();
@ -79,9 +78,10 @@ namespace BTCPayServer.Services.Notifications
break;
}
case StoreScope s:
var roles = s.Roles?.Select(role => role.Id);
query = ctx.UserStore
.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);
break;
case UserScope userScope:

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Amazon.S3;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
using BTCPayServer.Data;
@ -87,7 +86,17 @@ namespace BTCPayServer.Services.Stores
{
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()
@ -166,11 +175,11 @@ namespace BTCPayServer.Services.Stores
return ToStoreRole(match);
}
public async Task<StoreUser[]> GetStoreUsers(string storeId)
public async Task<StoreUser[]> GetStoreUsers(string storeId, IEnumerable<StoreRoleId>? filterRoles = null)
{
ArgumentNullException.ThrowIfNull(storeId);
await using var ctx = _ContextFactory.CreateContext();
var roles = filterRoles?.Select(role => role.Id);
return (await
ctx
.UserStore
@ -181,7 +190,9 @@ namespace BTCPayServer.Services.Stores
Id = u.ApplicationUserId,
u.ApplicationUser.Email,
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),
Id = arg.Id,
@ -191,7 +202,7 @@ namespace BTCPayServer.Services.Stores
public static StoreRole ToStoreRole(Data.StoreRole storeRole)
{
return new StoreRole()
return new StoreRole
{
Id = storeRole.Id,
Role = storeRole.Role,
@ -262,13 +273,19 @@ namespace BTCPayServer.Services.Stores
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)
{
ArgumentNullException.ThrowIfNull(storeId);
AssertStoreRoleIfNeeded(storeId, roleId);
roleId ??= await GetDefaultRole();
await using var ctx = _ContextFactory.CreateContext();
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id };
var userStore = new UserStore { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id };
ctx.UserStore.Add(userStore);
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)
{
if (roleId?.StoreId != null && storeId != roleId.StoreId)
@ -645,8 +690,12 @@ retry:
Role = role;
}
public static StoreRoleId Owner { get; } = new StoreRoleId("Owner");
public static StoreRoleId Guest { get; } = new StoreRoleId("Guest");
public static StoreRoleId Owner { get; } = new ("Owner");
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 Role { get; }
public string Id

View File

@ -8,6 +8,10 @@ namespace BTCPayServer
[Obsolete("You should check authorization policies instead of roles")]
public const string Owner = "Owner";
[Obsolete("You should check authorization policies instead of roles")]
public const string 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";
}
}

View File

@ -26,14 +26,14 @@
<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">
<h2 class="mb-0">@ViewData["Title"]</h2>
<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)
{
<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)
{

View File

@ -61,7 +61,7 @@
}
</div>
<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">
<vc:icon symbol="caret-down"/>
<span class="ms-1">Advanced settings</span>
@ -75,7 +75,7 @@
</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>

View File

@ -33,14 +33,14 @@
</script>
}
<form method="post">
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
<h2 class="mb-0">@ViewData["Title"]</h2>
<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)
{
<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
{

View File

@ -4,51 +4,53 @@
</div>
<script>
const modal = document.getElementById('ConfirmModal')
modal.addEventListener('show.bs.modal', event => {
const $target = event.relatedTarget
const $form = document.getElementById('ConfirmForm')
const $text = document.getElementById('ConfirmText')
const $title = document.getElementById('ConfirmTitle')
const $description = document.getElementById('ConfirmDescription')
const $input = document.getElementById('ConfirmInput')
const $inputText = document.getElementById('ConfirmInputText')
const $continue = document.getElementById('ConfirmContinue')
const { title, description, confirm, confirmInput } = $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 (title) $title.textContent = title
if (description) $description.innerHTML = description
if (confirm) $continue.textContent = confirm
if (confirmInput) {
$text.removeAttribute('hidden')
$continue.setAttribute('disabled', 'disabled')
$inputText.textContent = confirmInput
$input.setAttribute("autocomplete", "off")
$input.addEventListener('input', event => {
event.target.value.trim() === confirmInput
? $continue.removeAttribute('disabled')
: $continue.setAttribute('disabled', 'disabled')
})
$form.addEventListener('submit', event => {
if ($input.value.trim() !== confirmInput) {
event.preventDefault()
}
})
} else {
$text.setAttribute('hidden', 'hidden')
$continue.removeAttribute('disabled')
}
});
modal.addEventListener('shown.bs.modal', event => {
const $target = event.relatedTarget
const $input = document.getElementById('ConfirmInput')
const { confirmInput } = $target.dataset
if (confirmInput) {
$input.focus();
}
});
(function () {
const modal = document.getElementById('ConfirmModal')
modal.addEventListener('show.bs.modal', event => {
const $target = event.relatedTarget
const $form = document.getElementById('ConfirmForm')
const $text = document.getElementById('ConfirmText')
const $title = document.getElementById('ConfirmTitle')
const $description = document.getElementById('ConfirmDescription')
const $input = document.getElementById('ConfirmInput')
const $inputText = document.getElementById('ConfirmInputText')
const $continue = document.getElementById('ConfirmContinue')
const { title, description, confirm, confirmInput } = $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 (title) $title.textContent = title
if (description) $description.innerHTML = description
if (confirm) $continue.textContent = confirm
if (confirmInput) {
$text.removeAttribute('hidden')
$continue.setAttribute('disabled', 'disabled')
$inputText.textContent = confirmInput
$input.setAttribute("autocomplete", "off")
$input.addEventListener('input', event => {
event.target.value.trim() === confirmInput
? $continue.removeAttribute('disabled')
: $continue.setAttribute('disabled', 'disabled')
})
$form.addEventListener('submit', event => {
if ($input.value.trim() !== confirmInput) {
event.preventDefault()
}
})
} else {
$text.setAttribute('hidden', 'hidden')
$continue.removeAttribute('disabled')
}
})
modal.addEventListener('shown.bs.modal', event => {
const $target = event.relatedTarget
const $input = document.getElementById('ConfirmInput')
const { confirmInput } = $target.dataset
if (confirmInput) {
$input.focus()
}
})
})()
</script>

View File

@ -1,6 +1,7 @@
@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";
}
@ -35,5 +36,5 @@
<input asp-for="ConfirmPassword" class="form-control"/>
<span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</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>

View File

@ -37,7 +37,7 @@
<tr>
<td>
<a asp-action="Modify" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Edit-@item.Name" permission="@Policies.CanModifyStoreSettings">@item.Name</a>
<a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" not-permission="@Policies.CanModifyStoreSettings">View</a>
<a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name" not-permission="@Policies.CanModifyStoreSettings">@item.Name</a>
</td>
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
<a asp-action="Remove" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Remove-@item.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Remove</a> -

View File

@ -17,7 +17,7 @@
{
<div asp-validation-summary="All"></div>
}
<form method="post">
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
<div class="d-flex my-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>
@ -41,7 +41,7 @@
<div class="form-text">If a payout fails this many times, it will be cancelled.</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>
</div>
</div>

View File

@ -18,7 +18,7 @@
{
<div asp-validation-summary="All"></div>
}
<form method="post">
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
<div class="d-flex my-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>
@ -49,7 +49,7 @@
</div>
<div class="form-text">Only process payouts when this payout sum is reached.</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>
</div>
</div>

View File

@ -61,7 +61,7 @@
<div class="row">
<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)
{
<div asp-validation-summary="All"></div>
@ -275,7 +275,7 @@
<input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="btcpay-toggle me-3" />
<label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label>
</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>
</div>
</div>

View File

@ -17,7 +17,7 @@
{
<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>
<div class="form-group">
<label asp-for="Id" class="form-label"></label>
@ -159,7 +159,7 @@
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
</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>
<div permission="@Policies.CanModifyStoreSettings">
<h3 class="mt-5 mb-3">Additional Actions</h3>

View File

@ -15,7 +15,7 @@
{
<div asp-validation-summary="All"></div>
}
<form method="post">
<form method="post" permissioned="@Policies.CanModifyStoreSettings">
<input type="hidden" asp-for="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" />
<span asp-validation-for="DefaultCurrencyPairs" class="text-danger"></span>
</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>
</div>
</div>

View File

@ -4,7 +4,7 @@
@{
Layout = "../Shared/_NavLayout.cshtml";
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">
@ -15,27 +15,27 @@
Configure
</a>
</div>
<p>
<p class="mb-0">
<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.
</p>
</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())
{
<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>
<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>
</label>
<div class="checkout-settings collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
<div class="collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
<partial name="EmailsBody" model="Model" />
</div>
}

View File

@ -14,7 +14,7 @@
<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="d-flex align-items-center justify-content-between mt-n1 mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>

View File

@ -9,11 +9,21 @@
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id);
var roles = new SelectList(await StoreRepository.GetStoreRoles(ScopeProvider.GetCurrentStoreId()), nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role), Model.Role);
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="col-xxl-constrain col-xl-8">
<div class="col-xxl-constrain">
<h3 class="mb-3">@ViewData["Title"]</h3>
<p>
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">
<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>
</form>
@ -39,14 +49,17 @@
<th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
</tr>
</thead>
<tbody>
<tbody id="StoreUsersList">
@foreach (var user in Model.Users)
{
<tr>
<td>@user.Email</td>
<td>@user.Role</td>
<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>
</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" />
<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 {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -23,7 +23,7 @@
<tr>
<th>Status</th>
<th>Url</th>
<th class="text-end" permission="@Policies.CanModifyStoreSettings">Actions</th>
<th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
</tr>
</thead>
<tbody>
@ -48,7 +48,7 @@
}
</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="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>

View File

@ -74,3 +74,16 @@
.editor .nested-fields .list-group-item {
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;
}

View File

@ -78,6 +78,12 @@ hr.primary {
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) {
.text-md-nowrap {
white-space: nowrap;
@ -648,6 +654,11 @@ label.btcpay-list-select-item:hover {
border-color: var(--btcpay-form-border-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) {
.btcpay-list-select-item {
flex-basis: 100%;