mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Add mobile login requests to admin dashboard, update migration (#1347)
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="RecentUsageMobileLogins.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Admin.Main.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Model representing IP addresses with mobile login request counts.
|
||||
/// </summary>
|
||||
public class RecentUsageMobileLogins
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the anonymized IP address (last octet masked).
|
||||
/// </summary>
|
||||
public string IpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the original IP address for linking purposes.
|
||||
/// </summary>
|
||||
public string OriginalIpAddress { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the count of mobile login requests from this IP in the last 72 hours.
|
||||
/// </summary>
|
||||
public int MobileLoginCount72h { get; set; }
|
||||
}
|
||||
@@ -26,4 +26,9 @@ public class RecentUsageStatistics
|
||||
/// Gets or sets the list of IP addresses with most registrations in the last 72 hours.
|
||||
/// </summary>
|
||||
public List<RecentUsageRegistrations> TopIpsByRegistrations72h { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of IP addresses with most mobile login requests in the last 72 hours.
|
||||
/// </summary>
|
||||
public List<RecentUsageMobileLogins> TopIpsByMobileLogins72h { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
@using AliasVault.Admin.Main.Models
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
|
||||
<div class="p-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Top IP Addresses by Mobile Login Requests (Last 72h)</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">IP addresses with the most mobile login requests in the last 72 hours (last octet anonymized)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Data != null && Data.Any())
|
||||
{
|
||||
<div class="mb-3">
|
||||
<Paginator CurrentPage="@CurrentPage" PageSize="@PageSize" TotalRecords="@Data.Count" OnPageChanged="@HandlePageChanged" />
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<SortableTable Columns="@_tableColumns">
|
||||
@foreach (var ip in PagedData)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">
|
||||
<a href="mobile-login-history?search=@Uri.EscapeDataString(ip.OriginalIpAddress)" class="font-mono text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer">
|
||||
@ip.IpAddress
|
||||
</a>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>@ip.MobileLoginCount72h.ToString("N0")</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
else if (Data != null)
|
||||
{
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No Recent Mobile Logins</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No mobile login requests occurred in the last 72 hours.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-6 py-8 flex justify-center">
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public List<RecentUsageMobileLogins>? Data { get; set; }
|
||||
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 20;
|
||||
|
||||
private IEnumerable<RecentUsageMobileLogins> PagedData =>
|
||||
Data?.Skip((CurrentPage - 1) * PageSize).Take(PageSize) ?? Enumerable.Empty<RecentUsageMobileLogins>();
|
||||
|
||||
private readonly List<TableColumn> _tableColumns = new()
|
||||
{
|
||||
new() { Title = "Client IP Address", PropertyName = "IpAddress", Sortable = false },
|
||||
new() { Title = "Mobile Logins (72h)", PropertyName = "MobileLoginCount72h", Sortable = false }
|
||||
};
|
||||
|
||||
private void HandlePageChanged(int page)
|
||||
{
|
||||
CurrentPage = page;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@
|
||||
|
||||
<!-- Top IP Addresses by Registrations ---->
|
||||
<RecentUsageRegistrationsTable Data="@_recentUsageStats?.TopIpsByRegistrations72h" />
|
||||
|
||||
<!-- Top IP Addresses by Mobile Login Requests ---->
|
||||
<RecentUsageMobileLoginsTable Data="@_recentUsageStats?.TopIpsByMobileLogins72h" />
|
||||
</div>
|
||||
|
||||
@if (_loadingError)
|
||||
|
||||
372
apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor
Normal file
372
apps/server/AliasVault.Admin/Main/Pages/MobileLoginHistory.razor
Normal file
@@ -0,0 +1,372 @@
|
||||
@page "/mobile-login-history"
|
||||
@using AliasVault.RazorComponents.Tables
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@inject NavigationManager NavigationManager
|
||||
@inherits MainBase
|
||||
|
||||
<LayoutPageTitle>Mobile Login History</LayoutPageTitle>
|
||||
|
||||
<PageHeader
|
||||
BreadcrumbItems="@BreadcrumbItems"
|
||||
Title="@(TotalRecords > 0 ? $"Mobile Login History ({TotalRecords:N0})" : "Mobile Login History")"
|
||||
Description="View all mobile login requests with IP addresses, timestamps, and fulfillment status.">
|
||||
<CustomActions>
|
||||
<RefreshButton OnClick="() => RefreshData(CancellationToken.None)" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@if (IsInitialized)
|
||||
{
|
||||
<div class="px-4">
|
||||
<ResponsivePaginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
||||
<div class="mb-3 flex space-x-4">
|
||||
<div class="w-3/4">
|
||||
<div class="relative">
|
||||
<SearchIcon />
|
||||
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search by username or IP address..." class="w-full px-4 ps-10 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-1/4">
|
||||
<select @bind="SelectedStatusFilter" class="w-full px-4 py-2 border rounded text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white">
|
||||
<option value="">All Requests</option>
|
||||
<option value="retrieved">Retrieved</option>
|
||||
<option value="fulfilled">Fulfilled</option>
|
||||
<option value="pending">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsLoading)
|
||||
{
|
||||
<LoadingIndicator />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="px-4">
|
||||
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
||||
@foreach (var request in RequestList)
|
||||
{
|
||||
<SortableTableRow>
|
||||
<SortableTableColumn IsPrimary="true">@request.CreatedAt.ToString("yyyy-MM-dd HH:mm:ss")</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<span class="font-mono text-sm">@(request.ClientIpAddress ?? "N/A")</span>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
<span class="font-mono text-sm">@(request.MobileIpAddress ?? "N/A")</span>
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (request.FulfilledAt.HasValue)
|
||||
{
|
||||
<span class="text-sm">@request.FulfilledAt.Value.ToString("yyyy-MM-dd HH:mm:ss")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm italic">-</span>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (request.RetrievedAt.HasValue)
|
||||
{
|
||||
<span class="text-sm">@request.RetrievedAt.Value.ToString("yyyy-MM-dd HH:mm:ss")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-gray-500 dark:text-gray-400 text-sm italic">-</span>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (!string.IsNullOrEmpty(request.Username))
|
||||
{
|
||||
<a href="users/@request.UserId" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
@request.Username
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-gray-500 dark:text-gray-400 italic">-</span>
|
||||
}
|
||||
</SortableTableColumn>
|
||||
<SortableTableColumn>
|
||||
@if (request.RetrievedAt.HasValue)
|
||||
{
|
||||
<StatusPill Enabled="true" TextTrue="Retrieved" />
|
||||
}
|
||||
else if (request.FulfilledAt.HasValue)
|
||||
{
|
||||
<StatusPill Enabled="true" TextTrue="Fulfilled" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<StatusPill Enabled="false" TextFalse="Pending" />
|
||||
}
|
||||
</SortableTableColumn>
|
||||
</SortableTableRow>
|
||||
}
|
||||
</SortableTable>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private readonly List<TableColumn> _tableColumns = [
|
||||
new TableColumn { Title = "Created At", PropertyName = "CreatedAt" },
|
||||
new TableColumn { Title = "Client IP", PropertyName = "ClientIpAddress" },
|
||||
new TableColumn { Title = "Mobile IP", PropertyName = "MobileIpAddress" },
|
||||
new TableColumn { Title = "Fulfilled At", PropertyName = "FulfilledAt" },
|
||||
new TableColumn { Title = "Retrieved At", PropertyName = "RetrievedAt" },
|
||||
new TableColumn { Title = "Username", PropertyName = "Username" },
|
||||
new TableColumn { Title = "Status", Sortable = false },
|
||||
];
|
||||
|
||||
private List<MobileLoginRequestModel> RequestList { get; set; } = [];
|
||||
private bool IsInitialized { get; set; } = false;
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private int CurrentPage { get; set; } = 1;
|
||||
private int PageSize { get; set; } = 50;
|
||||
private int TotalRecords { get; set; }
|
||||
|
||||
private string _searchTerm = string.Empty;
|
||||
private CancellationTokenSource? _searchCancellationTokenSource;
|
||||
private string _lastSearchTerm = string.Empty;
|
||||
|
||||
private string SearchTerm
|
||||
{
|
||||
get => _searchTerm;
|
||||
set
|
||||
{
|
||||
if (_searchTerm != value)
|
||||
{
|
||||
_searchTerm = value;
|
||||
_searchCancellationTokenSource?.Cancel();
|
||||
_searchCancellationTokenSource = new CancellationTokenSource();
|
||||
_ = RefreshData(_searchCancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string _selectedStatusFilter = string.Empty;
|
||||
private string _lastSelectedStatusFilter = string.Empty;
|
||||
private string SelectedStatusFilter
|
||||
{
|
||||
get => _selectedStatusFilter;
|
||||
set
|
||||
{
|
||||
if (_selectedStatusFilter != value)
|
||||
{
|
||||
_selectedStatusFilter = value;
|
||||
_searchCancellationTokenSource?.Cancel();
|
||||
_searchCancellationTokenSource = new CancellationTokenSource();
|
||||
_ = RefreshData(_searchCancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string SortColumn { get; set; } = "CreatedAt";
|
||||
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
||||
|
||||
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
||||
{
|
||||
SortColumn = sort.column;
|
||||
SortDirection = sort.direction;
|
||||
await RefreshData(CancellationToken.None);
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Users", Url = "users" });
|
||||
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Mobile Login History" });
|
||||
|
||||
// Check for search query parameter
|
||||
var uri = new Uri(NavigationManager.Uri);
|
||||
var queryParams = QueryHelpers.ParseQuery(uri.Query);
|
||||
if (queryParams.TryGetValue("search", out var search))
|
||||
{
|
||||
_searchTerm = search.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await RefreshData(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandlePageChanged(int newPage)
|
||||
{
|
||||
CurrentPage = newPage;
|
||||
_ = RefreshData(CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task RefreshData(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
await using var dbContext = await DbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
IQueryable<MobileLoginRequest> query = dbContext.MobileLoginRequests;
|
||||
|
||||
query = ApplySearchFilter(query);
|
||||
query = ApplyStatusFilter(query);
|
||||
query = ApplySort(query);
|
||||
|
||||
TotalRecords = await query.CountAsync(cancellationToken);
|
||||
var requests = await query
|
||||
.Skip((CurrentPage - 1) * PageSize)
|
||||
.Take(PageSize)
|
||||
.Select(r => new
|
||||
{
|
||||
r.CreatedAt,
|
||||
r.ClientIpAddress,
|
||||
r.MobileIpAddress,
|
||||
r.FulfilledAt,
|
||||
r.RetrievedAt,
|
||||
r.Username,
|
||||
r.UserId
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RequestList = requests.Select(r => new MobileLoginRequestModel
|
||||
{
|
||||
CreatedAt = r.CreatedAt,
|
||||
ClientIpAddress = r.ClientIpAddress,
|
||||
MobileIpAddress = r.MobileIpAddress,
|
||||
FulfilledAt = r.FulfilledAt,
|
||||
RetrievedAt = r.RetrievedAt,
|
||||
Username = r.Username,
|
||||
UserId = r.UserId
|
||||
}).ToList();
|
||||
|
||||
IsLoading = false;
|
||||
IsInitialized = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected when cancellation is requested, do nothing
|
||||
}
|
||||
}
|
||||
|
||||
private IQueryable<MobileLoginRequest> ApplySearchFilter(IQueryable<MobileLoginRequest> query)
|
||||
{
|
||||
if (SearchTerm.Length > 0)
|
||||
{
|
||||
// Reset page number back to 1 if the search term has changed
|
||||
if (SearchTerm != _lastSearchTerm && CurrentPage != 1)
|
||||
{
|
||||
CurrentPage = 1;
|
||||
}
|
||||
_lastSearchTerm = SearchTerm;
|
||||
|
||||
var searchTerm = SearchTerm.Trim().ToLower();
|
||||
query = query.Where(r =>
|
||||
(r.Username != null && EF.Functions.Like(r.Username.ToLower(), "%" + searchTerm + "%")) ||
|
||||
(r.ClientIpAddress != null && EF.Functions.Like(r.ClientIpAddress.ToLower(), "%" + searchTerm + "%")) ||
|
||||
(r.MobileIpAddress != null && EF.Functions.Like(r.MobileIpAddress.ToLower(), "%" + searchTerm + "%"))
|
||||
);
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private IQueryable<MobileLoginRequest> ApplyStatusFilter(IQueryable<MobileLoginRequest> query)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(SelectedStatusFilter))
|
||||
{
|
||||
// Reset page number back to 1 if the filter has changed
|
||||
if (SelectedStatusFilter != _lastSelectedStatusFilter && CurrentPage != 1)
|
||||
{
|
||||
CurrentPage = 1;
|
||||
}
|
||||
_lastSelectedStatusFilter = SelectedStatusFilter;
|
||||
|
||||
switch (SelectedStatusFilter)
|
||||
{
|
||||
case "retrieved":
|
||||
query = query.Where(r => r.RetrievedAt != null);
|
||||
break;
|
||||
case "fulfilled":
|
||||
query = query.Where(r => r.FulfilledAt != null && r.RetrievedAt == null);
|
||||
break;
|
||||
case "pending":
|
||||
query = query.Where(r => r.FulfilledAt == null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private IQueryable<MobileLoginRequest> ApplySort(IQueryable<MobileLoginRequest> query)
|
||||
{
|
||||
switch (SortColumn)
|
||||
{
|
||||
case "CreatedAt":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.CreatedAt)
|
||||
: query.OrderByDescending(x => x.CreatedAt);
|
||||
break;
|
||||
case "ClientIpAddress":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.ClientIpAddress)
|
||||
: query.OrderByDescending(x => x.ClientIpAddress);
|
||||
break;
|
||||
case "MobileIpAddress":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.MobileIpAddress)
|
||||
: query.OrderByDescending(x => x.MobileIpAddress);
|
||||
break;
|
||||
case "FulfilledAt":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.FulfilledAt)
|
||||
: query.OrderByDescending(x => x.FulfilledAt);
|
||||
break;
|
||||
case "RetrievedAt":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.RetrievedAt)
|
||||
: query.OrderByDescending(x => x.RetrievedAt);
|
||||
break;
|
||||
case "Username":
|
||||
query = SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(x => x.Username)
|
||||
: query.OrderByDescending(x => x.Username);
|
||||
break;
|
||||
default:
|
||||
query = query.OrderByDescending(x => x.CreatedAt);
|
||||
break;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_searchCancellationTokenSource?.Cancel();
|
||||
_searchCancellationTokenSource?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
public class MobileLoginRequestModel
|
||||
{
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string? ClientIpAddress { get; set; }
|
||||
public string? MobileIpAddress { get; set; }
|
||||
public DateTime? FulfilledAt { get; set; }
|
||||
public DateTime? RetrievedAt { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,12 @@
|
||||
Title="@(TotalRecords > 0 ? $"Users ({TotalRecords:N0})" : "Users")"
|
||||
Description="This page shows an overview of all registered users and the associated vaults.">
|
||||
<CustomActions>
|
||||
<a href="mobile-login-history" class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-600 bg-gray-100 border border-gray-300 rounded-md hover:bg-gray-200 hover:text-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600 dark:hover:text-gray-200 mr-3">
|
||||
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Mobile Login History
|
||||
</a>
|
||||
<RefreshButton OnClick="() => RefreshData(CancellationToken.None)" ButtonText="Refresh" />
|
||||
</CustomActions>
|
||||
</PageHeader>
|
||||
|
||||
@@ -115,6 +115,7 @@ public class StatisticsService
|
||||
GetTopUsersByAliases72hAsync().ContinueWith(t => stats.TopUsersByAliases72h = t.Result),
|
||||
GetTopUsersByEmails72hAsync().ContinueWith(t => stats.TopUsersByEmails72h = t.Result),
|
||||
GetTopIpsByRegistrations72hAsync().ContinueWith(t => stats.TopIpsByRegistrations72h = t.Result),
|
||||
GetTopIpsByMobileLogins72hAsync().ContinueWith(t => stats.TopIpsByMobileLogins72h = t.Result),
|
||||
};
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
@@ -570,4 +571,36 @@ public class StatisticsService
|
||||
RegistrationCount72h = ip.RegistrationCount72h,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the top 20 IP addresses by number of mobile login requests in the last 72 hours.
|
||||
/// </summary>
|
||||
/// <returns>List of top IP addresses by mobile login requests.</returns>
|
||||
private async Task<List<RecentUsageMobileLogins>> GetTopIpsByMobileLogins72hAsync()
|
||||
{
|
||||
await using var context = await _contextFactory.CreateDbContextAsync();
|
||||
var cutoffDate = DateTime.UtcNow.AddHours(-72);
|
||||
|
||||
// Get mobile login requests by client IP
|
||||
var topIps = await context.MobileLoginRequests
|
||||
.Where(mlr => mlr.CreatedAt >= cutoffDate &&
|
||||
mlr.ClientIpAddress != null &&
|
||||
mlr.ClientIpAddress != "xxx.xxx.xxx.xxx")
|
||||
.GroupBy(mlr => mlr.ClientIpAddress)
|
||||
.Select(g => new
|
||||
{
|
||||
IpAddress = g.Key,
|
||||
MobileLoginCount72h = g.Count(),
|
||||
})
|
||||
.OrderByDescending(ip => ip.MobileLoginCount72h)
|
||||
.Take(20)
|
||||
.ToListAsync();
|
||||
|
||||
return topIps.Select(ip => new RecentUsageMobileLogins
|
||||
{
|
||||
OriginalIpAddress = ip.IpAddress!,
|
||||
IpAddress = AnonymizeIpAddress(ip.IpAddress!),
|
||||
MobileLoginCount72h = ip.MobileLoginCount72h,
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
await JsInteropService.GenerateQrCode("mobile-login-qr");
|
||||
|
||||
// Wait for QR code to be fully rendered before hiding loading animation
|
||||
await Task.Delay(500);
|
||||
await Task.Delay(300);
|
||||
|
||||
_isLoading = false;
|
||||
StateHasChanged();
|
||||
|
||||
@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
namespace AliasServerDb.Migrations
|
||||
{
|
||||
[DbContext(typeof(AliasServerDbContext))]
|
||||
[Migration("20251117162503_AddMobileLoginRequest")]
|
||||
[Migration("20251117175358_AddMobileLoginRequest")]
|
||||
partial class AddMobileLoginRequest
|
||||
{
|
||||
/// <inheritdoc />
|
||||
@@ -535,7 +535,17 @@ namespace AliasServerDb.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress");
|
||||
|
||||
b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt");
|
||||
|
||||
b.HasIndex(new[] { "FulfilledAt" }, "IX_FulfilledAt");
|
||||
|
||||
b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress");
|
||||
|
||||
b.HasIndex(new[] { "RetrievedAt" }, "IX_RetrievedAt");
|
||||
|
||||
b.HasIndex(new[] { "UserId" }, "IX_UserId");
|
||||
|
||||
b.ToTable("MobileLoginRequests");
|
||||
});
|
||||
@@ -40,7 +40,32 @@ namespace AliasServerDb.Migrations
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MobileLoginRequests_UserId",
|
||||
name: "IX_ClientIpAddress",
|
||||
table: "MobileLoginRequests",
|
||||
column: "ClientIpAddress");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CreatedAt",
|
||||
table: "MobileLoginRequests",
|
||||
column: "CreatedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FulfilledAt",
|
||||
table: "MobileLoginRequests",
|
||||
column: "FulfilledAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MobileIpAddress",
|
||||
table: "MobileLoginRequests",
|
||||
column: "MobileIpAddress");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RetrievedAt",
|
||||
table: "MobileLoginRequests",
|
||||
column: "RetrievedAt");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserId",
|
||||
table: "MobileLoginRequests",
|
||||
column: "UserId");
|
||||
}
|
||||
@@ -532,7 +532,17 @@ namespace AliasServerDb.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
b.HasIndex(new[] { "ClientIpAddress" }, "IX_ClientIpAddress");
|
||||
|
||||
b.HasIndex(new[] { "CreatedAt" }, "IX_CreatedAt");
|
||||
|
||||
b.HasIndex(new[] { "FulfilledAt" }, "IX_FulfilledAt");
|
||||
|
||||
b.HasIndex(new[] { "MobileIpAddress" }, "IX_MobileIpAddress");
|
||||
|
||||
b.HasIndex(new[] { "RetrievedAt" }, "IX_RetrievedAt");
|
||||
|
||||
b.HasIndex(new[] { "UserId" }, "IX_UserId");
|
||||
|
||||
b.ToTable("MobileLoginRequests");
|
||||
});
|
||||
|
||||
@@ -7,13 +7,21 @@
|
||||
|
||||
namespace AliasServerDb;
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// Mobile unlock request entity for storing temporary unlock requests.
|
||||
/// Mobile login request entity for storing temporary login requests.
|
||||
/// </summary>
|
||||
[Index(nameof(ClientIpAddress), Name = "IX_ClientIpAddress")]
|
||||
[Index(nameof(MobileIpAddress), Name = "IX_MobileIpAddress")]
|
||||
[Index(nameof(CreatedAt), Name = "IX_CreatedAt")]
|
||||
[Index(nameof(FulfilledAt), Name = "IX_FulfilledAt")]
|
||||
[Index(nameof(RetrievedAt), Name = "IX_RetrievedAt")]
|
||||
[Index(nameof(UserId), Name = "IX_UserId")]
|
||||
public class MobileLoginRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier for this unlock request.
|
||||
/// Gets or sets the unique identifier for this login request.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user