From 594da5b92b84bfdc0815cd48c5e8e31ab79a71c9 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 10 Oct 2026 10:55:16 +0200 Subject: [PATCH 1/4] feat(dashboard): statistics, charts and a per-user arrangement with a realm default The dashboard becomes a catalog of widgets on a 12-column grid. Which widgets show, in which order and how wide resolves in three layers: the user's own arrangement, the realm default an admin set, the layout built into the app. Backend - GET /api/dashboard/stats: counts plus per-day login and security-event series, aggregated in SQL and bucketed by the viewer's calendar day. Each block is computed only when the caller holds the permission guarding the matching list surface and is omitted otherwise. - GET/PUT/DELETE /api/dashboard/layout for the caller's own arrangement; PUT/DELETE /api/admin/dashboard/default-layout (realm-settings:write) for the realm default. A layout is shape-checked only and grants nothing. - A permanent erase drops the user's arrangement. Frontend - TrendChart (line/area with crosshair tooltip) and DonutChart as plain SVG components on the design-system tokens, no chart dependency. - Thirteen widgets; an edit mode to reorder, resize, remove and add them, and for realm-settings:write to save or remove the realm default. The old "failed attempts, 24h" tile read a Level field the security-log endpoint never returned; the number now comes from the statistics endpoint. Co-Authored-By: Claude Opus 5.5 --- .../Dashboard/DashboardEndpointsTests.cs | 251 ++++ .../Features/Dashboard/DashboardEndpoints.cs | 423 +++++++ src/dotnet/Modgud.Api/Program.cs | 1 + .../Modgud.Authentication/Gdpr/GdprService.cs | 5 + .../Dashboard/DashboardLayout.cs | 43 + .../Configuration/MartenConfiguration.cs | 7 + src/frontend-vue/public/i18n/de.json | 83 +- .../src/views/dashboard/DashboardView.vue | 1022 ++++++----------- .../src/views/dashboard/charts/DonutChart.vue | 184 +++ .../src/views/dashboard/charts/TrendChart.vue | 296 +++++ .../views/dashboard/charts/chart-theme.css | 67 ++ .../src/views/dashboard/dashboardStats.ts | 71 ++ .../src/views/dashboard/layout.ts | 52 + .../src/views/dashboard/relativeTime.ts | 18 + .../widgets/AccountSecurityWidget.vue | 64 ++ .../views/dashboard/widgets/CountsWidget.vue | 143 +++ .../dashboard/widgets/LoginMethodsWidget.vue | 68 ++ .../widgets/LoginProvidersWidget.vue | 59 + .../dashboard/widgets/LoginsChartWidget.vue | 78 ++ .../dashboard/widgets/MySessionsWidget.vue | 77 ++ .../widgets/RecentActivityWidget.vue | 66 ++ .../widgets/SecurityEventsWidget.vue | 107 ++ .../views/dashboard/widgets/StatKpiWidget.vue | 79 ++ .../views/dashboard/widgets/WidgetCard.vue | 170 +++ .../src/views/dashboard/widgets/registry.ts | 138 +++ 25 files changed, 2862 insertions(+), 710 deletions(-) create mode 100644 src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs create mode 100644 src/dotnet/Modgud.Api/Features/Dashboard/DashboardEndpoints.cs create mode 100644 src/dotnet/Modgud.Domain/Dashboard/DashboardLayout.cs create mode 100644 src/frontend-vue/src/views/dashboard/charts/DonutChart.vue create mode 100644 src/frontend-vue/src/views/dashboard/charts/TrendChart.vue create mode 100644 src/frontend-vue/src/views/dashboard/charts/chart-theme.css create mode 100644 src/frontend-vue/src/views/dashboard/dashboardStats.ts create mode 100644 src/frontend-vue/src/views/dashboard/layout.ts create mode 100644 src/frontend-vue/src/views/dashboard/relativeTime.ts create mode 100644 src/frontend-vue/src/views/dashboard/widgets/AccountSecurityWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/LoginMethodsWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/LoginProvidersWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/LoginsChartWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/MySessionsWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/RecentActivityWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/SecurityEventsWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/StatKpiWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/WidgetCard.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/registry.ts diff --git a/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs new file mode 100644 index 00000000..1eb5625c --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs @@ -0,0 +1,251 @@ +using System.Net; +using System.Net.Http.Json; +using Modgud.Api.Features.Dashboard; +using Modgud.Api.Tests.Infrastructure; +using Modgud.Authentication.Audit; +using Modgud.Domain.Dashboard; +using Modgud.Infrastructure.Audit; + +namespace Modgud.Api.Tests.Dashboard; + +[Collection(IntegrationTestCollection.Name)] +public class DashboardEndpointsTests(SharedPostgresFixture fixture) : IntegrationTestBase(fixture) +{ + // ── Statistics ────────────────────────────────────────────────────────── + + [Fact] + public async Task Stats_bucket_logins_per_day_and_fold_failure_streaks_into_the_count() + { + var ct = TestContext.Current.CancellationToken; + var day = DateTimeOffset.UtcNow.Date.AddDays(-2); + var noon = new DateTimeOffset(day.AddHours(12), TimeSpan.Zero); + + await using (var session = GetTenantedDocumentSession()) + { + session.Store( + Login(noon, "password"), + Login(noon.AddMinutes(1), "password"), + Login(noon.AddMinutes(2), "password"), + Login(noon.AddMinutes(3), "magic_link"), + Audit(noon.AddMinutes(4), AuditEvents.LoginFailuresObserved, count: 4), + Audit(noon.AddMinutes(5), AuditEvents.LoginFailed), + // Not a login at all — must not leak into either series. + Audit(noon.AddMinutes(6), AuditEvents.AccountLockedOut), + // Outside the requested window. + Login(noon.AddDays(-60), "passkey")); + await session.SaveChangesAsync(ct); + } + + var stats = await Client.GetFromJsonAsync("/api/dashboard/stats?days=30&tz=UTC", ct); + + Assert.NotNull(stats?.Logins); + Assert.Equal(30, stats!.Logins!.Series.Count); + Assert.Equal(DateOnly.FromDateTime(DateTime.UtcNow).ToString("yyyy-MM-dd"), stats.Logins.Series[^1].Day); + + var bucket = Assert.Single(stats.Logins.Series, s => s.Day == day.ToString("yyyy-MM-dd")); + Assert.Equal(4, bucket.Succeeded); + Assert.Equal(5, bucket.Failed); + + Assert.Equal(1, Assert.Single(stats.Logins.Methods, m => m.Method == "magic_link").Count); + Assert.DoesNotContain(stats.Logins.Methods, m => m.Method == "passkey"); + Assert.Equal(5, stats.Logins.Failed); + } + + [Fact] + public async Task Stats_bucket_by_the_viewers_calendar_day_not_utc() + { + var ct = TestContext.Current.CancellationToken; + // 23:30 UTC is already the next calendar day in Vienna (UTC+1/+2). + var utcDay = DateTimeOffset.UtcNow.Date.AddDays(-5); + var lateEvening = new DateTimeOffset(utcDay.AddHours(23).AddMinutes(30), TimeSpan.Zero); + + await using (var session = GetTenantedDocumentSession()) + { + session.Store(Login(lateEvening, "password")); + await session.SaveChangesAsync(ct); + } + + var utc = await Client.GetFromJsonAsync("/api/dashboard/stats?tz=UTC", ct); + var vienna = await Client.GetFromJsonAsync("/api/dashboard/stats?tz=Europe/Vienna", ct); + var bogus = await Client.GetFromJsonAsync("/api/dashboard/stats?tz=Not/A_Zone", ct); + + Assert.Equal(1, utc!.Logins!.Series.Single(s => s.Day == utcDay.ToString("yyyy-MM-dd")).Succeeded); + + Assert.Equal("Europe/Vienna", vienna!.TimeZone); + Assert.Equal(0, vienna.Logins!.Series.Single(s => s.Day == utcDay.ToString("yyyy-MM-dd")).Succeeded); + Assert.Equal(1, vienna.Logins.Series.Single(s => s.Day == utcDay.AddDays(1).ToString("yyyy-MM-dd")).Succeeded); + + // An unknown zone buckets in UTC rather than failing the dashboard. + Assert.Equal("UTC", bogus!.TimeZone); + } + + [Fact] + public async Task Stats_split_security_events_by_severity_and_rank_the_ones_needing_attention() + { + var ct = TestContext.Current.CancellationToken; + var day = DateTimeOffset.UtcNow.Date.AddDays(-3); + var noon = new DateTimeOffset(day.AddHours(12), TimeSpan.Zero); + + await using (var session = GetTenantedDocumentSession()) + { + session.Store( + Security(noon, AuditEvents.LoginFailedUnknownUser, AuditSeverity.Warning), + Security(noon.AddMinutes(1), AuditEvents.LoginFailedUnknownUser, AuditSeverity.Warning), + Security(noon.AddMinutes(2), AuditEvents.RefreshTokenReuseDetected, AuditSeverity.Error), + Security(noon.AddMinutes(3), AuditEvents.SigningKeyRotated, AuditSeverity.Info)); + await session.SaveChangesAsync(ct); + } + + var stats = await Client.GetFromJsonAsync("/api/dashboard/stats?tz=UTC", ct); + + var bucket = Assert.Single(stats!.Security!.Series, s => s.Day == day.ToString("yyyy-MM-dd")); + Assert.Equal(1, bucket.Info); + Assert.Equal(2, bucket.Warning); + Assert.Equal(1, bucket.Error); + + Assert.Equal(2, Assert.Single( + stats.Security.TopEventTypes, t => t.EventType == AuditEvents.LoginFailedUnknownUser).Count); + // Routine Info events are not "needs attention". + Assert.DoesNotContain(stats.Security.TopEventTypes, t => t.EventType == AuditEvents.SigningKeyRotated); + } + + [Fact] + public async Task Stats_count_what_the_list_surfaces_show() + { + var ct = TestContext.Current.CancellationToken; + await Factory.CreateTestUserWithIdentityAsync("Plain", "Person", "PP", "pp@test.com"); + await Factory.WaitForProjectionsAsync(); + + var stats = await Client.GetFromJsonAsync("/api/dashboard/stats", ct); + var users = await Client.GetFromJsonAsync>("/api/user", ct); + var roles = await Client.GetFromJsonAsync>("/api/role", ct); + + Assert.Equal(users!.Count, stats!.Counts.Users); + Assert.Equal(roles!.Count, stats.Counts.Roles); + Assert.True(stats.Counts.ActiveSessions >= 1, "the calling admin's own session is active"); + Assert.Equal(0, stats.Counts.PendingChangeRequests); + } + + [Fact] + public async Task Stats_withhold_every_block_the_caller_has_no_permission_for() + { + var ct = TestContext.Current.CancellationToken; + await Factory.CreateTestUserWithIdentityAsync("Plain", "Person", "PP", "pp@test.com"); + var plain = await CreateAuthenticatedClientAsync("pp", DefaultPassword); + + var stats = await plain.GetFromJsonAsync("/api/dashboard/stats", ct); + + Assert.NotNull(stats); + Assert.Equal(new DashboardCountsDto(null, null, null, null, null, null, null, null, null, null), stats!.Counts); + Assert.Null(stats.Logins); + Assert.Null(stats.Security); + } + + // ── Layout ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Layout_is_per_user_and_resets_to_nothing() + { + var ct = TestContext.Current.CancellationToken; + await Factory.CreateTestUserWithIdentityAsync("Plain", "Person", "PP", "pp@test.com"); + var plain = await CreateAuthenticatedClientAsync("pp", DefaultPassword); + + var initial = await plain.GetFromJsonAsync("/api/dashboard/layout", ct); + Assert.Null(initial!.User); + Assert.Null(initial.RealmDefault); + + var saved = await plain.PutAsJsonAsync("/api/dashboard/layout", Layout(("my-sessions", "m"), ("logins-chart", "xl")), ct); + Assert.Equal(HttpStatusCode.NoContent, saved.StatusCode); + + var mine = await plain.GetFromJsonAsync("/api/dashboard/layout", ct); + Assert.Equal( + [new DashboardWidgetPlacement("my-sessions", "m"), new DashboardWidgetPlacement("logins-chart", "xl")], + mine!.User); + + // Another user's dashboard is untouched by it. + var theirs = await Client.GetFromJsonAsync("/api/dashboard/layout", ct); + Assert.Null(theirs!.User); + + Assert.Equal(HttpStatusCode.NoContent, (await plain.DeleteAsync("/api/dashboard/layout", ct)).StatusCode); + Assert.Null((await plain.GetFromJsonAsync("/api/dashboard/layout", ct))!.User); + } + + [Fact] + public async Task Realm_default_needs_realm_settings_write_and_reaches_every_user() + { + var ct = TestContext.Current.CancellationToken; + await Factory.CreateTestUserWithIdentityAsync("Plain", "Person", "PP", "pp@test.com"); + var plain = await CreateAuthenticatedClientAsync("pp", DefaultPassword); + var layout = Layout(("account-security", "l")); + + var refused = await plain.PutAsJsonAsync("/api/admin/dashboard/default-layout", layout, ct); + Assert.Equal(HttpStatusCode.Forbidden, refused.StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, + (await plain.DeleteAsync("/api/admin/dashboard/default-layout", ct)).StatusCode); + + var accepted = await Client.PutAsJsonAsync("/api/admin/dashboard/default-layout", layout, ct); + Assert.Equal(HttpStatusCode.NoContent, accepted.StatusCode); + + var seen = await plain.GetFromJsonAsync("/api/dashboard/layout", ct); + Assert.Null(seen!.User); + Assert.Equal([new DashboardWidgetPlacement("account-security", "l")], seen.RealmDefault); + + Assert.Equal(HttpStatusCode.NoContent, + (await Client.DeleteAsync("/api/admin/dashboard/default-layout", ct)).StatusCode); + Assert.Null((await plain.GetFromJsonAsync("/api/dashboard/layout", ct))!.RealmDefault); + } + + [Theory] + [InlineData("logins-chart", "huge")] + [InlineData("Not A Widget", "m")] + [InlineData("", "m")] + public async Task Layout_rejects_a_malformed_placement(string widget, string size) + { + var ct = TestContext.Current.CancellationToken; + + var response = await Client.PutAsJsonAsync("/api/dashboard/layout", Layout((widget, size)), ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains("Dashboard.InvalidLayout", await response.Content.ReadAsStringAsync(ct)); + Assert.Null((await Client.GetFromJsonAsync("/api/dashboard/layout", ct))!.User); + } + + [Fact] + public async Task Layout_rejects_the_same_widget_twice() + { + var ct = TestContext.Current.CancellationToken; + + var response = await Client.PutAsJsonAsync( + "/api/dashboard/layout", Layout(("logins-chart", "m"), ("logins-chart", "xl")), ct); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private static SaveDashboardLayoutDto Layout(params (string Widget, string Size)[] widgets) => + new(widgets.Select(w => new DashboardWidgetPlacement(w.Widget, w.Size)).ToList()); + + private static AuthAuditView Login(DateTimeOffset at, string method) => + Audit(at, AuditEvents.LoginSucceeded) with { Method = method }; + + private static AuthAuditView Audit(DateTimeOffset at, string eventType, int? count = null) => new() + { + Id = Guid.NewGuid(), + Timestamp = at, + Category = AuditCategories.Authentication, + EventType = eventType, + UserId = Guid.NewGuid(), + Count = count, + Level = eventType == AuditEvents.LoginSucceeded ? "Info" : "Warning", + }; + + private static RealmSecurityAuditEvent Security(DateTimeOffset at, string eventType, AuditSeverity severity) => new() + { + Timestamp = at, + EventType = eventType, + Category = AuditEvents.CategoryOf(eventType), + Severity = severity, + OutcomeCode = AuditOutcomes.Observed, + }; +} diff --git a/src/dotnet/Modgud.Api/Features/Dashboard/DashboardEndpoints.cs b/src/dotnet/Modgud.Api/Features/Dashboard/DashboardEndpoints.cs new file mode 100644 index 00000000..9fff51ad --- /dev/null +++ b/src/dotnet/Modgud.Api/Features/Dashboard/DashboardEndpoints.cs @@ -0,0 +1,423 @@ +using System.Text.RegularExpressions; +using Marten; +using Microsoft.AspNetCore.Mvc; +using Modgud.Authentication.Audit; +using Modgud.Authentication.Domain; +using Modgud.Authentication.Domain.LoginProviders; +using Modgud.Authentication.ExtensionMethods; +using Modgud.Authentication.RealmSettings; +using Modgud.Authorization.Apps; +using Modgud.Authorization.AspNetCore; +using Modgud.Authorization.Principals; +using Modgud.Authorization.Roles; +using Modgud.Authorization.Services; +using Modgud.Domain.Dashboard; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.Realms; +using Modgud.Infrastructure.Audit; +using Modgud.Infrastructure.Persistence.Marten.Projections.Users; +using Modgud.Permissions; + +namespace Modgud.Api.Features.Dashboard; + +/// +/// Backs the dashboard: one aggregated statistics read, plus the stored widget +/// arrangement (per user, with a realm default an admin can set). +/// +/// Statistics are gated per block, not per endpoint. Any signed-in +/// user may call /dashboard/stats; each number is computed only when the +/// caller holds the permission that guards the corresponding list surface +/// (user:read for the user count, audit-log:read for the login +/// series, …) and is null otherwise. The dashboard therefore never shows +/// a figure the caller could not have counted themselves. +/// +public static partial class DashboardEndpoints +{ + private const int DefaultDays = 30; + private const int MinDays = 7; + private const int MaxDays = 90; + + public static WebApplication MapDashboardEndpoints(this WebApplication app, string path) + { + var group = app.MapGroup($"{path}/dashboard") + .WithTags("Dashboard") + .RequireAuthorization(); + + group.MapGet("stats", GetStatsAsync).WithName("Dashboard_Stats"); + + // ── Layout: the caller's own arrangement ───────────────────────── + group.MapGet("layout", async (HttpContext http, IQuerySession session, CancellationToken ct) => + { + var userId = http.GetUserId(); + if (userId is null) return Results.Unauthorized(); + + var user = await session.LoadAsync(DashboardLayout.UserId(userId.Value), ct); + var realm = await session.LoadAsync(DashboardLayout.RealmDefaultId, ct); + return Results.Ok(new DashboardLayoutsDto(user?.Widgets, realm?.Widgets)); + }) + .WithName("Dashboard_Layout_Get"); + + group.MapPut("layout", async ( + [FromBody] SaveDashboardLayoutDto body, + HttpContext http, + IDocumentSession session, + CancellationToken ct) => + { + var userId = http.GetUserId(); + if (userId is null) return Results.Unauthorized(); + return await SaveAsync(session, DashboardLayout.UserId(userId.Value), body, ct); + }) + .WithName("Dashboard_Layout_Save"); + + group.MapDelete("layout", async (HttpContext http, IDocumentSession session, CancellationToken ct) => + { + var userId = http.GetUserId(); + if (userId is null) return Results.Unauthorized(); + return await DeleteAsync(session, DashboardLayout.UserId(userId.Value), ct); + }) + .WithName("Dashboard_Layout_Reset"); + + // ── Layout: the realm default ──────────────────────────────────── + // A realm-wide setting, so it rides the realm-settings permission + // rather than introducing a resource of its own. + var admin = app.MapGroup($"{path}/admin/dashboard") + .WithTags("Dashboard") + .RequireAuthorization(); + + admin.MapPut("default-layout", async ( + [FromBody] SaveDashboardLayoutDto body, + IDocumentSession session, + CancellationToken ct) => + await SaveAsync(session, DashboardLayout.RealmDefaultId, body, ct)) + .WithName("Dashboard_DefaultLayout_Save") + .RequiresPermission("realm-settings:write"); + + admin.MapDelete("default-layout", async (IDocumentSession session, CancellationToken ct) => + await DeleteAsync(session, DashboardLayout.RealmDefaultId, ct)) + .WithName("Dashboard_DefaultLayout_Reset") + .RequiresPermission("realm-settings:write"); + + return app; + } + + // ───────────────────────────────────────────── Layout ────────────────── + + private static async Task SaveAsync( + IDocumentSession session, string id, SaveDashboardLayoutDto body, CancellationToken ct) + { + if (Validate(body) is { } problem) + return Results.BadRequest(new { Error = "Dashboard.InvalidLayout", Message = problem }); + + session.Store(new DashboardLayout + { + Id = id, + Widgets = body.Widgets!, + UpdatedAt = DateTimeOffset.UtcNow, + }); + await session.SaveChangesAsync(ct); + return Results.NoContent(); + } + + private static async Task DeleteAsync(IDocumentSession session, string id, CancellationToken ct) + { + session.Delete(id); + await session.SaveChangesAsync(ct); + return Results.NoContent(); + } + + /// Shape check only — the widget catalog is the frontend's, so an + /// unknown id is stored as-is and ignored at render time. + private static string? Validate(SaveDashboardLayoutDto body) + { + if (body.Widgets is null) return "Widgets is required."; + if (body.Widgets.Count > DashboardLayout.MaxWidgets) + return $"A layout holds at most {DashboardLayout.MaxWidgets} widgets."; + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var placement in body.Widgets) + { + if (placement is null || placement.Widget is null || !WidgetIdPattern().IsMatch(placement.Widget)) + return "Widget ids are lower-case letters, digits and dashes (1-64 characters)."; + if (placement.Size is null || !DashboardWidgetPlacement.Sizes.Contains(placement.Size)) + return $"Size must be one of: {string.Join(", ", DashboardWidgetPlacement.Sizes.Order())}."; + if (!seen.Add(placement.Widget)) + return $"Widget '{placement.Widget}' is listed twice."; + } + + return null; + } + + [GeneratedRegex("^[a-z0-9][a-z0-9-]{0,63}$")] + private static partial Regex WidgetIdPattern(); + + // ───────────────────────────────────────────── Statistics ────────────── + + private static async Task GetStatsAsync( + HttpContext http, + IDocumentSession session, + IPermissionService permissions, + IRealmSettingsService realmSettings, + int? days, + string? tz, + CancellationToken ct) + { + var userId = http.GetUserId(); + if (userId is null) return Results.Unauthorized(); + + var grants = await permissions.GetUserPermissionsAsync(userId.Value, AppSlugs.Modgud, ct); + bool Can(string permission) => PermissionEvaluator.Evaluate(grants, permission); + + var now = DateTimeOffset.UtcNow; + var window = Math.Clamp(days ?? DefaultDays, MinDays, MaxDays); + var (zoneId, zone) = ResolveZone(tz); + + var counts = new DashboardCountsDto( + Users: Can("user:read") + ? await session.Query().CountAsync(u => !u.IsDeleted, ct) : null, + ServiceAccounts: Can("service-account:read") + ? await session.Query().CountAsync(s => !s.IsDeleted, ct) : null, + Groups: Can("authorization-group:read") + ? await session.Query().CountAsync(g => !g.IsDeleted, ct) : null, + Roles: Can("permission-role:read") + ? await session.Query().CountAsync(r => !r.IsDeleted, ct) : null, + Apps: Can("app:read") + ? await session.Query().CountAsync(a => !a.IsDeleted, ct) : null, + OAuthClients: Can("oauth-client:read") + ? await session.Query().CountAsync(c => !c.IsDeleted, ct) : null, + LoginProviders: Can("login-provider:read") + ? await session.Query().CountAsync(p => !p.IsDeleted, ct) : null, + LoginProvidersEnabled: Can("login-provider:read") + ? await session.Query().CountAsync(p => !p.IsDeleted && p.Enabled, ct) : null, + ActiveSessions: Can("session:read") + ? await session.Query().CountAsync(s => s.ExpiresAt > now && s.AbsoluteExpiresAt > now, ct) : null, + PendingChangeRequests: Can("user:write") + ? await session.Query().CountAsync( + r => r.Status == ChangeRequestStatus.AdminApprovalPending + || r.Status == ChangeRequestStatus.EmailVerificationPending, ct) + : null); + + LoginStatsDto? logins = null; + if (Can("audit-log:read")) + { + // Same view bound as the audit grid: never chart further back than + // the realm lets an admin read. + var visible = ((await realmSettings.LoadAsync(ct)).Audit ?? AuditSettings.Defaults).VisibilityWindowDays; + logins = await LoadLoginsAsync(session, Math.Min(window, Math.Max(1, visible)), zoneId, zone, now, ct); + } + + var security = Can("auth-log:read") + ? await LoadSecurityAsync(session, window, zoneId, zone, now, ct) + : null; + + return Results.Ok(new DashboardStatsDto(window, zoneId, counts, logins, security)); + } + + /// One group by row, shipped back as JSON so Marten's + /// serializer materialises it like any other document. + private sealed record Bucket(string Day, string? Key, string? Sub, int N); + + private const string LoginBucketsSql = """ + select jsonb_build_object('Day', b.day, 'Key', b.key, 'Sub', b.sub, 'N', b.n) + from ( + select to_char(((d.data ->> 'Timestamp')::timestamptz at time zone ?)::date, 'YYYY-MM-DD') as day, + d.data ->> 'EventType' as key, + d.data ->> 'Method' as sub, + sum(coalesce((d.data ->> 'Count')::int, 1))::int as n + from mt_doc_auth_audit_view d + where d.data ->> 'Category' = ? + and (d.data ->> 'Timestamp')::timestamptz >= ? + group by 1, 2, 3 + ) b + """; + + private const string SecurityBucketsSql = """ + select jsonb_build_object('Day', b.day, 'Key', b.key, 'Sub', b.sub, 'N', b.n) + from ( + select to_char(((d.data ->> 'Timestamp')::timestamptz at time zone ?)::date, 'YYYY-MM-DD') as day, + d.data ->> 'Severity' as key, + d.data ->> 'EventType' as sub, + count(*)::int as n + from mt_doc_realm_security_audit_event d + where (d.data ->> 'Timestamp')::timestamptz >= ? + group by 1, 2, 3 + ) b + """; + + private static async Task LoadLoginsAsync( + IDocumentSession session, int days, string zoneId, TimeZoneInfo zone, DateTimeOffset now, CancellationToken ct) + { + var (dayKeys, since) = Window(days, zone, now); + + // The raw SQL below bypasses Marten's lazy schema creation. + await session.Database.EnsureStorageExistsAsync(typeof(AuthAuditView), ct); + var buckets = await session.QueryAsync( + LoginBucketsSql, ct, zoneId, AuditCategories.Authentication, since); + + var succeeded = dayKeys.ToDictionary(d => d, _ => 0); + var failed = dayKeys.ToDictionary(d => d, _ => 0); + var methods = new Dictionary(StringComparer.Ordinal); + foreach (var b in buckets) + { + if (!succeeded.ContainsKey(b.Day)) continue; + switch (b.Key) + { + case AuditEvents.LoginSucceeded: + succeeded[b.Day] += b.N; + var method = string.IsNullOrEmpty(b.Sub) ? "unknown" : b.Sub; + methods[method] = methods.GetValueOrDefault(method) + b.N; + break; + // A failure is either a single row or a streak summary carrying + // its count — the SQL already folded Count into N. + case AuditEvents.LoginFailed or AuditEvents.LoginFailuresObserved: + failed[b.Day] += b.N; + break; + } + } + + var dayAgo = now.AddHours(-24); + var failedLast24h = (await session.Query() + .Where(x => x.Timestamp >= dayAgo + && (x.EventType == AuditEvents.LoginFailed || x.EventType == AuditEvents.LoginFailuresObserved)) + .Select(x => x.Count) + .ToListAsync(ct)) + .Sum(count => count ?? 1); + + return new LoginStatsDto( + days, + succeeded.Values.Sum(), + failed.Values.Sum(), + failedLast24h, + dayKeys.Select(d => new LoginDayDto(d, succeeded[d], failed[d])).ToList(), + methods.OrderByDescending(m => m.Value).ThenBy(m => m.Key, StringComparer.Ordinal) + .Select(m => new LoginMethodDto(m.Key, m.Value)).ToList()); + } + + private static async Task LoadSecurityAsync( + IDocumentSession session, int days, string zoneId, TimeZoneInfo zone, DateTimeOffset now, CancellationToken ct) + { + var (dayKeys, since) = Window(days, zone, now); + + await session.Database.EnsureStorageExistsAsync(typeof(RealmSecurityAuditEvent), ct); + var buckets = await session.QueryAsync(SecurityBucketsSql, ct, zoneId, since); + + var info = dayKeys.ToDictionary(d => d, _ => 0); + var warning = dayKeys.ToDictionary(d => d, _ => 0); + var error = dayKeys.ToDictionary(d => d, _ => 0); + var attentionByType = new Dictionary(StringComparer.Ordinal); + foreach (var b in buckets) + { + if (!info.ContainsKey(b.Day)) continue; + var target = b.Key switch + { + nameof(AuditSeverity.Error) => error, + nameof(AuditSeverity.Warning) => warning, + _ => info, + }; + target[b.Day] += b.N; + + if (!ReferenceEquals(target, info) && !string.IsNullOrEmpty(b.Sub)) + attentionByType[b.Sub] = attentionByType.GetValueOrDefault(b.Sub) + b.N; + } + + var dayAgo = now.AddHours(-24); + var attentionLast24h = await session.Query() + .CountAsync(x => x.Timestamp >= dayAgo && x.Severity != AuditSeverity.Info, ct); + + return new SecurityStatsDto( + days, + info.Values.Sum(), + warning.Values.Sum(), + error.Values.Sum(), + attentionLast24h, + dayKeys.Select(d => new SecurityDayDto(d, info[d], warning[d], error[d])).ToList(), + attentionByType.OrderByDescending(e => e.Value).ThenBy(e => e.Key, StringComparer.Ordinal) + .Take(5).Select(e => new SecurityEventTypeDto(e.Key, e.Value)).ToList()); + } + + /// The viewer's last calendar days (today + /// included) as yyyy-MM-dd keys, and the UTC instant the first begins. + private static (List DayKeys, DateTimeOffset Since) Window(int days, TimeZoneInfo zone, DateTimeOffset now) + { + var today = DateOnly.FromDateTime(TimeZoneInfo.ConvertTime(now, zone).DateTime); + var first = today.AddDays(-(days - 1)); + + var midnight = first.ToDateTime(TimeOnly.MinValue, DateTimeKind.Unspecified); + // A zone whose DST jump skips midnight has no 00:00 on that date. + if (zone.IsInvalidTime(midnight)) midnight = midnight.AddHours(1); + var since = new DateTimeOffset(TimeZoneInfo.ConvertTimeToUtc(midnight, zone), TimeSpan.Zero); + + var keys = Enumerable.Range(0, days) + .Select(i => first.AddDays(i).ToString("yyyy-MM-dd")) + .ToList(); + return (keys, since); + } + + /// Accepts an IANA zone id (what the browser reports and what + /// PostgreSQL's AT TIME ZONE understands); anything else buckets in UTC. + private static (string Id, TimeZoneInfo Zone) ResolveZone(string? tz) + { + if (tz is not null && IanaZonePattern().IsMatch(tz) + && TimeZoneInfo.TryFindSystemTimeZoneById(tz, out var zone)) + return (tz, zone); + return ("UTC", TimeZoneInfo.Utc); + } + + [GeneratedRegex(@"^[A-Za-z][A-Za-z0-9_+\-]*(/[A-Za-z0-9_+\-]+){1,2}$")] + private static partial Regex IanaZonePattern(); +} + +public sealed record DashboardLayoutsDto( + List? User, + List? RealmDefault); + +public sealed record SaveDashboardLayoutDto(List? Widgets); + +/// is the requested window; a block may cover +/// fewer days (see ). +public sealed record DashboardStatsDto( + int Days, + string TimeZone, + DashboardCountsDto Counts, + LoginStatsDto? Logins, + SecurityStatsDto? Security); + +/// Each count is null when the caller lacks the permission that +/// guards the matching list. +public sealed record DashboardCountsDto( + int? Users, + int? ServiceAccounts, + int? Groups, + int? Roles, + int? Apps, + int? OAuthClients, + int? LoginProviders, + int? LoginProvidersEnabled, + int? ActiveSessions, + int? PendingChangeRequests); + +/// Sign-ins of known accounts, from the realm's audit trail. Attempts +/// against identifiers matching no account are security events, not logins. +public sealed record LoginStatsDto( + int Days, + int Succeeded, + int Failed, + int FailedLast24h, + List Series, + List Methods); + +public sealed record LoginDayDto(string Day, int Succeeded, int Failed); + +public sealed record LoginMethodDto(string Method, int Count); + +public sealed record SecurityStatsDto( + int Days, + int Info, + int Warning, + int Error, + int AttentionLast24h, + List Series, + List TopEventTypes); + +public sealed record SecurityDayDto(string Day, int Info, int Warning, int Error); + +public sealed record SecurityEventTypeDto(string EventType, int Count); diff --git a/src/dotnet/Modgud.Api/Program.cs b/src/dotnet/Modgud.Api/Program.cs index 597396bc..f001cc7d 100644 --- a/src/dotnet/Modgud.Api/Program.cs +++ b/src/dotnet/Modgud.Api/Program.cs @@ -1499,6 +1499,7 @@ Modgud.Api.Features.Admin.Jobs.JobsEndpoints.MapJobsEndpoints(app, "api"); Modgud.Api.Features.Inbox.InboxEndpoints.MapInboxEndpoints(app, "api"); Modgud.Api.Features.Inbox.InboxSettingsEndpoints.MapInboxSettingsEndpoints(app, "api"); + Modgud.Api.Features.Dashboard.DashboardEndpoints.MapDashboardEndpoints(app, "api"); // Account & Setup Endpoints (have additional strict "auth" rate limit) app.MapAccountEndpoints("api"); diff --git a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs index 1f5698c3..222fe81e 100644 --- a/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs +++ b/src/dotnet/Modgud.Authentication/Gdpr/GdprService.cs @@ -318,6 +318,11 @@ private async Task> PerformPermanentEraseAsync(Guid userId, Guid? // ADR 0026 — a test account's fixed-code hash goes with the account. session.Delete(userId); + // The user's own dashboard arrangement — no PII in it, but it is keyed + // on the user id and has no reason to outlive the account. + session.Delete( + Modgud.Domain.Dashboard.DashboardLayout.UserId(userId)); + // External identity links carry Email, DisplayName, and the raw IdP // claim payload on their OWN streams (keyed by link id). Drop the // projection doc here; the PII-bearing events are masked + archived diff --git a/src/dotnet/Modgud.Domain/Dashboard/DashboardLayout.cs b/src/dotnet/Modgud.Domain/Dashboard/DashboardLayout.cs new file mode 100644 index 00000000..56cf2925 --- /dev/null +++ b/src/dotnet/Modgud.Domain/Dashboard/DashboardLayout.cs @@ -0,0 +1,43 @@ +namespace Modgud.Domain.Dashboard; + +/// +/// A stored dashboard arrangement: which widgets, in which order, how wide. +/// Two kinds share this document, told apart by : +/// +/// — the realm's default, set by an admin. +/// — one user's own arrangement. +/// +/// The dashboard resolves user layout → realm default → the default built into +/// the frontend, so a realm with neither document still shows a dashboard. +/// +/// A layout grants nothing. Widget ids are opaque to the server; the +/// catalog lives in the frontend, and each widget's data is gated by the +/// permission on the endpoint that serves it. A layout naming a widget the viewer +/// may not see simply renders without it. +/// +/// Persistence binding lives in MartenConfiguration so Domain stays free +/// of Marten attributes. +/// +public sealed class DashboardLayout +{ + public const string RealmDefaultId = "realm-default"; + + /// Upper bound on stored placements — far above any real catalog. + public const int MaxWidgets = 60; + + public static string UserId(Guid userId) => $"user:{userId:N}"; + + public string Id { get; set; } = ""; + + public List Widgets { get; set; } = []; + + public DateTimeOffset UpdatedAt { get; set; } +} + +/// One widget on the dashboard. List order is display order. +public sealed record DashboardWidgetPlacement(string Widget, string Size) +{ + /// Column spans the grid understands (see the frontend's size map). + public static readonly IReadOnlySet Sizes = + new HashSet(StringComparer.Ordinal) { "xs", "s", "m", "l", "xl", "full" }; +} diff --git a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Configuration/MartenConfiguration.cs b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Configuration/MartenConfiguration.cs index 916c730d..449cb7dd 100644 --- a/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Configuration/MartenConfiguration.cs +++ b/src/dotnet/Modgud.Infrastructure/Persistence/Marten/Configuration/MartenConfiguration.cs @@ -118,6 +118,13 @@ public static void ConfigureDocumentStore(this StoreOptions options) options.Schema.For() .Identity(x => x.Id); + // Dashboard arrangements — the realm default (fixed id) and one per user + // ("user:"). Plain documents; the widget catalog itself lives in the + // frontend. + options.Schema.For() + .DocumentAlias("dashboard_layout") + .Identity(x => x.Id); + // Authentication-specific Marten setup (documents + events + projections) // is wired via UseModgudAuthentication(), called from AddInfrastructure's // additionalMartenConfig callback so Infrastructure stays unaware of Authentication. diff --git a/src/frontend-vue/public/i18n/de.json b/src/frontend-vue/public/i18n/de.json index 76535423..5abc42f3 100644 --- a/src/frontend-vue/public/i18n/de.json +++ b/src/frontend-vue/public/i18n/de.json @@ -2637,10 +2637,6 @@ }, "dashboard": { "title": "Dashboard", - "section": { - "personal": "Mein Konto", - "operations": "Realm-Betrieb" - }, "welcomeTitle": "Willkommen bei Modgud", "welcomeBody": "Du bist angemeldet als", "errors": { @@ -2654,12 +2650,10 @@ }, "kpi": { "attention": "Achtung", - "activeSessions": "Aktive Sitzungen", - "securityScore": "Sicherheits-Score", - "userCount": "User im Realm", - "failedLast24h": "Fehlversuche 24h", + "failedLast24h": "Fehlgeschlagene Anmeldungen, 24 h", "pendingChangeRequests": "Offene Anfragen", - "activeLoginProviders": "Login-Provider aktiv" + "realmSessions": "Aktive Sitzungen im Realm", + "securityAttention": "Sicherheitsereignisse, 24 h" }, "security": { "title": "Account-Sicherheit", @@ -2682,10 +2676,11 @@ "unknownDevice": "Unbekanntes Gerät", "unknownBrowser": "Browser", "deviceLabel": "{browser} auf {os}", - "cta": "Sessions verwalten →" + "cta": "Sessions verwalten →", + "app": "App" }, "systemActivity": { - "title": "System-Aktivität", + "title": "Letzte Sicherheitsereignisse", "none": "Noch keine Ereignisse.", "cta": "Vollständig anzeigen →" }, @@ -2696,6 +2691,72 @@ "disabled": "Deaktiviert", "system": "System", "cta": "Verwalten →" + }, + "directory": { + "title": "Verzeichnis" + }, + "applications": { + "title": "Anwendungen & Anmeldung", + "enabled": "{n} aktiviert" + }, + "logins": { + "title": "Anmeldungen", + "succeeded": "Erfolgreich", + "failed": "Fehlgeschlagen", + "subtitle": "Bekannte Konten, letzte {n} Tage", + "cta": "Audit-Log öffnen →" + }, + "loginMethods": { + "title": "Anmeldemethoden", + "subtitle": "Erfolgreiche Anmeldungen, letzte {n} Tage", + "none": "Keine Anmeldungen in diesem Zeitraum.", + "total": "Anmeldungen", + "method": { + "password": "Passwort", + "passkey": "Passkey", + "external": "Externer Provider", + "magic_link": "Login-Link", + "email_otp": "E-Mail-Code", + "mfa": "Mit zweitem Faktor", + "other": "Sonstige" + } + }, + "securityEvents": { + "title": "Sicherheitsereignisse", + "subtitle": "Letzte {n} Tage", + "none": "Keine Sicherheitsereignisse in diesem Zeitraum.", + "cta": "Sicherheits-Log öffnen →", + "total": "Ereignisse", + "error": "Fehler", + "warning": "Warnungen", + "info": "Hinweise", + "top": "Häufigste Warnungen und Fehler" + }, + "customize": { + "start": "Anpassen", + "hint": "Widgets per Ziehen umsortieren, in der Breite ändern oder entfernen. Diese Anordnung gilt nur für dich.", + "reset": "Zurück zum Standard", + "add": "Hinzufügen:", + "allPlaced": "Alle verfügbaren Widgets sind auf dem Dashboard.", + "empty": "Keine Widgets auf dem Dashboard.", + "moveEarlier": "Nach vorne", + "moveLater": "Nach hinten", + "narrower": "Schmaler", + "wider": "Breiter", + "remove": "Entfernen", + "saved": "Dein Dashboard wurde gespeichert.", + "resetDone": "Dein Dashboard folgt wieder dem Standard.", + "saveFailed": "Das Dashboard konnte nicht gespeichert werden.", + "realmHintSet": "Dieser Realm hat einen eigenen Standard. Wer sein Dashboard nie angepasst hat, sieht ihn.", + "realmHintUnset": "Dieser Realm nutzt den eingebauten Standard. Du kannst die Anordnung unten zum Standard für alle machen, die ihr Dashboard nie angepasst haben.", + "saveDefault": "Als Realm-Standard speichern", + "saveDefaultTitle": "Als Realm-Standard verwenden?", + "saveDefaultMessage": "Alle in diesem Realm, die ihr Dashboard nicht selbst angepasst haben, bekommen diese Anordnung. Jeder sieht weiterhin nur die Widgets, für die er berechtigt ist.", + "defaultSaved": "Als Standard für diesen Realm gespeichert.", + "removeDefault": "Realm-Standard entfernen", + "removeDefaultTitle": "Realm-Standard entfernen?", + "removeDefaultMessage": "Wer kein eigenes Dashboard hat, bekommt wieder die eingebaute Anordnung.", + "defaultRemoved": "Der Realm-Standard wurde entfernt; es gilt die eingebaute Anordnung." } }, "grid": { diff --git a/src/frontend-vue/src/views/dashboard/DashboardView.vue b/src/frontend-vue/src/views/dashboard/DashboardView.vue index 2c58303b..e5cc7a53 100644 --- a/src/frontend-vue/src/views/dashboard/DashboardView.vue +++ b/src/frontend-vue/src/views/dashboard/DashboardView.vue @@ -1,31 +1,17 @@ diff --git a/src/frontend-vue/src/views/dashboard/charts/DonutChart.vue b/src/frontend-vue/src/views/dashboard/charts/DonutChart.vue new file mode 100644 index 00000000..84020922 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/charts/DonutChart.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/frontend-vue/src/views/dashboard/charts/TrendChart.vue b/src/frontend-vue/src/views/dashboard/charts/TrendChart.vue new file mode 100644 index 00000000..eabe1894 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/charts/TrendChart.vue @@ -0,0 +1,296 @@ + + + + + diff --git a/src/frontend-vue/src/views/dashboard/charts/chart-theme.css b/src/frontend-vue/src/views/dashboard/charts/chart-theme.css new file mode 100644 index 00000000..7846ef8b --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/charts/chart-theme.css @@ -0,0 +1,67 @@ +/* Chart colours by role. The categorical slots are assigned in this fixed order + and never cycled; the order is what keeps neighbouring series apart for + colour-vision deficiencies. Status colours are reserved for severity and are + always paired with an icon or a label. Dark values are separate steps of the + same hues, chosen for the dark surface. */ +.dash-chart { + --chart-surface: var(--coar-background-neutral-secondary, #ffffff); + --chart-ink: var(--coar-text-neutral-primary, #0b0b0b); + --chart-ink-secondary: var(--coar-text-neutral-secondary, #52514e); + --chart-muted: #898781; + --chart-grid: #e1e0d9; + --chart-axis: #c3c2b7; + + --chart-series-1: #2a78d6; + --chart-series-2: #eb6834; + --chart-series-3: #1baf7a; + --chart-series-4: #eda100; + --chart-series-5: #e87ba4; + --chart-series-6: #008300; + --chart-series-other: #898781; + + --chart-status-neutral: #c3c2b7; + --chart-status-warning: #fab219; + --chart-status-critical: #d03b3b; +} + +.dark-mode .dash-chart { + --chart-grid: #2c2c2a; + --chart-axis: #383835; + + --chart-series-1: #3987e5; + --chart-series-2: #d95926; + --chart-series-3: #199e70; + --chart-series-4: #c98500; + --chart-series-5: #d55181; + + --chart-status-neutral: #52514e; +} + +/* The table twin of a chart: read by assistive tech, invisible otherwise. */ +.dash-chart__table { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.dash-chart__legend { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + font-size: 0.75rem; + color: var(--chart-ink-secondary); +} +.dash-chart__legend-item { + display: inline-flex; + align-items: center; + gap: 0.375rem; +} +.dash-chart__swatch { + width: 0.625rem; + height: 0.625rem; + border-radius: 2px; + flex-shrink: 0; +} diff --git a/src/frontend-vue/src/views/dashboard/dashboardStats.ts b/src/frontend-vue/src/views/dashboard/dashboardStats.ts new file mode 100644 index 00000000..dad6640a --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/dashboardStats.ts @@ -0,0 +1,71 @@ +import { ref } from 'vue' +import { useHttpClient } from '@/composables/useHttpClient' + +// Mirrors DashboardStatsDto in Modgud.Api/Features/Dashboard/DashboardEndpoints.cs. +// Every block the viewer has no permission for arrives as null. + +export interface DashboardCounts { + Users: number | null + ServiceAccounts: number | null + Groups: number | null + Roles: number | null + Apps: number | null + OAuthClients: number | null + LoginProviders: number | null + LoginProvidersEnabled: number | null + ActiveSessions: number | null + PendingChangeRequests: number | null +} + +export interface LoginStats { + Days: number + Succeeded: number + Failed: number + FailedLast24h: number + Series: { Day: string; Succeeded: number; Failed: number }[] + Methods: { Method: string; Count: number }[] +} + +export interface SecurityStats { + Days: number + Info: number + Warning: number + Error: number + AttentionLast24h: number + Series: { Day: string; Info: number; Warning: number; Error: number }[] + TopEventTypes: { EventType: string; Count: number }[] +} + +export interface DashboardStats { + Days: number + TimeZone: string + Counts: DashboardCounts + Logins: LoginStats | null + Security: SecurityStats | null +} + +// One read backs every statistics widget, so the state is shared rather than +// fetched per widget. +const stats = ref(null) +const loading = ref(false) +const failed = ref(false) + +async function load() { + loading.value = true + failed.value = false + try { + // The browser's zone, so "today" on the chart is the viewer's today. + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone + stats.value = await useHttpClient('/api/dashboard/stats') + .setQueryParameter('tz', tz) + .get() + } catch { + failed.value = true + } finally { + loading.value = false + } +} + +export function useDashboardStats() { + return { stats, loading, failed, load } +} diff --git a/src/frontend-vue/src/views/dashboard/layout.ts b/src/frontend-vue/src/views/dashboard/layout.ts new file mode 100644 index 00000000..e764ed61 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/layout.ts @@ -0,0 +1,52 @@ +import type { Component } from 'vue' + +/** Width of a widget on the 12-column dashboard grid. */ +export type WidgetSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'full' + +/** One widget on the dashboard. List order is display order. */ +export interface WidgetPlacement { + Widget: string + Size: WidgetSize +} + +export interface WidgetDefinition { + id: string + /** i18n key + English fallback for the catalog and the edit chrome. */ + titleKey: string + titleEn: string + icon: string + /** + * The viewer needs ANY of these to see the widget. Empty = everyone. This + * only decides visibility — the data itself is gated by the endpoint. + */ + requirePermissions: string[] + /** Widths the widget lays out well at, narrowest first. */ + sizes: WidgetSize[] + defaultSize: WidgetSize + component: Component + props?: Record +} + +/** + * Turns a stored layout into what this viewer actually gets: placements whose + * widget still exists and which they may see, each at a size the widget supports. + * A layout can therefore never show more than the catalog and permissions allow. + */ +export function resolveLayout( + stored: WidgetPlacement[], + catalog: Map, + canSee: (def: WidgetDefinition) => boolean, +): WidgetPlacement[] { + const seen = new Set() + const result: WidgetPlacement[] = [] + for (const placement of stored) { + const def = catalog.get(placement.Widget) + if (!def || seen.has(def.id) || !canSee(def)) continue + seen.add(def.id) + result.push({ + Widget: def.id, + Size: def.sizes.includes(placement.Size) ? placement.Size : def.defaultSize, + }) + } + return result +} diff --git a/src/frontend-vue/src/views/dashboard/relativeTime.ts b/src/frontend-vue/src/views/dashboard/relativeTime.ts new file mode 100644 index 00000000..fc222450 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/relativeTime.ts @@ -0,0 +1,18 @@ +import { useI18n } from '@cocoar/vue-localization' + +/** "5 min ago"-style timestamps for the dashboard's list widgets. */ +export function useRelativeTime() { + const { t, language } = useI18n() + + return function relativeTime(iso: string): string { + const diffSec = Math.max(0, Math.floor((Date.now() - new Date(iso).getTime()) / 1000)) + if (diffSec < 60) return t('dashboard.time.justNow', {}, 'just now') + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return t('dashboard.time.minutesAgo', { n: diffMin }, '{n} min ago') + const diffH = Math.floor(diffMin / 60) + if (diffH < 24) return t('dashboard.time.hoursAgo', { n: diffH }, '{n} h ago') + const diffD = Math.floor(diffH / 24) + if (diffD < 30) return t('dashboard.time.daysAgo', { n: diffD }, '{n} days ago') + return new Date(iso).toLocaleDateString(language.value) + } +} diff --git a/src/frontend-vue/src/views/dashboard/widgets/AccountSecurityWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/AccountSecurityWidget.vue new file mode 100644 index 00000000..62874448 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/AccountSecurityWidget.vue @@ -0,0 +1,64 @@ + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue new file mode 100644 index 00000000..de055f16 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/LoginMethodsWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/LoginMethodsWidget.vue new file mode 100644 index 00000000..c63d13d6 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/LoginMethodsWidget.vue @@ -0,0 +1,68 @@ + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/LoginProvidersWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/LoginProvidersWidget.vue new file mode 100644 index 00000000..ce06254b --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/LoginProvidersWidget.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/LoginsChartWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/LoginsChartWidget.vue new file mode 100644 index 00000000..32e86ec6 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/LoginsChartWidget.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/MySessionsWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/MySessionsWidget.vue new file mode 100644 index 00000000..0cdcbc4e --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/MySessionsWidget.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/RecentActivityWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/RecentActivityWidget.vue new file mode 100644 index 00000000..bc334db7 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/RecentActivityWidget.vue @@ -0,0 +1,66 @@ + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/SecurityEventsWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/SecurityEventsWidget.vue new file mode 100644 index 00000000..945c2e60 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/SecurityEventsWidget.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/StatKpiWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/StatKpiWidget.vue new file mode 100644 index 00000000..3d99fdd0 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/StatKpiWidget.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/WidgetCard.vue b/src/frontend-vue/src/views/dashboard/widgets/WidgetCard.vue new file mode 100644 index 00000000..dffb20e0 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/WidgetCard.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/registry.ts b/src/frontend-vue/src/views/dashboard/widgets/registry.ts new file mode 100644 index 00000000..badd118c --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/registry.ts @@ -0,0 +1,138 @@ +import type { WidgetDefinition, WidgetPlacement } from '../layout' +import AccountSecurityWidget from './AccountSecurityWidget.vue' +import CountsWidget from './CountsWidget.vue' +import LoginMethodsWidget from './LoginMethodsWidget.vue' +import LoginProvidersWidget from './LoginProvidersWidget.vue' +import LoginsChartWidget from './LoginsChartWidget.vue' +import MySessionsWidget from './MySessionsWidget.vue' +import RecentActivityWidget from './RecentActivityWidget.vue' +import SecurityEventsWidget from './SecurityEventsWidget.vue' +import StatKpiWidget from './StatKpiWidget.vue' + +/** + * Every widget the dashboard can show. Ids are persisted in stored layouts — + * never rename one; a removed id simply drops out of the layouts that name it. + * `requirePermissions` mirrors the backend strings exactly. + */ +export const WIDGETS: WidgetDefinition[] = [ + // ── Personal — every signed-in user ────────────────────────────────── + { + id: 'account-security', + titleKey: 'dashboard.security.title', titleEn: 'Account security', icon: 'shield-check', + requirePermissions: [], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'l', + component: AccountSecurityWidget, + }, + { + id: 'my-sessions', + titleKey: 'dashboard.sessions.title', titleEn: 'Active sessions', icon: 'monitor', + requirePermissions: [], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'l', + component: MySessionsWidget, + }, + + // ── Realm operations — each gated on what backs its data ───────────── + { + id: 'kpi-active-sessions', + titleKey: 'dashboard.kpi.realmSessions', titleEn: 'Active sessions in the realm', icon: 'monitor', + requirePermissions: ['session:read'], + sizes: ['xs', 's', 'm'], defaultSize: 's', + component: StatKpiWidget, props: { kind: 'activeSessions' }, + }, + { + id: 'kpi-failed-logins', + titleKey: 'dashboard.kpi.failedLast24h', titleEn: 'Failed sign-ins, 24 h', icon: 'shield-alert', + requirePermissions: ['audit-log:read'], + sizes: ['xs', 's', 'm'], defaultSize: 's', + component: StatKpiWidget, props: { kind: 'failedLogins' }, + }, + { + id: 'kpi-security-attention', + titleKey: 'dashboard.kpi.securityAttention', titleEn: 'Security events, 24 h', icon: 'triangle-alert', + requirePermissions: ['auth-log:read'], + sizes: ['xs', 's', 'm'], defaultSize: 's', + component: StatKpiWidget, props: { kind: 'securityAttention' }, + }, + { + id: 'kpi-pending-requests', + titleKey: 'dashboard.kpi.pendingChangeRequests', titleEn: 'Open requests', icon: 'inbox', + requirePermissions: ['user:write'], + sizes: ['xs', 's', 'm'], defaultSize: 's', + component: StatKpiWidget, props: { kind: 'pendingRequests' }, + }, + { + id: 'logins-chart', + titleKey: 'dashboard.logins.title', titleEn: 'Sign-ins', icon: 'activity', + requirePermissions: ['audit-log:read'], + sizes: ['l', 'xl', 'full'], defaultSize: 'xl', + component: LoginsChartWidget, + }, + { + id: 'login-methods', + titleKey: 'dashboard.loginMethods.title', titleEn: 'Sign-in methods', icon: 'key-round', + requirePermissions: ['audit-log:read'], + sizes: ['m', 'l'], defaultSize: 'm', + component: LoginMethodsWidget, + }, + { + id: 'directory', + titleKey: 'dashboard.directory.title', titleEn: 'Directory', icon: 'users', + requirePermissions: ['user:read', 'service-account:read', 'authorization-group:read', 'permission-role:read'], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'l', + component: CountsWidget, props: { group: 'directory' }, + }, + { + id: 'applications', + titleKey: 'dashboard.applications.title', titleEn: 'Applications & sign-in', icon: 'layout-grid', + requirePermissions: ['app:read', 'oauth-client:read', 'login-provider:read'], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'l', + component: CountsWidget, props: { group: 'applications' }, + }, + { + id: 'security-events', + titleKey: 'dashboard.securityEvents.title', titleEn: 'Security events', icon: 'shield-alert', + requirePermissions: ['auth-log:read'], + sizes: ['m', 'l'], defaultSize: 'm', + component: SecurityEventsWidget, + }, + { + id: 'recent-activity', + titleKey: 'dashboard.systemActivity.title', titleEn: 'Recent security events', icon: 'scroll-text', + requirePermissions: ['auth-log:read'], + sizes: ['l', 'xl', 'full'], defaultSize: 'xl', + component: RecentActivityWidget, + }, + { + id: 'login-providers', + titleKey: 'dashboard.loginProviderStatus.title', titleEn: 'Login providers', icon: 'log-in', + requirePermissions: ['login-provider:read'], + sizes: ['m', 'l', 'xl'], defaultSize: 'l', + component: LoginProvidersWidget, + }, +] + +export const WIDGET_CATALOG = new Map(WIDGETS.map(w => [w.id, w])) + +/** + * What a realm shows before anyone arranged anything — the last of the three + * layers (own layout → realm default → this). Rows on the 12-column grid add up + * for a viewer who sees everything; with fewer permissions the rest closes ranks. + */ +export const BUILT_IN_LAYOUT: WidgetPlacement[] = [ + { Widget: 'account-security', Size: 'l' }, + { Widget: 'my-sessions', Size: 'l' }, + + { Widget: 'kpi-active-sessions', Size: 's' }, + { Widget: 'kpi-failed-logins', Size: 's' }, + { Widget: 'kpi-security-attention', Size: 's' }, + { Widget: 'kpi-pending-requests', Size: 's' }, + + { Widget: 'logins-chart', Size: 'xl' }, + { Widget: 'login-methods', Size: 'm' }, + + { Widget: 'directory', Size: 'l' }, + { Widget: 'applications', Size: 'l' }, + + { Widget: 'security-events', Size: 'm' }, + { Widget: 'recent-activity', Size: 'xl' }, +] From 8068efe85936658e265f09c48c09e1a1a5f078d9 Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 10 Oct 2026 15:47:13 +0200 Subject: [PATCH 2/4] fix(dashboard): count tiles one step lighter than their card; donut legend drops below the ring when narrow Co-Authored-By: Claude Opus 5.5 --- .../src/views/dashboard/charts/DonutChart.vue | 2 +- .../src/views/dashboard/widgets/CountsWidget.vue | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/frontend-vue/src/views/dashboard/charts/DonutChart.vue b/src/frontend-vue/src/views/dashboard/charts/DonutChart.vue index 84020922..69a987e0 100644 --- a/src/frontend-vue/src/views/dashboard/charts/DonutChart.vue +++ b/src/frontend-vue/src/views/dashboard/charts/DonutChart.vue @@ -143,7 +143,7 @@ const active = ref(null) } .donut__legend { - flex: 1 1 9rem; + flex: 1 1 15rem; min-width: 0; margin: 0; padding: 0; diff --git a/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue index de055f16..2983b2e0 100644 --- a/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue +++ b/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue @@ -94,15 +94,18 @@ const tiles = computed(() => group.value.tiles.flatMap(tile => { padding: 0.75rem; border: 1px solid var(--coar-border-neutral-tertiary, rgba(0, 0, 0, 0.08)); border-radius: 0.5rem; - background: var(--coar-background-neutral-secondary, #f9fafb); + /* One step lighter than the card it sits on, so the tile reads as a surface + of its own rather than an outline. */ + background: var(--coar-background-neutral-primary, #ffffff); color: inherit; text-align: left; cursor: pointer; - transition: border-color 0.15s ease; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } .counts__tile:hover, .counts__tile:focus-visible { border-color: var(--coar-accent, #1077be); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); } .counts__icon { display: flex; @@ -112,7 +115,7 @@ const tiles = computed(() => group.value.tiles.flatMap(tile => { height: 1.75rem; margin-bottom: 0.375rem; border-radius: 0.5rem; - background: var(--coar-background-neutral-tertiary, rgba(0, 0, 0, 0.05)); + background: var(--coar-background-neutral-secondary, rgba(0, 0, 0, 0.05)); color: var(--coar-text-neutral-secondary, #6b7280); } .counts__value { From 564f279ca30231bc74053e453b781d5cb17ff8ee Mon Sep 17 00:00:00 2001 From: Bernhard Windisch Date: Sat, 10 Oct 2026 16:03:59 +0200 Subject: [PATCH 3/4] feat(dashboard): per-widget settings, more counts and two personal widgets - A placement can carry options (option key -> chosen values). In edit mode a gear opens the widget's settings: which counts the directory and applications widgets show, how many rows the list widgets show. - Counts for positions (behind the PositionTerminals feature), OAuth APIs and OAuth scopes, each behind the permission of its list. - GET /api/dashboard/my-apps: the clients the caller holds a valid authorization with, one row per client. - New personal widgets "My apps" and "My passkeys". Co-Authored-By: Claude Opus 5.5 --- .../Dashboard/DashboardEndpointsTests.cs | 79 ++++++++++++++- .../Features/Dashboard/DashboardEndpoints.cs | 98 ++++++++++++++++++ .../Dashboard/DashboardLayout.cs | 16 ++- src/frontend-vue/public/i18n/de.json | 18 +++- .../src/views/dashboard/DashboardView.vue | 99 ++++++++++++++++++- .../src/views/dashboard/dashboardStats.ts | 3 + .../src/views/dashboard/layout.ts | 45 +++++++++ .../views/dashboard/widgets/CountsWidget.vue | 22 ++++- .../views/dashboard/widgets/MyAppsWidget.vue | 73 ++++++++++++++ .../dashboard/widgets/MyPasskeysWidget.vue | 38 +++++++ .../dashboard/widgets/MySessionsWidget.vue | 8 +- .../widgets/RecentActivityWidget.vue | 12 ++- .../src/views/dashboard/widgets/registry.ts | 78 +++++++++++++-- 13 files changed, 563 insertions(+), 26 deletions(-) create mode 100644 src/frontend-vue/src/views/dashboard/widgets/MyAppsWidget.vue create mode 100644 src/frontend-vue/src/views/dashboard/widgets/MyPasskeysWidget.vue diff --git a/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs b/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs index 1eb5625c..2347986b 100644 --- a/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs +++ b/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs @@ -4,6 +4,8 @@ using Modgud.Api.Tests.Infrastructure; using Modgud.Authentication.Audit; using Modgud.Domain.Dashboard; +using Modgud.Domain.OAuth.Applications; +using Modgud.Domain.OAuth.Storage; using Modgud.Infrastructure.Audit; namespace Modgud.Api.Tests.Dashboard; @@ -136,7 +138,7 @@ public async Task Stats_withhold_every_block_the_caller_has_no_permission_for() var stats = await plain.GetFromJsonAsync("/api/dashboard/stats", ct); Assert.NotNull(stats); - Assert.Equal(new DashboardCountsDto(null, null, null, null, null, null, null, null, null, null), stats!.Counts); + Assert.Equal(new DashboardCountsDto(null, null, null, null, null, null, null, null, null, null, null, null, null), stats!.Counts); Assert.Null(stats.Logins); Assert.Null(stats.Security); } @@ -195,6 +197,71 @@ public async Task Realm_default_needs_realm_settings_write_and_reaches_every_use Assert.Null((await plain.GetFromJsonAsync("/api/dashboard/layout", ct))!.RealmDefault); } + [Fact] + public async Task Layout_keeps_a_widgets_options() + { + var ct = TestContext.Current.CancellationToken; + var body = new SaveDashboardLayoutDto( + [ + new DashboardWidgetPlacement("directory", "l", new() { ["show"] = ["Users", "Positions"] }), + new DashboardWidgetPlacement("my-sessions", "m"), + ]); + + Assert.Equal(HttpStatusCode.NoContent, (await Client.PutAsJsonAsync("/api/dashboard/layout", body, ct)).StatusCode); + + var stored = (await Client.GetFromJsonAsync("/api/dashboard/layout", ct))!.User!; + Assert.Equal(["Users", "Positions"], stored[0].Options!["show"]); + Assert.Null(stored[1].Options); + } + + [Theory] + [InlineData("show me", "Users")] + [InlineData("show", "