diff --git a/BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.cs b/BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.cs new file mode 100644 index 000000000..22d5988be --- /dev/null +++ b/BTCPayServer.Abstractions/TagHelpers/PermissionedFormTagHelper.cs @@ -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($"
{html}
"); + } + } + + [GeneratedRegex("<(button|input).*?type=\"submit\".*?>.*?")] + private static partial Regex SubmitButtonRegex(); +} diff --git a/BTCPayServer.Data/Migrations/20240229092905_AddManagerAndEmployeeToStoreRoles.cs b/BTCPayServer.Data/Migrations/20240229092905_AddManagerAndEmployeeToStoreRoles.cs new file mode 100644 index 000000000..6a93968de --- /dev/null +++ b/BTCPayServer.Data/Migrations/20240229092905_AddManagerAndEmployeeToStoreRoles.cs @@ -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" + }) + }); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index a8031988e..116c57e87 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -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 => { diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 8e3e0e933..869bdbe8a 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -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)] diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 479e1404a..dac368760 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -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); diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index edcf786a0..19ce4171d 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -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) diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 0d5c3e566..81d1576d6 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -554,13 +554,23 @@ retry: public async Task AddGuest(string userId) { - var repo = this.parent.PayTester.GetService(); - await repo.AddStoreUser(StoreId, userId, StoreRoleId.Guest); + var repo = parent.PayTester.GetService(); + await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Guest); } public async Task AddOwner(string userId) { - var repo = this.parent.PayTester.GetService(); - await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner); + var repo = parent.PayTester.GetService(); + await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Owner); + } + public async Task AddManager(string userId) + { + var repo = parent.PayTester.GetService(); + await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Manager); + } + public async Task AddEmployee(string userId) + { + var repo = parent.PayTester.GetService(); + await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Employee); } public async Task PayOnChain(string invoiceId) diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 120f2ccf3..1d0cc2166 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -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(); var emailSenderFactory = tester.PayTester.GetService(); @@ -2807,14 +2807,14 @@ namespace BTCPayServer.Tests Assert.Equal("admin@admin.com", (await Assert.IsType(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login); Assert.Null(await Assert.IsType(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()); - Assert.IsType(await acc.GetController().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings() + Assert.IsType(await acc.GetController().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(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login); } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreRolesController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRolesController.cs index bce0b2a5e..5c6919866 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreRolesController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreRolesController.cs @@ -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 GetStoreRoles(string storeId) { @@ -34,7 +34,6 @@ namespace BTCPayServer.Controllers.Greenfield : Ok(FromModel(await _storeRepository.GetStoreRoles(storeId, false, false))); } - private List FromModel(StoreRepository.StoreRole[] data) { return data.Select(r => new RoleData() {Role = r.Role, Id = r.Id, Permissions = r.Permissions, IsServerRole = r.IsServerRole}).ToList(); diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreUsersController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreUsersController.cs index d4acbcbf1..1839d7071 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoreUsersController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreUsersController.cs @@ -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 RemoveStoreUser(string storeId, string idOrEmail) diff --git a/BTCPayServer/Controllers/UIAccountController.cs b/BTCPayServer/Controllers/UIAccountController.cs index 74973817b..644eae943 100644 --- a/BTCPayServer/Controllers/UIAccountController.cs +++ b/BTCPayServer/Controllers/UIAccountController.cs @@ -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())) { 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 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); diff --git a/BTCPayServer/Controllers/UIHomeController.cs b/BTCPayServer/Controllers/UIHomeController.cs index 98c5714e0..14492d6b4 100644 --- a/BTCPayServer/Controllers/UIHomeController.cs +++ b/BTCPayServer/Controllers/UIHomeController.cs @@ -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 SignInManager { get; } @@ -41,14 +40,12 @@ namespace BTCPayServer.Controllers ThemeSettings theme, LanguageService languageService, StoreRepository storeRepository, - BTCPayNetworkProvider networkProvider, IWebHostEnvironment environment, SignInManager 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}); - } } } diff --git a/BTCPayServer/Controllers/UIManageController.2FA.cs b/BTCPayServer/Controllers/UIManageController.2FA.cs index c48bc1ee0..44170c3c6 100644 --- a/BTCPayServer/Controllers/UIManageController.2FA.cs +++ b/BTCPayServer/Controllers/UIManageController.2FA.cs @@ -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)); } diff --git a/BTCPayServer/Controllers/UIServerController.Roles.cs b/BTCPayServer/Controllers/UIServerController.Roles.cs index 04bcfd699..59b53c501 100644 --- a/BTCPayServer/Controllers/UIServerController.Roles.cs +++ b/BTCPayServer/Controllers/UIServerController.Roles.cs @@ -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(); diff --git a/BTCPayServer/Controllers/UIServerController.Users.cs b/BTCPayServer/Controllers/UIServerController.Users.cs index 28ecf8b9a..cb508d843 100644 --- a/BTCPayServer/Controllers/UIServerController.Users.cs +++ b/BTCPayServer/Controllers/UIServerController.Users.cs @@ -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, diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs index 8e553a7b4..80e55bb47 100644 --- a/BTCPayServer/Controllers/UIServerController.cs +++ b/BTCPayServer/Controllers/UIServerController.cs @@ -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(); + 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); diff --git a/BTCPayServer/Controllers/UIStoresController.Email.cs b/BTCPayServer/Controllers/UIStoresController.Email.cs index 720e51de1..170eefec8 100644 --- a/BTCPayServer/Controllers/UIStoresController.Email.cs +++ b/BTCPayServer/Controllers/UIStoresController.Email.cs @@ -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 StoreEmailSettings(string storeId, EmailsViewModel model, string command) + public async Task 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 IsSetupComplete(IEmailSender emailSender) diff --git a/BTCPayServer/Controllers/UIStoresController.Roles.cs b/BTCPayServer/Controllers/UIStoresController.Roles.cs index 1a519260c..35169bb52 100644 --- a/BTCPayServer/Controllers/UIStoresController.Roles.cs +++ b/BTCPayServer/Controllers/UIStoresController.Roles.cs @@ -13,7 +13,7 @@ namespace BTCPayServer.Controllers { public partial class UIStoresController { - [Route("{storeId}/roles")] + [HttpGet("{storeId}/roles")] public async Task 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 CreateOrEditRole( string storeId, [FromServices] StoreRepository storeRepository, diff --git a/BTCPayServer/Controllers/UIStoresController.Users.cs b/BTCPayServer/Controllers/UIStoresController.Users.cs new file mode 100644 index 000000000..a6a13ee49 --- /dev/null +++ b/BTCPayServer/Controllers/UIStoresController.Users.cs @@ -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 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 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(); + 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() ?? new EmailSettings(); + var info = settings.IsComplete() + ? "An invitation email has been sent.
You may alternatively" + : "An invitation email has not been sent, because the server does not have an email server configured.
You need to"; + successInfo = $"{info} share this link with them: {callbackUrl}"; + } + 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 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 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(); + } + } +} diff --git a/BTCPayServer/Controllers/UIStoresController.cs b/BTCPayServer/Controllers/UIStoresController.cs index bf8ca7fc2..cf57413f3 100644 --- a/BTCPayServer/Controllers/UIStoresController.cs +++ b/BTCPayServer/Controllers/UIStoresController.cs @@ -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 _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 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 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 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 {Html.Encode(user.Email)} 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 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 ListTokens() { var model = new TokensViewModel(); diff --git a/BTCPayServer/Controllers/UIUserStoresController.cs b/BTCPayServer/Controllers/UIUserStoresController.cs index decf66cec..95772c2fa 100644 --- a/BTCPayServer/Controllers/UIUserStoresController.cs +++ b/BTCPayServer/Controllers/UIUserStoresController.cs @@ -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; diff --git a/BTCPayServer/Events/UserInviteAcceptedEvent.cs b/BTCPayServer/Events/UserInviteAcceptedEvent.cs new file mode 100644 index 000000000..9af8174ca --- /dev/null +++ b/BTCPayServer/Events/UserInviteAcceptedEvent.cs @@ -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; } +} diff --git a/BTCPayServer/Extensions/EmailSenderExtensions.cs b/BTCPayServer/Extensions/EmailSenderExtensions.cs index 9fb81ad6f..f0e2367e0 100644 --- a/BTCPayServer/Extensions/EmailSenderExtensions.cs +++ b/BTCPayServer/Extensions/EmailSenderExtensions.cs @@ -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 $"{HEADER_HTML}{body}"; } 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 this link."); + emailSender.SendEmail(address, "Confirm your email", CreateEmailBody( + $"Please confirm your account.

{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 login here."); + emailSender.SendEmail(address, "Your account has been approved", CreateEmailBody( + $"Your account has been approved and you can now login here.")); } 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.

{CallToAction("Update Password", HtmlEncoder.Default.Encode(link))}"; - emailSender.SendEmail(address, "BTCPay Server: Update Password", $"{HEADER_HTML}{body}"); + emailSender.SendEmail(address, "Update Password", CreateEmailBody( + $"A request has been made to reset your BTCPay Server password. Please set your password by clicking below.

{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 this link."); + emailSender.SendEmail(address, "Invitation", CreateEmailBody( + $"Please complete your account setup by clicking this link.")); } 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: User details"); + emailSender.SendEmail(address, newUserInfo, CreateEmailBody( + $"{newUserInfo}. You can verify and approve the account here: User details")); + } + + 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: Store users")); } } } diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index 1fb91278e..19d0eb713 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -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) { diff --git a/BTCPayServer/HostedServices/BitpayIPNSender.cs b/BTCPayServer/HostedServices/BitpayIPNSender.cs index 37b26ef47..15330757d 100644 --- a/BTCPayServer/HostedServices/BitpayIPNSender.cs +++ b/BTCPayServer/HostedServices/BitpayIPNSender.cs @@ -135,7 +135,7 @@ namespace BTCPayServer.HostedServices (await _EmailSenderFactory.GetEmailSender(invoice.StoreId)).SendEmail( notificationEmail, - $"{storeName} Invoice Notification - ${invoice.StoreId}", + $"Invoice Notification - ${invoice.StoreId}", emailBody); } diff --git a/BTCPayServer/HostedServices/UserEventHostedService.cs b/BTCPayServer/HostedServices/UserEventHostedService.cs index 2cfce8825..f2d65936e 100644 --- a/BTCPayServer/HostedServices/UserEventHostedService.cs +++ b/BTCPayServer/HostedServices/UserEventHostedService.cs @@ -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 userManager, EmailSenderFactory emailSenderFactory, NotificationSender notificationSender, + StoreRepository storeRepository, LinkGenerator generator, Logs logs) : EventHostedServiceBase(eventAggregator, logs) @@ -31,6 +33,7 @@ public class UserEventHostedService( Subscribe(); Subscribe(); Subscribe(); + Subscribe(); } 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); + } + } + } } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 8d5d77a6c..9922ebfad 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -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(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Services/Mails/EmailSender.cs b/BTCPayServer/Services/Mails/EmailSender.cs index e9da44bf6..d5a791088 100644 --- a/BTCPayServer/Services/Mails/EmailSender.cs +++ b/BTCPayServer/Services/Mails/EmailSender.cs @@ -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 GetEmailSettings(); + public abstract Task GetPrefixedSubject(string subject); } } diff --git a/BTCPayServer/Services/Mails/ServerEmailSender.cs b/BTCPayServer/Services/Mails/ServerEmailSender.cs index 978c3b3a0..bc9c986ca 100644 --- a/BTCPayServer/Services/Mails/ServerEmailSender.cs +++ b/BTCPayServer/Services/Mails/ServerEmailSender.cs @@ -20,5 +20,12 @@ namespace BTCPayServer.Services.Mails { return SettingsRepository.GetSettingAsync(); } + + public override async Task GetPrefixedSubject(string subject) + { + var settings = await SettingsRepository.GetSettingAsync(); + var prefix = string.IsNullOrEmpty(settings?.ServerName) ? "BTCPay Server" : settings.ServerName; + return $"{prefix}: {subject}"; + } } } diff --git a/BTCPayServer/Services/Mails/StoreEmailSender.cs b/BTCPayServer/Services/Mails/StoreEmailSender.cs index df06f6351..209603667 100644 --- a/BTCPayServer/Services/Mails/StoreEmailSender.cs +++ b/BTCPayServer/Services/Mails/StoreEmailSender.cs @@ -36,5 +36,11 @@ namespace BTCPayServer.Services.Mails return await FallbackSender?.GetEmailSettings(); return null; } + + public override async Task GetPrefixedSubject(string subject) + { + var store = await StoreRepository.FindStore(StoreId); + return string.IsNullOrEmpty(store?.StoreName) ? subject : $"{store.StoreName}: {subject}"; + } } } diff --git a/BTCPayServer/Services/Notifications/Blobs/InviteAcceptedNotification.cs b/BTCPayServer/Services/Notifications/Blobs/InviteAcceptedNotification.cs new file mode 100644 index 000000000..41701f343 --- /dev/null +++ b/BTCPayServer/Services/Notifications/Blobs/InviteAcceptedNotification.cs @@ -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 + { + 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); + } + } +} diff --git a/BTCPayServer/Services/Notifications/NotificationScopes.cs b/BTCPayServer/Services/Notifications/NotificationScopes.cs index 776da1d5a..a7440837b 100644 --- a/BTCPayServer/Services/Notifications/NotificationScopes.cs +++ b/BTCPayServer/Services/Notifications/NotificationScopes.cs @@ -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 roles = null) { + ArgumentNullException.ThrowIfNull(storeId); + StoreId = storeId; + Roles = roles; } + public string StoreId { get; } + public IEnumerable Roles { get; set; } } + +public class UserScope : INotificationScope +{ + public UserScope(string userId) + { + ArgumentNullException.ThrowIfNull(userId); + UserId = userId; + } + public string UserId { get; } +} + +public interface INotificationScope; diff --git a/BTCPayServer/Services/Notifications/NotificationSender.cs b/BTCPayServer/Services/Notifications/NotificationSender.cs index cb161d234..0ad148eff 100644 --- a/BTCPayServer/Services/Notifications/NotificationSender.cs +++ b/BTCPayServer/Services/Notifications/NotificationSender.cs @@ -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 GetUsers(NotificationScope scope, string notificationIdentifier) + private async Task 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: diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index 571dc8004..e83be9684 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -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 GetDefaultRole() @@ -166,11 +175,11 @@ namespace BTCPayServer.Services.Stores return ToStoreRole(match); } - - public async Task GetStoreUsers(string storeId) + public async Task GetStoreUsers(string storeId, IEnumerable? 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 GetStoreUser(string storeId, string userId) + { + await using var ctx = _ContextFactory.CreateContext(); + return await ctx.UserStore.FindAsync(userId, storeId); + } + public async Task 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 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 diff --git a/BTCPayServer/StorePolicies.cs b/BTCPayServer/StorePolicies.cs index dfc3cfed5..35d61c36c 100644 --- a/BTCPayServer/StorePolicies.cs +++ b/BTCPayServer/StorePolicies.cs @@ -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"; } } diff --git a/BTCPayServer/Views/Shared/Crowdfund/UpdateCrowdfund.cshtml b/BTCPayServer/Views/Shared/Crowdfund/UpdateCrowdfund.cshtml index 55863aa83..d6472fe7f 100644 --- a/BTCPayServer/Views/Shared/Crowdfund/UpdateCrowdfund.cshtml +++ b/BTCPayServer/Views/Shared/Crowdfund/UpdateCrowdfund.cshtml @@ -26,14 +26,14 @@ } -
+ - + diff --git a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml index e0c0225cd..bdf9bb6dd 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml @@ -33,14 +33,14 @@ } - + - +
diff --git a/BTCPayServer/Views/UIForms/FormsList.cshtml b/BTCPayServer/Views/UIForms/FormsList.cshtml index a23edee1f..8ab244fa7 100644 --- a/BTCPayServer/Views/UIForms/FormsList.cshtml +++ b/BTCPayServer/Views/UIForms/FormsList.cshtml @@ -37,7 +37,7 @@ @item.Name - View + @item.Name Remove - diff --git a/BTCPayServer/Views/UILightningAutomatedPayoutProcessors/Configure.cshtml b/BTCPayServer/Views/UILightningAutomatedPayoutProcessors/Configure.cshtml index 30005a197..530617557 100644 --- a/BTCPayServer/Views/UILightningAutomatedPayoutProcessors/Configure.cshtml +++ b/BTCPayServer/Views/UILightningAutomatedPayoutProcessors/Configure.cshtml @@ -17,7 +17,7 @@ {
} -
+
@@ -41,7 +41,7 @@
If a payout fails this many times, it will be cancelled.
- +
diff --git a/BTCPayServer/Views/UIOnChainAutomatedPayoutProcessors/Configure.cshtml b/BTCPayServer/Views/UIOnChainAutomatedPayoutProcessors/Configure.cshtml index e2da9c569..dd6a88002 100644 --- a/BTCPayServer/Views/UIOnChainAutomatedPayoutProcessors/Configure.cshtml +++ b/BTCPayServer/Views/UIOnChainAutomatedPayoutProcessors/Configure.cshtml @@ -18,7 +18,7 @@ {
} -
+
@@ -49,7 +49,7 @@
Only process payouts when this payout sum is reached.
- +
diff --git a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml index 1d6c278b3..b2d2e3326 100644 --- a/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml +++ b/BTCPayServer/Views/UIStores/CheckoutAppearance.cshtml @@ -61,7 +61,7 @@
-
+ @if (!ViewContext.ModelState.IsValid) {
@@ -275,7 +275,7 @@
- +
diff --git a/BTCPayServer/Views/UIStores/GeneralSettings.cshtml b/BTCPayServer/Views/UIStores/GeneralSettings.cshtml index 9ff5a6ce2..3f1878a84 100644 --- a/BTCPayServer/Views/UIStores/GeneralSettings.cshtml +++ b/BTCPayServer/Views/UIStores/GeneralSettings.cshtml @@ -17,7 +17,7 @@ {
} -
+

General

@@ -159,7 +159,7 @@
- +

Additional Actions

diff --git a/BTCPayServer/Views/UIStores/Rates.cshtml b/BTCPayServer/Views/UIStores/Rates.cshtml index a0b9cd3b3..374b4dd30 100644 --- a/BTCPayServer/Views/UIStores/Rates.cshtml +++ b/BTCPayServer/Views/UIStores/Rates.cshtml @@ -15,7 +15,7 @@ {
} -
+ @if (Model.ShowScripting) { @@ -178,7 +178,7 @@ X_X = kraken(X_X);
- + diff --git a/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml b/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml index 7808a0820..3f0a8694a 100644 --- a/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml +++ b/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml @@ -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; }
@@ -15,27 +15,27 @@ Configure
-

+

Email rules allow BTCPay Server to send customized emails from your store based on events.

-

Email Server

+

Email Server

-
+ @if (Model.IsFallbackSetup()) { -
+
} diff --git a/BTCPayServer/Views/UIStores/StoreEmails.cshtml b/BTCPayServer/Views/UIStores/StoreEmails.cshtml index 97e8ecff3..39fe4be3a 100644 --- a/BTCPayServer/Views/UIStores/StoreEmails.cshtml +++ b/BTCPayServer/Views/UIStores/StoreEmails.cshtml @@ -14,7 +14,7 @@ } - +

@ViewData["Title"]

diff --git a/BTCPayServer/Views/UIStores/StoreUsers.cshtml b/BTCPayServer/Views/UIStores/StoreUsers.cshtml index 2f2839d42..8274b86cd 100644 --- a/BTCPayServer/Views/UIStores/StoreUsers.cshtml +++ b/BTCPayServer/Views/UIStores/StoreUsers.cshtml @@ -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 { + }
-
+

@ViewData["Title"]

Give other registered BTCPay Server users access to your store.
@@ -27,7 +37,7 @@ - +

@@ -39,14 +49,17 @@ Actions - + @foreach (var user in Model.Users) { @user.Email @user.Role - Remove + } @@ -57,6 +70,50 @@ + + + + + @section PageFootContent { } diff --git a/BTCPayServer/Views/UIStores/Webhooks.cshtml b/BTCPayServer/Views/UIStores/Webhooks.cshtml index 860d8e188..6473a3993 100644 --- a/BTCPayServer/Views/UIStores/Webhooks.cshtml +++ b/BTCPayServer/Views/UIStores/Webhooks.cshtml @@ -23,7 +23,7 @@ Status Url - Actions + Actions @@ -48,7 +48,7 @@ } @wh.Url - + Test - Modify - Delete diff --git a/BTCPayServer/wwwroot/main/editor.css b/BTCPayServer/wwwroot/main/editor.css index 146118c6f..a645d1e59 100644 --- a/BTCPayServer/wwwroot/main/editor.css +++ b/BTCPayServer/wwwroot/main/editor.css @@ -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; +} diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css index b1f981aef..a8a3b1f40 100644 --- a/BTCPayServer/wwwroot/main/site.css +++ b/BTCPayServer/wwwroot/main/site.css @@ -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%;