mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-03-19 23:28:23 -04:00
383 lines
14 KiB
Plaintext
383 lines
14 KiB
Plaintext
@page "/logging/auth"
|
|
@using AliasVault.RazorComponents.Tables
|
|
@using AliasVault.Shared.Models.Enums
|
|
@using Microsoft.AspNetCore.WebUtilities
|
|
@inherits MainBase
|
|
@inject NavigationManager Navigation
|
|
@implements IDisposable
|
|
|
|
<LayoutPageTitle>Auth logs</LayoutPageTitle>
|
|
|
|
<PageHeader
|
|
BreadcrumbItems="@BreadcrumbItems"
|
|
Title="@GetTitle()"
|
|
Description="This page shows an overview of recent auth attempts.">
|
|
<CustomActions>
|
|
<DeleteButton OnClick="DeleteLogsWithConfirmation" ButtonText="Delete all logs" />
|
|
<RefreshButton OnClick="RefreshData" ButtonText="Refresh" />
|
|
</CustomActions>
|
|
</PageHeader>
|
|
|
|
@if (IsInitialized)
|
|
{
|
|
<div class="px-4">
|
|
<Paginator CurrentPage="CurrentPage" PageSize="PageSize" TotalRecords="TotalRecords" OnPageChanged="HandlePageChanged" />
|
|
|
|
<div class="mb-3 flex space-x-4">
|
|
<div class="flex w-full">
|
|
<div class="w-1/2 pr-2">
|
|
<div class="relative">
|
|
<SearchIcon />
|
|
<input type="text" @bind-value="SearchTerm" @bind-value:event="oninput" id="search" placeholder="Search logs..." 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 px-1">
|
|
<select @bind="SelectedEventType" 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 event types</option>
|
|
@foreach (var eventType in Enum.GetValues<AuthEventType>())
|
|
{
|
|
<option value="@eventType">@eventType</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
<div class="w-1/4 pl-2">
|
|
<button type="button" @onclick="ToggleUniqueUsernames"
|
|
class="w-full px-4 py-2 text-sm font-medium rounded border transition-colors duration-200 @(ShowUniqueUsernames ? "bg-orange-400 text-white border-orange-500 hover:bg-orange-600" : "bg-white text-gray-700 border-gray-300 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-600")">
|
|
@(ShowUniqueUsernames ? "✓ Unique users" : "Unique users")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
@if (IsLoading)
|
|
{
|
|
<LoadingIndicator />
|
|
}
|
|
else
|
|
{
|
|
<div class="px-4">
|
|
<SortableTable Columns="@_tableColumns" SortColumn="@SortColumn" SortDirection="@SortDirection" OnSortChanged="HandleSortChanged">
|
|
@foreach (var log in LogList)
|
|
{
|
|
<SortableTableRow>
|
|
<SortableTableColumn IsPrimary="true">@log.Id</SortableTableColumn>
|
|
<SortableTableColumn>@log.Timestamp.ToString("yyyy-MM-dd HH:mm")</SortableTableColumn>
|
|
<SortableTableColumn>
|
|
@if (UserLookup.TryGetValue(log.Username, out var userId))
|
|
{
|
|
<a href="users/@userId">
|
|
@log.Username
|
|
</a>
|
|
}
|
|
else
|
|
{
|
|
<span class="italic">@log.Username</span>
|
|
}
|
|
</SortableTableColumn>
|
|
<SortableTableColumn>@log.Client</SortableTableColumn>
|
|
<SortableTableColumn>@log.EventType</SortableTableColumn>
|
|
<SortableTableColumn><StatusPill Enabled="log.IsSuccess" TextTrue="Success" TextFalse="@log.FailureReason.ToString()" /></SortableTableColumn>
|
|
<SortableTableColumn>@log.IpAddress</SortableTableColumn>
|
|
</SortableTableRow>
|
|
}
|
|
</SortableTable>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
private readonly List<TableColumn> _tableColumns = [
|
|
new TableColumn { Title = "ID", PropertyName = "Id" },
|
|
new TableColumn { Title = "Time", PropertyName = "Timestamp" },
|
|
new TableColumn { Title = "Username", PropertyName = "Username" },
|
|
new TableColumn { Title = "Client", PropertyName = "Client" },
|
|
new TableColumn { Title = "Event", PropertyName = "EventType" },
|
|
new TableColumn { Title = "Success", PropertyName = "IsSuccess" },
|
|
new TableColumn { Title = "IP", PropertyName = "IpAddress" },
|
|
];
|
|
|
|
private List<AuthLog> LogList { get; set; } = [];
|
|
private Dictionary<string, string> UserLookup { 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 string _lastSearchTerm = string.Empty;
|
|
private string SearchTerm
|
|
{
|
|
get => _searchTerm;
|
|
set
|
|
{
|
|
if (_searchTerm != value)
|
|
{
|
|
_searchTerm = value;
|
|
_ = RefreshData();
|
|
}
|
|
}
|
|
}
|
|
|
|
private string _selectedEventType = string.Empty;
|
|
private string SelectedEventType
|
|
{
|
|
get => _selectedEventType;
|
|
set
|
|
{
|
|
if (_selectedEventType != value)
|
|
{
|
|
_selectedEventType = value;
|
|
_ = RefreshData();
|
|
}
|
|
}
|
|
}
|
|
|
|
private bool ShowUniqueUsernames { get; set; } = false;
|
|
|
|
private void ToggleUniqueUsernames()
|
|
{
|
|
ShowUniqueUsernames = !ShowUniqueUsernames;
|
|
_ = RefreshData();
|
|
}
|
|
|
|
private string SortColumn { get; set; } = "Id";
|
|
private SortDirection SortDirection { get; set; } = SortDirection.Descending;
|
|
|
|
private async Task HandleSortChanged((string column, SortDirection direction) sort)
|
|
{
|
|
SortColumn = sort.column;
|
|
SortDirection = sort.direction;
|
|
await RefreshData();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
await base.OnInitializedAsync();
|
|
|
|
Navigation.LocationChanged += OnLocationChanged;
|
|
ParseQueryAndRefresh();
|
|
await RefreshData();
|
|
}
|
|
|
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
|
{
|
|
ParseQueryAndRefresh();
|
|
}
|
|
|
|
private void ParseQueryAndRefresh()
|
|
{
|
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
|
var queryParameters = QueryHelpers.ParseQuery(uri.Query);
|
|
|
|
if (queryParameters.TryGetValue("search", out var searchValue))
|
|
{
|
|
_searchTerm = searchValue.FirstOrDefault() ?? string.Empty;
|
|
}
|
|
else
|
|
{
|
|
_searchTerm = string.Empty;
|
|
}
|
|
|
|
if (_searchTerm != _lastSearchTerm)
|
|
{
|
|
_lastSearchTerm = _searchTerm;
|
|
_ = RefreshData(); // Fire and forget
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
Navigation.LocationChanged -= OnLocationChanged;
|
|
}
|
|
|
|
private void HandlePageChanged(int newPage)
|
|
{
|
|
CurrentPage = newPage;
|
|
_ = RefreshData();
|
|
}
|
|
|
|
private async Task RefreshData()
|
|
{
|
|
IsLoading = true;
|
|
StateHasChanged();
|
|
|
|
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
|
var query = dbContext.AuthLogs.AsQueryable();
|
|
|
|
if (!string.IsNullOrEmpty(SearchTerm))
|
|
{
|
|
// Reset page number back to 1 if the search term has changed.
|
|
if (SearchTerm != _lastSearchTerm)
|
|
{
|
|
CurrentPage = 1;
|
|
}
|
|
_lastSearchTerm = SearchTerm;
|
|
|
|
// If the search term starts with "client:", we search for the client header.
|
|
if (SearchTerm.StartsWith("client:", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var clientSearchTerm = SearchTerm.Substring(7).ToLower();
|
|
query = query.Where(x => EF.Functions.Like((x.Client ?? string.Empty).ToLower(), "%" + clientSearchTerm + "%"));
|
|
}
|
|
else
|
|
{
|
|
var searchTerm = SearchTerm.Trim().ToLower();
|
|
query = query.Where(x => EF.Functions.Like((x.Username ?? string.Empty).ToLower(), "%" + searchTerm + "%") ||
|
|
EF.Functions.Like((x.IpAddress ?? string.Empty).ToLower(), "%" + searchTerm + "%"));
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(SelectedEventType))
|
|
{
|
|
var success = Enum.TryParse<AuthEventType>(SelectedEventType, out var eventType);
|
|
if (success)
|
|
{
|
|
query = query.Where(x => x.EventType == eventType);
|
|
}
|
|
}
|
|
|
|
query = ApplySort(query);
|
|
|
|
// Handle unique usernames filtering after getting all results (since GroupBy with OrderBy in Select is complex for EF)
|
|
if (ShowUniqueUsernames)
|
|
{
|
|
// Get all matching records first
|
|
var allLogs = await query.ToListAsync();
|
|
|
|
// Group by username and take the latest entry for each username
|
|
var uniqueLogs = allLogs
|
|
.GroupBy(x => x.Username)
|
|
.Select(g => g.OrderByDescending(x => x.Timestamp).First())
|
|
.ToList();
|
|
|
|
// Apply pagination to the unique results
|
|
TotalRecords = uniqueLogs.Count;
|
|
LogList = uniqueLogs
|
|
.Skip((CurrentPage - 1) * PageSize)
|
|
.Take(PageSize)
|
|
.ToList();
|
|
}
|
|
else
|
|
{
|
|
TotalRecords = await query.CountAsync();
|
|
LogList = await query
|
|
.Skip((CurrentPage - 1) * PageSize)
|
|
.Take(PageSize)
|
|
.ToListAsync();
|
|
}
|
|
|
|
// Create user lookup dictionary for the current page
|
|
var usernames = LogList.Select(x => x.Username).Distinct().Where(x => !string.IsNullOrEmpty(x)).ToList();
|
|
var users = await dbContext.AliasVaultUsers
|
|
.Where(u => u.UserName != null && usernames.Contains(u.UserName))
|
|
.Select(u => new { u.UserName, u.Id })
|
|
.ToListAsync();
|
|
|
|
UserLookup = users.Where(u => u.UserName != null).ToDictionary(u => u.UserName!, u => u.Id);
|
|
|
|
IsLoading = false;
|
|
IsInitialized = true;
|
|
StateHasChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply sort to the query.
|
|
/// </summary>
|
|
private IQueryable<AuthLog> ApplySort(IQueryable<AuthLog> query)
|
|
{
|
|
// Apply sort.
|
|
switch (SortColumn)
|
|
{
|
|
case "Timestamp":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Timestamp)
|
|
: query.OrderByDescending(x => x.Timestamp);
|
|
break;
|
|
case "Username":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Username)
|
|
: query.OrderByDescending(x => x.Username);
|
|
break;
|
|
case "Client":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Client)
|
|
: query.OrderByDescending(x => x.Client);
|
|
break;
|
|
case "EventType":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.EventType)
|
|
: query.OrderByDescending(x => x.EventType);
|
|
break;
|
|
case "IsSuccess":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.IsSuccess)
|
|
: query.OrderByDescending(x => x.IsSuccess);
|
|
break;
|
|
case "IpAddress":
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.IpAddress)
|
|
: query.OrderByDescending(x => x.IpAddress);
|
|
break;
|
|
default:
|
|
query = SortDirection == SortDirection.Ascending
|
|
? query.OrderBy(x => x.Id)
|
|
: query.OrderByDescending(x => x.Id);
|
|
break;
|
|
}
|
|
|
|
return query;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Show confirmation modal to delete all logs.
|
|
/// </summary>
|
|
private async Task DeleteLogsWithConfirmation()
|
|
{
|
|
if (await ConfirmModalService.ShowConfirmation("Confirm Delete", "Are you sure you want to delete all logs? This action cannot be undone."))
|
|
{
|
|
await DeleteLogs();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete all logs.
|
|
/// </summary>
|
|
private async Task DeleteLogs()
|
|
{
|
|
IsLoading = true;
|
|
StateHasChanged();
|
|
|
|
await using var dbContext = await DbContextFactory.CreateDbContextAsync();
|
|
dbContext.AuthLogs.RemoveRange(dbContext.AuthLogs);
|
|
await dbContext.SaveChangesAsync();
|
|
await RefreshData();
|
|
|
|
IsLoading = false;
|
|
StateHasChanged();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the title of the page.
|
|
/// </summary>
|
|
/// <returns>The title of the page.</returns>
|
|
private string GetTitle()
|
|
{
|
|
if (TotalRecords == 0)
|
|
{
|
|
return "Auth logs";
|
|
}
|
|
|
|
var title = $"Auth logs ({TotalRecords:N0})";
|
|
if (ShowUniqueUsernames)
|
|
{
|
|
title += " - Unique usernames";
|
|
}
|
|
|
|
return title;
|
|
}
|
|
}
|