A small, conventions-correct .NET 10 app that lists Active Directory / LDAP users in a co-hosted Blazor WebAssembly UI styled with Tailwind CSS v4.
CopilotDemo is a clean, single-feature directory browser: search and filter a list of users, then open a detail panel showing their attributes and group memberships. It runs zero-config on seeded in-memory data, and can connect to a real LDAP / Active Directory server by flipping one config value.
Its real purpose is to be a polished, conventions-correct playground for demonstrating how AI assistants (Claude, GitHub Copilot) build features, write docs, debug, and add tests on a realistic .NET codebase. The demo runbook lives in DEMO.md: a minute-marked timeline of acts (build a feature, document, debug, test, extend, review).
- Co-hosted Blazor WebAssembly served from the same origin as the API. One
dotnet runstarts everything. - Zero-config seed data: 40+ realistic users across 6 departments and 8 groups, with a few disabled accounts to exercise every UI state. No database, no AD, no setup.
- Live search (debounced) across display name, email, and username, plus a department filter.
- Sortable user table with hash-colored initials avatars, department chips, and keyboard-focusable rows.
- Slide-in detail drawer with the full attribute set, an enabled / disabled badge, and group memberships as chips. Focus-trapped, Esc to close.
- Pagination wired to the API (prev / next, page X of Y).
- Light / dark theme toggle, persisted to localStorage and applied before first paint to avoid a flash.
- Three states everywhere: loading skeletons, empty state, and an error state with retry.
- Responsive: the table collapses to cards on small screens.
- Multi-page shell: Home, Directory, and About pages with a sticky top nav and a linked footer (App and Resources columns, live data-source indicator).
- Two directory providers behind one
IDirectoryServiceabstraction: in-memory seed data (default) and real LDAP. - OpenAPI + Scalar API reference at
/docs, plus a/healthendpoint and structured Serilog logging.
| Home | User detail drawer |
|---|---|
![]() |
![]() |
| Dark mode | About page |
![]() |
![]() |
The solution is split into thin, dependency-isolated projects. The API project (Microsoft.NET.Sdk.Web) hosts the REST endpoints and serves the compiled Blazor WebAssembly client from the same origin, so there is no CORS to configure and no second process to run. The directory data is fetched through an IDirectoryService abstraction whose concrete provider (in-memory or LDAP) is chosen by configuration.
The shared Contracts project holds only record types and has zero dependencies. It is the only shared project the WASM client references, which keeps the server-only LDAP library out of the browser bundle. The server-only Directory project owns the providers and the LDAP client.
Co-hosting is wired in CopilotDemo.API middleware in this order: UseBlazorFrameworkFiles() and UseStaticFiles() serve the WASM and static assets, MapControllers() serves /api/*, and MapFallbackToFile("index.html") returns the client shell for any unmatched route so client-side navigation survives a refresh.
CopilotDemo/
├── Applications/
│ ├── CopilotDemo.API/ # ASP.NET Core 10 Web API, co-hosts the WASM client
│ │ ├── Controllers/ # UsersController
│ │ ├── Extensions/ # ServiceExtensions, MiddlewareExtensions, ExceptionMiddleware
│ │ ├── GlobalUsings.cs, Program.cs
│ │ └── appsettings.json, appsettings.Development.json
│ └── CopilotDemo.Web/ # Blazor WebAssembly client
│ ├── Layout/ # MainLayout, NavMenu
│ ├── Pages/ # Home, Users
│ ├── Components/ # UserTable, UserDetailDrawer, SearchBar, Pagination, states, ...
│ ├── Services/ # DirectoryApiClient (typed HttpClient), ThemeService, ...
│ ├── Styles/app.css # Tailwind v4 input
│ ├── wwwroot/ # index.html, css/ (built), favicon.svg, js/
│ └── package.json # Tailwind build scripts
├── Shared/
│ ├── CopilotDemo.Shared.Contracts/ # records shared API <-> Web, zero dependencies
│ └── CopilotDemo.Shared.Directory/ # IDirectoryService + InMemory/Ldap providers (server only)
├── Tests/
│ ├── CopilotDemo.Shared.Directory.Tests/ # unit: in-memory provider search/filter/paging/get
│ └── CopilotDemo.API.Tests/ # integration: endpoints + co-host fallback
├── Directory.Build.props
├── Directory.Packages.props
├── CopilotDemo.slnx
├── README.md, DEMO.md, CLAUDE.md, LICENSE
└── .github/ # CI workflow, dependabot, issue/PR templates
Prerequisites: the .NET 10 SDK (built and verified against 10.0.102).
git clone https://github.com/nathan5580/CopilotDemo.git
cd CopilotDemo
dotnet run --project Applications/CopilotDemo.APIThen open http://localhost:5179 in your browser.
That is the whole setup. The app runs zero-config on the seeded in-memory directory, so there is nothing to install, no database to migrate, and no AD to connect. The API reference is at http://localhost:5179/docs.
The default provider is the in-memory seed set. To query a live LDAP / Active Directory server instead, set Directory:Provider to Ldap and fill in the connection block (in appsettings.json, appsettings.Development.json, environment variables, or user secrets):
{
"Directory": {
"Provider": "Ldap",
"Ldap": {
"Host": "dc01.contoso.com",
"Port": 389,
"UseSsl": false,
"BaseDn": "DC=contoso,DC=com",
"BindDn": "CN=svc-directory,OU=Service Accounts,DC=contoso,DC=com",
"BindPassword": "your-service-account-password",
"UserFilter": "(&(objectCategory=person)(objectClass=user))"
}
}
}Keep secrets out of source control; prefer environment variables or dotnet user-secrets for BindPassword. Use Port 636 with "UseSsl": true for LDAPS.
Each user entry is mapped from these Active Directory attributes:
| AD attribute | Maps to | Notes |
|---|---|---|
objectGUID |
Id | Falls back to the entry DN. |
sAMAccountName |
UserName | |
displayName |
DisplayName | Falls back to cn. |
mail |
||
title |
JobTitle | |
department |
Department | Also feeds the filter dropdown. |
telephoneNumber |
PhoneNumber | |
physicalDeliveryOfficeName |
OfficeLocation | |
userAccountControl |
Enabled | The ACCOUNTDISABLE bit (0x2) cleared means enabled. |
memberOf |
Groups | The CN of each group DN. |
Connection, bind, or search failures are wrapped in a DirectoryException and surfaced as HTTP 502 by the API.
| Layer | Tech | Version |
|---|---|---|
| API | ASP.NET Core (Microsoft.NET.Sdk.Web) |
.NET 10 |
| Web | Blazor WebAssembly (Microsoft.NET.Sdk.BlazorWebAssembly) |
10.0.7 |
| Styling | Tailwind CSS (CSS-first, @tailwindcss/cli) |
v4 |
| Directory | Novell.Directory.Ldap.NETStandard (real) + seeded in-memory (default) |
3.6.0 |
| API docs | OpenAPI (Microsoft.AspNetCore.OpenApi) + Scalar (Scalar.AspNetCore) at /docs |
10.0.7 / 2.14.2 |
| Logging | Serilog (console + rolling file) | 10.0.0 |
| Tests | xUnit + Moq + Microsoft.AspNetCore.Mvc.Testing + coverlet |
2.9.3 / 4.20.72 / 10.0.6 |
The build runs with TreatWarningsAsErrors=true, so a successful build means zero warnings.
dotnet testThis runs both suites: unit tests for the in-memory provider (search, department filter, paging math, get-by-id, distinct departments, provider selection) and integration tests that drive the real host via WebApplicationFactory (the user endpoints plus the co-host fallback that returns index.html for client routes).
The committed wwwroot/css/app.css lets the app render straight after a clone, before npm has ever run. To edit styles, install the dev dependencies and run the watcher from the Web project:
cd Applications/CopilotDemo.Web
npm install
npm run css:watch # rebuilds wwwroot/css/app.css on every changeUse npm run css:build for a one-shot minified bundle. The .NET build also runs css:build automatically (the BuildTailwind MSBuild target) once node_modules exists.
Issues and pull requests are welcome. The repo ships issue templates and a pull request template. Please keep to the conventions in CLAUDE.md and .github/copilot-instructions.md, and make sure dotnet build is warning-free and dotnet test is green before opening a PR.
Released under the MIT License.




