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..2347986b --- /dev/null +++ b/src/dotnet/Modgud.Api.Tests/Dashboard/DashboardEndpointsTests.cs @@ -0,0 +1,328 @@ +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.Domain.OAuth.Applications; +using Modgud.Domain.OAuth.Storage; +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, 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); + } + + [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", " 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..69a987e0 --- /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..a013c964 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/dashboardStats.ts @@ -0,0 +1,74 @@ +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 + Positions: number | null + Groups: number | null + Roles: number | null + Apps: number | null + OAuthClients: number | null + OAuthApis: number | null + OAuthScopes: 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..a05b27eb --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/layout.ts @@ -0,0 +1,97 @@ +import type { Component } from 'vue' + +/** Width of a widget on the 12-column dashboard grid. */ +export type WidgetSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'full' + +/** A widget's own settings: option key → chosen values. */ +export type WidgetOptions = Record + +/** One widget on the dashboard. List order is display order. */ +export interface WidgetPlacement { + Widget: string + Size: WidgetSize + /** Only what differs from nothing-chosen-yet; absent = the widget's defaults. */ + Options?: WidgetOptions | null +} + +export interface WidgetOptionChoice { + value: string + labelKey: string + labelEn: string + /** Offered only to viewers holding ANY of these (the data is gated server-side anyway). */ + requirePermissions?: string[] + /** Offered only while this deployment feature is on. */ + requireFeature?: 'PageBuilder' | 'PositionTerminals' +} + +/** One setting of a widget: pick several (`multi`) or exactly one (`single`). */ +export interface WidgetOptionDefinition { + key: string + labelKey: string + labelEn: string + kind: 'multi' | 'single' + choices: WidgetOptionChoice[] + defaults: string[] +} + +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 + /** Settings the user can change; the component receives them as its `options` prop. */ + options?: WidgetOptionDefinition[] +} + +/** + * The complete option values a widget renders with: what the placement stores, + * reduced to choices the widget knows, with defaults filling the rest. + */ +export function resolveOptions(def: WidgetDefinition, stored?: WidgetOptions | null): WidgetOptions { + const result: WidgetOptions = {} + for (const option of def.options ?? []) { + const known = new Set(option.choices.map(c => c.value)) + const chosen = stored?.[option.key]?.filter(v => known.has(v)) + if (chosen === undefined) result[option.key] = [...option.defaults] + else if (option.kind === 'single') result[option.key] = chosen.length ? [chosen[0]!] : [...option.defaults] + // An empty multi-selection is a choice, not a missing one. + else result[option.key] = option.choices.map(c => c.value).filter(v => chosen.includes(v)) + } + return result +} + +/** + * 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, + ...(def.options && placement.Options ? { Options: placement.Options } : {}), + }) + } + 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..f47ce18b --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/CountsWidget.vue @@ -0,0 +1,162 @@ + + + + + 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/MyAppsWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/MyAppsWidget.vue new file mode 100644 index 00000000..c713c359 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/MyAppsWidget.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/src/frontend-vue/src/views/dashboard/widgets/MyPasskeysWidget.vue b/src/frontend-vue/src/views/dashboard/widgets/MyPasskeysWidget.vue new file mode 100644 index 00000000..7d7bae5e --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/MyPasskeysWidget.vue @@ -0,0 +1,38 @@ + + + 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..93f01ef2 --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/MySessionsWidget.vue @@ -0,0 +1,81 @@ + + + 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..37e8073a --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/RecentActivityWidget.vue @@ -0,0 +1,72 @@ + + + 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..cfdf35fa --- /dev/null +++ b/src/frontend-vue/src/views/dashboard/widgets/registry.ts @@ -0,0 +1,196 @@ +import type { WidgetDefinition, WidgetOptionDefinition, 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 MyAppsWidget from './MyAppsWidget.vue' +import MyPasskeysWidget from './MyPasskeysWidget.vue' +import MySessionsWidget from './MySessionsWidget.vue' +import RecentActivityWidget from './RecentActivityWidget.vue' +import SecurityEventsWidget from './SecurityEventsWidget.vue' +import StatKpiWidget from './StatKpiWidget.vue' + +/** "How many rows" — the one setting the list widgets share. */ +function rowsOption(choices: number[], fallback: number): WidgetOptionDefinition { + return { + key: 'rows', + labelKey: 'dashboard.options.rows', labelEn: 'Rows', + kind: 'single', + choices: choices.map(n => ({ value: String(n), labelKey: `dashboard.options.rowCount.${n}`, labelEn: String(n) })), + defaults: [String(fallback)], + } +} + +/** + * Every widget the dashboard can show. Ids, option keys and choice values are + * persisted in stored layouts — never rename one; a removed one simply drops out + * of the layouts that name it. `requirePermissions` mirrors the backend strings. + */ +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: 'm', + component: AccountSecurityWidget, + }, + { + id: 'my-sessions', + titleKey: 'dashboard.sessions.title', titleEn: 'Active sessions', icon: 'monitor', + requirePermissions: [], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'm', + component: MySessionsWidget, + options: [rowsOption([3, 5, 10], 3)], + }, + { + id: 'my-apps', + titleKey: 'dashboard.myApps.title', titleEn: 'My apps', icon: 'app-window', + requirePermissions: [], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'm', + component: MyAppsWidget, + options: [rowsOption([3, 5, 10], 5)], + }, + { + id: 'kpi-my-passkeys', + titleKey: 'dashboard.kpi.myPasskeys', titleEn: 'My passkeys', icon: 'fingerprint', + requirePermissions: [], + sizes: ['xs', 's', 'm'], defaultSize: 's', + component: MyPasskeysWidget, + }, + + // ── 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', 'position:read', 'authorization-group:read', 'permission-role:read'], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'l', + component: CountsWidget, props: { group: 'directory' }, + // Choice values are the count keys of the statistics endpoint. + options: [{ + key: 'show', + labelKey: 'dashboard.options.show', labelEn: 'Show', + kind: 'multi', + choices: [ + { value: 'Users', labelKey: 'nav.users', labelEn: 'Users', requirePermissions: ['user:read'] }, + { value: 'ServiceAccounts', labelKey: 'admin.serviceAccounts.title', labelEn: 'Service Accounts', requirePermissions: ['service-account:read'] }, + { value: 'Positions', labelKey: 'admin.positions.title', labelEn: 'Positions', requirePermissions: ['position:read'], requireFeature: 'PositionTerminals' }, + { value: 'Groups', labelKey: 'nav.groups', labelEn: 'Groups', requirePermissions: ['authorization-group:read'] }, + { value: 'Roles', labelKey: 'nav.roles', labelEn: 'Roles', requirePermissions: ['permission-role:read'] }, + ], + defaults: ['Users', 'ServiceAccounts', 'Positions', 'Groups', 'Roles'], + }], + }, + { + id: 'applications', + titleKey: 'dashboard.applications.title', titleEn: 'Applications & sign-in', icon: 'layout-grid', + requirePermissions: ['app:read', 'oauth-client:read', 'oauth-api:read', 'oauth-scope:read', 'login-provider:read'], + sizes: ['m', 'l', 'xl', 'full'], defaultSize: 'l', + component: CountsWidget, props: { group: 'applications' }, + options: [{ + key: 'show', + labelKey: 'dashboard.options.show', labelEn: 'Show', + kind: 'multi', + choices: [ + { value: 'Apps', labelKey: 'admin.apps.title', labelEn: 'Applications', requirePermissions: ['app:read'] }, + { value: 'OAuthClients', labelKey: 'admin.oauthClients.title', labelEn: 'OAuth Clients', requirePermissions: ['oauth-client:read'] }, + { value: 'OAuthApis', labelKey: 'admin.oauthApis.title', labelEn: 'OAuth APIs', requirePermissions: ['oauth-api:read'] }, + { value: 'OAuthScopes', labelKey: 'admin.oauthScopes.title', labelEn: 'OAuth Scopes', requirePermissions: ['oauth-scope:read'] }, + { value: 'LoginProviders', labelKey: 'admin.loginProviders.title', labelEn: 'Login Providers', requirePermissions: ['login-provider:read'] }, + ], + defaults: ['Apps', 'OAuthClients', 'LoginProviders'], + }], + }, + { + 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, + options: [rowsOption([5, 8, 15], 8)], + }, + { + 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: 'm' }, + { Widget: 'my-sessions', Size: 'm' }, + { Widget: 'my-apps', Size: 'm' }, + + { 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' }, +]