Greenfield: Improve store users API (#6427)

* Greenfield: Improve store users API

- Adds an endpoint to update store users (before they had to be removed ad re-added)
- Checks for the existance of a user and responds with 404 in that case (fixes #6423)
- Allows retrieval of user by user id or email for add and update (consistent with the other endpoints)
- Improves the API docs for the store users endpoints

* Swagger: Reuse UserIdOrEmail parameter component

* Add details to store user data
This commit is contained in:
d11n
2024-12-02 15:35:33 +01:00
committed by GitHub
parent 9175af4abe
commit 898f0f4481
9 changed files with 200 additions and 90 deletions

View File

@@ -29,4 +29,10 @@ public partial class BTCPayServerClient
if (request == null) throw new ArgumentNullException(nameof(request)); if (request == null) throw new ArgumentNullException(nameof(request));
await SendHttpRequest<StoreUserData>($"api/v1/stores/{storeId}/users", request, HttpMethod.Post, token); await SendHttpRequest<StoreUserData>($"api/v1/stores/{storeId}/users", request, HttpMethod.Post, token);
} }
public virtual async Task UpdateStoreUser(string storeId, string userId, StoreUserData request, CancellationToken token = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
await SendHttpRequest<StoreUserData>($"api/v1/stores/{storeId}/users/{userId}", request, HttpMethod.Put, token);
}
} }

View File

@@ -17,7 +17,25 @@ namespace BTCPayServer.Client.Models
/// </summary> /// </summary>
public string UserId { get; set; } public string UserId { get; set; }
/// <summary>
/// the store role of the user
/// </summary>
public string Role { get; set; } 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 public class RoleData

View File

@@ -3985,7 +3985,12 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount(); var user = tester.NewAccount();
await user.GrantAccessAsync(true); await user.GrantAccessAsync(true);
var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings); var client = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanModifyServerSettings, Policies.CanModifyProfile);
await client.UpdateCurrentUser(new UpdateApplicationUserRequest
{
Name = "The Admin",
ImageUrl = "avatar.jpg"
});
var roles = await client.GetServerRoles(); var roles = await client.GetServerRoles();
Assert.Equal(4, roles.Count); Assert.Equal(4, roles.Count);
@@ -3999,6 +4004,9 @@ namespace BTCPayServer.Tests
var storeUser = Assert.Single(users); var storeUser = Assert.Single(users);
Assert.Equal(user.UserId, storeUser.UserId); Assert.Equal(user.UserId, storeUser.UserId);
Assert.Equal(ownerRole.Id, storeUser.Role); Assert.Equal(ownerRole.Id, storeUser.Role);
Assert.Equal(user.Email, storeUser.Email);
Assert.Equal("The Admin", storeUser.Name);
Assert.Equal("avatar.jpg", storeUser.ImageUrl);
var manager = tester.NewAccount(); var manager = tester.NewAccount();
await manager.GrantAccessAsync(); await manager.GrantAccessAsync();
var employee = tester.NewAccount(); var employee = tester.NewAccount();
@@ -4029,7 +4037,14 @@ namespace BTCPayServer.Tests
// add users to store // 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 = 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 = employeeRole.Id, UserId = employee.UserId });
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = guestRole.Id, UserId = guest.UserId });
// add with email
await client.AddStoreUser(user.StoreId, new StoreUserData { Role = guestRole.Id, UserId = 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.RemoveStoreUser(user.StoreId, "unknown"));
//test no access to api for employee //test no access to api for employee
await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId)); await AssertPermissionError(Policies.CanViewStoreSettings, async () => await employeeClient.GetStore(user.StoreId));
@@ -4050,9 +4065,14 @@ namespace BTCPayServer.Tests
await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId)); await AssertPermissionError(Policies.CanModifyStoreSettings, async () => await managerClient.RemoveStoreUser(user.StoreId, user.UserId));
// updates // updates
await client.UpdateStoreUser(user.StoreId, employee.UserId, new StoreUserData { Role = ownerRole.Id });
await employeeClient.GetStore(user.StoreId);
// remove
await client.RemoveStoreUser(user.StoreId, employee.UserId); await client.RemoveStoreUser(user.StoreId, employee.UserId);
await AssertHttpError(403, async () => await employeeClient.GetStore(user.StoreId)); 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 { Role = ownerRole.Id, UserId = employee.UserId });
await AssertAPIError("duplicate-store-user-role", async () => 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 { Role = ownerRole.Id, UserId = employee.UserId }));

View File

@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield namespace BTCPayServer.Controllers.Greenfield
{ {
@@ -30,10 +31,10 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/stores/{storeId}/users")] [HttpGet("~/api/v1/stores/{storeId}/users")]
public IActionResult GetStoreUsers() public async Task<IActionResult> GetStoreUsers()
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
return store == null ? StoreNotFound() : Ok(FromModel(store)); return store == null ? StoreNotFound() : Ok(await FromModel(store));
} }
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
@@ -41,31 +42,28 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail) public async Task<IActionResult> RemoveStoreUser(string storeId, string idOrEmail)
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) if (store == null) return StoreNotFound();
{
return StoreNotFound();
}
var userId = await _userManager.FindByIdOrEmail(idOrEmail); var user = await _userManager.FindByIdOrEmail(idOrEmail);
if (userId != null && await _storeRepository.RemoveStoreUser(storeId, idOrEmail)) if (user == null) return UserNotFound();
{
return Ok();
}
return this.CreateAPIError(409, "store-user-role-orphaned", "Removing this user would result in the store having no owner."); return await _storeRepository.RemoveStoreUser(storeId, user.Id)
? Ok()
: this.CreateAPIError(409, "store-user-role-orphaned", "Removing this user would result in the store having no owner.");
} }
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/users")] [HttpPost("~/api/v1/stores/{storeId}/users")]
public async Task<IActionResult> AddStoreUser(string storeId, StoreUserData request) [HttpPut("~/api/v1/stores/{storeId}/users/{idOrEmail?}")]
public async Task<IActionResult> AddOrUpdateStoreUser(string storeId, StoreUserData request, string idOrEmail = null)
{ {
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
if (store == null) if (store == null) return StoreNotFound();
{
return StoreNotFound();
}
StoreRoleId roleId = null;
var user = await _userManager.FindByIdOrEmail(idOrEmail ?? request.UserId);
if (user == null) return UserNotFound();
StoreRoleId roleId = null;
if (request.Role is not null) if (request.Role is not null)
{ {
roleId = await _storeRepository.ResolveStoreRoleId(storeId, request.Role); roleId = await _storeRepository.ResolveStoreRoleId(storeId, request.Role);
@@ -76,21 +74,42 @@ namespace BTCPayServer.Controllers.Greenfield
if (!ModelState.IsValid) if (!ModelState.IsValid)
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
if (await _storeRepository.AddStoreUser(storeId, request.UserId, roleId)) var result = string.IsNullOrEmpty(idOrEmail)
{ ? await _storeRepository.AddStoreUser(storeId, user.Id, roleId)
return Ok(); : await _storeRepository.AddOrUpdateStoreUser(storeId, user.Id, roleId);
return result
? Ok()
: this.CreateAPIError(409, "duplicate-store-user-role", "The user is already added to the store");
} }
return this.CreateAPIError(409, "duplicate-store-user-role", "The user is already added to the store"); private async Task<IEnumerable<StoreUserData>> FromModel(StoreData data)
{
var storeUsers = new List<StoreUserData>();
foreach (var storeUser in data.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,
});
}
return storeUsers;
} }
private IEnumerable<StoreUserData> FromModel(Data.StoreData data)
{
return data.UserStores.Select(store => new StoreUserData() { UserId = store.ApplicationUserId, Role = store.StoreRoleId });
}
private IActionResult StoreNotFound() private IActionResult StoreNotFound()
{ {
return this.CreateAPIError(404, "store-not-found", "The store was not found"); return this.CreateAPIError(404, "store-not-found", "The store was not found");
} }
private IActionResult UserNotFound()
{
return this.CreateAPIError(404, "user-not-found", "The user was not found");
}
} }
} }

View File

@@ -985,17 +985,22 @@ namespace BTCPayServer.Controllers.Greenfield
return GetFromActionResult(await GetController<GreenfieldUsersController>().GetUsers()); return GetFromActionResult(await GetController<GreenfieldUsersController>().GetUsers());
} }
public override Task<IEnumerable<StoreUserData>> GetStoreUsers(string storeId, public override async Task<IEnumerable<StoreUserData>> GetStoreUsers(string storeId,
CancellationToken token = default) CancellationToken token = default)
{ {
return Task.FromResult( return GetFromActionResult<IEnumerable<StoreUserData>>(await GetController<GreenfieldStoreUsersController>().GetStoreUsers());
GetFromActionResult<IEnumerable<StoreUserData>>(GetController<GreenfieldStoreUsersController>().GetStoreUsers()));
} }
public override async Task AddStoreUser(string storeId, StoreUserData request, public override async Task AddStoreUser(string storeId, StoreUserData request,
CancellationToken token = default) CancellationToken token = default)
{ {
HandleActionResult(await GetController<GreenfieldStoreUsersController>().AddStoreUser(storeId, request)); HandleActionResult(await GetController<GreenfieldStoreUsersController>().AddOrUpdateStoreUser(storeId, request));
}
public override async Task UpdateStoreUser(string storeId, string userId, StoreUserData request,
CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldStoreUsersController>().AddOrUpdateStoreUser(storeId, request, userId));
} }
public override async Task RemoveStoreUser(string storeId, string userId, CancellationToken token = default) public override async Task RemoveStoreUser(string storeId, string userId, CancellationToken token = default)

View File

@@ -47,13 +47,7 @@
"description": "Revoke the API key of a target user so that it cannot be used anymore", "description": "Revoke the API key of a target user so that it cannot be used anymore",
"parameters": [ "parameters": [
{ {
"name": "idOrEmail", "$ref": "#/components/parameters/UserIdOrEmail"
"in": "path",
"required": true,
"description": "The target user's id or email",
"schema": {
"type": "string"
}
}, },
{ {
"name": "apikey", "name": "apikey",
@@ -244,13 +238,7 @@
"description": "Create a new API Key for a user", "description": "Create a new API Key for a user",
"parameters": [ "parameters": [
{ {
"name": "idOrEmail", "$ref": "#/components/parameters/UserIdOrEmail"
"in": "path",
"required": true,
"description": "The target user's id or email",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {

View File

@@ -42,6 +42,15 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
"UserIdOrEmail": {
"name": "idOrEmail",
"in": "path",
"required": true,
"description": "The user's id or email",
"schema": {
"type": "string"
}
} }
}, },
"schemas": { "schemas": {

View File

@@ -25,10 +25,10 @@
} }
}, },
"403": { "403": {
"description": "If you are authenticated but forbidden to view the specified store" "description": "If you are authenticated but forbidden to view the specified store's users"
}, },
"404": { "404": {
"description": "The key is not found for this store" "description": "The store could not be found"
} }
}, },
"security": [ "security": [
@@ -69,7 +69,7 @@
"description": "The user was added" "description": "The user was added"
}, },
"400": { "400": {
"description": "A list of errors that occurred when creating the store", "description": "A list of errors that occurred when adding the store user",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -79,7 +79,10 @@
} }
}, },
"403": { "403": {
"description": "If you are authenticated but forbidden to add new stores" "description": "If you are authenticated but forbidden to add new store users"
},
"404": {
"description": "The store or user could not be found"
}, },
"409": { "409": {
"description": "Error code: `duplicate-store-user-role`. Removing this user would result in the store having no owner.", "description": "Error code: `duplicate-store-user-role`. Removing this user would result in the store having no owner.",
@@ -103,6 +106,63 @@
} }
}, },
"/api/v1/stores/{storeId}/users/{idOrEmail}": { "/api/v1/stores/{storeId}/users/{idOrEmail}": {
"put": {
"tags": [
"Stores (Users)"
],
"summary": "Updates a store user",
"description": "Updates a store user",
"operationId": "Stores_UpdateStoreUser",
"parameters": [
{
"$ref": "#/components/parameters/StoreId"
},
{
"$ref": "#/components/parameters/UserIdOrEmail"
}
],
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StoreUserData"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "The user was updated"
},
"400": {
"description": "A list of errors that occurred when updating the store user",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to update store users"
},
"404": {
"description": "The store or user could not be found"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
},
"delete": { "delete": {
"tags": [ "tags": [
"Stores (Users)" "Stores (Users)"
@@ -115,13 +175,7 @@
"$ref": "#/components/parameters/StoreId" "$ref": "#/components/parameters/StoreId"
}, },
{ {
"name": "idOrEmail", "$ref": "#/components/parameters/UserIdOrEmail"
"in": "path",
"required": true,
"description": "The user's id or email",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -129,7 +183,7 @@
"description": "The user has been removed" "description": "The user has been removed"
}, },
"400": { "400": {
"description": "A list of errors that occurred when removing the store", "description": "A list of errors that occurred when removing the store user",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
@@ -149,10 +203,10 @@
} }
}, },
"403": { "403": {
"description": "If you are authenticated but forbidden to remove the specified store" "description": "If you are authenticated but forbidden to remove the specified store user"
}, },
"404": { "404": {
"description": "The key is not found for this store" "description": "The store or user could not be found"
} }
}, },
"security": [ "security": [
@@ -186,8 +240,23 @@
}, },
"role": { "role": {
"type": "string", "type": "string",
"description": "The role of the user. Default roles are `Owner` and `Guest`", "description": "The role of the user. Default roles are `Owner`, `Manager`, `Employee` and `Guest`",
"nullable": false "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
} }
} }
} }

View File

@@ -330,13 +330,7 @@
"description": "Get 1 user by ID or Email.", "description": "Get 1 user by ID or Email.",
"parameters": [ "parameters": [
{ {
"name": "idOrEmail", "$ref": "#/components/parameters/UserIdOrEmail"
"in": "path",
"required": true,
"description": "The ID or email of the user to load",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -378,13 +372,7 @@
"description": "Delete a user.\n\nMust be an admin to perform this operation.\n\nAttempting to delete the only admin user will not succeed.\n\nAll data associated with the user will be deleted as well if the operation succeeds.", "description": "Delete a user.\n\nMust be an admin to perform this operation.\n\nAttempting to delete the only admin user will not succeed.\n\nAll data associated with the user will be deleted as well if the operation succeeds.",
"parameters": [ "parameters": [
{ {
"name": "idOrEmail", "$ref": "#/components/parameters/UserIdOrEmail"
"in": "path",
"required": true,
"description": "The ID or email of the user to be deleted",
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -421,13 +409,7 @@
"description": "Lock or unlock a user.\n\nMust be an admin to perform this operation.\n\nAttempting to lock the only admin user will not succeed.", "description": "Lock or unlock a user.\n\nMust be an admin to perform this operation.\n\nAttempting to lock the only admin user will not succeed.",
"parameters": [ "parameters": [
{ {
"name": "idOrEmail", "$ref": "#/components/parameters/UserIdOrEmail"
"in": "path",
"required": true,
"description": "The ID of the user to be un/locked",
"schema": {
"type": "string"
}
} }
], ],
"requestBody": { "requestBody": {
@@ -474,13 +456,7 @@
"description": "Approve or unapprove a user.\n\nMust be an admin to perform this operation.\n\nAttempting to (un)approve a user for which this requirement does not exist will not succeed.", "description": "Approve or unapprove a user.\n\nMust be an admin to perform this operation.\n\nAttempting to (un)approve a user for which this requirement does not exist will not succeed.",
"parameters": [ "parameters": [
{ {
"name": "idOrEmail", "$ref": "#/components/parameters/UserIdOrEmail"
"in": "path",
"required": true,
"description": "The ID of the user to be un/approved",
"schema": {
"type": "string"
}
} }
], ],
"requestBody": { "requestBody": {