User: Add name and image URL (#6008)

* User: Add name and image URL

More personalization options, prerequisite for btcpayserver/app#3.

Additionally:
- Remove ambigious and read-only username from manage view.
- Improve email verification conditions and display.
- Greenfield: Update current user. Prerequisite for btcpayserver/app#13.

* Refactor UpdateCurrentUser

* Replace new columns by UserBlob

* Update email check and add test case for mailbox addresses

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2024-06-26 10:39:22 +02:00 committed by GitHub
parent 1ba7b67e70
commit bf66b54c9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 632 additions and 71 deletions

View file

@ -13,6 +13,11 @@ public partial class BTCPayServerClient
return await SendHttpRequest<ApplicationUserData>("api/v1/users/me", null, HttpMethod.Get, token);
}
public virtual async Task<ApplicationUserData> UpdateCurrentUser(UpdateApplicationUserRequest request, CancellationToken token = default)
{
return await SendHttpRequest<ApplicationUserData>("api/v1/users/me", request, HttpMethod.Put, token);
}
public virtual async Task<ApplicationUserData> CreateUser(CreateApplicationUserRequest request, CancellationToken token = default)
{
return await SendHttpRequest<ApplicationUserData>("api/v1/users", request, HttpMethod.Post, token);

View file

@ -14,6 +14,16 @@ namespace BTCPayServer.Client.Models
/// the email AND username of the user
/// </summary>
public string Email { get; set; }
/// <summary>
/// the name of the user
/// </summary>
public string Name { get; set; }
/// <summary>
/// the image url of the user
/// </summary>
public string ImageUrl { get; set; }
/// <summary>
/// Whether the user has verified their email

View file

@ -2,6 +2,16 @@ namespace BTCPayServer.Client.Models
{
public class CreateApplicationUserRequest
{
/// <summary>
/// the name of the new user
/// </summary>
public string Name { get; set; }
/// <summary>
/// the image url of the new user
/// </summary>
public string ImageUrl { get; set; }
/// <summary>
/// the email AND username of the new user
/// </summary>

View file

@ -0,0 +1,29 @@
namespace BTCPayServer.Client.Models;
public class UpdateApplicationUserRequest
{
/// <summary>
/// the name of the user
/// </summary>
public string Name { get; set; }
/// <summary>
/// the image url of the user
/// </summary>
public string ImageUrl { get; set; }
/// <summary>
/// the email AND username of the user
/// </summary>
public string Email { get; set; }
/// <summary>
/// current password of the user
/// </summary>
public string CurrentPassword { get; set; }
/// <summary>
/// new password of the user
/// </summary>
public string NewPassword { get; set; }
}

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data
{
@ -19,7 +18,7 @@ namespace BTCPayServer.Data
public List<APIKeyData> APIKeys { get; set; }
public DateTimeOffset? Created { get; set; }
public string DisabledNotifications { get; set; }
public List<NotificationData> Notifications { get; set; }
public List<UserStore> UserStores { get; set; }
public List<Fido2Credential> Fido2Credentials { get; set; }
@ -45,5 +44,7 @@ namespace BTCPayServer.Data
public class UserBlob
{
public bool ShowInvoiceStatusChangeHint { get; set; }
public string ImageUrl { get; set; }
public string Name { get; set; }
}
}

View file

@ -200,7 +200,13 @@ namespace BTCPayServer.Tests
acc = tester.NewAccount();
await acc.GrantAccessAsync(isAdmin: true);
unrestricted = await acc.CreateClient();
var newUser = await unrestricted.CreateUser(new CreateApplicationUserRequest() { Email = Utils.GenerateEmail(), Password = "Kitten0@" });
var newUser = await unrestricted.CreateUser(new CreateApplicationUserRequest
{
Email = Utils.GenerateEmail(),
Password = "Kitten0@",
Name = "New User",
ImageUrl = "avatar.jpg"
});
var newUserAPIKey = await unrestricted.CreateAPIKey(newUser.Id, new CreateApiKeyRequest()
{
Label = "Hello world",
@ -208,6 +214,8 @@ namespace BTCPayServer.Tests
});
var newUserClient = acc.CreateClientFromAPIKey(newUserAPIKey.ApiKey);
Assert.Equal(newUser.Id, (await newUserClient.GetCurrentUser()).Id);
Assert.Equal("New User", newUser.Name);
Assert.Equal("avatar.jpg", newUser.ImageUrl);
// Admin delete it
await unrestricted.RevokeAPIKey(newUser.Id, newUserAPIKey.ApiKey);
await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetCurrentUser());
@ -846,6 +854,63 @@ namespace BTCPayServer.Tests
new CreateApplicationUserRequest() { Email = "test9@gmail.com", Password = "afewfoiewiou" });
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUpdateUsersViaAPI()
{
using var tester = CreateServerTester(newDb: true);
tester.PayTester.DisableRegistration = true;
await tester.StartAsync();
var unauthClient = new BTCPayServerClient(tester.PayTester.ServerUri);
// We have no admin, so it should work
var user = await unauthClient.CreateUser(
new CreateApplicationUserRequest { Email = "test@gmail.com", Password = "abceudhqw" });
Assert.Empty(user.Roles);
// We have no admin, so it should work
var admin = await unauthClient.CreateUser(
new CreateApplicationUserRequest { Email = "admin@gmail.com", Password = "abceudhqw", IsAdministrator = true });
Assert.Contains("ServerAdmin", admin.Roles);
var adminAcc = tester.NewAccount();
adminAcc.UserId = admin.Id;
adminAcc.IsAdmin = true;
var adminClient = await adminAcc.CreateClient(Policies.CanModifyProfile);
// Invalid email
await AssertValidationError(["Email"],
async () => await adminClient.UpdateCurrentUser(
new UpdateApplicationUserRequest { Email = "test@" }));
await AssertValidationError(["Email"],
async () => await adminClient.UpdateCurrentUser(
new UpdateApplicationUserRequest { Email = "Firstname Lastname <blah@example.com>" }));
// Duplicate email
await AssertValidationError(["Email"],
async () => await adminClient.UpdateCurrentUser(
new UpdateApplicationUserRequest { Email = "test@gmail.com" }));
// Invalid current password
await AssertValidationError(["CurrentPassword"],
async () => await adminClient.UpdateCurrentUser(
new UpdateApplicationUserRequest { Email = "test@gmail.com", CurrentPassword = "123", NewPassword = "abceudhqw123"}));
// Change properties with valid state
var changed = await adminClient.UpdateCurrentUser(
new UpdateApplicationUserRequest
{
Email = "administrator@gmail.com",
CurrentPassword = "abceudhqw",
NewPassword = "abceudhqw123",
Name = "Changed Admin",
ImageUrl = "avatar.jpg"
});
Assert.Equal("administrator@gmail.com", changed.Email);
Assert.Equal("Changed Admin", changed.Name);
Assert.Equal("avatar.jpg", changed.ImageUrl);
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUsePullPaymentViaAPI()

View file

@ -329,12 +329,27 @@
<span>Account</span>
</a>
<ul class="dropdown-menu py-0 w-100" aria-labelledby="Nav-Account">
<li class="p-3 border-bottom">
<strong class="d-block text-truncate" style="max-width:195px">@User.Identity.Name</strong>
@if (User.IsInRole(Roles.ServerAdmin))
<li class="p-3 border-bottom d-flex align-items-center gap-2">
@if (!string.IsNullOrEmpty(Model.UserImageUrl))
{
<div class="text-secondary">Administrator</div>
<img src="@Model.UserImageUrl" alt="Profile picture" class="profile-picture"/>
}
<div>
<strong class="d-block text-truncate" style="max-width:@(string.IsNullOrEmpty(Model.UserImageUrl) ? "195px" : "160px")">
@if (string.IsNullOrEmpty(Model.UserName))
{
@(User.Identity.Name)
}
else
{
@($"{Model.UserName} ({User.Identity.Name})")
}
</strong>
@if (User.IsInRole(Roles.ServerAdmin))
{
<div class="text-secondary">Administrator</div>
}
</div>
</li>
@if (!Theme.CustomTheme)
{

View file

@ -1,12 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
@ -15,10 +14,8 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Caching.Memory;
using NBitcoin;
using NBitcoin.Secp256k1;
namespace BTCPayServer.Components.MainNav
{
@ -31,6 +28,7 @@ namespace BTCPayServer.Components.MainNav
private readonly UserManager<ApplicationUser> _userManager;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
private readonly SettingsRepository _settingsRepository;
private readonly UriResolver _uriResolver;
private readonly IMemoryCache _cache;
public PoliciesSettings PoliciesSettings { get; }
@ -44,6 +42,7 @@ namespace BTCPayServer.Components.MainNav
PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
SettingsRepository settingsRepository,
IMemoryCache cache,
UriResolver uriResolver,
PoliciesSettings policiesSettings)
{
_storeRepo = storeRepo;
@ -53,6 +52,7 @@ namespace BTCPayServer.Components.MainNav
_storesController = storesController;
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
_settingsRepository = settingsRepository;
_uriResolver = uriResolver;
_cache = cache;
PoliciesSettings = policiesSettings;
}
@ -124,6 +124,16 @@ namespace BTCPayServer.Components.MainNav
vm.ArchivedAppsCount = apps.Count(a => a.Archived);
}
var user = await _userManager.GetUserAsync(HttpContext.User);
if (user != null)
{
var blob = user.GetBlob();
vm.UserName = blob?.Name;
vm.UserImageUrl = string.IsNullOrEmpty(blob?.ImageUrl)
? null
: await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob?.ImageUrl));
}
return View(vm);
}

View file

@ -13,6 +13,8 @@ namespace BTCPayServer.Components.MainNav
public bool AltcoinsBuild { get; set; }
public int ArchivedAppsCount { get; set; }
public string ContactUrl { get; set; }
public string UserName { get; set; }
public string UserImageUrl { get; set; }
}
public class StoreApp

View file

@ -3,6 +3,7 @@ using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
@ -39,6 +40,7 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly BTCPayServerOptions _options;
private readonly IAuthorizationService _authorizationService;
private readonly UserService _userService;
private readonly UriResolver _uriResolver;
public GreenfieldUsersController(UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager,
@ -50,6 +52,7 @@ namespace BTCPayServer.Controllers.Greenfield
BTCPayServerOptions options,
IAuthorizationService authorizationService,
UserService userService,
UriResolver uriResolver,
Logs logs)
{
this.Logs = logs;
@ -63,6 +66,7 @@ namespace BTCPayServer.Controllers.Greenfield
_options = options;
_authorizationService = authorizationService;
_userService = userService;
_uriResolver = uriResolver;
}
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@ -127,6 +131,99 @@ namespace BTCPayServer.Controllers.Greenfield
return await FromModel(user!);
}
[Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPut("~/api/v1/users/me")]
public async Task<IActionResult> UpdateCurrentUser(UpdateApplicationUserRequest request, CancellationToken cancellationToken = default)
{
var user = await _userManager.GetUserAsync(User);
if (User.Identity is null || user is null)
return this.CreateAPIError(401, "unauthenticated", "User is not authenticated");
if (!string.IsNullOrEmpty(request.Email) && !request.Email.IsValidEmail())
{
ModelState.AddModelError(nameof(request.Email), "Invalid email");
}
bool needUpdate = false;
var setNewPassword = !string.IsNullOrEmpty(request.NewPassword);
if (setNewPassword)
{
if (!await _userManager.CheckPasswordAsync(user, request.CurrentPassword))
{
ModelState.AddModelError(nameof(request.CurrentPassword), "The current password is not correct.");
}
else
{
var passwordValidation = await _passwordValidator.ValidateAsync(_userManager, user, request.NewPassword);
if (passwordValidation.Succeeded)
{
var setUserResult = await _userManager.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword);
if (!setUserResult.Succeeded)
{
ModelState.AddModelError(nameof(request.Email), "Unexpected error occurred setting password for user.");
}
}
else
{
foreach (var error in passwordValidation.Errors)
{
ModelState.AddModelError(nameof(request.NewPassword), error.Description);
}
}
}
}
var email = user.Email;
if (!string.IsNullOrEmpty(request.Email) && request.Email != email)
{
var setUserResult = await _userManager.SetUserNameAsync(user, request.Email);
if (!setUserResult.Succeeded)
{
ModelState.AddModelError(nameof(request.Email), "Unexpected error occurred setting email for user.");
}
var setEmailResult = await _userManager.SetEmailAsync(user, request.Email);
if (!setEmailResult.Succeeded)
{
ModelState.AddModelError(nameof(request.Email), "Unexpected error occurred setting email for user.");
}
}
var blob = user.GetBlob() ?? new();
if (request.Name is not null && request.Name != blob.Name)
{
blob.Name = request.Name;
needUpdate = true;
}
if (request.ImageUrl is not null && request.ImageUrl != blob.ImageUrl)
{
blob.ImageUrl = request.ImageUrl;
needUpdate = true;
}
user.SetBlob(blob);
if (ModelState.IsValid && needUpdate)
{
var identityResult = await _userManager.UpdateAsync(user);
if (!identityResult.Succeeded)
{
foreach (var error in identityResult.Errors)
{
if (error.Code == "DuplicateUserName")
ModelState.AddModelError(nameof(request.Email), error.Description);
else
ModelState.AddModelError(string.Empty, error.Description);
}
}
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var model = await FromModel(user);
return Ok(model);
}
[Authorize(Policy = Policies.CanDeleteUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/users/me")]
public async Task<IActionResult> DeleteCurrentUser()
@ -187,6 +284,10 @@ namespace BTCPayServer.Controllers.Greenfield
Created = DateTimeOffset.UtcNow,
Approved = isAdmin // auto-approve first admin and users created by an admin
};
var blob = user.GetBlob() ?? new();
blob.Name = request.Name;
blob.ImageUrl = request.ImageUrl;
user.SetBlob(blob);
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
if (!passwordValidation.Succeeded)
{
@ -286,7 +387,11 @@ namespace BTCPayServer.Controllers.Greenfield
private async Task<ApplicationUserData> FromModel(ApplicationUser data)
{
var roles = (await _userManager.GetRolesAsync(data)).ToArray();
return UserService.FromModel(data, roles);
var model = UserService.FromModel(data, roles);
model.ImageUrl = string.IsNullOrEmpty(model.ImageUrl)
? null
: await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(model.ImageUrl));
return model;
}
}
}

View file

@ -875,6 +875,11 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldStoreOnChainWalletsController>().GetOnChainFeeRate(storeId, cryptoCode, blockTarget));
}
public override async Task<ApplicationUserData> UpdateCurrentUser(UpdateApplicationUserRequest request, CancellationToken token = default)
{
return GetFromActionResult<ApplicationUserData>(await GetController<GreenfieldUsersController>().UpdateCurrentUser(request, token));
}
public override async Task DeleteCurrentUser(CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldUsersController>().DeleteCurrentUser());

View file

@ -2,6 +2,8 @@ using System;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Fido2;
@ -41,6 +43,8 @@ namespace BTCPayServer.Controllers
private readonly UserLoginCodeService _userLoginCodeService;
private readonly IHtmlHelper Html;
private readonly UserService _userService;
private readonly UriResolver _uriResolver;
private readonly IFileService _fileService;
readonly StoreRepository _StoreRepository;
public UIManageController(
@ -56,6 +60,8 @@ namespace BTCPayServer.Controllers
Fido2Service fido2Service,
LinkGenerator linkGenerator,
UserService userService,
UriResolver uriResolver,
IFileService fileService,
UserLoginCodeService userLoginCodeService,
IHtmlHelper htmlHelper
)
@ -73,6 +79,8 @@ namespace BTCPayServer.Controllers
_userLoginCodeService = userLoginCodeService;
Html = htmlHelper;
_userService = userService;
_uriResolver = uriResolver;
_fileService = fileService;
_StoreRepository = storeRepository;
}
@ -84,12 +92,14 @@ namespace BTCPayServer.Controllers
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var blob = user.GetBlob() ?? new();
var model = new IndexViewModel
{
Username = user.UserName,
Email = user.Email,
IsEmailConfirmed = user.EmailConfirmed
Name = blob.Name,
ImageUrl = string.IsNullOrEmpty(blob.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
EmailConfirmed = user.EmailConfirmed,
RequiresEmailConfirmation = user.RequiresEmailConfirmation
};
return View(model);
}
@ -105,7 +115,7 @@ namespace BTCPayServer.Controllers
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var blob = user.GetBlob();
var blob = user.GetBlob() ?? new();
blob.ShowInvoiceStatusChangeHint = false;
user.SetBlob(blob);
await _userManager.UpdateAsync(user);
@ -114,19 +124,15 @@ namespace BTCPayServer.Controllers
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Index(IndexViewModel model)
public async Task<IActionResult> Index(IndexViewModel model, [FromForm] bool RemoveImageFile = false)
{
if (!ModelState.IsValid)
{
return View(model);
}
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
bool needUpdate = false;
var email = user.Email;
if (model.Email != email)
{
@ -145,8 +151,72 @@ namespace BTCPayServer.Controllers
{
throw new ApplicationException($"Unexpected error occurred setting email for user with ID '{user.Id}'.");
}
needUpdate = true;
}
TempData[WellKnownTempData.SuccessMessage] = "Your profile has been updated";
var blob = user.GetBlob() ?? new();
if (blob.Name != model.Name)
{
blob.Name = model.Name;
needUpdate = true;
}
if (model.ImageFile != null)
{
if (model.ImageFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(model.ImageFile), "The uploaded image file should be less than 1MB");
}
else if (!model.ImageFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(model.ImageFile), "The uploaded file needs to be an image");
}
else
{
var formFile = await model.ImageFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(model.ImageFile), "The uploaded file needs to be an image");
}
else
{
model.ImageFile = formFile;
// add new image
try
{
var storedFile = await _fileService.AddFile(model.ImageFile, user.Id);
var fileIdUri = new UnresolvedUri.FileIdUri(storedFile.Id);
blob.ImageUrl = fileIdUri.ToString();
needUpdate = true;
}
catch (Exception e)
{
ModelState.AddModelError(nameof(model.ImageFile), $"Could not save image: {e.Message}");
}
}
}
}
else if (RemoveImageFile && !string.IsNullOrEmpty(blob.ImageUrl))
{
blob.ImageUrl = null;
needUpdate = true;
}
user.SetBlob(blob);
if (!ModelState.IsValid)
{
return View(model);
}
if (needUpdate is true)
{
needUpdate = await _userManager.UpdateAsync(user) is { Succeeded: true };
TempData[WellKnownTempData.SuccessMessage] = "Your profile has been updated";
}
else
{
TempData[WellKnownTempData.ErrorMessage] = "Error updating profile";
}
return RedirectToAction(nameof(Index));
}

View file

@ -51,15 +51,17 @@ namespace BTCPayServer.Controllers
}
model.Roles = roleManager.Roles.ToDictionary(role => role.Id, role => role.Name);
model.Users = await usersQuery
model.Users = (await usersQuery
.Include(user => user.UserRoles)
.Include(user => user.UserStores)
.ThenInclude(data => data.StoreData)
.Skip(model.Skip)
.Take(model.Count)
.ToListAsync())
.Select(u => new UsersViewModel.UserViewModel
{
Name = u.UserName,
Name = u.GetBlob()?.Name,
ImageUrl = u.GetBlob()?.ImageUrl,
Email = u.Email,
Id = u.Id,
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
@ -69,8 +71,7 @@ namespace BTCPayServer.Controllers
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime,
Stores = u.UserStores.OrderBy(s => !s.StoreData.Archived).ToList()
})
.ToListAsync();
.ToList();
return View(model);
}
@ -81,10 +82,13 @@ namespace BTCPayServer.Controllers
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
var blob = user.GetBlob();
var model = new UsersViewModel.UserViewModel
{
Id = user.Id,
Email = user.Email,
Name = blob?.Name,
ImageUrl = string.IsNullOrEmpty(blob?.ImageUrl) ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null,
IsAdmin = Roles.HasServerAdmin(roles)
@ -93,7 +97,7 @@ namespace BTCPayServer.Controllers
}
[HttpPost("server/users/{userId}")]
public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel)
public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel, [FromForm] bool RemoveImageFile = false)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
@ -113,6 +117,54 @@ namespace BTCPayServer.Controllers
propertiesChanged = true;
}
var blob = user.GetBlob() ?? new();
if (blob.Name != viewModel.Name)
{
blob.Name = viewModel.Name;
propertiesChanged = true;
}
if (viewModel.ImageFile != null)
{
if (viewModel.ImageFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(viewModel.ImageFile), "The uploaded image file should be less than 1MB");
}
else if (!viewModel.ImageFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(viewModel.ImageFile), "The uploaded file needs to be an image");
}
else
{
var formFile = await viewModel.ImageFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(viewModel.ImageFile), "The uploaded file needs to be an image");
}
else
{
viewModel.ImageFile = formFile;
// add new image
try
{
var storedFile = await _fileService.AddFile(viewModel.ImageFile, userId);
var fileIdUri = new UnresolvedUri.FileIdUri(storedFile.Id);
blob.ImageUrl = fileIdUri.ToString();
propertiesChanged = true;
}
catch (Exception e)
{
ModelState.AddModelError(nameof(viewModel.ImageFile), $"Could not save image: {e.Message}");
}
}
}
}
else if (RemoveImageFile && !string.IsNullOrEmpty(blob.ImageUrl))
{
blob.ImageUrl = null;
propertiesChanged = true;
}
user.SetBlob(blob);
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var roles = await _UserManager.GetRolesAsync(user);
var wasAdmin = Roles.HasServerAdmin(roles);

View file

@ -137,9 +137,11 @@ public partial class UIStoresController
{
var users = await _storeRepo.GetStoreUsers(CurrentStore.Id);
vm.StoreId = CurrentStore.Id;
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel()
vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel
{
Email = u.Email,
Name = u.UserBlob.Name,
ImageUrl = u.UserBlob.ImageUrl,
Id = u.Id,
Role = u.StoreRole.Role
}).ToList();

View file

@ -1,21 +1,20 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http;
namespace BTCPayServer.Models.ManageViewModels
{
public class IndexViewModel
{
public string Username { get; set; }
[Required]
[EmailAddress]
[MaxLength(50)]
public string Email
{
get; set;
}
public bool IsEmailConfirmed { get; set; }
public string Email { get; set; }
public bool EmailConfirmed { get; set; }
public bool RequiresEmailConfirmation { get; set; }
public string Name { get; set; }
[Display(Name = "Profile Picture")]
public IFormFile ImageFile { get; set; }
public string ImageUrl { get; set; }
}
}

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Http;
namespace BTCPayServer.Models.ServerViewModels
{
@ -10,8 +12,12 @@ namespace BTCPayServer.Models.ServerViewModels
public class UserViewModel
{
public string Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public string Name { get; set; }
[Display(Name = "Image")]
public IFormFile ImageFile { get; set; }
public string ImageUrl { get; set; }
public bool? EmailConfirmed { get; set; }
public bool? Approved { get; set; }
public bool Disabled { get; set; }
@ -20,13 +26,13 @@ namespace BTCPayServer.Models.ServerViewModels
public IEnumerable<string> Roles { get; set; }
public IEnumerable<UserStore> Stores { get; set; }
}
public List<UserViewModel> Users { get; set; } = new List<UserViewModel>();
public List<UserViewModel> Users { get; set; } = [];
public override int CurrentPageCount => Users.Count;
public Dictionary<string, string> Roles { get; set; }
}
public class RolesViewModel : BasePagingViewModel
{
public List<StoreRepository.StoreRole> Roles { get; set; } = new List<StoreRepository.StoreRole>();
public List<StoreRepository.StoreRole> Roles { get; set; } = [];
public string DefaultRole { get; set; }
public override int CurrentPageCount => Roles.Count;
}

View file

@ -9,6 +9,8 @@ namespace BTCPayServer.Models.StoreViewModels
{
public string Email { get; set; }
public string Role { get; set; }
public string Name { get; set; }
public string ImageUrl { get; set; }
public string Id { get; set; }
}
[Required]

View file

@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection.Metadata;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client;
@ -61,6 +62,7 @@ namespace BTCPayServer.Services.Stores
public string Id { get; set; }
public string Email { get; set; }
public StoreRole StoreRole { get; set; }
public UserBlob UserBlob { get; set; }
}
public class StoreRole
@ -188,7 +190,7 @@ namespace BTCPayServer.Services.Stores
.Select(u => new
{
Id = u.ApplicationUserId,
u.ApplicationUser.Email,
u.ApplicationUser,
u.StoreRole
})
.Where(u => roles == null || roles.Contains(u.StoreRole.Id))
@ -196,7 +198,8 @@ namespace BTCPayServer.Services.Stores
{
StoreRole = ToStoreRole(arg.StoreRole),
Id = arg.Id,
Email = arg.Email
Email = arg.ApplicationUser.Email,
UserBlob = arg.ApplicationUser.GetBlob() ?? new()
}).ToArray();
}

View file

@ -49,6 +49,7 @@ namespace BTCPayServer.Services
public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles)
{
var blob = data.GetBlob() ?? new();
return new ApplicationUserData
{
Id = data.Id,
@ -58,6 +59,8 @@ namespace BTCPayServer.Services
Approved = data.Approved,
RequiresApproval = data.RequiresApproval,
Created = data.Created,
Name = blob.Name,
ImageUrl = blob.ImageUrl,
Roles = roles,
Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime
};

View file

@ -1,11 +1,14 @@
@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.Models
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject IFileService FileService
@model IndexViewModel
@{
ViewData.SetActivePage(ManageNavPages.Index, "Update your account");
var canUpload = await FileService.IsAvailable();
}
<form method="post">
<form method="post" enctype="multipart/form-data">
<div class="sticky-header">
<h2>@ViewData["Title"]</h2>
<button type="submit" id="save" class="btn btn-primary">Save</button>
@ -14,34 +17,56 @@
<div class="col-xxl-constrain col-xl-8">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="All"></div>
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
<div class="form-group">
<div class="col-md-6">
<label asp-for="Username" class="form-label"></label>
<input asp-for="Username" class="form-control" disabled />
</div>
</div>
<div class="form-group row">
<div class="col-md-6">
<div class="d-flex align-items-center justify-content-between gap-3">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="col-md-6 d-flex align-items-end">
@if (Model.IsEmailConfirmed)
@if (Model.RequiresEmailConfirmation)
{
<span class="badge bg-success p-2 my-1">
<button asp-action="SendVerificationEmail" class="d-inline-flex align-items-center gap-1 btn btn-link p-0">
<vc:icon symbol="actions-email" />
Send verification email
</button>
}
else if (Model.EmailConfirmed)
{
<span class="d-inline-flex align-items-center gap-1 text-success">
<vc:icon symbol="checkmark" />
confirmed
</span>
}
else
{
<button asp-action="SendVerificationEmail" class="btn btn-secondary">Send verification email</button>
}
</div>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
@if (canUpload)
{
<div class="form-group">
<div class="d-flex align-items-center justify-content-between gap-2">
<label asp-for="ImageFile" class="form-label"></label>
@if (!string.IsNullOrEmpty(Model.ImageUrl))
{
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveImageFile" value="true">
<vc:icon symbol="cross" /> Remove
</button>
}
</div>
<div class="d-flex align-items-center gap-3">
<input asp-for="ImageFile" type="file" class="form-control flex-grow">
@if (!string.IsNullOrEmpty(Model.ImageUrl))
{
<img src="@Model.ImageUrl" alt="Profile picture" class="profile-picture"/>
}
</div>
<span asp-validation-for="ImageFile" class="text-danger"></span>
</div>
}
<h3 class="mt-5 mb-4">Delete Account</h3>
<div id="danger-zone">
<a id="delete-user" class="btn btn-outline-danger mb-5" data-confirm-input="DELETE" data-bs-toggle="modal" data-bs-target="#ConfirmModal" asp-action="DeleteUserPost" data-description="This action will also delete all stores, invoices, apps and data associated with the user.">Delete Account</a>

View file

@ -1,9 +1,13 @@
@model UsersViewModel.UserViewModel
@using BTCPayServer.Abstractions.Contracts
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model UsersViewModel.UserViewModel
@inject IFileService FileService
@{
ViewData.SetActivePage(ServerNavPages.Users, Model.Email);
var canUpload = await FileService.IsAvailable();
}
<form method="post">
<form method="post" enctype="multipart/form-data">
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
@ -17,13 +21,45 @@
<button name="command" type="submit" class="btn btn-primary" value="Save" id="SaveUser">Save</button>
</div>
<partial name="_StatusMessage" />
<div class="form-check">
<div class="form-group">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="form-group">
<div class="d-flex align-items-center justify-content-between gap-2">
<label asp-for="ImageFile" class="form-label"></label>
@if (!string.IsNullOrEmpty(Model.ImageUrl))
{
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveImageFile" value="true">
<vc:icon symbol="cross" /> Remove
</button>
}
</div>
@if (canUpload)
{
<div class="d-flex align-items-center gap-3">
<input asp-for="ImageFile" type="file" class="form-control flex-grow">
@if (!string.IsNullOrEmpty(Model.ImageUrl))
{
<img src="@Model.ImageUrl" alt="Profile picture" class="profile-picture" />
}
</div>
<span asp-validation-for="ImageFile" class="text-danger"></span>
}
else
{
<input asp-for="ImageFile" type="file" class="form-control" disabled>
<div class="form-text">In order to upload an image, a <a asp-controller="UIServer" asp-action="Files">file storage</a> must be configured.</div>
}
</div>
<div class="form-check my-3">
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
<label asp-for="IsAdmin" class="form-check-label">User is admin</label>
</div>
@if (Model.Approved.HasValue)
{
<div class="form-check">
<div class="form-check my-3">
<input id="Approved" name="Approved" type="checkbox" value="true" class="form-check-input" @(Model.Approved.Value ? "checked" : "") />
<label for="Approved" class="form-check-label">User is approved</label>
</div>
@ -31,7 +67,7 @@
}
@if (Model.EmailConfirmed.HasValue)
{
<div class="form-check">
<div class="form-check my-3">
<input id="EmailConfirmed" name="EmailConfirmed" value="true" type="checkbox" class="form-check-input" @(Model.EmailConfirmed.Value ? "checked" : "") />
<label for="EmailConfirmed" class="form-check-label">Email address is confirmed</label>
</div>

View file

@ -45,6 +45,7 @@
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th>Role</th>
<th class="actions-col" permission="@Policies.CanModifyStoreSettings">Actions</th>
</tr>
@ -54,6 +55,7 @@
{
<tr>
<td>@user.Email</td>
<td>@user.Name</td>
<td>@user.Role</td>
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
<div class="d-inline-flex align-items-center gap-3">
@ -114,7 +116,6 @@
})()
</script>
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />
}

View file

@ -47,6 +47,13 @@
padding-left: 0;
}
.profile-picture {
height: var(--profile-picture-size, 2.1rem);
width: var(--profile-picture-size, 2.1rem);
border-radius: 50%;
object-fit: cover;
}
/* General and site-wide Bootstrap modifications */
p {
margin-bottom: 1.5rem;

View file

@ -32,6 +32,77 @@
}
]
},
"put": {
"tags": [
"Users"
],
"summary": "Update current user information",
"description": "Update the current user",
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"email": {
"type": "string",
"description": "The email of the user",
"nullable": true
},
"name": {
"type": "string",
"description": "The name of the user",
"nullable": true
},
"imageUrl": {
"type": "string",
"description": "The profile picture URL of the user",
"nullable": true
},
"currentPassword": {
"type": "string",
"description": "The current password of the user",
"nullable": true
},
"newPassword": {
"type": "string",
"description": "The new password of the user",
"nullable": true
}
}
}
}
},
"required": true,
"x-position": 1
},
"operationId": "Users_UpdateCurrentUser",
"responses": {
"200": {
"description": "Information about the current user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplicationUserData"
}
}
}
},
"404": {
"description": "The user could not be found"
}
},
"security": [
{
"API_Key": [
"btcpay.user.canmodifyprofile"
],
"Basic": []
}
]
},
"delete": {
"tags": [
"Users"
@ -106,6 +177,16 @@
"description": "The email of the new user",
"nullable": false
},
"name": {
"type": "string",
"description": "The name of the new user",
"nullable": true
},
"imageUrl": {
"type": "string",
"description": "The profile picture URL of the new user",
"nullable": true
},
"password": {
"type": "string",
"description": "The password of the new user (if no password is set, an email will be sent to the user requiring him to set the password)",
@ -186,7 +267,14 @@
],
"responses": {
"200": {
"description": "User found"
"description": "User found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplicationUserData"
}
}
}
},
"401": {
"description": "Missing authorization for loading the user"
@ -372,6 +460,16 @@
"description": "The email of the user",
"nullable": false
},
"name": {
"type": "string",
"description": "The name of the user",
"nullable": true
},
"imageUrl": {
"type": "string",
"description": "The profile picture URL of the user",
"nullable": true
},
"emailConfirmed": {
"type": "boolean",
"description": "True if the email has been confirmed by the user"