Greenfield: Expand user data (#6649)

This commit is contained in:
d11n
2025-04-04 09:15:33 +02:00
committed by GitHub
parent 65629f40b3
commit a916ae81be
11 changed files with 135 additions and 94 deletions

View File

@@ -50,6 +50,11 @@ namespace BTCPayServer.Client.Models
/// </summary>
public string[] Roles { get; set; }
/// <summary>
/// the invitation url of the user
/// </summary>
public string InvitationUrl { get; set; }
/// <summary>
/// the date the user was created. Null if created before v1.0.5.6.
/// </summary>

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models
{
@@ -10,32 +12,24 @@ namespace BTCPayServer.Client.Models
public string Id { get; set; }
}
public class StoreUserData
public class StoreUserData : ApplicationUserData
{
/// <summary>
/// the id of the user
/// </summary>
[Obsolete("Use Id instead")]
public string UserId { get; set; }
/// <summary>
/// the store role of the user
/// </summary>
public string StoreRole { get; set; }
/// <summary>
/// the store role of the user
/// </summary>
[Obsolete("Use StoreRole instead")]
public string Role { get; set; }
/// <summary>
/// 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; }
}
public class RoleData

View File

@@ -248,7 +248,7 @@ namespace BTCPayServer.Tests
await Assert.ThrowsAsync<GreenfieldAPIException>(() => newUserClient.GetInvoices(store.Id));
// if user is a guest or owner, then it should be ok
await unrestricted.AddStoreUser(store.Id, new StoreUserData() { UserId = newUser.Id });
await unrestricted.AddStoreUser(store.Id, new StoreUserData() { Id = newUser.Id });
await newUserClient.GetInvoices(store.Id);
}
@@ -4017,8 +4017,8 @@ namespace BTCPayServer.Tests
#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);
Assert.Equal(user.UserId, storeUser.Id);
Assert.Equal(ownerRole.Id, storeUser.StoreRole);
Assert.Equal(user.Email, storeUser.Email);
Assert.Equal("The Admin", storeUser.Name);
Assert.Equal("avatar.jpg", storeUser.ImageUrl);
@@ -4050,15 +4050,15 @@ namespace BTCPayServer.Tests
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await guestClient.RemoveStoreUser(user.StoreId, user.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 { StoreRole = managerRole.Id, Id = manager.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = employeeRole.Id, Id = employee.UserId });
// add with email
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = guestRole.Id, UserId = guest.Email });
await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = guestRole.Id, Id = guest.Email });
// test unknown user
await AssertAPIError("user-not-found", async () => await client.AddStoreUser(user.StoreId, new StoreUserData { Role = managerRole.Id, UserId = "unknown" }));
await AssertAPIError("user-not-found", async () => await client.UpdateStoreUser(user.StoreId, "unknown", new StoreUserData { Role = ownerRole.Id }));
await AssertAPIError("user-not-found", async () => await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = managerRole.Id, Id = "unknown" }));
await AssertAPIError("user-not-found", async () => await client.UpdateStoreUser(user.StoreId, "unknown", new StoreUserData { StoreRole = ownerRole.Id }));
await AssertAPIError("user-not-found", async () => await client.RemoveStoreUser(user.StoreId, "unknown"));
//test no access to api for employee
@@ -4080,7 +4080,7 @@ namespace BTCPayServer.Tests
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
// updates
await client.UpdateStoreUser(user.StoreId, employee.UserId, new StoreUserData { Role = ownerRole.Id });
await client.UpdateStoreUser(user.StoreId, employee.UserId, new StoreUserData { StoreRole = ownerRole.Id });
await employeeClient.GetStore(user.StoreId);
// remove
@@ -4088,9 +4088,9 @@ namespace BTCPayServer.Tests
await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId));
// test duplicate add
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = ownerRole.Id, Id = employee.UserId });
await AssertAPIError("duplicate-store-user-role", async () =>
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = ownerRole.Id, UserId = employee.UserId }));
await client.AddStoreUser(user.StoreId, new StoreUserData { StoreRole = ownerRole.Id, Id = employee.UserId }));
await employeeClient.RemoveStoreUser(user.StoreId, user.UserId);
//test no access to api when unrelated to store at all

View File

@@ -194,5 +194,5 @@
</Content>
</ItemGroup>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1misc_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-payment-methods_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1invoices_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1misc_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-payment-methods_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores-users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1users_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1webhooks_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
</Project>

View File

@@ -1,4 +1,7 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Reflection.Metadata;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@@ -11,6 +14,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using NicolasDorier.RateLimits;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield
@@ -22,15 +26,18 @@ namespace BTCPayServer.Controllers.Greenfield
{
private readonly StoreRepository _storeRepository;
private readonly UserManager<ApplicationUser> _userManager;
private readonly CallbackGenerator _callbackGenerator;
private readonly UriResolver _uriResolver;
public GreenfieldStoreUsersController(
StoreRepository storeRepository,
UserManager<ApplicationUser> userManager,
CallbackGenerator callbackGenerator,
UriResolver uriResolver)
{
_storeRepository = storeRepository;
_userManager = userManager;
_callbackGenerator = callbackGenerator;
_uriResolver = uriResolver;
}
@@ -47,10 +54,12 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail)
{
var store = HttpContext.GetStoreData();
if (store == null) return StoreNotFound();
if (store == null)
return StoreNotFound();
var user = await _userManager.FindByIdOrEmail(idOrEmail);
if (user == null) return UserNotFound();
if (user == null)
return UserNotFound();
return await _storeRepository.RemoveStoreUser(storeId, user.Id)
? Ok()
@@ -63,17 +72,23 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> AddOrUpdateStoreUser(string storeId, StoreUserData request, string idOrEmail = null)
{
var store = HttpContext.GetStoreData();
if (store == null) return StoreNotFound();
if (store == null)
return StoreNotFound();
#pragma warning disable CS0618 // Type or member is obsolete
request.StoreRole ??= request.Role;
request.Id ??= request.UserId;
#pragma warning restore CS0618 // Type or member is obsolete
var user = await _userManager.FindByIdOrEmail(idOrEmail ?? request.UserId);
if (user == null) return UserNotFound();
var user = await _userManager.FindByIdOrEmail(idOrEmail ?? request.Id);
if (user == null)
return UserNotFound();
StoreRoleId roleId = null;
if (request.Role is not null)
if (request.StoreRole is not null)
{
roleId = await _storeRepository.ResolveStoreRoleId(storeId, request.Role);
roleId = await _storeRepository.ResolveStoreRoleId(storeId, request.StoreRole);
if (roleId is null)
ModelState.AddModelError(nameof(request.Role), "The role id provided does not exist");
ModelState.AddModelError(nameof(request.StoreRole), "The role id provided does not exist");
}
if (!ModelState.IsValid)
@@ -87,21 +102,21 @@ namespace BTCPayServer.Controllers.Greenfield
: this.CreateAPIError(409, "duplicate-store-user-role", "The user is already added to the store");
}
private async Task<IEnumerable<StoreUserData>> FromModel(StoreData data)
private async Task<IEnumerable<StoreUserData>> FromModel(StoreData store)
{
var storeUsers = new List<StoreUserData>();
foreach (var storeUser in data.UserStores)
foreach (var storeUser in store.UserStores)
{
var user = await _userManager.FindByIdOrEmail(storeUser.ApplicationUserId);
var blob = user?.GetBlob();
storeUsers.Add(new StoreUserData
{
UserId = storeUser.ApplicationUserId,
Role = storeUser.StoreRoleId,
Email = user?.Email,
Name = blob?.Name,
ImageUrl = blob?.ImageUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl))
});
if (user == null)
continue;
var data = await UserService.ForAPI<StoreUserData>(user, [], _callbackGenerator, _uriResolver, Request);
data.StoreRole = storeUser.StoreRoleId;
#pragma warning disable CS0618 // Type or member is obsolete
data.UserId = storeUser.ApplicationUserId;
data.Role = storeUser.StoreRoleId;
#pragma warning restore CS0618 // Type or member is obsolete
storeUsers.Add(data);
}
return storeUsers;
}

View File

@@ -1,5 +1,6 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -84,7 +85,7 @@ namespace BTCPayServer.Controllers.Greenfield
var user = await _userManager.FindByIdOrEmail(idOrEmail);
if (user != null)
{
return Ok(await FromModel(user));
return Ok(await ForAPI(user));
}
return this.UserNotFound();
}
@@ -128,7 +129,13 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpGet("~/api/v1/users/")]
public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
{
return Ok(await _userService.GetUsersWithRoles());
var usersWithRoles = await _userService.GetUsersWithRoles();
List<ApplicationUserData> users = [];
foreach (var user in usersWithRoles)
{
users.Add(await UserService.ForAPI<ApplicationUserData>(user.User, user.Roles, _callbackGenerator, _uriResolver, Request));
}
return Ok(users);
}
[Authorize(Policy = Policies.CanViewProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -136,7 +143,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<ActionResult<ApplicationUserData>> GetCurrentUser()
{
var user = await _userManager.GetUserAsync(User);
return await FromModel(user!);
return await ForAPI(user!);
}
[Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -232,7 +239,7 @@ namespace BTCPayServer.Controllers.Greenfield
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var model = await FromModel(user);
var model = await ForAPI(user);
return Ok(model);
}
@@ -264,7 +271,7 @@ namespace BTCPayServer.Controllers.Greenfield
user.SetBlob(blob);
await _userManager.UpdateAsync(user);
_eventAggregator.Publish(new UserEvent.Updated(user));
var model = await FromModel(user);
var model = await ForAPI(user);
return Ok(model);
}
catch (Exception e)
@@ -414,7 +421,7 @@ namespace BTCPayServer.Controllers.Greenfield
};
_eventAggregator.Publish(userEvent);
var model = await FromModel(user);
var model = await ForAPI(user);
return CreatedAtAction(string.Empty, model);
}
@@ -449,14 +456,11 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok();
}
private async Task<ApplicationUserData> FromModel(ApplicationUser data)
private async Task<ApplicationUserData> ForAPI(ApplicationUser data)
{
var roles = (await _userManager.GetRolesAsync(data)).ToArray();
var model = UserService.FromModel(data, roles);
model.ImageUrl = string.IsNullOrEmpty(model.ImageUrl)
? null
: await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), UnresolvedUri.Create(model.ImageUrl));
return model;
var blob = data.GetBlob();
return await UserService.ForAPI<ApplicationUserData>(data, roles, _callbackGenerator, _uriResolver, Request);
}
}
}

View File

@@ -70,7 +70,7 @@ namespace BTCPayServer.Controllers
InvitationUrl =
string.IsNullOrEmpty(blob?.InvitationToken)
? null
: _callbackGenerator.ForInvitation(u, blob.InvitationToken, Request),
: _callbackGenerator.ForInvitation(u.Id, blob.InvitationToken, Request),
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created,
@@ -97,7 +97,7 @@ namespace BTCPayServer.Controllers
Id = user.Id,
Email = user.Email,
Name = blob?.Name,
InvitationUrl = string.IsNullOrEmpty(blob?.InvitationToken) ? null : _callbackGenerator.ForInvitation(user, blob.InvitationToken, Request),
InvitationUrl = string.IsNullOrEmpty(blob?.InvitationToken) ? null : _callbackGenerator.ForInvitation(user.Id, blob.InvitationToken, Request),
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,

View File

@@ -49,12 +49,12 @@ namespace BTCPayServer.Services
public async Task<string> ForInvitation(ApplicationUser user, HttpRequest request)
{
var code = await UserManager.GenerateInvitationTokenAsync<ApplicationUser>(user.Id) ?? throw Bug();
return ForInvitation(user, code, request);
return ForInvitation(user.Id, code, request);
}
public string ForInvitation(ApplicationUser user, string code, HttpRequest request)
public string ForInvitation(string userId, string code, HttpRequest request)
{
return LinkGenerator.GetUriByAction(nameof(UIAccountController.AcceptInvite), "UIAccount",
new { userId = user.Id, code }, request.Scheme, request.Host, request.PathBase) ?? throw Bug();
new { userId, code }, request.Scheme, request.Host, request.PathBase) ?? throw Bug();
}
public async Task<string> ForPasswordReset(ApplicationUser user, HttpRequest request)
{

View File

@@ -4,10 +4,12 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Storage.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -40,17 +42,30 @@ namespace BTCPayServer.Services
_logger = logger;
}
public async Task<List<ApplicationUserData>> GetUsersWithRoles()
public record ApplicationUserWithRoles(ApplicationUser User, string[] Roles);
public async Task<List<ApplicationUserWithRoles>> GetUsersWithRoles()
{
await using var context = _applicationDbContextFactory.CreateContext();
return await (context.Users.Select(p => FromModel(p, p.UserRoles.Join(context.Roles, userRole => userRole.RoleId, role => role.Id,
(userRole, role) => role.Name).ToArray()))).ToListAsync();
var res = await context.Users.Select(p =>
new
{
User = p,
Roles = p.UserRoles.Join(context.Roles, userRole => userRole.RoleId,
role => role.Id, (userRole, role) => role.Name).ToArray()
})
.ToListAsync();
return res.Select(p => new ApplicationUserWithRoles(p.User, (p.Roles ?? [])!)).ToList();
}
public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles)
public static async Task<T> ForAPI<T>(
ApplicationUser data,
string?[] roles,
CallbackGenerator callbackGenerator,
UriResolver uriResolver,
HttpRequest request) where T : ApplicationUserData, new()
{
var blob = data.GetBlob() ?? new();
return new ApplicationUserData
var blob = data.GetBlob() ?? new UserBlob();
return new T
{
Id = data.Id,
Email = data.Email,
@@ -60,9 +75,13 @@ namespace BTCPayServer.Services
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
Disabled = IsDisabled(data),
ImageUrl = string.IsNullOrEmpty(blob.ImageUrl)
? null
: await uriResolver.Resolve(request.GetAbsoluteRootUri(), UnresolvedUri.Create(blob.ImageUrl)),
InvitationUrl = string.IsNullOrEmpty(blob.InvitationToken) ? null
: callbackGenerator.ForInvitation(data.Id, blob.InvitationToken, request)
};
}
@@ -78,8 +97,8 @@ namespace BTCPayServer.Services
private static bool IsDisabled(ApplicationUser user)
{
return user.LockoutEnabled && user.LockoutEnd is not null &&
DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime;
return user is { LockoutEnabled: true, LockoutEnd: {} lockoutEnd } &&
DateTimeOffset.UtcNow < lockoutEnd.UtcDateTime;
}
public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error)

View File

@@ -235,30 +235,25 @@
"properties": {
"userId": {
"type": "string",
"description": "The id of the user",
"nullable": false
"description": "The id of the user (Deprecated, use `id` instead)",
"nullable": false,
"deprecated": true
},
"role": {
"type": "string",
"description": "The role of the user. Default roles are `Owner`, `Manager`, `Employee` and `Guest` (Deprecated, use `storeRole` instead)",
"nullable": false,
"deprecated": true
},
"storeRole": {
"type": "string",
"description": "The role of the user. Default roles are `Owner`, `Manager`, `Employee` and `Guest`",
"nullable": false
},
"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
}
}
},
{
"$ref": "#/components/schemas/ApplicationUserData"
}
]
}

View File

@@ -520,6 +520,11 @@
"description": "The profile picture URL of the user",
"nullable": true
},
"invitationUrl": {
"type": "string",
"description": "The pending invitation URL of the user",
"nullable": true
},
"emailConfirmed": {
"type": "boolean",
"description": "True if the email has been confirmed by the user"
@@ -546,6 +551,10 @@
}
]
},
"disabled": {
"type": "boolean",
"description": "True if an admin has disabled the user"
},
"roles": {
"type": "array",
"nullable": false,