This commit is contained in:
6d486f49
2026-08-17 00:45:08 -04:00
parent 3a2f112a73
commit 1d6207fbfe
154 changed files with 126007 additions and 47385 deletions
@@ -31,8 +31,9 @@
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by card name or note content..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterNotes" />
<input type="text" class="form-control form-control-chrono"
placeholder="Search by card name or note content..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterNotes"/>
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
@@ -72,37 +73,44 @@
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 25%;">Card Name (Primary Key)</th>
<th>Note Content</th>
<th style="width: 140px;" class="text-end">Actions</th>
</tr>
<tr>
<th style="width: 20%;">User</th>
<th style="width: 25%;">Card Name</th>
<th>Note Content</th>
<th style="width: 140px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var note in notes)
{
<tr>
<td>
<div class="fw-semibold text-white d-flex align-items-center gap-2">
<i class="bi bi-card-heading text-primary"></i>
@note.CardName
</div>
</td>
<td>
<div class="text-light text-break">@note.Note</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(note)" title="Edit Note">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(note)" title="Delete Note">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
@foreach (var note in notes)
{
<tr>
<td>
<span
class="badge bg-secondary font-monospace">@(string.IsNullOrEmpty(note.Username) ? "global" : note.Username)</span>
</td>
<td>
<div class="fw-semibold text-white d-flex align-items-center gap-2">
<i class="bi bi-card-heading text-primary"></i>
@note.CardName
</div>
</td>
<td>
<div class="text-light text-break">@note.Note</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(note)"
title="Edit Note">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(note)"
title="Delete Note">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -122,24 +130,44 @@
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body-custom">
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">USERNAME</label>
@if (isEditing)
{
<input type="text" class="form-control form-control-chrono" value="@currentNote.Username"
disabled/>
}
else
{
<input type="text" class="form-control form-control-chrono"
placeholder="Enter username (e.g. player1)..." @bind="currentNote.Username"/>
<div class="form-text text-secondary">The username this note is associated with.</div>
}
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">CARD NAME</label>
@if (isEditing)
{
<input type="text" class="form-control form-control-chrono" value="@currentNote.CardName" disabled />
<div class="form-text text-secondary">Card name acts as the primary key and cannot be altered.</div>
<input type="text" class="form-control form-control-chrono" value="@currentNote.CardName"
disabled/>
<div class="form-text text-secondary">Card name acts as part of the primary key and cannot be
altered.
</div>
}
else
{
<input type="text" class="form-control form-control-chrono" list="cardSuggestions"
placeholder="Enter or select card name..." @bind="currentNote.CardName" />
placeholder="Enter or select card name..." @bind="currentNote.CardName"/>
<datalist id="cardSuggestions">
@foreach (var c in availableCardNames.Take(50))
{
<option value="@c" />
<option value="@c"/>
}
</datalist>
<div class="form-text text-secondary">Select an existing card from the database or enter a custom name.</div>
<div class="form-text text-secondary">Select an existing card from the database or enter a
custom name.
</div>
}
</div>
@@ -170,17 +198,20 @@
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Note
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete the note for:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3">
@noteToDelete.CardName
</div>
<p class="text-secondary small mb-0">This action will remove the record from the database permanently.</p>
<p class="text-secondary small mb-0">This action will remove the record from the database
permanently.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Permanently")
</button>
@@ -194,7 +225,7 @@
private List<string> availableCardNames = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private bool isSaving;
private string? alertMessage;
private bool alertIsSuccess;
@@ -208,7 +239,7 @@
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
try
{
availableCardNames = CardDatabase.Cards
@@ -272,6 +303,7 @@
isEditing = true;
currentNote = new CardNote
{
Username = note.Username,
CardName = note.CardName,
Note = note.Note
};
@@ -317,7 +349,7 @@
if (noteToDelete == null) return;
isSaving = true;
var result = await DbService.DeleteCardNoteAsync(noteToDelete.CardName);
var result = await DbService.DeleteCardNoteAsync(noteToDelete.CardName, noteToDelete.Username);
isSaving = false;
alertIsSuccess = result.Success;
@@ -340,4 +372,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -10,7 +10,7 @@
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private int currentCount;
private void IncrementCount()
{
@@ -6,7 +6,8 @@
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Settings & Migrations</h2>
<p class="text-secondary mb-0">Configure database providers, update connection strings, inspect EF Core migrations, and run maintenance tasks.</p>
<p class="text-secondary mb-0">Configure database providers, update connection strings, inspect EF Core
migrations, and run maintenance tasks.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync">
@@ -38,12 +39,14 @@
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">ACTIVE PROVIDER</label>
<div class="d-flex gap-2">
<button class="btn flex-fill @(selectedProvider == DatabaseProviderType.PostgreSQL ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.PostgreSQL)">
<button
class="btn flex-fill @(selectedProvider == DatabaseProviderType.PostgreSQL ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.PostgreSQL)">
<i class="bi bi-database me-1"></i> PostgreSQL (Cloud DB)
</button>
<button class="btn flex-fill @(selectedProvider == DatabaseProviderType.InMemory ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.InMemory)">
<button
class="btn flex-fill @(selectedProvider == DatabaseProviderType.InMemory ? "btn-chrono-primary" : "btn-chrono-secondary")"
@onclick="() => SelectProvider(DatabaseProviderType.InMemory)">
<i class="bi bi-cpu me-1"></i> In-Memory Test DB
</button>
</div>
@@ -80,7 +83,7 @@
<div class="mb-3">
<label class="form-label text-secondary small fw-bold">IN-MEMORY DATABASE INSTANCE NAME</label>
<input type="text" class="form-control form-control-chrono font-monospace"
@bind="inMemoryNameInput" />
@bind="inMemoryNameInput"/>
<div class="form-text text-secondary">
In-memory EF Core provider creates an isolated database in RAM for instant testing.
</div>
@@ -112,29 +115,34 @@
@if (health != null)
{
<div class="d-flex flex-column gap-2 mb-4">
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">Connection Status</span>
<span class="fw-bold @(health.IsConnected ? "text-success" : "text-danger")">
@(health.IsConnected ? "Connected" : "Disconnected")
</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">Query Latency</span>
<span class="text-white font-monospace">@(health.ResponseTimeMs) ms</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">CardNotes Table Rows</span>
<span class="text-primary fw-bold">@health.CardNotesCount</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">UserDecks Table Rows</span>
<span class="text-success fw-bold">@health.UserDecksCount</span>
</div>
<div class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<div
class="p-2 rounded bg-dark border border-secondary d-flex justify-content-between align-items-center">
<span class="text-secondary small">PasskeyCredentials Table Rows</span>
<span class="text-purple fw-bold">@health.PasskeysCount</span>
</div>
@@ -162,7 +170,8 @@
@if (migrationReport?.SupportsMigrations == true && migrationReport.PendingMigrations.Any())
{
<button class="btn btn-chrono-primary btn-sm" @onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<i class="bi bi-arrow-up-circle-fill me-1"></i> Apply Pending Migrations (@migrationReport.PendingMigrations.Count)
<i class="bi bi-arrow-up-circle-fill me-1"></i> Apply Pending Migrations
(@migrationReport.PendingMigrations.Count)
</button>
}
</div>
@@ -170,14 +179,16 @@
@if (migrationReport == null)
{
<div class="text-center py-4 text-secondary">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> Loading migration report...
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
Loading migration report...
</div>
}
else if (!migrationReport.SupportsMigrations)
{
<div class="p-3 bg-dark rounded border border-secondary text-secondary small">
<i class="bi bi-info-circle me-1"></i>
Migrations are managed via relational schema (PostgreSQL). The active in-memory provider does not use relational migration tables.
Migrations are managed via relational schema (PostgreSQL). The active in-memory provider does not use
relational migration tables.
</div>
}
else
@@ -200,7 +211,8 @@
<div class="d-flex flex-column gap-2" style="max-height: 250px; overflow-y: auto;">
@foreach (var m in migrationReport.AppliedMigrations)
{
<div class="p-2 rounded bg-dark border border-success border-opacity-50 d-flex align-items-center justify-content-between">
<div
class="p-2 rounded bg-dark border border-success border-opacity-50 d-flex align-items-center justify-content-between">
<span class="text-white small font-monospace">@m</span>
<span class="badge bg-success"><i class="bi bi-check"></i> Applied</span>
</div>
@@ -218,7 +230,8 @@
@if (!migrationReport.PendingMigrations.Any())
{
<div class="p-3 bg-dark rounded border border-success border-opacity-25 text-success small">
<i class="bi bi-shield-check me-1"></i> All migrations are applied. Schema is in sync with AppDbContext.
<i class="bi bi-shield-check me-1"></i> All migrations are applied. Schema is in sync with
AppDbContext.
</div>
}
else
@@ -226,7 +239,8 @@
<div class="d-flex flex-column gap-2" style="max-height: 250px; overflow-y: auto;">
@foreach (var m in migrationReport.PendingMigrations)
{
<div class="p-2 rounded bg-dark border border-warning border-opacity-50 d-flex align-items-center justify-content-between">
<div
class="p-2 rounded bg-dark border border-warning border-opacity-50 d-flex align-items-center justify-content-between">
<span class="text-warning small font-monospace">@m</span>
<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle"></i> Pending</span>
</div>
@@ -357,4 +371,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -15,12 +15,14 @@
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
Swapping to <strong>Development</strong> environment will display more detailed information about the error that
occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong>
environment variable to <strong>Development</strong>
and restarting the app.
</p>
@@ -30,7 +32,9 @@
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
protected override void OnInitialized()
{
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
}
+52 -20
View File
@@ -6,7 +6,8 @@
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Test Dashboard</h2>
<p class="text-secondary mb-0">Monitor, manage, and validate all database features referenced in Chrono Cloud</p>
<p class="text-secondary mb-0">Monitor, manage, and validate all database features referenced in Chrono
Cloud</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="RefreshAsync" disabled="@isLoading">
@@ -78,49 +79,66 @@
<!-- Metrics Row -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="col-md">
<a href="card-notes" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-blue">
<i class="bi bi-card-text"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.CardNotesCount.ToString() : "-")</div>
<div
class="metric-value">@(health?.IsConnected == true ? health.CardNotesCount.ToString() : "-")</div>
<div class="metric-label">Card Notes</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<div class="col-md">
<a href="users" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
<i class="bi bi-people-fill"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.UsersCount.ToString() : "-")</div>
<div class="metric-label">Users</div>
</div>
</div>
</a>
</div>
<div class="col-md">
<a href="user-decks" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-green">
<i class="bi bi-collection-play"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.UserDecksCount.ToString() : "-")</div>
<div
class="metric-value">@(health?.IsConnected == true ? health.UserDecksCount.ToString() : "-")</div>
<div class="metric-label">User Decks</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<div class="col-md">
<a href="passkeys" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-purple">
<i class="bi bi-key-fill"></i>
</div>
<div>
<div class="metric-value">@(health?.IsConnected == true ? health.PasskeysCount.ToString() : "-")</div>
<div
class="metric-value">@(health?.IsConnected == true ? health.PasskeysCount.ToString() : "-")</div>
<div class="metric-label">Passkeys</div>
</div>
</div>
</a>
</div>
<div class="col-md-3">
<div class="col-md">
<a href="diagnostics" class="text-decoration-none">
<div class="metric-card">
<div class="metric-icon-box metric-icon-yellow">
@@ -128,11 +146,11 @@
</div>
<div>
<div class="metric-value">
@(health?.ProviderType == DatabaseProviderType.PostgreSQL
? $"{health.AppliedMigrationsCount} / {health.AppliedMigrationsCount + health.PendingMigrationsCount}"
@(health?.ProviderType == DatabaseProviderType.PostgreSQL
? $"{health.AppliedMigrationsCount} / {health.AppliedMigrationsCount + health.PendingMigrationsCount}"
: "N/A")
</div>
<div class="metric-label">Applied Migrations</div>
<div class="metric-label">Migrations</div>
</div>
</div>
</a>
@@ -158,7 +176,8 @@
<a href="card-notes" class="btn btn-sm btn-chrono-secondary">Manage Notes</a>
</div>
<p class="text-secondary small mb-0">
Tracks developer and user strategic card notes with primary key indexing on <code>CardName</code>. Supports live search, adding, updating, and deletion.
Tracks developer and user strategic card notes with primary key indexing on
<code>CardName</code>. Supports live search, adding, updating, and deletion.
</p>
</div>
@@ -168,7 +187,8 @@
<a href="user-decks" class="btn btn-sm btn-chrono-secondary">Manage Decks</a>
</div>
<p class="text-secondary small mb-0">
Stores full player decks with native PostgreSQL <code>jsonb</code> mapping for <code>Cards</code> list (with counts) and <code>Divers</code> list.
Stores full player decks with native PostgreSQL <code>jsonb</code> mapping for
<code>Cards</code> list (with counts) and <code>Divers</code> list.
</p>
</div>
@@ -178,7 +198,8 @@
<a href="passkeys" class="btn btn-sm btn-chrono-secondary">Manage Passkeys</a>
</div>
<p class="text-secondary small mb-0">
Stores WebAuthn / FIDO2 binary credentials (raw <code>byte[]</code> for CredentialId, PublicKey, UserHandle) with counter verification.
Stores WebAuthn / FIDO2 binary credentials (raw <code>byte[]</code> for CredentialId, PublicKey,
UserHandle) with counter verification.
</p>
</div>
</div>
@@ -195,37 +216,47 @@
</div>
<div class="d-flex flex-column gap-2">
<button class="btn btn-chrono-primary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SeedDataAsync" disabled="@isActionBusy">
<button class="btn btn-chrono-primary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="SeedDataAsync" disabled="@isActionBusy">
<span><i class="bi bi-box-arrow-in-down me-2"></i> Seed Realistic Sample Data</span>
<i class="bi bi-arrow-right"></i>
</button>
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<button
class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="ApplyMigrationsAsync" disabled="@isActionBusy">
<span><i class="bi bi-database-check me-2"></i> Apply Pending Migrations</span>
<i class="bi bi-arrow-right"></i>
</button>
@if (ConfigService.ProviderType == DatabaseProviderType.PostgreSQL)
{
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SwitchToInMemoryAsync">
<button
class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="SwitchToInMemoryAsync">
<span><i class="bi bi-cpu me-2"></i> Switch to In-Memory DB</span>
<span class="badge bg-purple">Instant</span>
</button>
}
else
{
<button class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between" @onclick="SwitchToPostgresAsync">
<button
class="btn btn-chrono-secondary py-2 text-start d-flex align-items-center justify-content-between"
@onclick="SwitchToPostgresAsync">
<span><i class="bi bi-database me-2"></i> Switch to PostgreSQL</span>
<span class="badge bg-primary">Live</span>
</button>
}
<a href="test-suite" class="btn btn-chrono-outline-primary py-2 text-start d-flex align-items-center justify-content-between">
<a href="test-suite"
class="btn btn-chrono-outline-primary py-2 text-start d-flex align-items-center justify-content-between">
<span><i class="bi bi-clipboard2-check me-2"></i> Open Test Suite Runner</span>
<i class="bi bi-arrow-right"></i>
</a>
<button class="btn btn-chrono-danger py-2 text-start d-flex align-items-center justify-content-between mt-2" @onclick="ResetDatabaseAsync" disabled="@isActionBusy">
<button
class="btn btn-chrono-danger py-2 text-start d-flex align-items-center justify-content-between mt-2"
@onclick="ResetDatabaseAsync" disabled="@isActionBusy">
<span><i class="bi bi-trash3-fill me-2"></i> Wipe & Reset Database</span>
<i class="bi bi-exclamation-triangle"></i>
</button>
@@ -310,4 +341,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -1,13 +1,14 @@
@page "/passkeys"
@implements IDisposable
@using System.Security.Cryptography
@implements IDisposable
<PageTitle>Passkeys Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Passkey Credentials Database (`PasskeyCredential`)</h2>
<p class="text-secondary mb-0">Browse, add, edit, and delete WebAuthn / FIDO2 security credentials with raw binary byte arrays.</p>
<p class="text-secondary mb-0">Browse, add, edit, and delete WebAuthn / FIDO2 security credentials with raw
binary byte arrays.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadPasskeysAsync">
@@ -32,8 +33,9 @@
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by AaGuid, ID, or SignCount..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterPasskeys" />
<input type="text" class="form-control form-control-chrono"
placeholder="Search by AaGuid, ID, or SignCount..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterPasskeys"/>
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
@@ -73,53 +75,61 @@
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 80px;">ID</th>
<th style="width: 220px;">AAGUID</th>
<th style="width: 120px;">Sign Count</th>
<th>Credential ID (Byte[])</th>
<th>Public Key (Byte[])</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
<tr>
<th style="width: 80px;">ID</th>
<th style="width: 220px;">AAGUID</th>
<th style="width: 120px;">Sign Count</th>
<th>Credential ID (Byte[])</th>
<th>Public Key (Byte[])</th>
<th style="width: 170px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var p in passkeys)
{
<tr>
<td>
<span class="badge bg-dark border border-secondary text-white">#@p.Id</span>
</td>
<td>
<div class="text-white small font-monospace">@(string.IsNullOrEmpty(p.AaGuid) ? "(None)" : p.AaGuid)</div>
</td>
<td>
<span class="badge bg-purple">@p.SignCount</span>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.CredentialId)">
@Convert.ToHexString(p.CredentialId)[..Math.Min(16, p.CredentialId.Length * 2)]... (@p.CredentialId.Length bytes)
</div>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;" title="@Convert.ToHexString(p.PublicKey)">
@Convert.ToHexString(p.PublicKey)[..Math.Min(16, p.PublicKey.Length * 2)]... (@p.PublicKey.Length bytes)
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(p)" title="View Binary Details">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(p)" title="Edit Passkey">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(p)" title="Delete Passkey">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
@foreach (var p in passkeys)
{
<tr>
<td>
<span class="badge bg-dark border border-secondary text-white">#@p.Id</span>
</td>
<td>
<div
class="text-white small font-monospace">@(string.IsNullOrEmpty(p.AaGuid) ? "(None)" : p.AaGuid)</div>
</td>
<td>
<span class="badge bg-purple">@p.SignCount</span>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;"
title="@Convert.ToHexString(p.CredentialId)">
@Convert.ToHexString(p.CredentialId)[..Math.Min(16, p.CredentialId.Length * 2)]...
(@p.CredentialId.Length bytes)
</div>
</td>
<td>
<div class="text-secondary small font-monospace text-truncate" style="max-width: 200px;"
title="@Convert.ToHexString(p.PublicKey)">
@Convert.ToHexString(p.PublicKey)[..Math.Min(16, p.PublicKey.Length * 2)]...
(@p.PublicKey.Length bytes)
</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenViewModal(p)"
title="View Binary Details">
<i class="bi bi-eye-fill"></i>
</button>
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(p)"
title="Edit Passkey">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(p)"
title="Delete Passkey">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
@@ -151,13 +161,14 @@
<div class="row g-3 mb-3">
<div class="col-md-8">
<label class="form-label text-secondary small fw-bold">AAGUID</label>
<input type="text" class="form-control form-control-chrono font-monospace" placeholder="e.g. 00000000-0000-0000-0000-000000000000"
@bind="currentPasskey.AaGuid" />
<input type="text" class="form-control form-control-chrono font-monospace"
placeholder="e.g. 00000000-0000-0000-0000-000000000000"
@bind="currentPasskey.AaGuid"/>
</div>
<div class="col-md-4">
<label class="form-label text-secondary small fw-bold">SIGN COUNT</label>
<input type="number" class="form-control form-control-chrono" min="0"
@bind="currentPasskey.SignCount" />
@bind="currentPasskey.SignCount"/>
</div>
</div>
@@ -188,7 +199,7 @@
</div>
<input type="text" class="form-control form-control-chrono font-monospace"
placeholder="e.g. DEADBEAF01020304"
@bind="userHandleHex" />
@bind="userHandleHex"/>
</div>
</div>
<div class="modal-footer-custom">
@@ -254,7 +265,8 @@
</div>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close</button>
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showViewModal = false">Close
</button>
</div>
</div>
</div>
@@ -270,17 +282,20 @@
<i class="bi bi-exclamation-triangle-fill me-2"></i>
Confirm Delete Passkey
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="() => showDeleteModal = false"></button>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body-custom">
<p class="text-white mb-2">Are you sure you want to delete passkey credential:</p>
<div class="p-2 rounded bg-dark border border-secondary fw-bold text-primary mb-3 font-monospace">
Passkey #@passkeyToDelete.Id (AAGUID: @(passkeyToDelete.AaGuid ?? "N/A"))
</div>
<p class="text-secondary small mb-0">This will permanently delete the binary security credential from the database.</p>
<p class="text-secondary small mb-0">This will permanently delete the binary security credential from
the database.</p>
</div>
<div class="modal-footer-custom">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel</button>
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync" disabled="@isSaving">
@(isSaving ? "Deleting..." : "Delete Passkey")
</button>
@@ -293,18 +308,20 @@
private List<PasskeyCredential> passkeys = [];
private string searchTerm = "";
private bool isLoading = true;
private bool isSaving = false;
private bool isSaving;
private string? alertMessage;
private bool alertIsSuccess;
private bool showModal;
private bool isEditing;
private PasskeyCredential currentPasskey = new()
{
CredentialId = [],
PublicKey = [],
UserHandle = []
};
private string credIdHex = "";
private string pubKeyHex = "";
private string userHandleHex = "";
@@ -429,12 +446,14 @@
alertMessage = "Invalid Credential ID hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanPub) || cleanPub.Length % 2 != 0)
{
alertIsSuccess = false;
alertMessage = "Invalid Public Key hex string (must be even length).";
return;
}
if (string.IsNullOrWhiteSpace(cleanUser) || cleanUser.Length % 2 != 0)
{
alertIsSuccess = false;
@@ -506,4 +525,5 @@
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
@@ -6,7 +6,8 @@
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Database Feature Test Suite</h2>
<p class="text-secondary mb-0">Automated end-to-end verification of all database features, constraints, and JSONB mappings referenced in Chrono Cloud.</p>
<p class="text-secondary mb-0">Automated end-to-end verification of all database features, constraints, and
JSONB mappings referenced in Chrono Cloud.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="ClearResults" disabled="@isRunning">
@@ -47,7 +48,8 @@
<div class="col-md-3">
<div class="metric-card">
<div class="metric-icon-box @(testResults.Any(t => t.Status == TestStatus.Failed) ? "metric-icon-red" : "metric-icon-purple")">
<div
class="metric-icon-box @(testResults.Any(t => t.Status == TestStatus.Failed) ? "metric-icon-red" : "metric-icon-purple")">
<i class="bi @(testResults.Any(t => t.Status == TestStatus.Failed) ? "bi-x-circle-fill text-danger" : "bi-shield-check")"></i>
</div>
<div>
@@ -94,7 +96,8 @@
</h5>
<div class="d-flex align-items-center gap-2">
<span class="text-secondary small">Target Provider:</span>
<span class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")">
<span
class="badge @(ConfigService.ProviderType == DatabaseProviderType.PostgreSQL ? "bg-primary" : "bg-purple")">
@ConfigService.ProviderType
</span>
</div>
@@ -121,7 +124,8 @@
<div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary font-monospace">@test.Id</span>
<span class="fw-bold text-white fs-6">@test.Name</span>
<span class="badge bg-dark border border-secondary text-secondary small">@test.Category</span>
<span
class="badge bg-dark border border-secondary text-secondary small">@test.Category</span>
</div>
<div class="d-flex align-items-center gap-3">
@@ -187,7 +191,7 @@
@code {
private List<DatabaseTestCaseResult> testResults = [];
private HashSet<string> expandedTestLogs = [];
private readonly HashSet<string> expandedTestLogs = [];
private bool isRunning;
private string currentProgressStatus = "";
@@ -240,24 +244,31 @@
expandedTestLogs.Add(testId);
}
private static string GetBorderClass(TestStatus status) => status switch
private static string GetBorderClass(TestStatus status)
{
TestStatus.Passed => "border-success border-opacity-50",
TestStatus.Failed => "border-danger border-opacity-75",
TestStatus.Running => "border-primary border-opacity-50",
_ => "border-secondary border-opacity-25"
};
return status switch
{
TestStatus.Passed => "border-success border-opacity-50",
TestStatus.Failed => "border-danger border-opacity-75",
TestStatus.Running => "border-primary border-opacity-50",
_ => "border-secondary border-opacity-25"
};
}
private static string GetBadgeClass(TestStatus status) => status switch
private static string GetBadgeClass(TestStatus status)
{
TestStatus.Passed => "bg-success",
TestStatus.Failed => "bg-danger",
TestStatus.Running => "bg-primary",
_ => "bg-secondary"
};
return status switch
{
TestStatus.Passed => "bg-success",
TestStatus.Failed => "bg-danger",
TestStatus.Running => "bg-primary",
_ => "bg-secondary"
};
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,344 @@
@page "/users"
@implements IDisposable
<PageTitle>Users Management - Chrono DB</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="fw-bold mb-1 text-white">Users Database (`User`)</h2>
<p class="text-secondary mb-0">Browse, add, edit credentials, and delete user accounts.</p>
</div>
<div class="d-flex gap-2">
<button class="btn btn-chrono-secondary" @onclick="LoadUsersAsync">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add New User
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(alertMessage))
{
<div class="alert @(alertIsSuccess ? "alert-success" : "alert-danger") alert-dismissible fade show" role="alert">
<i class="bi @(alertIsSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@alertMessage
<button type="button" class="btn-close" @onclick="() => alertMessage = null"></button>
</div>
}
<div class="chrono-card">
<div class="d-flex align-items-center justify-content-between mb-3 gap-3">
<div class="input-group" style="max-width: 400px;">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control form-control-chrono" placeholder="Search by username..."
@bind="searchTerm" @bind:event="oninput" @bind:after="FilterUsers"/>
@if (!string.IsNullOrEmpty(searchTerm))
{
<button class="btn btn-outline-secondary" @onclick="ClearSearch"><i class="bi bi-x-lg"></i></button>
}
</div>
<div class="text-secondary small">
Showing @users.Count user(s)
</div>
</div>
@if (isLoading)
{
<div class="text-center py-5 text-secondary">
<div class="spinner-border text-primary mb-2" role="status"></div>
<div>Loading users from database...</div>
</div>
}
else if (!users.Any())
{
<div class="text-center py-5 text-secondary">
<i class="bi bi-people fs-1 d-block mb-2"></i>
<h5>No Users Found</h5>
<p class="small">There are currently no users in the database.</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<button class="btn btn-chrono-primary" @onclick="OpenAddModal">
<i class="bi bi-plus-lg"></i> Add First User
</button>
<button class="btn btn-chrono-secondary" @onclick="SeedUsersAsync">
<i class="bi bi-box-arrow-in-down"></i> Seed Sample Users
</button>
</div>
</div>
}
else
{
<div class="table-responsive">
<table class="chrono-table">
<thead>
<tr>
<th style="width: 30%;">Username (Primary Key)</th>
<th style="width: 35%;">Password Hash / Credential</th>
<th style="width: 20%;">Created At</th>
<th style="width: 140px;" class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var user in users)
{
<tr>
<td>
<div class="fw-semibold text-white d-flex align-items-center gap-2">
<i class="bi bi-person-circle text-info"></i>
@user.Username
</div>
</td>
<td>
<div class="text-secondary font-monospace">••••••••</div>
</td>
<td>
<div class="text-secondary small">@user.CreatedAt.ToString("g")</div>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-chrono-secondary" @onclick="() => OpenEditModal(user)"
title="Edit Password">
<i class="bi bi-pencil-fill"></i>
</button>
<button class="btn btn-chrono-danger" @onclick="() => OpenDeleteModal(user)"
title="Delete User">
<i class="bi bi-trash3-fill"></i>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@if (showEditModal)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content chrono-modal">
<div class="modal-header border-secondary border-opacity-25">
<h5 class="modal-title text-white">
<i class="bi @(isNewUser ? "bi-person-plus-fill" : "bi-pencil-square") me-2 text-primary"></i>
@(isNewUser ? "Add New User" : $"Edit User: {currentUser.Username}")
</h5>
<button type="button" class="btn-close btn-close-white" @onclick="CloseModal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">USERNAME</label>
<input type="text" class="form-control form-control-chrono"
@bind="currentUser.Username" disabled="@(!isNewUser)"
placeholder="e.g. player1"/>
</div>
<div class="mb-3">
<label class="form-label text-secondary small fw-semibold">PASSWORD</label>
<input type="password" class="form-control form-control-chrono"
@bind="currentUser.Password"
placeholder="Enter password..."/>
</div>
</div>
<div class="modal-footer border-secondary border-opacity-25">
<button type="button" class="btn btn-chrono-secondary" @onclick="CloseModal">Cancel</button>
<button type="button" class="btn btn-chrono-primary" @onclick="SaveUserAsync" disabled="@isSaving">
<i class="bi bi-save me-1"></i> @(isSaving ? "Saving..." : "Save User")
</button>
</div>
</div>
</div>
</div>
}
@if (showDeleteModal && userToDelete != null)
{
<div class="modal-backdrop fade show"></div>
<div class="modal fade show d-block" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content chrono-modal">
<div class="modal-header border-secondary border-opacity-25">
<h5 class="modal-title text-danger">
<i class="bi bi-exclamation-triangle-fill me-2"></i> Confirm Delete
</h5>
<button type="button" class="btn-close btn-close-white"
@onclick="() => showDeleteModal = false"></button>
</div>
<div class="modal-body">
<p class="text-light mb-0">
Are you sure you want to delete user <strong class="text-white">@userToDelete.Username</strong>?
</p>
</div>
<div class="modal-footer border-secondary border-opacity-25">
<button type="button" class="btn btn-chrono-secondary" @onclick="() => showDeleteModal = false">
Cancel
</button>
<button type="button" class="btn btn-chrono-danger" @onclick="ConfirmDeleteAsync"
disabled="@isSaving">
<i class="bi bi-trash3-fill me-1"></i> @(isSaving ? "Deleting..." : "Delete")
</button>
</div>
</div>
</div>
</div>
}
@code {
private List<User> users = [];
private bool isLoading = true;
private bool isSaving;
private string searchTerm = string.Empty;
private bool showEditModal;
private bool isNewUser;
private User currentUser = new();
private bool showDeleteModal;
private User? userToDelete;
private string? alertMessage;
private bool alertIsSuccess;
protected override async Task OnInitializedAsync()
{
ConfigService.OnConfigurationChanged += HandleConfigChanged;
await LoadUsersAsync();
}
private async void HandleConfigChanged()
{
await InvokeAsync(LoadUsersAsync);
}
private async Task LoadUsersAsync()
{
isLoading = true;
alertMessage = null;
try
{
users = await DbService.GetUsersAsync(searchTerm);
}
catch (Exception ex)
{
alertMessage = $"Error loading users: {ex.Message}";
alertIsSuccess = false;
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private async Task FilterUsers()
{
users = await DbService.GetUsersAsync(searchTerm);
}
private async Task ClearSearch()
{
searchTerm = string.Empty;
await LoadUsersAsync();
}
private void OpenAddModal()
{
currentUser = new User();
isNewUser = true;
showEditModal = true;
}
private void OpenEditModal(User user)
{
currentUser = new User
{
Username = user.Username,
Password = user.Password,
CreatedAt = user.CreatedAt
};
isNewUser = false;
showEditModal = true;
}
private void CloseModal()
{
showEditModal = false;
}
private async Task SaveUserAsync()
{
if (string.IsNullOrWhiteSpace(currentUser.Username) || string.IsNullOrWhiteSpace(currentUser.Password))
{
alertMessage = "Username and password cannot be empty.";
alertIsSuccess = false;
return;
}
isSaving = true;
try
{
var result = await DbService.SaveUserAsync(currentUser, isNewUser);
alertMessage = result.Message;
alertIsSuccess = result.Success;
if (result.Success)
{
showEditModal = false;
await LoadUsersAsync();
}
}
finally
{
isSaving = false;
}
}
private void OpenDeleteModal(User user)
{
userToDelete = user;
showDeleteModal = true;
}
private async Task ConfirmDeleteAsync()
{
if (userToDelete == null) return;
isSaving = true;
try
{
var result = await DbService.DeleteUserAsync(userToDelete.Username);
alertMessage = result.Message;
alertIsSuccess = result.Success;
showDeleteModal = false;
userToDelete = null;
await LoadUsersAsync();
}
finally
{
isSaving = false;
}
}
private async Task SeedUsersAsync()
{
isLoading = true;
try
{
var res = await DbService.SeedSampleDataAsync();
alertMessage = res.Message;
alertIsSuccess = res.Success;
await LoadUsersAsync();
}
finally
{
isLoading = false;
}
}
public void Dispose()
{
ConfigService.OnConfigurationChanged -= HandleConfigChanged;
}
}