Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
5e87c6e
Add back-office tenant overview API with queries, repositories, and t…
tjementum May 3, 2026
ec5cb85
Add back-office accounts list, side pane, and detail pages
tjementum May 3, 2026
bcacbab
Polish back-office accounts pages with design fixes and new components
tjementum May 3, 2026
29eb872
Add back-office tenant subscription tracking and blob proxy
tjementum May 3, 2026
d97d0f3
Polish back-office tenant overview list and detail
tjementum May 3, 2026
e08595a
Cover back-office tenant overview list and detail flow
tjementum May 3, 2026
c579d59
Expose HasEverSubscribed on tenant detail response
tjementum May 3, 2026
28bc40e
Polish back-office accounts toolbar filters, side pane status, and cu…
tjementum May 3, 2026
4c0699d
Update back button assertion in back-office accounts e2e
tjementum May 3, 2026
50214b2
Add multi-select plan and status filters to back-office tenants list
tjementum May 3, 2026
e703de4
Add back-office endpoints for Users search and detail
tjementum May 3, 2026
33cdeb7
Extend back-office Users queries with cross-tenant sessions and tenan…
tjementum May 4, 2026
f611eb2
Add back-office dashboard KPIs and trends endpoints
tjementum May 4, 2026
702d91b
Add back-office dashboard with KPI cards and trend charts
tjementum May 4, 2026
ae24b2a
Add back-office dashboard and users e2e smoke coverage
tjementum May 4, 2026
0e283c1
Drop redundant py-4 class on user KPI cards
tjementum May 4, 2026
1b2eb04
Rebuild back-office dashboard with full mockup layout and Stripe events
tjementum May 4, 2026
af5f626
Add period-over-period comparison overlay to dashboard trend charts
tjementum May 4, 2026
44d279a
Polish back-office dashboard: account terminology, legend padding, an…
tjementum May 4, 2026
70498de
Wrap recharts chart types in shared component with accessibilityLayer…
tjementum May 4, 2026
26db15c
Add LinkCard shared component and use it in dashboard KPI tiles and a…
tjementum May 4, 2026
80c0d0f
Consolidate Table styling into shared component
tjementum May 4, 2026
ce6a289
Use shared Card primitives in dashboard card shell
tjementum May 5, 2026
51c8066
Fix back-office dashboard e2e to match CardTitle div instead of headi…
tjementum May 5, 2026
f727577
Add append-only BillingEvent log written from Stripe sync
tjementum May 6, 2026
ae01508
Read recent Stripe events from BillingEvent log and add back-office b…
tjementum May 6, 2026
9566c09
Enrich PaymentTransaction with active plan from Stripe price catalog
tjementum May 6, 2026
15d958c
Add /billing-events back-office page and wire dashboard card to it
tjementum May 6, 2026
ca7f3e4
Add @smoke e2e test for back-office /billing-events flow
tjementum May 6, 2026
9871b87
Add Sync with Stripe admin action on tenant detail page
tjementum May 6, 2026
3e521e4
Add inline billing drift detection with Subscription flags and BackOf…
tjementum May 6, 2026
4ba60d4
Polish billing events surfaces and add Billing tab on tenant detail
tjementum May 6, 2026
fbfaa0f
Reorder billing sections and add view-all links on tenant detail
tjementum May 6, 2026
7451680
Polish back-office tenant detail UI and complete Danish translations
tjementum May 7, 2026
58586d0
Add back-office tenant overview with Stripe billing reconciliation, b…
tjementum May 7, 2026
797ebef
List all back-office users newest-first by default with pagination
tjementum May 7, 2026
db11a14
Rewrite MRR trend using BillingEvent log and add data-quality banners
tjementum May 7, 2026
06a679f
Move Sync with Stripe into a kebab menu and align detail header avatars
tjementum May 7, 2026
4170f87
Polish detail headers with responsive layout and align Current plan h…
tjementum May 8, 2026
4010f9c
Restrict Sync with Stripe to back-office admins
tjementum May 8, 2026
694f965
Add kiosk mode toggle to back-office dashboard
tjementum May 8, 2026
158f0ff
Hide mobile floating sidebar trigger in kiosk mode and center dashboa…
tjementum May 8, 2026
05ee4a0
Convert dashboard recent-events and recent-signups cards to tables
tjementum May 8, 2026
358a3e3
Fix Recent billing events nav target and update e2e tests for back-of…
tjementum May 8, 2026
2770703
Collapse five back-office migrations into one
tjementum May 8, 2026
70cf72e
Display tenant and user IDs on detail surfaces and polish dashboard c…
tjementum May 8, 2026
b743b0e
Replace Created with Signed up for accounts in back-office surfaces
tjementum May 9, 2026
56ac3c3
Make billing events 1:1 with Stripe events and add unsynced and drift…
tjementum May 9, 2026
10d686f
Add multi-source reconciliation to billing event ledger
tjementum May 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.Endpoints;
namespace Account.Api.BackOffice;

public sealed class BackOfficeEndpoints : IEndpoints
{
Expand All @@ -14,7 +14,7 @@ public sealed class BackOfficeEndpoints : IEndpoints
public void MapEndpoints(IEndpointRouteBuilder routes)
{
// BackOffice:Host is required (validated at startup via ValidateOnStart in
// ApiDependencyConfiguration.AddBackOfficeHostOptions). PP-1149 must keep that validation in place
// ApiDependencyConfiguration.AddBackOfficeHostOptions). The startup validation must stay in place
// so a missing/blank value fails loudly rather than silently 404-ing back-office endpoints.
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

Expand Down
37 changes: 37 additions & 0 deletions application/account/Api/BackOffice/BillingDriftEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using Account.Features.BackOffice.BillingDrift.Queries;
using Microsoft.Extensions.Options;
using SharedKernel.ApiResults;
using SharedKernel.Authentication.BackOfficeIdentity;
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.BackOffice;

public sealed class BillingDriftEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/back-office/billing-drift";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

var group = routes.MapGroup(RoutesPrefix)
.WithTags("BackOfficeBillingDrift")
.WithGroupName(OpenApiDocumentNames.BackOffice)
.RequireHost(backOfficeHost)
.RequireAuthorization(BackOfficeIdentityDefaults.PolicyName)
.ProducesValidationProblem();

group.MapGet("/summary", async Task<ApiResult<BillingDriftSummaryResponse>> ([AsParameters] GetBillingDriftSummaryQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BillingDriftSummaryResponse>();

group.MapGet("/unsynced-summary", async Task<ApiResult<UnsyncedSubscriptionsSummaryResponse>> ([AsParameters] GetUnsyncedSubscriptionsSummaryQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<UnsyncedSubscriptionsSummaryResponse>();

group.MapGet("/mrr-consistency-summary", async Task<ApiResult<DashboardMrrConsistencySummaryResponse>> ([AsParameters] GetDashboardMrrConsistencySummaryQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<DashboardMrrConsistencySummaryResponse>();
}
}
29 changes: 29 additions & 0 deletions application/account/Api/BackOffice/BillingEventsEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using Account.Features.BackOffice.BillingEvents.Queries;
using Microsoft.Extensions.Options;
using SharedKernel.ApiResults;
using SharedKernel.Authentication.BackOfficeIdentity;
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.BackOffice;

public sealed class BillingEventsEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/back-office/billing-events";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

var group = routes.MapGroup(RoutesPrefix)
.WithTags("BackOfficeBillingEvents")
.WithGroupName(OpenApiDocumentNames.BackOffice)
.RequireHost(backOfficeHost)
.RequireAuthorization(BackOfficeIdentityDefaults.PolicyName)
.ProducesValidationProblem();

group.MapGet("/", async Task<ApiResult<BillingEventsResponse>> ([AsParameters] GetBackOfficeBillingEventsQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BillingEventsResponse>();
}
}
49 changes: 49 additions & 0 deletions application/account/Api/BackOffice/DashboardEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Account.Features.BackOffice.Dashboard.Queries;
using Microsoft.Extensions.Options;
using SharedKernel.ApiResults;
using SharedKernel.Authentication.BackOfficeIdentity;
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.BackOffice;

public sealed class DashboardEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/back-office/dashboard";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

var group = routes.MapGroup(RoutesPrefix)
.WithTags("BackOfficeDashboard")
.WithGroupName(OpenApiDocumentNames.BackOffice)
.RequireHost(backOfficeHost)
.RequireAuthorization(BackOfficeIdentityDefaults.PolicyName)
.ProducesValidationProblem();

group.MapGet("/kpis", async Task<ApiResult<BackOfficeDashboardKpisResponse>> ([AsParameters] GetDashboardKpisQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BackOfficeDashboardKpisResponse>();

group.MapGet("/trends", async Task<ApiResult<BackOfficeDashboardTrendsResponse>> ([AsParameters] GetDashboardTrendsQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BackOfficeDashboardTrendsResponse>();

group.MapGet("/mrr-trend", async Task<ApiResult<BackOfficeDashboardMrrTrendResponse>> ([AsParameters] GetDashboardMrrTrendQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BackOfficeDashboardMrrTrendResponse>();

group.MapGet("/plan-distribution", async Task<ApiResult<BackOfficeDashboardPlanDistributionResponse>> ([AsParameters] GetDashboardPlanDistributionQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BackOfficeDashboardPlanDistributionResponse>();

group.MapGet("/recent-signups", async Task<ApiResult<BackOfficeDashboardRecentSignupsResponse>> ([AsParameters] GetDashboardRecentSignupsQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BackOfficeDashboardRecentSignupsResponse>();

group.MapGet("/recent-stripe-events", async Task<ApiResult<BackOfficeDashboardRecentStripeEventsResponse>> ([AsParameters] GetDashboardRecentStripeEventsQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BackOfficeDashboardRecentStripeEventsResponse>();
}
}
59 changes: 59 additions & 0 deletions application/account/Api/BackOffice/TenantsEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using Account.Features.Tenants.BackOffice.Commands;
using Account.Features.Tenants.BackOffice.Queries;
using Microsoft.Extensions.Options;
using SharedKernel.ApiResults;
using SharedKernel.Authentication.BackOfficeIdentity;
using SharedKernel.Domain;
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.BackOffice;

public sealed class TenantsEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/back-office/tenants";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

var group = routes.MapGroup(RoutesPrefix)
.WithTags("BackOfficeTenants")
.WithGroupName(OpenApiDocumentNames.BackOffice)
.RequireHost(backOfficeHost)
.RequireAuthorization(BackOfficeIdentityDefaults.PolicyName)
.ProducesValidationProblem();

group.MapGet("/", async Task<ApiResult<TenantsResponse>> ([AsParameters] GetTenantsQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<TenantsResponse>();

group.MapGet("/{id}", async Task<ApiResult<TenantDetailResponse>> (TenantId id, IMediator mediator)
=> await mediator.Send(new GetTenantDetailQuery(id))
).Produces<TenantDetailResponse>();

group.MapGet("/{id}/user-counts", async Task<ApiResult<TenantUserCountsResponse>> (TenantId id, IMediator mediator)
=> await mediator.Send(new GetTenantUserCountsQuery(id))
).Produces<TenantUserCountsResponse>();

group.MapGet("/{id}/users", async Task<ApiResult<TenantUsersResponse>> (TenantId id, [AsParameters] GetTenantUsersQuery query, IMediator mediator)
=> await mediator.Send(query with { Id = id })
).Produces<TenantUsersResponse>();

group.MapGet("/{id}/activity", async Task<ApiResult<TenantActivityResponse>> (TenantId id, IMediator mediator)
=> await mediator.Send(new GetTenantActivityQuery(id))
).Produces<TenantActivityResponse>();

group.MapGet("/{id}/payment-history", async Task<ApiResult<TenantPaymentHistoryResponse>> (TenantId id, [AsParameters] GetTenantPaymentHistoryQuery query, IMediator mediator)
=> await mediator.Send(query with { Id = id })
).Produces<TenantPaymentHistoryResponse>();

group.MapPost("/{id}/sync-with-stripe", async Task<ApiResult<SyncTenantWithStripeResponse>> (TenantId id, IMediator mediator)
=> await mediator.Send(new SyncTenantWithStripeCommand { TenantId = id })
).Produces<SyncTenantWithStripeResponse>().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName);

group.MapPost("/{id}/drift/acknowledge", async Task<ApiResult> (TenantId id, IMediator mediator)
=> await mediator.Send(new AcknowledgeBillingDriftCommand { TenantId = id })
);
}
}
42 changes: 42 additions & 0 deletions application/account/Api/BackOffice/UsersEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using Account.Features.Users.BackOffice.Queries;
using Microsoft.Extensions.Options;
using SharedKernel.ApiResults;
using SharedKernel.Authentication.BackOfficeIdentity;
using SharedKernel.Domain;
using SharedKernel.Endpoints;
using SharedKernel.OpenApi;

namespace Account.Api.BackOffice;

public sealed class UsersEndpoints : IEndpoints
{
private const string RoutesPrefix = "/api/back-office/users";

public void MapEndpoints(IEndpointRouteBuilder routes)
{
var backOfficeHost = routes.ServiceProvider.GetRequiredService<IOptions<BackOfficeHostOptions>>().Value.Host;

var group = routes.MapGroup(RoutesPrefix)
.WithTags("BackOfficeUsers")
.WithGroupName(OpenApiDocumentNames.BackOffice)
.RequireHost(backOfficeHost)
.RequireAuthorization(BackOfficeIdentityDefaults.PolicyName)
.ProducesValidationProblem();

group.MapGet("/", async Task<ApiResult<BackOfficeUsersResponse>> ([AsParameters] GetBackOfficeUsersQuery query, IMediator mediator)
=> await mediator.Send(query)
).Produces<BackOfficeUsersResponse>();

group.MapGet("/{id}", async Task<ApiResult<BackOfficeUserDetailResponse>> (UserId id, IMediator mediator)
=> await mediator.Send(new GetBackOfficeUserDetailQuery(id))
).Produces<BackOfficeUserDetailResponse>();

group.MapGet("/{id}/sessions", async Task<ApiResult<BackOfficeUserSessionsResponse>> (UserId id, [AsParameters] GetBackOfficeUserSessionsQuery query, IMediator mediator)
=> await mediator.Send(query with { Id = id })
).Produces<BackOfficeUserSessionsResponse>();

group.MapGet("/{id}/login-history", async Task<ApiResult<BackOfficeUserLoginHistoryResponse>> (UserId id, [AsParameters] GetBackOfficeUserLoginHistoryQuery query, IMediator mediator)
=> await mediator.Send(query with { Id = id })
).Produces<BackOfficeUserLoginHistoryResponse>();
}
}
34 changes: 34 additions & 0 deletions application/account/Api/BackOfficeBlobProxy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.AspNetCore.Mvc;
using SharedKernel.Integrations.BlobStorage;

namespace Account.Api;

// Back-office Kestrel listens on its own port (BACK_OFFICE_KESTREL_PORT) and bypasses AppGateway, so
// the avatar/logo routes that AppGateway forwards on the user-facing host are not available here.
// Map equivalent endpoints scoped to the back-office host that stream blobs directly from the
// keyed account-storage IBlobStorageClient. This keeps account list/side-pane logos and owner
// avatars working when the back-office SPA is loaded over the dedicated Kestrel port.
public static class BackOfficeBlobProxy
{
public static IEndpointRouteBuilder MapBackOfficeBlobProxy(this IEndpointRouteBuilder routes, string backOfficeHostname)
{
routes.MapGet("/avatars/{**path}", async ([FromRoute] string path, [FromKeyedServices("account-storage")] IBlobStorageClient blobStorageClient, HttpContext httpContext, CancellationToken cancellationToken)
=> await StreamBlobAsync(blobStorageClient, "avatars", path, httpContext, cancellationToken)
).RequireHost(backOfficeHostname).AllowAnonymous();

routes.MapGet("/logos/{**path}", async ([FromRoute] string path, [FromKeyedServices("account-storage")] IBlobStorageClient blobStorageClient, HttpContext httpContext, CancellationToken cancellationToken)
=> await StreamBlobAsync(blobStorageClient, "logos", path, httpContext, cancellationToken)
).RequireHost(backOfficeHostname).AllowAnonymous();

return routes;
}

private static async Task<IResult> StreamBlobAsync(IBlobStorageClient blobStorageClient, string containerName, string blobName, HttpContext httpContext, CancellationToken cancellationToken)
{
var blob = await blobStorageClient.DownloadAsync(containerName, blobName, cancellationToken);
if (blob is null) return Results.NotFound();

httpContext.Response.Headers.CacheControl = "public, max-age=2592000, immutable";
return Results.Stream(blob.Value.Stream, blob.Value.ContentType);
}
}
4 changes: 4 additions & 0 deletions application/account/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@

app.UseApiServices(); // Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage.

// Back-office Kestrel listens on its own port and bypasses AppGateway, so the avatar/logo routes
// that AppGateway proxies on the user-facing host must be served here directly from blob storage.
app.MapBackOfficeBlobProxy(backOfficeHostname);

app.UseEmailStaticFiles("WebApp");

if (SharedInfrastructureConfiguration.IsRunningInAzure)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";

import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@repo/ui/components/Card";

interface DashboardCardShellProps {
title: ReactNode;
subtitle?: ReactNode;
action?: ReactNode;
children: ReactNode;
}

export function DashboardCardShell({ title, subtitle, action, children }: Readonly<DashboardCardShellProps>) {
return (
<Card className="h-full pb-4">
<CardHeader>
<CardTitle>{title}</CardTitle>
{subtitle && <CardDescription>{subtitle}</CardDescription>}
{action && <CardAction>{action}</CardAction>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { t } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
import { Trans } from "@lingui/react/macro";
import { Button } from "@repo/ui/components/Button";
import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup";
import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip";
import { MaximizeIcon, MinimizeIcon } from "lucide-react";
import { useEffect, useState } from "react";

import { DashboardTrendPeriod } from "@/shared/lib/api/client";

interface DashboardHeaderProps {
period: DashboardTrendPeriod;
onPeriodChange: (period: DashboardTrendPeriod) => void;
}

export function DashboardHeader({ period, onPeriodChange }: Readonly<DashboardHeaderProps>) {
const { i18n } = useLingui();
const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { weekday: "long", month: "long", day: "numeric" });
const today = dateFormatter.format(new Date());

// Browser fullscreen for kiosk mode — chrome (sidebar, tabs) hides until the user exits.
const [isFullscreen, setIsFullscreen] = useState(false);

useEffect(() => {
const updateState = () => setIsFullscreen(document.fullscreenElement !== null);
updateState();
document.addEventListener("fullscreenchange", updateState);
return () => document.removeEventListener("fullscreenchange", updateState);
}, []);

const toggleFullscreen = () => {
if (document.fullscreenElement === null) {
void document.documentElement.requestFullscreen();
} else {
void document.exitFullscreen();
}
};

const fullscreenLabel = isFullscreen ? t`Exit kiosk mode` : t`Enter kiosk mode`;

return (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1>
<Trans>Dashboard</Trans>
</h1>
<p className="mt-1 text-muted-foreground">
<Trans>BackOffice overview · {today}</Trans>
</p>
</div>
<div className="flex items-center gap-2">
<ToggleGroup
variant="outline"
aria-label={t`Period`}
value={[period]}
onValueChange={(values) => {
const next = values[0];
if (next) {
onPeriodChange(next as DashboardTrendPeriod);
}
}}
>
<ToggleGroupItem value={DashboardTrendPeriod.Last7Days} className="min-w-[3.5rem] justify-center">
<Trans>7d</Trans>
</ToggleGroupItem>
<ToggleGroupItem value={DashboardTrendPeriod.Last30Days} className="min-w-[3.5rem] justify-center">
<Trans>30d</Trans>
</ToggleGroupItem>
<ToggleGroupItem value={DashboardTrendPeriod.Last90Days} className="min-w-[3.5rem] justify-center">
<Trans>90d</Trans>
</ToggleGroupItem>
</ToggleGroup>
<Tooltip>
<TooltipTrigger
render={
<Button variant="outline" size="icon-sm" onClick={toggleFullscreen} aria-label={fullscreenLabel}>
{isFullscreen ? <MinimizeIcon className="size-4" /> : <MaximizeIcon className="size-4" />}
</Button>
}
/>
<TooltipContent>{fullscreenLabel}</TooltipContent>
</Tooltip>
</div>
</div>
);
}
Loading
Loading