Onboarding: Invite new users on store level (#5719)

* Onboarding: Invite new users

- Separates the user self-registration and invite cases
- Adds invitation email for users created by the admin
- Adds invitation tokens to verify user was invited
- Adds handler action for invite links
- Refactors `UserEventHostedService`
- Fixes #5726.

* Add permissioned form tag helper

* Better way of changing a user's role

* Test fixes
This commit is contained in:
d11n 2024-03-19 14:58:33 +01:00 committed by GitHub
parent b7ce6b7400
commit 09dbe44bca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1124 additions and 378 deletions

View File

@ -0,0 +1,35 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace BTCPayServer.Abstractions.TagHelpers;
[HtmlTargetElement("form", Attributes = "[permissioned]")]
public partial class PermissionedFormTagHelper(
IAuthorizationService authorizationService,
IHttpContextAccessor httpContextAccessor)
: TagHelper
{
public string Permissioned { get; set; }
public string PermissionResource { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
if (httpContextAccessor.HttpContext is null || string.IsNullOrEmpty(Permissioned))
return;
var res = await authorizationService.AuthorizeAsync(httpContextAccessor.HttpContext.User,
PermissionResource, Permissioned);
if (!res.Succeeded)
{
var content = await output.GetChildContentAsync();
var html = SubmitButtonRegex().Replace(content.GetContent(), "");
output.Content.SetHtmlContent($"<fieldset disabled>{html}</fieldset>");
}
}
[GeneratedRegex("<(button|input).*?type=\"submit\".*?>.*?</\\1>")]
private static partial Regex SubmitButtonRegex();
}

View File

@ -0,0 +1,98 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Newtonsoft.Json;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240229092905_AddManagerAndEmployeeToStoreRoles")]
public partial class AddManagerAndEmployeeToStoreRoles : Migration
{
object GetPermissionsData(MigrationBuilder migrationBuilder, string[] permissions)
{
return migrationBuilder.IsNpgsql()
? permissions
: JsonConvert.SerializeObject(permissions);
}
protected override void Up(MigrationBuilder migrationBuilder)
{
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
migrationBuilder.InsertData(
"StoreRoles",
columns: new[] { "Id", "Role", "Permissions" },
columnTypes: new[] { "TEXT", "TEXT", permissionsType },
values: new object[,]
{
{
"Manager", "Manager", GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifyinvoices",
"btcpay.store.webhooks.canmodifywebhooks",
"btcpay.store.canmodifypaymentrequests",
"btcpay.store.canmanagepullpayments",
"btcpay.store.canmanagepayouts"
})
},
{
"Employee", "Employee", GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canmodifyinvoices",
"btcpay.store.canmodifypaymentrequests",
"btcpay.store.cancreatenonapprovedpullpayments",
"btcpay.store.canviewpayouts",
"btcpay.store.canviewpullpayments"
})
}
});
migrationBuilder.UpdateData(
"StoreRoles",
keyColumns: new[] { "Id" },
keyColumnTypes: new[] { "TEXT" },
keyValues: new[] { "Guest" },
columns: new[] { "Permissions" },
columnTypes: new[] { permissionsType },
values: new object[]
{
GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canmodifyinvoices",
"btcpay.store.canviewpaymentrequests",
"btcpay.store.canviewpullpayments",
"btcpay.store.canviewpayouts"
})
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData("StoreRoles", "Id", "Manager");
migrationBuilder.DeleteData("StoreRoles", "Id", "Employee");
var permissionsType = migrationBuilder.IsNpgsql() ? "TEXT[]" : "TEXT";
migrationBuilder.UpdateData(
"StoreRoles",
keyColumns: new[] { "Id" },
keyColumnTypes: new[] { "TEXT" },
keyValues: new[] { "Guest" },
columns: new[] { "Permissions" },
columnTypes: new[] { permissionsType },
values: new object[]
{
GetPermissionsData(migrationBuilder, new[]
{
"btcpay.store.canviewstoresettings",
"btcpay.store.canmodifyinvoices",
"btcpay.store.canviewcustodianaccounts",
"btcpay.store.candeposittocustodianaccount"
})
});
}
}
}

View File

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

View File

@ -3490,7 +3490,6 @@ namespace BTCPayServer.Tests
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public async Task StoreUsersAPITest() public async Task StoreUsersAPITest()
{ {
using var tester = CreateServerTester(); using var tester = CreateServerTester();
await tester.StartAsync(); await tester.StartAsync();
@ -3500,52 +3499,83 @@ namespace BTCPayServer.Tests
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings); var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings);
var roles = await client.GetServerRoles(); var roles = await client.GetServerRoles();
Assert.Equal(2,roles.Count); Assert.Equal(4, roles.Count);
#pragma warning disable CS0618 #pragma warning disable CS0618
var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner); var ownerRole = roles.Single(data => data.Role == StoreRoles.Owner);
var managerRole = roles.Single(data => data.Role == StoreRoles.Manager);
var employeeRole = roles.Single(data => data.Role == StoreRoles.Employee);
var guestRole = roles.Single(data => data.Role == StoreRoles.Guest); var guestRole = roles.Single(data => data.Role == StoreRoles.Guest);
#pragma warning restore CS0618 #pragma warning restore CS0618
var users = await client.GetStoreUsers(user.StoreId); var users = await client.GetStoreUsers(user.StoreId);
var storeuser = Assert.Single(users); var storeUser = Assert.Single(users);
Assert.Equal(user.UserId, storeuser.UserId); Assert.Equal(user.UserId, storeUser.UserId);
Assert.Equal(ownerRole.Id, storeuser.Role); Assert.Equal(ownerRole.Id, storeUser.Role);
var user2 = tester.NewAccount(); var manager = tester.NewAccount();
await user2.GrantAccessAsync(false); await manager.GrantAccessAsync();
var employee = tester.NewAccount();
await employee.GrantAccessAsync();
var guest = tester.NewAccount();
await guest.GrantAccessAsync();
var user2Client = await user2.CreateClient(Policies.CanModifyStoreSettings); var managerClient = await manager.CreateClient(Policies.CanModifyStoreSettings);
var employeeClient = await employee.CreateClient(Policies.CanModifyStoreSettings);
var guestClient = await guest.CreateClient(Policies.CanModifyStoreSettings);
//test no access to api when unrelated to store at all //test no access to api when unrelated to store at all
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId)); await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData())); await AssertPermissionError(Policies.CanViewStoreSettings, async () => await managerClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId)); await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = guestRole.Id, UserId = user2.UserId }); // add users to store
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = managerRole.Id, UserId = manager.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = employeeRole.Id, UserId = employee.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = guestRole.Id, UserId = guest.UserId });
//test no access to api when only a guest //test no access to api for employee
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.GetStoreUsers(user.StoreId)); await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.AddStoreUser(user.StoreId, new StoreUserData())); await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await user2Client.RemoveStoreUser(user.StoreId, user.UserId)); await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await employeeClient.RemoveStoreUser(user.StoreId, user.UserId));
//test no access to api for guest
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await guestClient.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.UserId));
//test access to api for manager
await managerClient.GetStore(user.StoreId);
await managerClient.GetStoreUsers(user.StoreId);
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
await user2Client.GetStore(user.StoreId); // updates
await client.RemoveStoreUser(user.StoreId, employee.UserId);
await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId));
await client.RemoveStoreUser(user.StoreId, user2.UserId); await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId });
await AssertHttpError(403, async () =>
await user2Client.GetStore(user.StoreId));
await client.AddStoreUser(user.StoreId, new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId });
await AssertAPIError("duplicate-store-user-role", async () => await AssertAPIError("duplicate-store-user-role", async () =>
await client.AddStoreUser(user.StoreId, await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId }));
new StoreUserData() { Role = ownerRole.Id, UserId = user2.UserId })); await employeeClient.RemoveStoreUser(user.StoreId, user.UserId);
await user2Client.RemoveStoreUser(user.StoreId, user.UserId);
//test no access to api when unrelated to store at all //test no access to api when unrelated to store at all
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.GetStoreUsers(user.StoreId)); await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStore(user.StoreId));
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await client.GetStoreUsers(user.StoreId));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.AddStoreUser(user.StoreId, new StoreUserData())); await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.AddStoreUser(user.StoreId, new StoreUserData()));
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.RemoveStoreUser(user.StoreId, user.UserId)); await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await client.RemoveStoreUser(user.StoreId, user.UserId));
await AssertAPIError("store-user-role-orphaned", async () => await user2Client.RemoveStoreUser(user.StoreId, user2.UserId)); await AssertAPIError("store-user-role-orphaned", async () => await employeeClient.RemoveStoreUser(user.StoreId, employee.UserId));
} }
[Fact(Timeout = 60 * 2 * 1000)] [Fact(Timeout = 60 * 2 * 1000)]

View File

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

View File

@ -380,13 +380,13 @@ namespace BTCPayServer.Tests
s.Driver.Navigate().GoToUrl(url); s.Driver.Navigate().GoToUrl(url);
Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type")); Assert.Equal("hidden", s.Driver.FindElement(By.Id("Email")).GetAttribute("type"));
Assert.Equal(usr, s.Driver.FindElement(By.Id("Email")).GetAttribute("value")); Assert.Equal(usr, s.Driver.FindElement(By.Id("Email")).GetAttribute("value"));
Assert.Equal("Set your password", s.Driver.FindElement(By.CssSelector("h4")).Text); Assert.Equal("Create Account", s.Driver.FindElement(By.CssSelector("h4")).Text);
Assert.Contains("Invitation accepted. Please set your password.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info).Text); Assert.Contains("Invitation accepted. Please set your password.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Info).Text);
s.Driver.FindElement(By.Id("Password")).SendKeys("123456"); s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456"); s.Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456");
s.Driver.FindElement(By.Id("SetPassword")).Click(); s.Driver.FindElement(By.Id("SetPassword")).Click();
Assert.Contains("Password successfully set.", s.FindAlertMessage().Text); Assert.Contains("Account successfully created.", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("Email")).SendKeys(usr); s.Driver.FindElement(By.Id("Email")).SendKeys(usr);
s.Driver.FindElement(By.Id("Password")).SendKeys("123456"); s.Driver.FindElement(By.Id("Password")).SendKeys("123456");
@ -928,11 +928,9 @@ namespace BTCPayServer.Tests
s.GoToHome(); s.GoToHome();
s.Logout(); s.Logout();
// Let's add Bob as a guest to alice's store // Let's add Bob as an employee to alice's store
s.LogIn(alice); s.LogIn(alice);
s.GoToUrl(storeUrl + "/users"); s.AddUserToStore(storeId, bob, "Employee");
s.Driver.FindElement(By.Id("Email")).SendKeys(bob + Keys.Enter);
Assert.Contains("User added successfully", s.Driver.PageSource);
s.Logout(); s.Logout();
// Bob should not have access to store, but should have access to invoice // Bob should not have access to store, but should have access to invoice
@ -1063,7 +1061,8 @@ namespace BTCPayServer.Tests
Policies.CanViewInvoices, Policies.CanViewInvoices,
Policies.CanModifyInvoices, Policies.CanModifyInvoices,
Policies.CanViewPaymentRequests, Policies.CanViewPaymentRequests,
Policies.CanViewStoreSettings, Policies.CanViewPullPayments,
Policies.CanViewPayouts,
Policies.CanModifyStoreSettingsUnscoped, Policies.CanModifyStoreSettingsUnscoped,
Policies.CanDeleteUser Policies.CanDeleteUser
}); });
@ -1148,13 +1147,8 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester(newDb: true); using var s = CreateSeleniumTester(newDb: true);
await s.StartAsync(); await s.StartAsync();
var userId = s.RegisterNewUser(true); var userId = s.RegisterNewUser(true);
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}";
s.CreateNewStore(); s.CreateNewStore();
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click(); (_, string appId) = s.CreateApp("PointOfSale");
s.Driver.FindElement(By.Name("AppName")).SendKeys(appName);
s.Driver.FindElement(By.Id("Create")).Click();
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
Assert.Equal(appName, s.Driver.FindElement(By.Id("Title")).GetAttribute("value"));
s.Driver.FindElement(By.Id("Title")).Clear(); s.Driver.FindElement(By.Id("Title")).Clear();
s.Driver.FindElement(By.Id("Title")).SendKeys("Tea shop"); s.Driver.FindElement(By.Id("Title")).SendKeys("Tea shop");
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click(); s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
@ -1169,7 +1163,6 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("SaveSettings")).Click(); s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text); Assert.Contains("App updated", s.FindAlertMessage().Text);
var appId = s.Driver.Url.Split('/')[4];
s.Driver.FindElement(By.Id("ViewApp")).Click(); s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles; var windows = s.Driver.WindowHandles;
@ -1268,12 +1261,7 @@ namespace BTCPayServer.Tests
s.CreateNewStore(); s.CreateNewStore();
s.AddDerivationScheme(); s.AddDerivationScheme();
var appName = $"CF-{Guid.NewGuid().ToString()[..21]}"; (_, string appId) = s.CreateApp("Crowdfund");
s.Driver.FindElement(By.Id("StoreNav-CreateCrowdfund")).Click();
s.Driver.FindElement(By.Name("AppName")).SendKeys(appName);
s.Driver.FindElement(By.Id("Create")).Click();
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
Assert.Equal(appName, s.Driver.FindElement(By.Id("Title")).GetAttribute("value"));
s.Driver.FindElement(By.Id("Title")).Clear(); s.Driver.FindElement(By.Id("Title")).Clear();
s.Driver.FindElement(By.Id("Title")).SendKeys("Kukkstarter"); s.Driver.FindElement(By.Id("Title")).SendKeys("Kukkstarter");
s.Driver.FindElement(By.CssSelector("div.note-editable.card-block")).SendKeys("1BTC = 1BTC"); s.Driver.FindElement(By.CssSelector("div.note-editable.card-block")).SendKeys("1BTC = 1BTC");
@ -1293,7 +1281,6 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("SaveSettings")).Click(); s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text); Assert.Contains("App updated", s.FindAlertMessage().Text);
var editUrl = s.Driver.Url; var editUrl = s.Driver.Url;
var appId = editUrl.Split('/')[4];
// Check public page // Check public page
s.Driver.FindElement(By.Id("ViewApp")).Click(); s.Driver.FindElement(By.Id("ViewApp")).Click();
@ -3333,6 +3320,7 @@ retry:
Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url); Assert.StartsWith(s.ServerUri.ToString(), s.Driver.Url);
}); });
} }
[Fact] [Fact]
[Trait("Selenium", "Selenium")] [Trait("Selenium", "Selenium")]
public async Task CanUseRoleManager() public async Task CanUseRoleManager()
@ -3343,8 +3331,10 @@ retry:
s.GoToHome(); s.GoToHome();
s.GoToServer(ServerNavPages.Roles); s.GoToServer(ServerNavPages.Roles);
var existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr")); var existingServerRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(3, existingServerRoles.Count); Assert.Equal(5, existingServerRoles.Count);
IWebElement ownerRow = null; IWebElement ownerRow = null;
IWebElement managerRow = null;
IWebElement employeeRow = null;
IWebElement guestRow = null; IWebElement guestRow = null;
foreach (var roleItem in existingServerRoles) foreach (var roleItem in existingServerRoles)
{ {
@ -3352,6 +3342,14 @@ retry:
{ {
ownerRow = roleItem; ownerRow = roleItem;
} }
else if (roleItem.Text.Contains("manager", StringComparison.InvariantCultureIgnoreCase))
{
managerRow = roleItem;
}
else if (roleItem.Text.Contains("employee", StringComparison.InvariantCultureIgnoreCase))
{
employeeRow = roleItem;
}
else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase)) else if (roleItem.Text.Contains("guest", StringComparison.InvariantCultureIgnoreCase))
{ {
guestRow = roleItem; guestRow = roleItem;
@ -3359,11 +3357,21 @@ retry:
} }
Assert.NotNull(ownerRow); Assert.NotNull(ownerRow);
Assert.NotNull(managerRow);
Assert.NotNull(employeeRow);
Assert.NotNull(guestRow); Assert.NotNull(guestRow);
var ownerBadges = ownerRow.FindElements(By.CssSelector(".badge")); var ownerBadges = ownerRow.FindElements(By.CssSelector(".badge"));
Assert.Contains(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase)); Assert.Contains(ownerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
Assert.Contains(ownerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase)); Assert.Contains(ownerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
var managerBadges = managerRow.FindElements(By.CssSelector(".badge"));
Assert.DoesNotContain(managerBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
Assert.Contains(managerBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
var employeeBadges = employeeRow.FindElements(By.CssSelector(".badge"));
Assert.DoesNotContain(employeeBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
Assert.Contains(employeeBadges, element => element.Text.Equals("Server-wide", StringComparison.InvariantCultureIgnoreCase));
var guestBadges = guestRow.FindElements(By.CssSelector(".badge")); var guestBadges = guestRow.FindElements(By.CssSelector(".badge"));
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase)); Assert.DoesNotContain(guestBadges, element => element.Text.Equals("Default", StringComparison.InvariantCultureIgnoreCase));
@ -3391,13 +3399,11 @@ retry:
ownerRow.FindElement(By.Id("SetDefault")).Click(); ownerRow.FindElement(By.Id("SetDefault")).Click();
s.FindAlertMessage(); s.FindAlertMessage();
s.CreateNewStore(); s.CreateNewStore();
s.GoToStore(StoreNavPages.Roles); s.GoToStore(StoreNavPages.Roles);
var existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr")); var existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(3, existingStoreRoles.Count); Assert.Equal(5, existingStoreRoles.Count);
Assert.Equal(2, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase))); Assert.Equal(4, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
foreach (var roleItem in existingStoreRoles) foreach (var roleItem in existingStoreRoles)
{ {
@ -3448,20 +3454,19 @@ retry:
Assert.DoesNotContain(guestBadges, element => element.Text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase)); Assert.DoesNotContain(guestBadges, element => element.Text.Equals("server-wide", StringComparison.InvariantCultureIgnoreCase));
s.GoToStore(StoreNavPages.Users); s.GoToStore(StoreNavPages.Users);
var options = s.Driver.FindElements(By.CssSelector("#Role option")); var options = s.Driver.FindElements(By.CssSelector("#Role option"));
Assert.Equal(2, options.Count); Assert.Equal(4, options.Count);
Assert.Contains(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase)); Assert.Contains(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
s.CreateNewStore(); s.CreateNewStore();
s.GoToStore(StoreNavPages.Roles); s.GoToStore(StoreNavPages.Roles);
existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr")); existingStoreRoles = s.Driver.FindElement(By.CssSelector("table")).FindElements(By.CssSelector("tr"));
Assert.Equal(2, existingStoreRoles.Count); Assert.Equal(4, existingStoreRoles.Count);
Assert.Equal(1, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase))); Assert.Equal(3, existingStoreRoles.Count(element => element.Text.Contains("Server-wide", StringComparison.InvariantCultureIgnoreCase)));
Assert.Equal(0, existingStoreRoles.Count(element => element.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase))); Assert.Equal(0, existingStoreRoles.Count(element => element.Text.Contains("store role", StringComparison.InvariantCultureIgnoreCase)));
s.GoToStore(StoreNavPages.Users); s.GoToStore(StoreNavPages.Users);
options = s.Driver.FindElements(By.CssSelector("#Role option")); options = s.Driver.FindElements(By.CssSelector("#Role option"));
Assert.Single(options); Assert.Equal(3, options.Count);
Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase)); Assert.DoesNotContain(options, element => element.Text.Equals("store role", StringComparison.InvariantCultureIgnoreCase));
s.GoToStore(StoreNavPages.Roles); s.GoToStore(StoreNavPages.Roles);
s.Driver.FindElement(By.Id("CreateRole")).Click(); s.Driver.FindElement(By.Id("CreateRole")).Click();
s.Driver.FindElement(By.Id("Role")).SendKeys("Malice"); s.Driver.FindElement(By.Id("Role")).SendKeys("Malice");
@ -3502,7 +3507,7 @@ retry:
s.RegisterNewUser(true); s.RegisterNewUser(true);
string GetStorePath(string subPath) => $"/stores/{storeId}/{subPath}"; string GetStorePath(string subPath) => $"/stores/{storeId}/{subPath}";
// Owner access // Admin access
s.AssertPageAccess(false, GetStorePath("")); s.AssertPageAccess(false, GetStorePath(""));
s.AssertPageAccess(true, GetStorePath("reports")); s.AssertPageAccess(true, GetStorePath("reports"));
s.AssertPageAccess(true, GetStorePath("invoices")); s.AssertPageAccess(true, GetStorePath("invoices"));
@ -3518,9 +3523,214 @@ retry:
s.AssertPageAccess(false, GetStorePath("apps/create")); s.AssertPageAccess(false, GetStorePath("apps/create"));
foreach (var path in storeSettingsPaths) foreach (var path in storeSettingsPaths)
{ // should have view access to settings, but no submit buttons or create links { // should have view access to settings, but no submit buttons or create links
TestLogs.LogInformation($"Checking access to store page {path} as admin");
s.AssertPageAccess(true, $"stores/{storeId}/{path}");
if (path != "payout-processors")
{
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .btn-primary"));
}
}
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
public async Task CanUsePredefinedRoles()
{
using var s = CreateSeleniumTester(newDb: true);
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
var storeSettingsPaths = new [] {"settings", "rates", "checkout", "tokens", "users", "roles", "webhooks", "payout-processors", "payout-processors/onchain-automated/BTC", "payout-processors/lightning-automated/BTC", "emails", "email-settings", "forms"};
// Setup users
var manager = s.RegisterNewUser();
s.Logout();
s.GoToRegister();
var employee = s.RegisterNewUser();
s.Logout();
s.GoToRegister();
var guest = s.RegisterNewUser();
s.Logout();
s.GoToRegister();
// Setup store, wallets and add users
s.RegisterNewUser(true);
(_, string storeId) = s.CreateNewStore();
s.GoToStore();
s.GenerateWallet(isHotWallet: true);
s.AddLightningNode(LightningConnectionType.CLightning, false);
s.AddUserToStore(storeId, manager, "Manager");
s.AddUserToStore(storeId, employee, "Employee");
s.AddUserToStore(storeId, guest, "Guest");
// Add apps
(_, string posId) = s.CreateApp("PointOfSale");
(_, string crowdfundId) = s.CreateApp("Crowdfund");
string GetStorePath(string subPath) => $"/stores/{storeId}/{subPath}";
// Owner access
s.AssertPageAccess(true, GetStorePath(""));
s.AssertPageAccess(true, GetStorePath("reports"));
s.AssertPageAccess(true, GetStorePath("invoices"));
s.AssertPageAccess(true, GetStorePath("invoices/create"));
s.AssertPageAccess(true, GetStorePath("payment-requests"));
s.AssertPageAccess(true, GetStorePath("payment-requests/edit"));
s.AssertPageAccess(true, GetStorePath("pull-payments"));
s.AssertPageAccess(true, GetStorePath("payouts"));
s.AssertPageAccess(true, GetStorePath("onchain/BTC"));
s.AssertPageAccess(true, GetStorePath("onchain/BTC/settings"));
s.AssertPageAccess(true, GetStorePath("lightning/BTC"));
s.AssertPageAccess(true, GetStorePath("lightning/BTC/settings"));
s.AssertPageAccess(true, GetStorePath("apps/create"));
s.AssertPageAccess(true, $"/apps/{posId}/settings/pos");
s.AssertPageAccess(true, $"/apps/{crowdfundId}/settings/crowdfund");
foreach (var path in storeSettingsPaths)
{ // should have manage access to settings, hence should see submit buttons or create links
TestLogs.LogInformation($"Checking access to store page {path} as owner");
s.AssertPageAccess(true, $"stores/{storeId}/{path}");
if (path != "payout-processors")
{
s.Driver.FindElement(By.CssSelector("#mainContent .btn-primary"));
}
}
s.Logout();
// Manager access
s.LogIn(manager);
s.AssertPageAccess(false, GetStorePath(""));
s.AssertPageAccess(true, GetStorePath("reports"));
s.AssertPageAccess(true, GetStorePath("invoices"));
s.AssertPageAccess(true, GetStorePath("invoices/create"));
s.AssertPageAccess(true, GetStorePath("payment-requests"));
s.AssertPageAccess(true, GetStorePath("payment-requests/edit"));
s.AssertPageAccess(true, GetStorePath("pull-payments"));
s.AssertPageAccess(true, GetStorePath("payouts"));
s.AssertPageAccess(false, GetStorePath("onchain/BTC"));
s.AssertPageAccess(false, GetStorePath("onchain/BTC/settings"));
s.AssertPageAccess(false, GetStorePath("lightning/BTC"));
s.AssertPageAccess(false, GetStorePath("lightning/BTC/settings"));
s.AssertPageAccess(false, GetStorePath("apps/create"));
s.AssertPageAccess(true, $"/apps/{posId}/settings/pos");
s.AssertPageAccess(true, $"/apps/{crowdfundId}/settings/crowdfund");
foreach (var path in storeSettingsPaths)
{ // should have view access to settings, but no submit buttons or create links
TestLogs.LogInformation($"Checking access to store page {path} as manager");
s.AssertPageAccess(true, $"stores/{storeId}/{path}"); s.AssertPageAccess(true, $"stores/{storeId}/{path}");
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .btn-primary")); s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .btn-primary"));
} }
s.Logout();
// Employee access
s.LogIn(employee);
s.AssertPageAccess(false, GetStorePath(""));
s.AssertPageAccess(false, GetStorePath("reports"));
s.AssertPageAccess(true, GetStorePath("invoices"));
s.AssertPageAccess(true, GetStorePath("invoices/create"));
s.AssertPageAccess(true, GetStorePath("payment-requests"));
s.AssertPageAccess(true, GetStorePath("payment-requests/edit"));
s.AssertPageAccess(true, GetStorePath("pull-payments"));
s.AssertPageAccess(true, GetStorePath("payouts"));
s.AssertPageAccess(false, GetStorePath("onchain/BTC"));
s.AssertPageAccess(false, GetStorePath("onchain/BTC/settings"));
s.AssertPageAccess(false, GetStorePath("lightning/BTC"));
s.AssertPageAccess(false, GetStorePath("lightning/BTC/settings"));
s.AssertPageAccess(false, GetStorePath("apps/create"));
s.AssertPageAccess(false, $"/apps/{posId}/settings/pos");
s.AssertPageAccess(false, $"/apps/{crowdfundId}/settings/crowdfund");
foreach (var path in storeSettingsPaths)
{ // should not have access to settings
TestLogs.LogInformation($"Checking access to store page {path} as employee");
s.AssertPageAccess(false, $"stores/{storeId}/{path}");
}
s.Logout();
// Guest access
s.LogIn(guest);
s.AssertPageAccess(false, GetStorePath(""));
s.AssertPageAccess(false, GetStorePath("reports"));
s.AssertPageAccess(true, GetStorePath("invoices"));
s.AssertPageAccess(true, GetStorePath("invoices/create"));
s.AssertPageAccess(true, GetStorePath("payment-requests"));
s.AssertPageAccess(false, GetStorePath("payment-requests/edit"));
s.AssertPageAccess(true, GetStorePath("pull-payments"));
s.AssertPageAccess(true, GetStorePath("payouts"));
s.AssertPageAccess(false, GetStorePath("onchain/BTC"));
s.AssertPageAccess(false, GetStorePath("onchain/BTC/settings"));
s.AssertPageAccess(false, GetStorePath("lightning/BTC"));
s.AssertPageAccess(false, GetStorePath("lightning/BTC/settings"));
s.AssertPageAccess(false, GetStorePath("apps/create"));
s.AssertPageAccess(false, $"/apps/{posId}/settings/pos");
s.AssertPageAccess(false, $"/apps/{crowdfundId}/settings/crowdfund");
foreach (var path in storeSettingsPaths)
{ // should not have access to settings
TestLogs.LogInformation($"Checking access to store page {path} as guest");
s.AssertPageAccess(false, $"stores/{storeId}/{path}");
}
s.Logout();
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanChangeUserRoles()
{
using var s = CreateSeleniumTester(newDb: true);
await s.StartAsync();
// Setup users and store
var employee = s.RegisterNewUser();
s.Logout();
s.GoToRegister();
var owner = s.RegisterNewUser(true);
(_, string storeId) = s.CreateNewStore();
s.GoToStore();
s.AddUserToStore(storeId, employee, "Employee");
// Should successfully change the role
var userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr"));
Assert.Equal(2, userRows.Count);
IWebElement employeeRow = null;
foreach (var row in userRows)
{
if (row.Text.Contains(employee, StringComparison.InvariantCultureIgnoreCase)) employeeRow = row;
}
Assert.NotNull(employeeRow);
employeeRow.FindElement(By.CssSelector("a[data-bs-target=\"#EditModal\"]")).Click();
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, employee);
new SelectElement(s.Driver.FindElement(By.Id("EditUserRole"))).SelectByValue("Manager");
s.Driver.FindElement(By.Id("EditContinue")).Click();
Assert.Contains($"The role of {employee} has been changed to Manager.", s.FindAlertMessage().Text);
// Should not see a message when not changing role
userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr"));
Assert.Equal(2, userRows.Count);
employeeRow = null;
foreach (var row in userRows)
{
if (row.Text.Contains(employee, StringComparison.InvariantCultureIgnoreCase)) employeeRow = row;
}
Assert.NotNull(employeeRow);
employeeRow.FindElement(By.CssSelector("a[data-bs-target=\"#EditModal\"]")).Click();
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, employee);
// no change, no alert message
s.Driver.FindElement(By.Id("EditContinue")).Click();
s.Driver.ElementDoesNotExist(By.CssSelector("#mainContent .alert"));
// Should not change last owner
userRows = s.Driver.FindElements(By.CssSelector("#StoreUsersList tr"));
Assert.Equal(2, userRows.Count);
IWebElement ownerRow = null;
foreach (var row in userRows)
{
if (row.Text.Contains(owner, StringComparison.InvariantCultureIgnoreCase)) ownerRow = row;
}
Assert.NotNull(ownerRow);
ownerRow.FindElement(By.CssSelector("a[data-bs-target=\"#EditModal\"]")).Click();
Assert.Equal(s.Driver.WaitForElement(By.Id("EditUserEmail")).Text, owner);
new SelectElement(s.Driver.FindElement(By.Id("EditUserRole"))).SelectByValue("Employee");
s.Driver.FindElement(By.Id("EditContinue")).Click();
Assert.Contains($"User {owner} is the last owner. Their role cannot be changed.", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Error).Text);
} }
private static void CanBrowseContent(SeleniumTester s) private static void CanBrowseContent(SeleniumTester s)

View File

@ -554,13 +554,23 @@ retry:
public async Task AddGuest(string userId) public async Task AddGuest(string userId)
{ {
var repo = this.parent.PayTester.GetService<StoreRepository>(); var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Guest); await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Guest);
} }
public async Task AddOwner(string userId) public async Task AddOwner(string userId)
{ {
var repo = this.parent.PayTester.GetService<StoreRepository>(); var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner); await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Owner);
}
public async Task AddManager(string userId)
{
var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Manager);
}
public async Task AddEmployee(string userId)
{
var repo = parent.PayTester.GetService<StoreRepository>();
await repo.AddOrUpdateStoreUser(StoreId, userId, StoreRoleId.Employee);
} }
public async Task<uint256> PayOnChain(string invoiceId) public async Task<uint256> PayOnChain(string invoiceId)

View File

@ -2782,7 +2782,7 @@ namespace BTCPayServer.Tests
await tester.StartAsync(); await tester.StartAsync();
var acc = tester.NewAccount(); var acc = tester.NewAccount();
acc.GrantAccess(true); await acc.GrantAccessAsync(true);
var settings = tester.PayTester.GetService<SettingsRepository>(); var settings = tester.PayTester.GetService<SettingsRepository>();
var emailSenderFactory = tester.PayTester.GetService<EmailSenderFactory>(); var emailSenderFactory = tester.PayTester.GetService<EmailSenderFactory>();
@ -2807,14 +2807,14 @@ namespace BTCPayServer.Tests
Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login); Assert.Equal("admin@admin.com", (await Assert.IsType<ServerEmailSender>(await emailSenderFactory.GetEmailSender()).GetEmailSettings()).Login);
Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()); Assert.Null(await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings());
Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings() Assert.IsType<RedirectToActionResult>(await acc.GetController<UIStoresController>().StoreEmailSettings(acc.StoreId, new EmailsViewModel(new EmailSettings
{ {
From = "store@store.com", From = "store@store.com",
Login = "store@store.com", Login = "store@store.com",
Password = "store@store.com", Password = "store@store.com",
Port = 1234, Port = 1234,
Server = "store.com" Server = "store.com"
}), "")); }), "", true));
Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login); Assert.Equal("store@store.com", (await Assert.IsType<StoreEmailSender>(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login);
} }

View File

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

View File

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

View File

@ -147,7 +147,7 @@ namespace BTCPayServer.Controllers
return await Login(returnUrl); return await Login(returnUrl);
} }
_logger.LogInformation("User with ID {UserId} logged in with a login code", user!.Id); _logger.LogInformation("User {Email} logged in with a login code", user!.Email);
await _signInManager.SignInAsync(user, false, "LoginCode"); await _signInManager.SignInAsync(user, false, "LoginCode");
return RedirectToLocal(returnUrl); return RedirectToLocal(returnUrl);
} }
@ -215,7 +215,7 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true); var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded) if (result.Succeeded)
{ {
_logger.LogInformation("User {UserId} logged in", user.Id); _logger.LogInformation("User {Email} logged in", user.Email);
return RedirectToLocal(returnUrl); return RedirectToLocal(returnUrl);
} }
if (result.RequiresTwoFactor) if (result.RequiresTwoFactor)
@ -230,7 +230,7 @@ namespace BTCPayServer.Controllers
} }
if (result.IsLockedOut) if (result.IsLockedOut)
{ {
_logger.LogWarning("User {UserId} account locked out", user.Id); _logger.LogWarning("User {Email} tried to log in, but is locked out", user.Email);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd }); return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
} }
@ -368,7 +368,7 @@ namespace BTCPayServer.Controllers
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>())) if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
{ {
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2"); await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in"); _logger.LogInformation("User {Email} logged in with FIDO2", user.Email);
return RedirectToLocal(returnUrl); return RedirectToLocal(returnUrl);
} }
} }
@ -455,11 +455,11 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine); var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
if (result.Succeeded) if (result.Succeeded)
{ {
_logger.LogInformation("User with ID {UserId} logged in with 2fa", user.Id); _logger.LogInformation("User {Email} logged in with 2FA", user.Email);
return RedirectToLocal(returnUrl); return RedirectToLocal(returnUrl);
} }
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}", user.Id); _logger.LogWarning("User {Email} entered invalid authenticator code", user.Email);
ModelState.AddModelError(string.Empty, "Invalid authenticator code."); ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel return View("SecondaryLogin", new SecondaryLoginViewModel
{ {
@ -524,17 +524,17 @@ namespace BTCPayServer.Controllers
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode); var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
if (result.Succeeded) if (result.Succeeded)
{ {
_logger.LogInformation("User with ID {UserId} logged in with a recovery code", user.Id); _logger.LogInformation("User {Email} logged in with a recovery code", user.Email);
return RedirectToLocal(returnUrl); return RedirectToLocal(returnUrl);
} }
if (result.IsLockedOut) if (result.IsLockedOut)
{ {
_logger.LogWarning("User with ID {UserId} account locked out", user.Id); _logger.LogWarning("User {Email} account locked out", user.Email);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd }); return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
} }
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id); _logger.LogWarning("User {Email} entered invalid recovery code", user.Email);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered."); ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View(); return View();
} }
@ -650,9 +650,11 @@ namespace BTCPayServer.Controllers
[HttpGet("/logout")] [HttpGet("/logout")]
public async Task<IActionResult> Logout() public async Task<IActionResult> Logout()
{ {
var userId = _signInManager.UserManager.GetUserId(HttpContext.User);
var user = await _userManager.FindByIdAsync(userId);
await _signInManager.SignOutAsync(); await _signInManager.SignOutAsync();
HttpContext.DeleteUserPrefsCookie(); HttpContext.DeleteUserPrefsCookie();
_logger.LogInformation("User logged out"); _logger.LogInformation("User {Email} logged out", user!.Email);
return RedirectToAction(nameof(Login)); return RedirectToAction(nameof(Login));
} }
@ -747,7 +749,7 @@ namespace BTCPayServer.Controllers
{ {
if (code == null) if (code == null)
{ {
throw new ApplicationException("A code must be supplied for password reset."); throw new ApplicationException("A code must be supplied for this action.");
} }
var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId); var user = string.IsNullOrEmpty(userId) ? null : await _userManager.FindByIdAsync(userId);
@ -777,6 +779,7 @@ namespace BTCPayServer.Controllers
} }
var user = await _userManager.FindByEmailAsync(model.Email); var user = await _userManager.FindByEmailAsync(model.Email);
var hasPassword = user != null && await _userManager.HasPasswordAsync(user);
if (!UserService.TryCanLogin(user, out _)) if (!UserService.TryCanLogin(user, out _))
{ {
// Don't reveal that the user does not exist // Don't reveal that the user does not exist
@ -789,7 +792,7 @@ namespace BTCPayServer.Controllers
TempData.SetStatusMessageModel(new StatusMessageModel TempData.SetStatusMessageModel(new StatusMessageModel
{ {
Severity = StatusMessageModel.StatusSeverity.Success, Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Password successfully set." Message = hasPassword ? "Password successfully set." : "Account successfully created."
}); });
return RedirectToAction(nameof(Login)); return RedirectToAction(nameof(Login));
} }
@ -817,6 +820,12 @@ namespace BTCPayServer.Controllers
var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed; var requiresEmailConfirmation = user.RequiresEmailConfirmation && !user.EmailConfirmed;
var requiresSetPassword = !await _userManager.HasPasswordAsync(user); var requiresSetPassword = !await _userManager.HasPasswordAsync(user);
_eventAggregator.Publish(new UserInviteAcceptedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
});
if (requiresEmailConfirmation) if (requiresEmailConfirmation)
{ {
return await RedirectToConfirmEmail(user); return await RedirectToConfirmEmail(user);

View File

@ -29,7 +29,6 @@ namespace BTCPayServer.Controllers
{ {
private readonly ThemeSettings _theme; private readonly ThemeSettings _theme;
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private readonly BTCPayNetworkProvider _networkProvider;
private IHttpClientFactory HttpClientFactory { get; } private IHttpClientFactory HttpClientFactory { get; }
private SignInManager<ApplicationUser> SignInManager { get; } private SignInManager<ApplicationUser> SignInManager { get; }
@ -41,14 +40,12 @@ namespace BTCPayServer.Controllers
ThemeSettings theme, ThemeSettings theme,
LanguageService languageService, LanguageService languageService,
StoreRepository storeRepository, StoreRepository storeRepository,
BTCPayNetworkProvider networkProvider,
IWebHostEnvironment environment, IWebHostEnvironment environment,
SignInManager<ApplicationUser> signInManager) SignInManager<ApplicationUser> signInManager)
{ {
_theme = theme; _theme = theme;
HttpClientFactory = httpClientFactory; HttpClientFactory = httpClientFactory;
LanguageService = languageService; LanguageService = languageService;
_networkProvider = networkProvider;
_storeRepository = storeRepository; _storeRepository = storeRepository;
SignInManager = signInManager; SignInManager = signInManager;
_WebRootFileProvider = environment.WebRootFileProvider; _WebRootFileProvider = environment.WebRootFileProvider;
@ -79,14 +76,14 @@ namespace BTCPayServer.Controllers
var store = await _storeRepository.FindStore(storeId); var store = await _storeRepository.FindStore(storeId);
if (store != null) if (store != null)
{ {
return RedirectToStore(userId, storeId); return RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId });
} }
} }
var stores = await _storeRepository.GetStoresByUserId(userId); var stores = await _storeRepository.GetStoresByUserId(userId!);
var activeStore = stores.FirstOrDefault(s => !s.Archived); var activeStore = stores.FirstOrDefault(s => !s.Archived);
return activeStore != null return activeStore != null
? RedirectToStore(userId, activeStore.Id) ? RedirectToAction(nameof(UIStoresController.Index), "UIStores", new { storeId = activeStore.Id })
: RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores"); : RedirectToAction(nameof(UIUserStoresController.CreateStore), "UIUserStores");
} }
@ -198,9 +195,5 @@ namespace BTCPayServer.Controllers
{ {
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
} }
public static RedirectToActionResult RedirectToStore(string userId, string storeId)
{
return new RedirectToActionResult("Index", "UIStores", new {storeId});
}
} }
} }

View File

@ -50,7 +50,7 @@ namespace BTCPayServer.Controllers
$"Unexpected error occurred disabling 2FA for user with ID '{user.Id}'."); $"Unexpected error occurred disabling 2FA for user with ID '{user.Id}'.");
} }
_logger.LogInformation("User with ID {UserId} has disabled 2fa.", user.Id); _logger.LogInformation("User {Email} has disabled 2fa", user.Email);
return RedirectToAction(nameof(TwoFactorAuthentication)); return RedirectToAction(nameof(TwoFactorAuthentication));
} }
@ -100,7 +100,7 @@ namespace BTCPayServer.Controllers
} }
await _userManager.SetTwoFactorEnabledAsync(user, true); await _userManager.SetTwoFactorEnabledAsync(user, true);
_logger.LogInformation("User with ID {UserId} has enabled 2FA with an authenticator app.", user.Id); _logger.LogInformation("User {Email} has enabled 2FA with an authenticator app", user.Email);
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10); var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
TempData[RecoveryCodesKey] = recoveryCodes.ToArray(); TempData[RecoveryCodesKey] = recoveryCodes.ToArray();
@ -117,7 +117,7 @@ namespace BTCPayServer.Controllers
await _userManager.SetTwoFactorEnabledAsync(user, false); await _userManager.SetTwoFactorEnabledAsync(user, false);
await _userManager.ResetAuthenticatorKeyAsync(user); await _userManager.ResetAuthenticatorKeyAsync(user);
_logger.LogInformation("User with id '{UserId}' has reset their authentication app key.", user.Id); _logger.LogInformation("User {Email} has reset their authentication app key", user.Email);
return RedirectToAction(nameof(EnableAuthenticator)); return RedirectToAction(nameof(EnableAuthenticator));
} }

View File

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

View File

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

View File

@ -1204,8 +1204,10 @@ namespace BTCPayServer.Controllers
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail))); ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid) if (!ModelState.IsValid)
return View(model); return View(model);
var serverSettings = await _SettingsRepository.GetSettingAsync<ServerSettings>();
var serverName = string.IsNullOrEmpty(serverSettings?.ServerName) ? "BTCPay Server" : serverSettings.ServerName;
using (var client = await model.Settings.CreateSmtpClient()) using (var client = await model.Settings.CreateSmtpClient())
using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false)) using (var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{serverName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false))
{ {
await client.SendAsync(message); await client.SendAsync(message);
await client.DisconnectAsync(true); await client.DisconnectAsync(true);

View File

@ -121,7 +121,7 @@ namespace BTCPayServer.Controllers
.Where(o => o != null) .Where(o => o != null)
.ToArray(); .ToArray();
emailSender.SendEmail(recipients.ToArray(), null, null, $"({store.StoreName} test) {rule.Subject}", rule.Body); emailSender.SendEmail(recipients.ToArray(), null, null, $"[TEST] {rule.Subject}", rule.Body);
message += "Test email sent — please verify you received it."; message += "Test email sent — please verify you received it.";
} }
else else
@ -189,32 +189,39 @@ namespace BTCPayServer.Controllers
[HttpPost("{storeId}/email-settings")] [HttpPost("{storeId}/email-settings")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command) public async Task<IActionResult> StoreEmailSettings(string storeId, EmailsViewModel model, string command, [FromForm] bool useCustomSMTP = false)
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) if (store == null)
return NotFound(); return NotFound();
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender ViewBag.UseCustomSMTP = useCustomSMTP;
model.FallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings() ? await storeSender.FallbackSender.GetEmailSettings()
: null; : null;
model.FallbackSettings = fallbackSettings; if (useCustomSMTP)
{
model.Settings.Validate("Settings.", ModelState);
}
if (command == "Test") if (command == "Test")
{ {
try try
{ {
if (model.PasswordSet) if (useCustomSMTP)
{ {
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password; if (model.PasswordSet)
{
model.Settings.Password = store.GetStoreBlob().EmailSettings.Password;
}
} }
model.Settings.Validate("Settings.", ModelState);
if (string.IsNullOrEmpty(model.TestEmail)) if (string.IsNullOrEmpty(model.TestEmail))
ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail))); ModelState.AddModelError(nameof(model.TestEmail), new RequiredAttribute().FormatErrorMessage(nameof(model.TestEmail)));
if (!ModelState.IsValid) if (!ModelState.IsValid)
return View(model); return View(model);
using var client = await model.Settings.CreateSmtpClient(); var settings = useCustomSMTP ? model.Settings : model.FallbackSettings;
var message = model.Settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), "BTCPay test", "BTCPay test", false); using var client = await settings.CreateSmtpClient();
var message = settings.CreateMailMessage(MailboxAddress.Parse(model.TestEmail), $"{store.StoreName}: Email test", "You received it, the BTCPay Server SMTP settings work.", false);
await client.SendAsync(message); await client.SendAsync(message);
await client.DisconnectAsync(true); await client.DisconnectAsync(true);
TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it."; TempData[WellKnownTempData.SuccessMessage] = $"Email sent to {model.TestEmail}. Please verify you received it.";
@ -232,17 +239,17 @@ namespace BTCPayServer.Controllers
store.SetStoreBlob(storeBlob); store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store); await _Repo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Email server password reset"; TempData[WellKnownTempData.SuccessMessage] = "Email server password reset";
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
} }
else // if (command == "Save") if (useCustomSMTP)
{ {
if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From)) if (model.Settings.From is not null && !MailboxAddressValidator.IsMailboxAddress(model.Settings.From))
{ {
ModelState.AddModelError("Settings.From", "Invalid email"); ModelState.AddModelError("Settings.From", "Invalid email");
return View(model);
} }
if (!ModelState.IsValid)
return View(model);
var storeBlob = store.GetStoreBlob(); var storeBlob = store.GetStoreBlob();
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, fallbackSettings).PasswordSet) if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, model.FallbackSettings).PasswordSet)
{ {
model.Settings.Password = storeBlob.EmailSettings.Password; model.Settings.Password = storeBlob.EmailSettings.Password;
} }
@ -250,8 +257,8 @@ namespace BTCPayServer.Controllers
store.SetStoreBlob(storeBlob); store.SetStoreBlob(storeBlob);
await _Repo.UpdateStore(store); await _Repo.UpdateStore(store);
TempData[WellKnownTempData.SuccessMessage] = "Email settings modified"; TempData[WellKnownTempData.SuccessMessage] = "Email settings modified";
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
} }
return RedirectToAction(nameof(StoreEmailSettings), new { storeId });
} }
private static async Task<bool> IsSetupComplete(IEmailSender emailSender) private static async Task<bool> IsSetupComplete(IEmailSender emailSender)

View File

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

View File

@ -0,0 +1,172 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating;
using BTCPayServer.Security.Bitpay;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Options;
using NBitcoin;
using NBitcoin.DataEncoders;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers
{
public partial class UIStoresController
{
[HttpGet("{storeId}/users")]
public async Task<IActionResult> StoreUsers()
{
var vm = new StoreUsersViewModel { Role = StoreRoleId.Guest.Role };
await FillUsers(vm);
return View(vm);
}
[HttpPost("{storeId}/users")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
{
await FillUsers(vm);
if (!ModelState.IsValid)
{
return View(vm);
}
var roles = await _Repo.GetStoreRoles(CurrentStore.Id);
if (roles.All(role => role.Id != vm.Role))
{
ModelState.AddModelError(nameof(vm.Role), "Invalid role");
return View(vm);
}
var user = await _UserManager.FindByEmailAsync(vm.Email);
var isExistingUser = user is not null;
var isExistingStoreUser = isExistingUser && await _Repo.GetStoreUser(storeId, user!.Id) is not null;
var successInfo = string.Empty;
if (user == null)
{
user = new ApplicationUser
{
UserName = vm.Email,
Email = vm.Email,
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
RequiresApproval = _policiesSettings.RequiresUserApproval,
Created = DateTimeOffset.UtcNow
};
var result = await _UserManager.CreateAsync(user);
if (result.Succeeded)
{
var tcs = new TaskCompletionSource<Uri>();
var currentUser = await _UserManager.GetUserAsync(HttpContext.User);
_eventAggregator.Publish(new UserRegisteredEvent
{
RequestUri = Request.GetAbsoluteRootUri(),
Kind = UserRegisteredEventKind.Invite,
User = user,
InvitedByUser = currentUser,
CallbackUrlGenerated = tcs
});
var callbackUrl = await tcs.Task;
var settings = await _settingsRepository.GetSettingAsync<EmailSettings>() ?? new EmailSettings();
var info = settings.IsComplete()
? "An invitation email has been sent.<br/>You may alternatively"
: "An invitation email has not been sent, because the server does not have an email server configured.<br/> You need to";
successInfo = $"{info} share this link with them: <a class='alert-link' href='{callbackUrl}'>{callbackUrl}</a>";
}
else
{
ModelState.AddModelError(nameof(vm.Email), "User could not be invited");
return View(vm);
}
}
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
var action = isExistingUser
? isExistingStoreUser ? "updated" : "added"
: "invited";
if (await _Repo.AddOrUpdateStoreUser(CurrentStore.Id, user.Id, roleId))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
AllowDismiss = false,
Html = $"User {action} successfully." + (string.IsNullOrEmpty(successInfo) ? "" : $" {successInfo}")
});
return RedirectToAction(nameof(StoreUsers));
}
ModelState.AddModelError(nameof(vm.Email), $"The user could not be {action}");
return View(vm);
}
[HttpPost("{storeId}/users/{userId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> UpdateStoreUser(string storeId, string userId, StoreUsersViewModel.StoreUserViewModel vm)
{
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
var storeUsers = await _Repo.GetStoreUsers(storeId);
var user = storeUsers.First(user => user.Id == userId);
var isOwner = user.StoreRole.Id == StoreRoleId.Owner.Id;
var isLastOwner = isOwner && storeUsers.Count(u => u.StoreRole.Id == StoreRoleId.Owner.Id) == 1;
if (isLastOwner && roleId != StoreRoleId.Owner)
TempData[WellKnownTempData.ErrorMessage] = $"User {user.Email} is the last owner. Their role cannot be changed.";
else if (await _Repo.AddOrUpdateStoreUser(storeId, userId, roleId))
TempData[WellKnownTempData.SuccessMessage] = $"The role of {user.Email} has been changed to {vm.Role}.";
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
}
[HttpPost("{storeId}/users/{userId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteStoreUser(string storeId, string userId)
{
if (await _Repo.RemoveStoreUser(storeId, userId))
TempData[WellKnownTempData.SuccessMessage] = "User removed successfully.";
else
TempData[WellKnownTempData.ErrorMessage] = "Removing this user would result in the store having no owner.";
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
}
private async Task FillUsers(StoreUsersViewModel vm)
{
var users = await _Repo.GetStoreUsers(CurrentStore.Id);
vm.StoreId = CurrentStore.Id;
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel()
{
Email = u.Email,
Id = u.Id,
Role = u.StoreRole.Role
}).ToList();
}
}
}

View File

@ -8,10 +8,12 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices.Webhooks; using BTCPayServer.HostedServices.Webhooks;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.StoreViewModels;
@ -60,7 +62,6 @@ namespace BTCPayServer.Controllers
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
PoliciesSettings policiesSettings, PoliciesSettings policiesSettings,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
EventAggregator eventAggregator,
AppService appService, AppService appService,
IFileService fileService, IFileService fileService,
WebhookSender webhookNotificationManager, WebhookSender webhookNotificationManager,
@ -70,7 +71,9 @@ namespace BTCPayServer.Controllers
IHtmlHelper html, IHtmlHelper html,
LightningClientFactoryService lightningClientFactoryService, LightningClientFactoryService lightningClientFactoryService,
EmailSenderFactory emailSenderFactory, EmailSenderFactory emailSenderFactory,
WalletFileParsers onChainWalletParsers) WalletFileParsers onChainWalletParsers,
SettingsRepository settingsRepository,
EventAggregator eventAggregator)
{ {
_RateFactory = rateFactory; _RateFactory = rateFactory;
_Repo = repo; _Repo = repo;
@ -97,6 +100,8 @@ namespace BTCPayServer.Controllers
_lightningClientFactoryService = lightningClientFactoryService; _lightningClientFactoryService = lightningClientFactoryService;
_emailSenderFactory = emailSenderFactory; _emailSenderFactory = emailSenderFactory;
_onChainWalletParsers = onChainWalletParsers; _onChainWalletParsers = onChainWalletParsers;
_settingsRepository = settingsRepository;
_eventAggregator = eventAggregator;
Html = html; Html = html;
} }
@ -110,6 +115,7 @@ namespace BTCPayServer.Controllers
readonly TokenRepository _TokenRepository; readonly TokenRepository _TokenRepository;
readonly UserManager<ApplicationUser> _UserManager; readonly UserManager<ApplicationUser> _UserManager;
readonly RateFetcher _RateFactory; readonly RateFetcher _RateFactory;
readonly SettingsRepository _settingsRepository;
private readonly ExplorerClientProvider _ExplorerProvider; private readonly ExplorerClientProvider _ExplorerProvider;
private readonly LanguageService _LangService; private readonly LanguageService _LangService;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary; private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
@ -122,6 +128,7 @@ namespace BTCPayServer.Controllers
private readonly LightningClientFactoryService _lightningClientFactoryService; private readonly LightningClientFactoryService _lightningClientFactoryService;
private readonly EmailSenderFactory _emailSenderFactory; private readonly EmailSenderFactory _emailSenderFactory;
private readonly WalletFileParsers _onChainWalletParsers; private readonly WalletFileParsers _onChainWalletParsers;
private readonly EventAggregator _eventAggregator;
public string? GeneratedPairingCode { get; set; } public string? GeneratedPairingCode { get; set; }
public WebhookSender WebhookNotificationManager { get; } public WebhookSender WebhookNotificationManager { get; }
@ -157,85 +164,9 @@ namespace BTCPayServer.Controllers
} }
return Forbid(); return Forbid();
} }
[HttpGet("{storeId}/users")]
public async Task<IActionResult> StoreUsers()
{
var vm = new StoreUsersViewModel { Role = StoreRoleId.Guest.Role };
await FillUsers(vm);
return View(vm);
}
private async Task FillUsers(StoreUsersViewModel vm)
{
var users = await _Repo.GetStoreUsers(CurrentStore.Id);
vm.StoreId = CurrentStore.Id;
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel()
{
Email = u.Email,
Id = u.Id,
Role = u.StoreRole.Role
}).ToList();
}
public StoreData? CurrentStore => HttpContext.GetStoreData(); public StoreData? CurrentStore => HttpContext.GetStoreData();
[HttpPost("{storeId}/users")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreUsers(string storeId, StoreUsersViewModel vm)
{
await FillUsers(vm);
if (!ModelState.IsValid)
{
return View(vm);
}
var user = await _UserManager.FindByEmailAsync(vm.Email);
if (user == null)
{
ModelState.AddModelError(nameof(vm.Email), "User not found");
return View(vm);
}
var roles = await _Repo.GetStoreRoles(CurrentStore.Id);
if (roles.All(role => role.Id != vm.Role))
{
ModelState.AddModelError(nameof(vm.Role), "Invalid role");
return View(vm);
}
var roleId = await _Repo.ResolveStoreRoleId(storeId, vm.Role);
if (!await _Repo.AddStoreUser(CurrentStore.Id, user.Id, roleId))
{
ModelState.AddModelError(nameof(vm.Email), "The user already has access to this store");
return View(vm);
}
TempData[WellKnownTempData.SuccessMessage] = "User added successfully.";
return RedirectToAction(nameof(StoreUsers));
}
[HttpGet("{storeId}/users/{userId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteStoreUser(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
return View("Confirm", new ConfirmModel("Remove store user", $"This action will prevent <strong>{Html.Encode(user.Email)}</strong> from accessing this store and its settings. Are you sure?", "Remove"));
}
[HttpPost("{storeId}/users/{userId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeleteStoreUserPost(string storeId, string userId)
{
if (await _Repo.RemoveStoreUser(storeId, userId))
TempData[WellKnownTempData.SuccessMessage] = "User removed successfully.";
else
{
TempData[WellKnownTempData.ErrorMessage] = "Removing this user would result in the store having no owner.";
}
return RedirectToAction(nameof(StoreUsers), new { storeId, userId });
}
[HttpGet("{storeId}/rates")] [HttpGet("{storeId}/rates")]
public IActionResult Rates() public IActionResult Rates()
{ {
@ -930,6 +861,7 @@ namespace BTCPayServer.Controllers
} }
[HttpGet("{storeId}/tokens")] [HttpGet("{storeId}/tokens")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ListTokens() public async Task<IActionResult> ListTokens()
{ {
var model = new TokensViewModel(); var model = new TokensViewModel();

View File

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

View File

@ -0,0 +1,10 @@
using System;
using BTCPayServer.Data;
namespace BTCPayServer.Events;
public class UserInviteAcceptedEvent
{
public ApplicationUser User { get; set; }
public Uri RequestUri { get; set; }
}

View File

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

View File

@ -29,6 +29,12 @@ namespace Microsoft.AspNetCore.Mvc
return urlHelper.GetUriByAction(nameof(UIServerController.User), "UIServer", return urlHelper.GetUriByAction(nameof(UIServerController.User), "UIServer",
new { userId }, scheme, host, pathbase); new { userId }, scheme, host, pathbase);
} }
public static string StoreUsersLink(this LinkGenerator urlHelper, string storeId, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(nameof(UIStoresController.StoreUsers), "UIStores",
new { storeId }, scheme, host, pathbase);
}
public static string InvitationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase) public static string InvitationLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
{ {

View File

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

View File

@ -8,6 +8,7 @@ using BTCPayServer.Services;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -21,6 +22,7 @@ public class UserEventHostedService(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory, EmailSenderFactory emailSenderFactory,
NotificationSender notificationSender, NotificationSender notificationSender,
StoreRepository storeRepository,
LinkGenerator generator, LinkGenerator generator,
Logs logs) Logs logs)
: EventHostedServiceBase(eventAggregator, logs) : EventHostedServiceBase(eventAggregator, logs)
@ -31,6 +33,7 @@ public class UserEventHostedService(
Subscribe<UserApprovedEvent>(); Subscribe<UserApprovedEvent>();
Subscribe<UserConfirmedEmailEvent>(); Subscribe<UserConfirmedEmailEvent>();
Subscribe<UserPasswordResetRequestedEvent>(); Subscribe<UserPasswordResetRequestedEvent>();
Subscribe<UserInviteAcceptedEvent>();
} }
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
@ -121,14 +124,22 @@ public class UserEventHostedService(
if (!user.RequiresApproval || user.Approved) return; if (!user.RequiresApproval || user.Approved) return;
await NotifyAdminsAboutUserRequiringApproval(user, uri, confirmedUserInfo); await NotifyAdminsAboutUserRequiringApproval(user, uri, confirmedUserInfo);
break; break;
case UserInviteAcceptedEvent inviteAcceptedEvent:
user = inviteAcceptedEvent.User;
uri = inviteAcceptedEvent.RequestUri;
Logs.PayServer.LogInformation("User {Email} accepted the invite", user.Email);
await NotifyAboutUserAcceptingInvite(user, uri);
break;
} }
} }
private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, Uri uri, string newUserInfo) private async Task NotifyAdminsAboutUserRequiringApproval(ApplicationUser user, Uri uri, string newUserInfo)
{ {
if (!user.RequiresApproval || user.Approved) return; if (!user.RequiresApproval || user.Approved) return;
// notification
await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user)); await notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
// email
var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin); var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var host = new HostString(uri.Host, uri.Port); var host = new HostString(uri.Host, uri.Port);
var approvalLink = generator.UserDetailsLink(user.Id, uri.Scheme, host, uri.PathAndQuery); var approvalLink = generator.UserDetailsLink(user.Id, uri.Scheme, host, uri.PathAndQuery);
@ -138,4 +149,27 @@ public class UserEventHostedService(
emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink); emailSender.SendNewUserInfo(admin.GetMailboxAddress(), newUserInfo, approvalLink);
} }
} }
private async Task NotifyAboutUserAcceptingInvite(ApplicationUser user, Uri uri)
{
var stores = await storeRepository.GetStoresByUserId(user.Id);
var notifyRoles = new[] { StoreRoleId.Owner, StoreRoleId.Manager };
foreach (var store in stores)
{
// notification
await notificationSender.SendNotification(new StoreScope(store.Id, notifyRoles), new InviteAcceptedNotification(user, store));
// email
var notifyUsers = await storeRepository.GetStoreUsers(store.Id, notifyRoles);
var host = new HostString(uri.Host, uri.Port);
var storeUsersLink = generator.StoreUsersLink(store.Id, uri.Scheme, host, uri.PathAndQuery);
var emailSender = await emailSenderFactory.GetEmailSender(store.Id);
foreach (var storeUser in notifyUsers)
{
if (storeUser.Id == user.Id) continue; // do not notify the user itself (if they were added as owner or manager)
var notifyUser = await userManager.FindByIdOrEmail(storeUser.Id);
var info = $"User {user.Email} accepted the invite to {store.StoreName}";
emailSender.SendUserInviteAcceptedInfo(notifyUser.GetMailboxAddress(), info, storeUsersLink);
}
}
}
} }

View File

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

View File

@ -1,5 +1,4 @@
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -26,7 +25,7 @@ namespace BTCPayServer.Services.Mails
public void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message) public void SendEmail(MailboxAddress[] email, MailboxAddress[] cc, MailboxAddress[] bcc, string subject, string message)
{ {
_JobClient.Schedule(async (cancellationToken) => _JobClient.Schedule(async cancellationToken =>
{ {
var emailSettings = await GetEmailSettings(); var emailSettings = await GetEmailSettings();
if (emailSettings?.IsComplete() != true) if (emailSettings?.IsComplete() != true)
@ -36,12 +35,14 @@ namespace BTCPayServer.Services.Mails
} }
using var smtp = await emailSettings.CreateSmtpClient(); using var smtp = await emailSettings.CreateSmtpClient();
var mail = emailSettings.CreateMailMessage(email, cc, bcc, subject, message, true); var prefixedSubject = await GetPrefixedSubject(subject);
var mail = emailSettings.CreateMailMessage(email, cc, bcc, prefixedSubject, message, true);
await smtp.SendAsync(mail, cancellationToken); await smtp.SendAsync(mail, cancellationToken);
await smtp.DisconnectAsync(true, cancellationToken); await smtp.DisconnectAsync(true, cancellationToken);
}, TimeSpan.Zero); }, TimeSpan.Zero);
} }
public abstract Task<EmailSettings> GetEmailSettings(); public abstract Task<EmailSettings> GetEmailSettings();
public abstract Task<string> GetPrefixedSubject(string subject);
} }
} }

View File

@ -20,5 +20,12 @@ namespace BTCPayServer.Services.Mails
{ {
return SettingsRepository.GetSettingAsync<EmailSettings>(); return SettingsRepository.GetSettingAsync<EmailSettings>();
} }
public override async Task<string> GetPrefixedSubject(string subject)
{
var settings = await SettingsRepository.GetSettingAsync<ServerSettings>();
var prefix = string.IsNullOrEmpty(settings?.ServerName) ? "BTCPay Server" : settings.ServerName;
return $"{prefix}: {subject}";
}
} }
} }

View File

@ -36,5 +36,11 @@ namespace BTCPayServer.Services.Mails
return await FallbackSender?.GetEmailSettings(); return await FallbackSender?.GetEmailSettings();
return null; return null;
} }
public override async Task<string> GetPrefixedSubject(string subject)
{
var store = await StoreRepository.FindStore(StoreId);
return string.IsNullOrEmpty(store?.StoreName) ? subject : $"{store.StoreName}: {subject}";
}
} }
} }

View File

@ -0,0 +1,53 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs;
internal class InviteAcceptedNotification : BaseNotification
{
private const string TYPE = "inviteaccepted";
public string UserId { get; set; }
public string UserEmail { get; set; }
public string StoreId { get; set; }
public string StoreName { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
public InviteAcceptedNotification()
{
}
public InviteAcceptedNotification(ApplicationUser user, StoreData store)
{
UserId = user.Id;
UserEmail = user.Email;
StoreId = store.Id;
StoreName = store.StoreName;
}
internal class Handler(LinkGenerator linkGenerator, BTCPayServerOptions options)
: NotificationHandler<InviteAcceptedNotification>
{
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return [(TYPE, "User accepted invitation")];
}
}
protected override void FillViewModel(InviteAcceptedNotification notification, NotificationViewModel vm)
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.Body = $"User {notification.UserEmail} accepted the invite to {notification.StoreName}.";
vm.ActionLink = linkGenerator.GetPathByAction(nameof(UIStoresController.StoreUsers),
"UIStores",
new { storeId = notification.StoreId }, options.RootPath);
}
}
}

View File

@ -1,33 +1,30 @@
using System; using System;
using System.Collections.Generic;
using BTCPayServer.Services.Stores;
namespace BTCPayServer.Services.Notifications namespace BTCPayServer.Services.Notifications;
public class AdminScope : INotificationScope;
public class StoreScope : INotificationScope
{ {
public class AdminScope : NotificationScope public StoreScope(string storeId, IEnumerable<StoreRoleId> roles = null)
{
public AdminScope()
{
}
}
public class StoreScope : NotificationScope
{
public StoreScope(string storeId)
{
ArgumentNullException.ThrowIfNull(storeId);
StoreId = storeId;
}
public string StoreId { get; }
}
public class UserScope : NotificationScope
{
public UserScope(string userId)
{
ArgumentNullException.ThrowIfNull(userId);
UserId = userId;
}
public string UserId { get; }
}
public interface NotificationScope
{ {
ArgumentNullException.ThrowIfNull(storeId);
StoreId = storeId;
Roles = roles;
} }
public string StoreId { get; }
public IEnumerable<StoreRoleId> Roles { get; set; }
} }
public class UserScope : INotificationScope
{
public UserScope(string userId)
{
ArgumentNullException.ThrowIfNull(userId);
UserId = userId;
}
public string UserId { get; }
}
public interface INotificationScope;

View File

@ -6,7 +6,6 @@ using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Data; using BTCPayServer.Data;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Notifications namespace BTCPayServer.Services.Notifications
{ {
@ -31,7 +30,7 @@ namespace BTCPayServer.Services.Notifications
_notificationManager = notificationManager; _notificationManager = notificationManager;
} }
public async Task SendNotification(NotificationScope scope, BaseNotification notification) public async Task SendNotification(INotificationScope scope, BaseNotification notification)
{ {
ArgumentNullException.ThrowIfNull(scope); ArgumentNullException.ThrowIfNull(scope);
ArgumentNullException.ThrowIfNull(notification); ArgumentNullException.ThrowIfNull(notification);
@ -59,7 +58,7 @@ namespace BTCPayServer.Services.Notifications
} }
} }
private async Task<string[]> GetUsers(NotificationScope scope, string notificationIdentifier) private async Task<string[]> GetUsers(INotificationScope scope, string notificationIdentifier)
{ {
await using var ctx = _contextFactory.CreateContext(); await using var ctx = _contextFactory.CreateContext();
@ -79,9 +78,10 @@ namespace BTCPayServer.Services.Notifications
break; break;
} }
case StoreScope s: case StoreScope s:
var roles = s.Roles?.Select(role => role.Id);
query = ctx.UserStore query = ctx.UserStore
.Include(store => store.ApplicationUser) .Include(store => store.ApplicationUser)
.Where(u => u.StoreDataId == s.StoreId) .Where(u => u.StoreDataId == s.StoreId && (roles == null || roles.Contains(u.StoreRoleId)))
.Select(u => u.ApplicationUser); .Select(u => u.ApplicationUser);
break; break;
case UserScope userScope: case UserScope userScope:

View File

@ -3,7 +3,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.S3;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
@ -87,7 +86,17 @@ namespace BTCPayServer.Services.Stores
{ {
query = query.Include(u => u.Users); query = query.Include(u => u.Users);
} }
return (await query.ToArrayAsync()).Select(role => ToStoreRole(role)).ToArray();
var roles = await query.ToArrayAsync();
// return ordered: default role comes first, then server-wide roles in specified order, followed by store roles
var defaultRole = await GetDefaultRole();
var defaultOrder = StoreRoleId.DefaultOrder.Select(r => r.Role).ToArray();
return roles.OrderBy(role =>
{
if (role.Role == defaultRole.Role) return -1;
int index = Array.IndexOf(defaultOrder, role.Role);
return index == -1 ? int.MaxValue : index;
}).Select(ToStoreRole).ToArray();
} }
public async Task<StoreRoleId> GetDefaultRole() public async Task<StoreRoleId> GetDefaultRole()
@ -166,11 +175,11 @@ namespace BTCPayServer.Services.Stores
return ToStoreRole(match); return ToStoreRole(match);
} }
public async Task<StoreUser[]> GetStoreUsers(string storeId, IEnumerable<StoreRoleId>? filterRoles = null)
public async Task<StoreUser[]> GetStoreUsers(string storeId)
{ {
ArgumentNullException.ThrowIfNull(storeId); ArgumentNullException.ThrowIfNull(storeId);
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
var roles = filterRoles?.Select(role => role.Id);
return (await return (await
ctx ctx
.UserStore .UserStore
@ -181,7 +190,9 @@ namespace BTCPayServer.Services.Stores
Id = u.ApplicationUserId, Id = u.ApplicationUserId,
u.ApplicationUser.Email, u.ApplicationUser.Email,
u.StoreRole u.StoreRole
}).ToArrayAsync()).Select(arg => new StoreUser() })
.Where(u => roles == null || roles.Contains(u.StoreRole.Id))
.ToArrayAsync()).Select(arg => new StoreUser
{ {
StoreRole = ToStoreRole(arg.StoreRole), StoreRole = ToStoreRole(arg.StoreRole),
Id = arg.Id, Id = arg.Id,
@ -191,7 +202,7 @@ namespace BTCPayServer.Services.Stores
public static StoreRole ToStoreRole(Data.StoreRole storeRole) public static StoreRole ToStoreRole(Data.StoreRole storeRole)
{ {
return new StoreRole() return new StoreRole
{ {
Id = storeRole.Id, Id = storeRole.Id,
Role = storeRole.Role, Role = storeRole.Role,
@ -262,13 +273,19 @@ namespace BTCPayServer.Services.Stores
return null; return null;
} }
public async Task<UserStore?> GetStoreUser(string storeId, string userId)
{
await using var ctx = _ContextFactory.CreateContext();
return await ctx.UserStore.FindAsync(userId, storeId);
}
public async Task<bool> AddStoreUser(string storeId, string userId, StoreRoleId? roleId = null) public async Task<bool> AddStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
{ {
ArgumentNullException.ThrowIfNull(storeId); ArgumentNullException.ThrowIfNull(storeId);
AssertStoreRoleIfNeeded(storeId, roleId); AssertStoreRoleIfNeeded(storeId, roleId);
roleId ??= await GetDefaultRole(); roleId ??= await GetDefaultRole();
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id }; var userStore = new UserStore { StoreDataId = storeId, ApplicationUserId = userId, StoreRoleId = roleId.Id };
ctx.UserStore.Add(userStore); ctx.UserStore.Add(userStore);
try try
{ {
@ -281,6 +298,34 @@ namespace BTCPayServer.Services.Stores
} }
} }
public async Task<bool> AddOrUpdateStoreUser(string storeId, string userId, StoreRoleId? roleId = null)
{
ArgumentNullException.ThrowIfNull(storeId);
AssertStoreRoleIfNeeded(storeId, roleId);
roleId ??= await GetDefaultRole();
await using var ctx = _ContextFactory.CreateContext();
var userStore = await ctx.UserStore.FindAsync(userId, storeId);
if (userStore is null)
{
userStore = new UserStore { StoreDataId = storeId, ApplicationUserId = userId };
ctx.UserStore.Add(userStore);
}
if (userStore.StoreRoleId == roleId.Id)
return false;
userStore.StoreRoleId = roleId.Id;
try
{
await ctx.SaveChangesAsync();
return true;
}
catch (DbUpdateException)
{
return false;
}
}
static void AssertStoreRoleIfNeeded(string storeId, StoreRoleId? roleId) static void AssertStoreRoleIfNeeded(string storeId, StoreRoleId? roleId)
{ {
if (roleId?.StoreId != null && storeId != roleId.StoreId) if (roleId?.StoreId != null && storeId != roleId.StoreId)
@ -645,8 +690,12 @@ retry:
Role = role; Role = role;
} }
public static StoreRoleId Owner { get; } = new StoreRoleId("Owner"); public static StoreRoleId Owner { get; } = new ("Owner");
public static StoreRoleId Guest { get; } = new StoreRoleId("Guest"); public static StoreRoleId Manager { get; } = new ("Manager");
public static StoreRoleId Employee { get; } = new ("Employee");
public static StoreRoleId Guest { get; } = new ("Guest");
public static readonly StoreRoleId[] DefaultOrder = [Owner, Manager, Employee, Guest];
public string? StoreId { get; } public string? StoreId { get; }
public string Role { get; } public string Role { get; }
public string Id public string Id

View File

@ -8,6 +8,10 @@ namespace BTCPayServer
[Obsolete("You should check authorization policies instead of roles")] [Obsolete("You should check authorization policies instead of roles")]
public const string Owner = "Owner"; public const string Owner = "Owner";
[Obsolete("You should check authorization policies instead of roles")] [Obsolete("You should check authorization policies instead of roles")]
public const string Manager = "Manager";
[Obsolete("You should check authorization policies instead of roles")]
public const string Employee = "Employee";
[Obsolete("You should check authorization policies instead of roles")]
public const string Guest = "Guest"; public const string Guest = "Guest";
} }
} }

View File

@ -26,14 +26,14 @@
<script src="~/crowdfund/admin.js" asp-append-version="true"></script> <script src="~/crowdfund/admin.js" asp-append-version="true"></script>
} }
<form method="post"> <form method="post" permissioned="@Policies.CanModifyStoreSettings">
<div class="sticky-header d-sm-flex align-items-center justify-content-between"> <div class="sticky-header d-sm-flex align-items-center justify-content-between">
<h2 class="mb-0">@ViewData["Title"]</h2> <h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex gap-3 mt-3 mt-sm-0"> <div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings" permission="@Policies.CanModifyStoreSettings">Save</button> <button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button>
@if (Model.Archived) @if (Model.Archived)
{ {
<button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" permission="@Policies.CanModifyStoreSettings">Unarchive</button> <button type="submit" class="btn btn-outline-secondary" name="Archived" value="False">Unarchive</button>
} }
else if (Model.ModelWithMinimumData) else if (Model.ModelWithMinimumData)
{ {

View File

@ -61,7 +61,7 @@
} }
</div> </div>
<input asp-for="PasswordSet" type="hidden"/> <input asp-for="PasswordSet" type="hidden"/>
<div class="my-4"> <div class="my-4">
<button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton" data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings"> <button class="d-inline-flex align-items-center btn btn-link text-primary fw-semibold p-0" type="button" id="AdvancedSettingsButton" data-bs-toggle="collapse" data-bs-target="#AdvancedSettings" aria-expanded="false" aria-controls="AdvancedSettings">
<vc:icon symbol="caret-down"/> <vc:icon symbol="caret-down"/>
<span class="ms-1">Advanced settings</span> <span class="ms-1">Advanced settings</span>
@ -75,7 +75,7 @@
</div> </div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-primary mt-2" name="command" value="Save" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button> <button type="submit" class="btn btn-primary mt-2" name="command" value="Save" id="Save">Save</button>
</div> </div>
</div> </div>

View File

@ -33,14 +33,14 @@
</script> </script>
} }
<form method="post"> <form method="post" permissioned="@Policies.CanModifyStoreSettings">
<div class="sticky-header d-sm-flex align-items-center justify-content-between"> <div class="sticky-header d-sm-flex align-items-center justify-content-between">
<h2 class="mb-0">@ViewData["Title"]</h2> <h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex gap-3 mt-3 mt-sm-0"> <div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings" permission="@Policies.CanModifyStoreSettings">Save</button> <button type="submit" class="btn btn-primary order-sm-1" id="SaveSettings">Save</button>
@if (Model.Archived) @if (Model.Archived)
{ {
<button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" permission="@Policies.CanModifyStoreSettings">Unarchive</button> <button type="submit" class="btn btn-outline-secondary" name="Archived" value="False">Unarchive</button>
} }
else else
{ {

View File

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

View File

@ -1,6 +1,7 @@
@model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel @model BTCPayServer.Models.AccountViewModels.SetPasswordViewModel
@{ @{
ViewData["Title"] = $"{(Model.HasPassword ? "Reset" : "Set")} your password"; var cta = Model.HasPassword ? "Reset your password" : "Create Account";
ViewData["Title"] = cta;
Layout = "_LayoutSignedOut"; Layout = "_LayoutSignedOut";
} }
@ -35,5 +36,5 @@
<input asp-for="ConfirmPassword" class="form-control"/> <input asp-for="ConfirmPassword" class="form-control"/>
<span asp-validation-for="ConfirmPassword" class="text-danger"></span> <span asp-validation-for="ConfirmPassword" class="text-danger"></span>
</div> </div>
<button type="submit" class="btn btn-primary w-100 btn-lg" id="SetPassword">Set Password</button> <button type="submit" class="btn btn-primary w-100 btn-lg" id="SetPassword">@cta</button>
</form> </form>

View File

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

View File

@ -17,7 +17,7 @@
{ {
<div asp-validation-summary="All"></div> <div asp-validation-summary="All"></div>
} }
<form method="post"> <form method="post" permissioned="@Policies.CanModifyStoreSettings">
<div class="d-flex my-3"> <div class="d-flex my-3">
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" /> <input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" />
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label> <label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
@ -41,7 +41,7 @@
<div class="form-text">If a payout fails this many times, it will be cancelled.</div> <div class="form-text">If a payout fails this many times, it will be cancelled.</div>
</div> </div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button> <button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -18,7 +18,7 @@
{ {
<div asp-validation-summary="All"></div> <div asp-validation-summary="All"></div>
} }
<form method="post"> <form method="post" permissioned="@Policies.CanModifyStoreSettings">
<div class="d-flex my-3"> <div class="d-flex my-3">
<input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" /> <input asp-for="ProcessNewPayoutsInstantly" type="checkbox" class="btcpay-toggle me-3" />
<label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label> <label asp-for="ProcessNewPayoutsInstantly" class="form-check-label">Process approved payouts instantly</label>
@ -49,7 +49,7 @@
</div> </div>
<div class="form-text">Only process payouts when this payout sum is reached.</div> <div class="form-text">Only process payouts when this payout sum is reached.</div>
</div> </div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button> <button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -61,7 +61,7 @@
<div class="row"> <div class="row">
<div class="col-xxl-constrain col-xl-8"> <div class="col-xxl-constrain col-xl-8">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data" permissioned="@Policies.CanModifyStoreSettings">
@if (!ViewContext.ModelState.IsValid) @if (!ViewContext.ModelState.IsValid)
{ {
<div asp-validation-summary="All"></div> <div asp-validation-summary="All"></div>
@ -275,7 +275,7 @@
<input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="btcpay-toggle me-3" /> <input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="btcpay-toggle me-3" />
<label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label> <label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label>
</div> </div>
<button type="submit" class="btn btn-primary mt-4" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button> <button type="submit" class="btn btn-primary mt-4" id="Save">Save</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -17,7 +17,7 @@
{ {
<div asp-validation-summary="All"></div> <div asp-validation-summary="All"></div>
} }
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data" permissioned="@Policies.CanModifyStoreSettings">
<h3 class="mb-3">General</h3> <h3 class="mb-3">General</h3>
<div class="form-group"> <div class="form-group">
<label asp-for="Id" class="form-label"></label> <label asp-for="Id" class="form-label"></label>
@ -159,7 +159,7 @@
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span> <span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
</div> </div>
<button type="submit" class="btn btn-primary mt-2" id="Save" permission="@Policies.CanModifyStoreSettings">Save</button> <button type="submit" class="btn btn-primary mt-2" id="Save">Save</button>
</form> </form>
<div permission="@Policies.CanModifyStoreSettings"> <div permission="@Policies.CanModifyStoreSettings">
<h3 class="mt-5 mb-3">Additional Actions</h3> <h3 class="mt-5 mb-3">Additional Actions</h3>

View File

@ -15,7 +15,7 @@
{ {
<div asp-validation-summary="All"></div> <div asp-validation-summary="All"></div>
} }
<form method="post"> <form method="post" permissioned="@Policies.CanModifyStoreSettings">
<input type="hidden" asp-for="ShowScripting" /> <input type="hidden" asp-for="ShowScripting" />
@if (Model.ShowScripting) @if (Model.ShowScripting)
{ {
@ -178,7 +178,7 @@ X_X = kraken(X_X);</code></pre>
<input asp-for="DefaultCurrencyPairs" class="form-control" placeholder="BTC_USD, BTC_CAD" /> <input asp-for="DefaultCurrencyPairs" class="form-control" placeholder="BTC_USD, BTC_CAD" />
<span asp-validation-for="DefaultCurrencyPairs" class="text-danger"></span> <span asp-validation-for="DefaultCurrencyPairs" class="text-danger"></span>
</div> </div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" permission="@Policies.CanModifyStoreSettings">Save</button> <button name="command" type="submit" class="btn btn-primary mt-2" value="Save">Save</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id);
var hasCustomSettings = Model.IsSetup() && !Model.UsesFallback(); var hasCustomSettings = (Model.IsSetup() && !Model.UsesFallback()) || ViewBag.UseCustomSMTP ?? false;
} }
<div class="row mb-4"> <div class="row mb-4">
@ -15,27 +15,27 @@
Configure Configure
</a> </a>
</div> </div>
<p> <p class="mb-0">
<a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id">Email rules</a> <a asp-action="StoreEmails" asp-controller="UIStores" asp-route-storeId="@Context.GetStoreData().Id">Email rules</a>
allow BTCPay Server to send customized emails from your store based on events. allow BTCPay Server to send customized emails from your store based on events.
</p> </p>
</div> </div>
</div> </div>
<h3 class="mb-4">Email Server</h3> <h3 class="mt-5 mb-4">Email Server</h3>
<form method="post" autocomplete="off"> <form method="post" autocomplete="off" permissioned="@Policies.CanModifyStoreSettings">
@if (Model.IsFallbackSetup()) @if (Model.IsFallbackSetup())
{ {
<label class="d-flex align-items-center mb-4"> <label class="d-flex align-items-center mb-4">
<input type="checkbox" id="UseCustomSMTP" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" /> <input type="checkbox" id="UseCustomSMTP" name="UseCustomSMTP" value="true" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" />
<div> <div>
<span>Use custom SMTP settings for this store</span> <span>Use custom SMTP settings for this store</span>
<div class="form-text">Otherwise, the server's SMTP settings will be used to send emails.</div> <div class="form-text">Otherwise, the server's SMTP settings will be used to send emails.</div>
</div> </div>
</label> </label>
<div class="checkout-settings collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings"> <div class="collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
<partial name="EmailsBody" model="Model" /> <partial name="EmailsBody" model="Model" />
</div> </div>
} }

View File

@ -14,7 +14,7 @@
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" /> <link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
} }
<form class="row" asp-action="StoreEmails" method="post" asp-route-storeId="@Context.GetStoreData().Id"> <form class="row" asp-action="StoreEmails" method="post" asp-route-storeId="@Context.GetStoreData().Id" permissioned="@Policies.CanModifyStoreSettings">
<div class="col-xxl-constrain"> <div class="col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mt-n1 mb-3"> <div class="d-flex align-items-center justify-content-between mt-n1 mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3> <h3 class="mb-0">@ViewData["Title"]</h3>

View File

@ -9,11 +9,21 @@
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.Users, "Store Users", Context.GetStoreData().Id);
var roles = new SelectList(await StoreRepository.GetStoreRoles(ScopeProvider.GetCurrentStoreId()), nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role), Model.Role); var roles = new SelectList(
await StoreRepository.GetStoreRoles(ScopeProvider.GetCurrentStoreId()),
nameof(StoreRepository.StoreRole.Id), nameof(StoreRepository.StoreRole.Role),
Model.Role);
}
@section PageHeadContent {
<style>
@@media (min-width: 576px) {
#Role { width: auto !important; }
}
</style>
} }
<div class="row"> <div class="row">
<div class="col-xxl-constrain col-xl-8"> <div class="col-xxl-constrain">
<h3 class="mb-3">@ViewData["Title"]</h3> <h3 class="mb-3">@ViewData["Title"]</h3>
<p> <p>
Give other registered BTCPay Server users access to your store.<br /> Give other registered BTCPay Server users access to your store.<br />
@ -27,7 +37,7 @@
<form method="post" class="d-flex flex-wrap align-items-center gap-3" permission="@Policies.CanModifyStoreSettings"> <form method="post" class="d-flex flex-wrap align-items-center gap-3" permission="@Policies.CanModifyStoreSettings">
<input asp-for="Email" type="text" class="form-control" placeholder="user@example.com" style="flex: 1 1 14rem"> <input asp-for="Email" type="text" class="form-control" placeholder="user@example.com" style="flex: 1 1 14rem">
<select asp-for="Role" class="form-select w-auto" asp-items="roles"></select> <select asp-for="Role" class="form-select" asp-items="roles"></select>
<button type="submit" role="button" class="btn btn-primary text-nowrap flex-grow-1 flex-sm-grow-0" id="AddUser">Add User</button> <button type="submit" role="button" class="btn btn-primary text-nowrap flex-grow-1 flex-sm-grow-0" id="AddUser">Add User</button>
</form> </form>
@ -39,14 +49,17 @@
<th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th> <th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="StoreUsersList">
@foreach (var user in Model.Users) @foreach (var user in Model.Users)
{ {
<tr> <tr>
<td>@user.Email</td> <td>@user.Email</td>
<td>@user.Role</td> <td>@user.Role</td>
<td class="actions-col" permission="@Policies.CanModifyStoreSettings"> <td class="actions-col" permission="@Policies.CanModifyStoreSettings">
<a asp-action="DeleteStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="This action will prevent <strong>@Html.Encode(user.Email)</strong> from accessing this store and its settings." data-confirm-input="REMOVE">Remove</a> <div class="d-inline-flex align-items-center gap-3">
<a asp-action="UpdateStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#EditModal" data-user-email="@user.Email" data-user-role="@user.Role">Change Role</a>
<a asp-action="DeleteStoreUser" asp-route-storeId="@Model.StoreId" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="This action will prevent <strong>@Html.Encode(user.Email)</strong> from accessing this store and its settings." data-confirm-input="REMOVE">Remove</a>
</div>
</td> </td>
</tr> </tr>
} }
@ -57,6 +70,50 @@
<partial name="_Confirm" model="@(new ConfirmModel("Remove store user", "This action will prevent the user from accessing this store and its settings. Are you sure?", "Delete"))" permission="@Policies.CanModifyStoreSettings" /> <partial name="_Confirm" model="@(new ConfirmModel("Remove store user", "This action will prevent the user from accessing this store and its settings. Are you sure?", "Delete"))" permission="@Policies.CanModifyStoreSettings" />
<div class="modal fade" id="EditModal" tabindex="-1" aria-labelledby="EditTitle" aria-hidden="true" permission="@Policies.CanModifyStoreSettings">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="EditTitle">Edit <span id="EditUserEmail">store user</span></h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close" />
</button>
</div>
<form id="EditForm" method="post" rel="noreferrer noopener">
<div class="modal-body">
<label asp-for="Role" for="EditUserRole" class="form-label">New role</label>
<select asp-for="Role" id="EditUserRole" class="form-select w-auto" asp-items="roles"></select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary only-for-js" data-bs-dismiss="modal" id="EditCancel">Cancel</button>
<button type="submit" class="btn btn-primary" id="EditContinue">Change Role</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function () {
const modal = document.getElementById('EditModal')
modal.addEventListener('show.bs.modal', event => {
const $target = event.relatedTarget
const $form = document.getElementById('EditForm')
const $role = document.getElementById('EditUserRole')
const $email = document.getElementById('EditUserEmail')
const { userEmail, userRole } = $target.dataset
const action = $target.dataset.action || ($target.nodeName === 'A'
? $target.getAttribute('href')
: $target.form.getAttribute('action'))
if ($form && !$form.hasAttribute('action')) $form.setAttribute('action', action)
if (userEmail) $email.textContent = userEmail
if (userRole) $role.value = userRole
});
})()
</script>
@section PageFootContent { @section PageFootContent {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
} }

View File

@ -23,7 +23,7 @@
<tr> <tr>
<th>Status</th> <th>Status</th>
<th>Url</th> <th>Url</th>
<th class="text-end" permission="@Policies.CanModifyStoreSettings">Actions</th> <th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -48,7 +48,7 @@
} }
</td> </td>
<td class="d-block text-break">@wh.Url</td> <td class="d-block text-break">@wh.Url</td>
<td class="text-end text-md-nowrap" permission="@Policies.CanModifyStoreSettings"> <td class="actions-col text-md-nowrap" permission="@Policies.CanModifyStoreSettings">
<a asp-action="TestWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Test</a> - <a asp-action="TestWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Test</a> -
<a asp-action="ModifyWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Modify</a> - <a asp-action="ModifyWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id">Modify</a> -
<a asp-action="DeleteWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Delete</a> <a asp-action="DeleteWebhook" asp-route-storeId="@Context.GetRouteValue("storeId")" asp-route-webhookId="@wh.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Delete</a>

View File

@ -74,3 +74,16 @@
.editor .nested-fields .list-group-item { .editor .nested-fields .list-group-item {
padding-right: 1rem; padding-right: 1rem;
} }
:disabled .editor {
pointer-events: none;
}
:disabled .editor .bg-tile {
background-color: var(--btcpay-form-bg-disabled);
}
:disabled .editor .control,
:disabled .editor button {
display: none !important;
}

View File

@ -78,6 +78,12 @@ hr.primary {
background-color: var(--btcpay-form-bg); background-color: var(--btcpay-form-bg);
} }
:disabled .note-editable {
border-color: var(--btcpay-form-border-disabled);
background-color: var(--btcpay-form-bg-disabled);
pointer-events: none;
}
@media (min-width: 768px) { @media (min-width: 768px) {
.text-md-nowrap { .text-md-nowrap {
white-space: nowrap; white-space: nowrap;
@ -648,6 +654,11 @@ label.btcpay-list-select-item:hover {
border-color: var(--btcpay-form-border-hover); border-color: var(--btcpay-form-border-hover);
background-color: var(--btcpay-form-bg-hover); background-color: var(--btcpay-form-bg-hover);
} }
:disabled label.btcpay-list-select-item {
border-color: var(--btcpay-form-border-disabled);
background-color: var(--btcpay-form-bg-disabled);
pointer-events: none;
}
@media (max-width: 575px) { @media (max-width: 575px) {
.btcpay-list-select-item { .btcpay-list-select-item {
flex-basis: 100%; flex-basis: 100%;