Allow Users to be disabled/enabled (#3639)

* Allow Users to be disabled/enabled

* rebrand to locked for api

* Update BTCPayServer/Views/UIAccount/Lockout.cshtml

Co-authored-by: d11n <mail@dennisreimann.de>

* fix docker compose and an uneeded check in api handler

* fix

* Add enabled user test

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri
2022-04-26 14:27:35 +02:00
committed by GitHub
parent 261a3ecee3
commit 273bc78db3
16 changed files with 290 additions and 61 deletions

View File

@@ -33,6 +33,13 @@ namespace BTCPayServer.Client
return await HandleResponse<ApplicationUserData>(response); return await HandleResponse<ApplicationUserData>(response);
} }
public virtual async Task LockUser(string idOrEmail, bool locked, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/{idOrEmail}/lock", null,
new LockUserRequest() {Locked = locked}, HttpMethod.Post), token);
await HandleResponse(response);
}
public virtual async Task<ApplicationUserData[]> GetUsers( CancellationToken token = default) public virtual async Task<ApplicationUserData[]> GetUsers( CancellationToken token = default)
{ {
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token); var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);

View File

@@ -35,5 +35,7 @@ namespace BTCPayServer.Client.Models
/// </summary> /// </summary>
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? Created { get; set; } public DateTimeOffset? Created { get; set; }
public bool Disabled { get; set; }
} }
} }

View File

@@ -0,0 +1,6 @@
namespace BTCPayServer.Client;
public class LockUserRequest
{
public bool Locked { get; set; }
}

View File

@@ -2341,6 +2341,45 @@ namespace BTCPayServer.Tests
new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" }); new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" });
} }
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task DisabledEnabledUserTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
var newUser = tester.NewAccount();
await newUser.GrantAccessAsync();
var newUserClient = await newUser.CreateClient(Policies.Unrestricted);
Assert.False((await newUserClient.GetCurrentUser()).Disabled);
await adminClient.LockUser(newUser.UserId, true, CancellationToken.None);
Assert.True((await adminClient.GetUserByIdOrEmail(newUser.UserId)).Disabled);
await AssertAPIError("unauthenticated",async () =>
{
await newUserClient.GetCurrentUser();
});
var newUserBasicClient = new BTCPayServerClient(newUserClient.Host, newUser.RegisterDetails.Email,
newUser.RegisterDetails.Password);
await AssertAPIError("unauthenticated",async () =>
{
await newUserBasicClient.GetCurrentUser();
});
await adminClient.LockUser(newUser.UserId, false, CancellationToken.None);
Assert.False((await adminClient.GetUserByIdOrEmail(newUser.UserId)).Disabled);
await newUserClient.GetCurrentUser();
await newUserBasicClient.GetCurrentUser();
// Twice for good measure
await adminClient.LockUser(newUser.UserId, false, CancellationToken.None);
Assert.False((await adminClient.GetUserByIdOrEmail(newUser.UserId)).Disabled);
await newUserClient.GetCurrentUser();
await newUserBasicClient.GetCurrentUser();
}
[Fact(Timeout = 60 * 2 * 1000)] [Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]

View File

@@ -73,6 +73,19 @@ namespace BTCPayServer.Controllers.Greenfield
} }
return UserNotFound(); return UserNotFound();
} }
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/users/{idOrEmail}/lock")]
public async Task<IActionResult> LockUser(string idOrEmail, LockUserRequest request )
{
var user = (await _userManager.FindByIdAsync(idOrEmail) ) ?? await _userManager.FindByEmailAsync(idOrEmail);
if (user is null)
{
return UserNotFound();
}
await _userService.ToggleUser(user.Id, request.Locked ? DateTimeOffset.MaxValue : null);
return Ok();
}
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/")] [HttpGet("~/api/v1/users/")]
@@ -219,7 +232,7 @@ namespace BTCPayServer.Controllers.Greenfield
} }
// User shouldn't be deleted if it's the only admin // User shouldn't be deleted if it's the only admin
if (await IsUserTheOnlyOneAdmin(user)) if (await _userService.IsUserTheOnlyOneAdmin(user))
{ {
return Forbid(AuthenticationSchemes.GreenfieldBasic); return Forbid(AuthenticationSchemes.GreenfieldBasic);
} }
@@ -236,21 +249,7 @@ namespace BTCPayServer.Controllers.Greenfield
return UserService.FromModel(data, roles); return UserService.FromModel(data, roles);
} }
private async Task<bool> IsUserTheOnlyOneAdmin()
{
return await IsUserTheOnlyOneAdmin(await _userManager.GetUserAsync(User));
}
private async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
{
var isUserAdmin = await _userService.IsAdminUser(user);
if (!isUserAdmin)
{
return false;
}
return (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Count == 1;
}
private IActionResult UserNotFound() private IActionResult UserNotFound()
{ {

View File

@@ -140,7 +140,6 @@ namespace BTCPayServer.Controllers.Greenfield
Host = new HostString("dummy.com"), Host = new HostString("dummy.com"),
Path = new PathString(), Path = new PathString(),
PathBase = new PathString(), PathBase = new PathString(),
} }
}); });
} }
@@ -533,13 +532,6 @@ namespace BTCPayServer.Controllers.Greenfield
await _storeLightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token)); await _storeLightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token));
} }
public override async Task<LightningPaymentData> GetLightningPayment(string storeId, string cryptoCode,
string paymentHash, CancellationToken token = default)
{
return GetFromActionResult<LightningPaymentData>(
await _storeLightningNodeApiController.GetPayment(cryptoCode, paymentHash, token));
}
public override async Task<LightningInvoiceData> CreateLightningInvoice(string storeId, string cryptoCode, public override async Task<LightningInvoiceData> CreateLightningInvoice(string storeId, string cryptoCode,
CreateLightningInvoiceRequest request, CancellationToken token = default) CreateLightningInvoiceRequest request, CancellationToken token = default)
{ {
@@ -594,13 +586,6 @@ namespace BTCPayServer.Controllers.Greenfield
await _lightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token)); await _lightningNodeApiController.GetInvoice(cryptoCode, invoiceId, token));
} }
public override async Task<LightningPaymentData> GetLightningPayment(string cryptoCode,
string paymentHash, CancellationToken token = default)
{
return GetFromActionResult<LightningPaymentData>(
await _lightningNodeApiController.GetPayment(cryptoCode, paymentHash, token));
}
public override async Task<LightningInvoiceData> CreateLightningInvoice(string cryptoCode, public override async Task<LightningInvoiceData> CreateLightningInvoice(string cryptoCode,
CreateLightningInvoiceRequest request, CreateLightningInvoiceRequest request,
CancellationToken token = default) CancellationToken token = default)
@@ -624,9 +609,9 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
switch (result) switch (result)
{ {
case UnprocessableEntityObjectResult { Value: List<GreenfieldValidationError> validationErrors }: case UnprocessableEntityObjectResult {Value: List<GreenfieldValidationError> validationErrors}:
throw new GreenfieldValidationException(validationErrors.ToArray()); throw new GreenfieldValidationException(validationErrors.ToArray());
case BadRequestObjectResult { Value: GreenfieldAPIError error }: case BadRequestObjectResult {Value: GreenfieldAPIError error}:
throw new GreenfieldAPIException(400, error); throw new GreenfieldAPIException(400, error);
case NotFoundResult _: case NotFoundResult _:
throw new GreenfieldAPIException(404, new GreenfieldAPIError("not-found", "")); throw new GreenfieldAPIException(404, new GreenfieldAPIError("not-found", ""));
@@ -775,7 +760,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return GetFromActionResult<NotificationData>( return GetFromActionResult<NotificationData>(
await _notificationsController.UpdateNotification(notificationId, await _notificationsController.UpdateNotification(notificationId,
new UpdateNotification() { Seen = seen })); new UpdateNotification() {Seen = seen}));
} }
public override async Task RemoveNotification(string notificationId, CancellationToken token = default) public override async Task RemoveNotification(string notificationId, CancellationToken token = default)
@@ -1135,7 +1120,29 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return GetFromActionResult<ApplicationUserData>(await _usersController.GetUser(idOrEmail)); return GetFromActionResult<ApplicationUserData>(await _usersController.GetUser(idOrEmail));
} }
public override async Task LockUser(string idOrEmail, bool disabled, CancellationToken token = default)
{
HandleActionResult(await _usersController.LockUser(idOrEmail, new LockUserRequest()
{
Locked = disabled
}));
}
public override async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(string storeId, string cryptoCode, string transactionId,
PatchOnChainTransactionRequest request, CancellationToken token = default)
{
return GetFromActionResult<OnChainWalletTransactionData>(await _storeOnChainWalletsController.PatchOnChainWalletTransaction(storeId, cryptoCode, transactionId, request));
}
public override async Task<LightningPaymentData> GetLightningPayment(string cryptoCode, string paymentHash, CancellationToken token = default)
{
return GetFromActionResult<LightningPaymentData>(await _lightningNodeApiController.GetPayment(cryptoCode, paymentHash));
}
public override async Task<LightningPaymentData> GetLightningPayment(string storeId, string cryptoCode, string paymentHash, CancellationToken token = default)
{
return GetFromActionResult<LightningPaymentData>(await _storeLightningNodeApiController.GetPayment(cryptoCode, paymentHash));
}
public override async Task<PayoutData> CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest, public override async Task<PayoutData> CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {

View File

@@ -209,7 +209,7 @@ namespace BTCPayServer.Controllers
if (result.IsLockedOut) if (result.IsLockedOut)
{ {
_logger.LogWarning($"User '{user.Id}' account locked out."); _logger.LogWarning($"User '{user.Id}' account locked out.");
return RedirectToAction(nameof(Lockout)); return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
} }
else else
{ {
@@ -428,7 +428,7 @@ namespace BTCPayServer.Controllers
else if (result.IsLockedOut) else if (result.IsLockedOut)
{ {
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id); _logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
return RedirectToAction(nameof(Lockout)); return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
} }
else else
{ {
@@ -497,7 +497,8 @@ namespace BTCPayServer.Controllers
if (result.IsLockedOut) if (result.IsLockedOut)
{ {
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id); _logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
return RedirectToAction(nameof(Lockout));
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd});
} }
else else
{ {
@@ -509,9 +510,9 @@ namespace BTCPayServer.Controllers
[HttpGet("/login/lockout")] [HttpGet("/login/lockout")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult Lockout() public IActionResult Lockout(DateTimeOffset? lockoutEnd)
{ {
return View(); return View(lockoutEnd);
} }
[HttpGet("/register")] [HttpGet("/register")]

View File

@@ -64,7 +64,8 @@ namespace BTCPayServer.Controllers
Id = u.Id, Id = u.Id,
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation, Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
Created = u.Created, Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId) Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime
}) })
.ToListAsync(); .ToListAsync();
model.Total = await usersQuery.CountAsync(); model.Total = await usersQuery.CountAsync();
@@ -217,12 +218,11 @@ namespace BTCPayServer.Controllers
var roles = await _UserManager.GetRolesAsync(user); var roles = await _UserManager.GetRolesAsync(user);
if (_userService.IsRoleAdmin(roles)) if (_userService.IsRoleAdmin(roles))
{ {
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin); if (await _userService.IsUserTheOnlyOneAdmin(user))
if (admins.Count == 1)
{ {
// return // return
return View("Confirm", new ConfirmModel("Delete admin", return View("Confirm", new ConfirmModel("Delete admin",
$"Unable to proceed: As the user <strong>{user.Email}</strong> is the last admin, it cannot be removed.")); $"Unable to proceed: As the user <strong>{user.Email}</strong> is the last enabled admin, it cannot be removed."));
} }
return View("Confirm", new ConfirmModel("Delete admin", return View("Confirm", new ConfirmModel("Delete admin",
@@ -245,6 +245,41 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.SuccessMessage] = "User deleted"; TempData[WellKnownTempData.SuccessMessage] = "User deleted";
return RedirectToAction(nameof(ListUsers)); return RedirectToAction(nameof(ListUsers));
} }
[HttpGet("server/users/{userId}/toggle")]
public async Task<IActionResult> ToggleUser(string userId, bool enable)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
if (!enable && await _userService.IsUserTheOnlyOneAdmin(user))
{
return View("Confirm", new ConfirmModel("Disable admin",
$"Unable to proceed: As the user <strong>{user.Email}</strong> is the last enabled admin, it cannot be disabled."));
}
return View("Confirm", new ConfirmModel($"{(enable? "Enable" : "Disable")} user", $"The user <strong>{user.Email}</strong> will be {(enable? "enabled" : "disabled")}. Are you sure?", (enable? "Enable" : "Disable")));
}
[HttpPost("server/users/{userId}/toggle")]
public async Task<IActionResult> ToggleUserPost(string userId, bool enable)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
if (!enable && await _userService.IsUserTheOnlyOneAdmin(user))
{
TempData[WellKnownTempData.SuccessMessage] = $"User was the last enabled admin and could not be disabled.";
return RedirectToAction(nameof(ListUsers));
}
await _userService.ToggleUser(userId, enable? null: DateTimeOffset.MaxValue);
TempData[WellKnownTempData.SuccessMessage] = $"User {(enable? "enabled": "disabled")}";
return RedirectToAction(nameof(ListUsers));
}
} }
public class RegisterFromAdminViewModel public class RegisterFromAdminViewModel

View File

@@ -11,6 +11,7 @@ namespace BTCPayServer.Models.ServerViewModels
public string Name { get; set; } public string Name { get; set; }
public string Email { get; set; } public string Email { get; set; }
public bool Verified { get; set; } public bool Verified { get; set; }
public bool Disabled { get; set; }
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
public DateTimeOffset? Created { get; set; } public DateTimeOffset? Created { get; set; }
public IEnumerable<string> Roles { get; set; } public IEnumerable<string> Roles { get; set; }

View File

@@ -45,14 +45,14 @@ public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory
new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance)); new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance));
} }
public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings) public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
{ {
if (settings.Processor != Processor) if (settings.Processor != Processor)
{ {
throw new NotSupportedException("This processor cannot handle the provided requirements"); throw new NotSupportedException("This processor cannot handle the provided requirements");
} }
return ActivatorUtilities.CreateInstance<LightningAutomatedPayoutProcessor>(_serviceProvider, settings); return Task.FromResult<IHostedService>(ActivatorUtilities.CreateInstance<LightningAutomatedPayoutProcessor>(_serviceProvider, settings));
} }
} }

View File

@@ -50,13 +50,13 @@ public class OnChainAutomatedPayoutSenderFactory : EventHostedServiceBase, IPayo
new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance)); new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance));
} }
public async Task<IHostedService> ConstructProcessor(PayoutProcessorData settings) public Task<IHostedService> ConstructProcessor(PayoutProcessorData settings)
{ {
if (settings.Processor != Processor) if (settings.Processor != Processor)
{ {
throw new NotSupportedException("This processor cannot handle the provided requirements"); throw new NotSupportedException("This processor cannot handle the provided requirements");
} }
return ActivatorUtilities.CreateInstance<OnChainAutomatedPayoutProcessor>(_serviceProvider, settings); return Task.FromResult<IHostedService>(ActivatorUtilities.CreateInstance<OnChainAutomatedPayoutProcessor>(_serviceProvider, settings));
} }
} }

View File

@@ -39,7 +39,7 @@ namespace BTCPayServer.Security.Greenfield
var key = await _apiKeyRepository.GetKey(apiKey, true); var key = await _apiKeyRepository.GetKey(apiKey, true);
if (key == null) if (key == null || await _userManager.IsLockedOutAsync(key.User))
{ {
return AuthenticateResult.Fail("ApiKey authentication failed"); return AuthenticateResult.Fail("ApiKey authentication failed");
} }

View File

@@ -7,37 +7,35 @@ using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using BTCPayServer.Storage.Services; using BTCPayServer.Storage.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Services namespace BTCPayServer.Services
{ {
public class UserService public class UserService
{ {
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IAuthorizationService _authorizationService;
private readonly StoredFileRepository _storedFileRepository; private readonly StoredFileRepository _storedFileRepository;
private readonly FileService _fileService; private readonly FileService _fileService;
private readonly StoreRepository _storeRepository; private readonly StoreRepository _storeRepository;
private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly ILogger<UserService> _logger;
public UserService( public UserService(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IAuthorizationService authorizationService,
StoredFileRepository storedFileRepository, StoredFileRepository storedFileRepository,
FileService fileService, FileService fileService,
StoreRepository storeRepository, StoreRepository storeRepository,
ApplicationDbContextFactory applicationDbContextFactory ApplicationDbContextFactory applicationDbContextFactory,
ILogger<UserService> logger)
)
{ {
_userManager = userManager; _userManager = userManager;
_authorizationService = authorizationService;
_storedFileRepository = storedFileRepository; _storedFileRepository = storedFileRepository;
_fileService = fileService; _fileService = fileService;
_storeRepository = storeRepository; _storeRepository = storeRepository;
_applicationDbContextFactory = applicationDbContextFactory; _applicationDbContextFactory = applicationDbContextFactory;
_logger = logger;
} }
public async Task<List<ApplicationUserData>> GetUsersWithRoles() public async Task<List<ApplicationUserData>> GetUsersWithRoles()
@@ -57,10 +55,40 @@ namespace BTCPayServer.Services
EmailConfirmed = data.EmailConfirmed, EmailConfirmed = data.EmailConfirmed,
RequiresEmailConfirmation = data.RequiresEmailConfirmation, RequiresEmailConfirmation = data.RequiresEmailConfirmation,
Created = data.Created, Created = data.Created,
Roles = roles Roles = roles,
Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime
}; };
} }
private bool IsDisabled(ApplicationUser user)
{
return user.LockoutEnabled && user.LockoutEnd is not null &&
DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime;
}
public async Task ToggleUser(string userId, DateTimeOffset? lockedOutDeadline)
{
var user = await _userManager.FindByIdAsync(userId);
if (user is null)
{
return;
}
if (lockedOutDeadline is not null)
{
await _userManager.SetLockoutEnabledAsync(user, true);
}
var res = await _userManager.SetLockoutEndDateAsync(user, lockedOutDeadline);
if (res.Succeeded)
{
_logger.LogInformation($"User {user.Id} is now {(lockedOutDeadline is null ? "unlocked" : "locked")}");
}
else
{
_logger.LogError($"Failed to set lockout for user {user.Id}");
}
}
public async Task<bool> IsAdminUser(string userId) public async Task<bool> IsAdminUser(string userId)
{ {
return IsRoleAdmin(await _userManager.GetRolesAsync(new ApplicationUser() { Id = userId })); return IsRoleAdmin(await _userManager.GetRolesAsync(new ApplicationUser() { Id = userId }));
@@ -89,5 +117,17 @@ namespace BTCPayServer.Services
{ {
return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal); return roles.Contains(Roles.ServerAdmin, StringComparer.Ordinal);
} }
public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
{
var isUserAdmin = await IsAdminUser(user);
if (!isUserAdmin)
{
return false;
}
return (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Count(applicationUser => !IsDisabled(applicationUser)) == 1;
}
} }
} }

View File

@@ -1,5 +1,8 @@
@{ @using BTCPayServer.Abstractions.Extensions
ViewData["Title"] = "Locked out"; @model DateTimeOffset?
@{
ViewData["Title"] = "Account disabled";
Layout = "_LayoutSignedOut";
} }
<div class="row"> <div class="row">
@@ -8,6 +11,17 @@
<hr class="primary"> <hr class="primary">
</div> </div>
<div class="col-lg-12 lead"> <div class="col-lg-12 lead">
<p>This account has been locked out because of multiple invalid login attempts. Please try again later.</p> @if (DateTimeOffset.MaxValue - Model.Value < TimeSpan.FromSeconds(1))
{
<p>Your account has been disabled. Please contact server administrator.</p>
}
else if(Model is null)
{
<p>This account has been locked out because of multiple invalid login attempts. Please try again later.</p>
}
else
{
<p>This account has been locked out. Please try again <span data-timeago-unixms="@Model.Value.ToUnixTimeMilliseconds()">@Model.Value.ToTimeAgo()</span>.</p>
}
</div> </div>
</div> </div>

View File

@@ -56,8 +56,9 @@
<span class="fa @(sortIconClass)" /> <span class="fa @(sortIconClass)" />
</a> </a>
</th> </th>
<th>Created</th> <th >Created</th>
<th>Verified</th> <th class="text-center">Verified</th>
<th class="text-center">Enabled</th>
<th class="text-end">Actions</th> <th class="text-end">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -83,8 +84,23 @@
<span class="text-danger fa fa-times"></span> <span class="text-danger fa fa-times"></span>
} }
</td> </td>
<td class="text-center">
@if (!user.Disabled)
{
<span class="text-success fa fa-check" title="User is enabled"></span>
}
else
{
<span class="text-danger fa fa-times" title="User is disabled"></span>
}
</td>
<td class="text-end"> <td class="text-end">
<a asp-action="User" asp-route-userId="@user.Id">Edit</a> <span> - </span> <a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a> <a asp-action="User" asp-route-userId="@user.Id">Edit</a> <span> - </span> <a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
- <a asp-action="ToggleUser"
asp-route-enable="@user.Disabled"
asp-route-userId="@user.Id">
@(user.Disabled ? "Enable" : "Disable")
</a>
</td> </td>
</tr> </tr>
} }

View File

@@ -237,6 +237,58 @@
} }
] ]
} }
},
"/api/v1/users/{idOrEmail}/lock": {
"delete": {
"tags": [
"Users"
],
"summary": "Toggle user",
"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": [
{
"name": "userId",
"in": "path",
"required": true,
"description": "The ID of the user to be un/locked",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"x-name": "request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LockUserRequest"
}
}
}
},
"responses": {
"200": {
"description": "User has been successfully toggled"
},
"401": {
"description": "Missing authorization for deleting the user"
},
"403": {
"description": "Authorized but forbidden to disable the user. Can happen if you attempt to disable the only admin user."
},
"404": {
"description": "User with provided ID was not found"
}
},
"security": [
{
"API_Key": [
"btcpay.user.canmodifyserversettings"
],
"Basic": []
}
]
}
} }
}, },
"components": { "components": {
@@ -281,6 +333,16 @@
"description": "The roles of the user" "description": "The roles of the user"
} }
} }
},
"LockUserRequest": {
"type": "object",
"additionalProperties": false,
"properties": {
"locked": {
"type": "boolean",
"description": "Whether to lock or unlock the user"
}
}
} }
} }
}, },