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