From 09ec2cf1d93cf025a11aa9a6a5fe790753675b54 Mon Sep 17 00:00:00 2001 From: Claude Bot Date: Fri, 25 Sep 2026 08:24:00 +0200 Subject: [PATCH] feat(ui): Facts page deposits a raw source under the signed-in user Same DepositFactUseCase as POST /api/v1/facts, attributed as a human identity from the Authentik proxy header. Cascading authentication state carries the principal into the Blazor circuit; without a signed-in user the form is replaced by a notice. Lists the raw sources awaiting the nightly distill. Co-Authored-By: Claude Fable 5.1 --- .../Components/Layout/MainLayout.razor | 1 + src/Wiki.Host/Components/Pages/Facts.razor | 165 ++++++++++++++++++ src/Wiki.Host/Program.cs | 1 + test/Wiki.Host.Tests/EndpointTests.cs | 12 ++ 4 files changed, 179 insertions(+) create mode 100644 src/Wiki.Host/Components/Pages/Facts.razor diff --git a/src/Wiki.Host/Components/Layout/MainLayout.razor b/src/Wiki.Host/Components/Layout/MainLayout.razor index 0d52127..f66687d 100644 --- a/src/Wiki.Host/Components/Layout/MainLayout.razor +++ b/src/Wiki.Host/Components/Layout/MainLayout.razor @@ -14,6 +14,7 @@ Search Answers + Facts Browse Maintenance Lab diff --git a/src/Wiki.Host/Components/Pages/Facts.razor b/src/Wiki.Host/Components/Pages/Facts.razor new file mode 100644 index 0000000..981e1c1 --- /dev/null +++ b/src/Wiki.Host/Components/Pages/Facts.razor @@ -0,0 +1,165 @@ +@page "/facts" +@rendermode InteractiveServer +@using Microsoft.AspNetCore.Components.Authorization +@using Wiki.Application.Facts +@inject DepositFactUseCase Deposit +@inject NoteQueries Notes +@inject TimeProvider Clock +@inject ISnackbar Snackbar + +Facts + +Facts + + A fact is one verified observation with its provenance. It lands verbatim in raw/ under your name and the nightly distill turns it into wiki notes. Raw holds observations only, never hypotheses. + + +@if (_user is null) +{ + Not signed in. Facts are attributed to the person depositing them, so this page needs the Authentik sign-in at wiki.renner.uno. +} +else +{ + + + + + + + + + + + + + + + + + + + + + + captured_by: @_user + Deposit + + +} + +@if (_running) +{ + +} +@if (_error is not null) +{ + @_error +} +@if (_receipt is not null) +{ + + Stored as @_receipt.Path (commit @_receipt.Commit[..Math.Min(8, _receipt.Commit.Length)]). + +} + +Raw sources awaiting distillation +@if (_raw is null) +{ + +} +else if (_raw.Count == 0) +{ + Nothing queued. +} +else +{ + + + CapturedTitleSource + + + @foreach (var r in _raw.OrderByDescending(r => r.Captured).ThenByDescending(r => r.Slug).Take(30)) + { + + @(r.Captured?.ToString("yyyy-MM-dd") ?? "") + @r.Title + @r.Source + + } + + +} + +@code { + [CascadingParameter] private Task? AuthState { get; set; } + + private MudForm? _form; + private bool _valid; + private bool _running; + private string? _user; + private string? _title; + private string? _source; + private string? _tags; + private string? _content; + private DateTime? _observedDate; + private string? _error; + private FactReceipt? _receipt; + private IReadOnlyList? _raw; + + protected override async Task OnInitializedAsync() + { + _observedDate = Clock.GetUtcNow().Date; + if (AuthState is not null) + { + var state = await AuthState; + _user = state.User.Identity?.IsAuthenticated == true ? state.User.Identity.Name : null; + } + await LoadRawAsync(); + } + + private async Task LoadRawAsync() => _raw = await Notes.RawQueueAsync(CancellationToken.None); + + private async Task DepositAsync() + { + if (_running || _user is null) return; + _running = true; + _error = null; + _receipt = null; + try + { + var observed = _observedDate is { } d ? new DateTimeOffset(DateTime.SpecifyKind(d, DateTimeKind.Utc)) : Clock.GetUtcNow(); + var fact = new RawSource( + _title?.Trim() ?? string.Empty, + _source?.Trim() ?? string.Empty, + _content ?? string.Empty, + observed, + (_tags ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + var errors = fact.Validate(); + if (errors.Count > 0) + { + _error = string.Join("; ", errors); + return; + } + _receipt = await Deposit.DepositAsync(fact, new Identity(_user, IdentityKind.Human), CancellationToken.None); + Snackbar.Add($"stored {_receipt.Path}", Severity.Success); + _title = _source = _tags = _content = null; + await LoadRawAsync(); + } + catch (HttpRequestException e) + { + _error = $"The store did not accept the fact: {e.Message}"; + } + finally + { + _running = false; + } + } + + private static string Slug(string path) + { + var file = path[(path.LastIndexOf('/') + 1)..]; + return file.EndsWith(".md", StringComparison.Ordinal) ? file[..^3] : file; + } +} diff --git a/src/Wiki.Host/Program.cs b/src/Wiki.Host/Program.cs index 6d1d86f..1673c5e 100644 --- a/src/Wiki.Host/Program.cs +++ b/src/Wiki.Host/Program.cs @@ -132,6 +132,7 @@ builder.Services.AddAuthorization(options => // ---- UI ---------------------------------------------------------------------------------- builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddCascadingAuthenticationState(); builder.Services.AddMudServices(); var app = builder.Build(); diff --git a/test/Wiki.Host.Tests/EndpointTests.cs b/test/Wiki.Host.Tests/EndpointTests.cs index e31b34a..b013147 100644 --- a/test/Wiki.Host.Tests/EndpointTests.cs +++ b/test/Wiki.Host.Tests/EndpointTests.cs @@ -310,6 +310,18 @@ public class EndpointTests(WikiHostFactory factory) : IClassFixture Rows); + [Fact] + public async Task FactsPageRendersAndNamesTheSignInWhenAnonymous() + { + // The test server has no peer address, so the proxy identity header is never trusted here. + var page = await Client.GetAsync("/facts"); + Assert.Equal(HttpStatusCode.OK, page.StatusCode); + var html = await page.Content.ReadAsStringAsync(); + Assert.Contains("Facts", html); + Assert.Contains("Not signed in", html); + Assert.Contains("Raw sources awaiting distillation", html); + } + [Fact] public async Task StatusListsAdaptersAndIndexes() { -- 2.54.0