Skip to content

Microsoft Entra sign-in, and a self-contained Windows installer - #6

Open
sameerk27 wants to merge 119 commits into
masterfrom
feature/microsoft-login
Open

Microsoft Entra sign-in, and a self-contained Windows installer#6
sameerk27 wants to merge 119 commits into
masterfrom
feature/microsoft-login

Conversation

@sameerk27

Copy link
Copy Markdown
Owner

What this is

Vigil365 gains Microsoft Entra sign-in, a large amount of dashboard and QA work, and — most of the recent commits — a real installer. It is a big branch (109 commits, ~220 files); the sections below are ordered by what a reviewer most needs to look at.

A self-contained installer

The wizard used to run npm install, npm run build and dotnet publish against the source tree on the customer's server. That made it a guided build rather than an installer: it needed the repo, Node and the .NET SDK on a production box, and produced whatever that machine's toolchain resolved that day instead of the build we tested.

The build moved to release time. scripts/build-installer.ps1 builds the SPA from the lockfile, publishes the API self-contained for win-x64, compresses it into a payload embedded in the installer, and emits a single ~121 MB dist/Vigil365-Setup.exe that needs nothing on the target — no source, no Node, no .NET, not even the runtime. Install is now extraction, with the service stopped first and every zip entry checked against the install root.

Prerequisites went from four to one. Azure CLI remains, only because the wizard registers the Entra application.

Bugs found by running it, not reading it

Each of these produced an install that reported success and did not work:

  • The app registered into the wrong tenant. It used whichever tenant the Azure CLI happened to be signed into and never said which. On the machine this was found on, three tenants were in play: the administrator's, the operator's Azure sign-in, and a third one the CLI was actually pointed at. The app was created there as a single-tenant registration, so the intended users could never sign in. The tenant is now derived from the administrator's email, resolved via public OpenID discovery before anything is installed, signed in to explicitly, and verified afterwards.
  • The service could never reach SQL. It runs as LOCAL SERVICE with Trusted_Connection, but SQL Express grants sysadmin to BUILTIN\ADMINISTRATORS only, so no login existed for it. The installer now creates the login and database while it still holds administrator rights, scoped to db_owner on that one database.
  • DataProtection keys were written under Program Files, which LOCAL SERVICE cannot write. The keyring never persisted, so every restart invalidated anything protected with it — including the Graph secret saved on the Setup page. Moved to ProgramData with an explicit ACL.
  • The Windows service was never created. sc was invoked through cmd.exe, which re-parsed the quotes and split the path (binPath=C:\Program plus a stray argument). RunCommand discarded exit codes, so nothing noticed. sc.exe is now called directly, its exit code checked, and the install waits for the service to report RUNNING.
  • A fresh database crashed the app on startup. NotificationSettings and GraphConfig are singleton rows whose model fixes the key at 1, but InitialCreate made both keys identity columns by EF convention, so the explicit key was rejected. Existing installs were unaffected because the legacy DDL creates those columns without identity — which is why this only ever appeared on new machines. Both are now ValueGeneratedNever, with a hand-written migration (the scaffolded AlterColumn does not work; SQL Server cannot change IDENTITY in place).
  • The app registration was recreated on every run, so re-running the wizard — the documented way to replace a certificate — littered the tenant and stranded whichever registration was configured last.

HTTPS and certificates

Sign-in goes through Entra, which refuses plain http:// redirect URIs for anything but loopback, so HTTPS is not optional once the app is reachable by name. The wizard opens with who needs to reach this: a loopback evaluation install binds 127.0.0.1, needs no certificate and touches no firewall rule; a network install asks for a hostname and takes a certificate from the Windows store, a .pfx, or one it generates and trusts locally.

The store list initially offered Entra MS-Organization-P2P-Access certificates — they carry Server Authentication and a private key, so they passed every structural test while being useless (device GUID subject, untrusted issuer, daily rotation). Machine-identity certificates are now excluded, and a certificate that does not cover the address is labelled and never preselected.

scripts/request-cert.ps1 is new, for publicly-reachable installs that want a real Let's Encrypt certificate.

Reviewer notes

  • History was rewritten. installer-bin/ held two committed executables (146 MB and 64 MB) that exceeded GitHub's file limit and blocked every push. They are build output, superseded by the ignored dist/. Stripped from the branch and added to .gitignore. The pre-rewrite history is tagged locally as backup/pre-filter-installer-bin.
  • Not addressed: Azure CLI is still required for Entra registration. Removing it means device-code auth and Graph REST from the installer, and would also let installs proceed where the operator lacks rights to create app registrations.
  • Verification. Client typecheck clean and 74 tests passing; installer config generated and parsed across all four shapes; installer SQL parse-checked against a real SQL Express; the app confirmed to start against a real database returning HTTP 200 on /health; the shipped exe launches. A full unattended end-to-end install on a clean machine has not been run.

🤖 Generated with Claude Code

sameerk27 and others added 30 commits June 23, 2026 17:08
- MSAL browser login gate (AuthGate component)
- User avatar + sign-out menu in header
- SQL connection string: Encrypt=True
- Generic error responses (no internal detail leaks)
- Swagger dev-only
- CORS restricted to explicit methods
- Security headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- Microsoft.Identity.Web + Azure.Identity packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- Validate Entra ID Bearer tokens (audience api://{clientId})
- App-managed roles (Admin/Analyst/Viewer) in AppUsers table, not Entra App Roles
- RoleClaimsTransformation attaches role claim after token validation
- Role policies: reads=any authed, ack/snooze/resolve/policy-edit=Analyst+, settings/collector=Admin
- Bootstrap admin (configured email, else first-login-wins)
- Admin-only user management endpoints (list/add/change-role/remove) with last-admin guards
- Capture real signed-in identity in AcknowledgedBy/SnoozedBy

Frontend:
- useAuth() role context from /api/auth/me; role badge in header
- Role-aware UI: hide Run Collection + actions from non-privileged roles
- User Management page (Admin only) with add/change-role/remove
- apiFetch (Bearer token) used for all data calls

Adds ROADMAP.md tracking auth + hosting phases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- NotificationSender.SendInviteEmailAsync sends a sign-in-link email via existing SMTP
- POST /api/admin/users/{email}/invite (Admin) resends the access email
- Add User accepts optional sendInvite flag to email on creation
- Frontend: 'Send invite email' checkbox on Add form; Send/Resend invite button per row
- Gracefully reports when SMTP is not configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AuditEntry model + AuditEntries table (idempotent schema)
- AuditLogger service resolves actor from validated token; never blocks the action
- Wired into user add/role-change/remove/invite and notification-settings update
- GET /api/admin/audit-log (Admin only)
- Frontend: Activity Log card on User Management page

Addresses SOC2/ISO logging controls (ROADMAP Phase 3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- HSTS + HTTPS redirection active outside Development (dev stays HTTP)
- README: reverse-proxy (Caddy/Nginx/IIS) and Kestrel-cert TLS options
- Production appsettings template updated: Encrypt=True, AzureAd, Auth sections
- Credential-hygiene note (cert auth, vault, rotate exposed secrets)

ROADMAP Phase 5 (HTTPS portion).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reduces install friction from ~7 manual steps to: run install.ps1 -> sign in -> fill wizard.

- install.ps1: checks prereqs, builds frontend, publishes API, optional Windows service install
- GraphConfig table stores Graph creds (secret DPAPI-encrypted); loaded over the
  GraphOptions singleton at startup, so DB-entered creds work with no JSON editing
- GET/POST /api/setup/graph (Admin): view status, save+apply+test connection live
- Mutating the IOptions<GraphOptions> singleton applies new creds without restart
- Frontend: Setup page (Admin only) with Save & Test Connection + status badge
- Audit: setup.graph entry on credential update

ROADMAP: install-simplicity track.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…dance

- Compute repo root in body (PSScriptRoot is empty in param() defaults) so
  publish lands in the project folder, not the drive root
- Replace em-dashes with ASCII to avoid PowerShell parser/encoding errors
- Manual-run guidance now cds into the publish folder so config + wwwroot resolve
  (Windows service handles working directory automatically)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
deploy.ps1 generates appsettings.Production.json (DB + AzureAd login + bootstrap
admin), trusts a local HTTPS cert, and launches the published app over HTTPS in
Production. Collapses manual production setup to one command + the irreducible
Entra redirect-URI step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Automates the last manual step: creates the app registration with read-only Graph
permissions, SPA redirect URI, exposed access_as_user scope, client secret, and
admin consent via Azure CLI. Outputs TenantId/ClientId/secret and the deploy.ps1
command. User runs it themselves (creates an identity + grants consent in their tenant).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enables running on Linux and via Docker (previously Windows-only).

- SecretProtector: switch to ASP.NET Core Data Protection (cross-platform);
  still reads legacy Windows DPAPI values for backward compatibility. Key ring
  persisted to disk (DataProtection:KeyPath), mountable as a Docker volume.
- Program.cs: configure persisted Data Protection; DB-readiness retry (~60s) so
  the app waits for the SQL container; Security:RequireHttps flag so a TLS proxy
  can front the container without in-app redirect loops.
- Dockerfile: multi-stage (node frontend build -> dotnet publish -> aspnet runtime).
- docker-compose.yml: app + SQL Server, volumes for DB and key ring; one-command up.
- .env.example, .dockerignore; ignore .env.

Note: image build/run not exercised in this environment; Windows path verified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… HTTPS

- Production Kestrel does not auto-use the dev cert; export a PFX and configure
  Kestrel:Endpoints:Https so https actually binds
- -Hostname: generate a self-signed cert for an internal name, trust it for the
  current user, add a hosts entry (needs admin), and serve https on it
- HTTP urls set Security:RequireHttps=false (TLS handled by a proxy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In Production, user secrets don't load and Graph creds come from the Setup wizard,
so /api/auth/config returned placeholder tenant/client and MSAL login failed
(AADSTS900023). Now prefer AzureAd:ClientId/TenantId (set by deploy.ps1), falling
back to Graph for older setups; ignore YOUR_* placeholders.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Planning docs for the weekend build:
- ENTERPRISE_BACKLOG.md: features + UI/UX gaps (incl. Zero Trust score,
  SharePoint/OneDrive/Teams monitoring, error boundary, a11y, trends, compliance)
- ORG_READINESS.md: personal -> organizational product, 6 robustness pillars,
  adoption checklist, optional SQLite backend

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…raphic

- ENTERPRISE_BACKLOG: section 0 perfect-existing-tabs (affected-entity on alerts),
  alert coverage gap analysis, section F (alert workflow, notifications, sovereign
  clouds, MSP, integrations, supply-chain, session/quality)
- docs/assets: roadmap hero graphic (svg + png) for announcements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Replace fully-manual setup with 3 clear paths: Docker, Windows one-script, manual
- Fix broken clone URL (YOUR_ORG -> sameerk27/vigil365)
- Document register-app.ps1 for Entra app registration
- Fix Entra steps: SPA redirect URI + Expose-an-API scope are required for sign-in
- Prerequisites split by install path (Docker needs only Docker)
- Production section points at deploy.ps1; manual kept as fallback

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…s, tests

Frontend refactored into pages/components/services (from single-file main.tsx).
- Trends & history page (metric snapshots, charts, PDF export)
- Compliance framework engine (16 CIS/NIST/ISO/GDPR controls) + configurable thresholds
- RecommendationsEngine + SecurityRecommendation/AlertBaselineRule models + page
- Alert affected-entities capture + detail table; copy buttons; KPI-tile drill-through;
  alert->entity cross-links; per-tab tooltips/skeletons
- Overview: data-trust banner (last collection freshness + source failures)
- Sidebar overflow/zoom fix (flex column: pinned logo, scrolling nav, pinned collapse)
- SecretProtector/RoleClaimsTransformation/NotificationSender/GraphCollector tests (24 total)
- Planning docs: MSP multi-tenant plan; backlog framed as Edition 1 vs Edition 2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ache, logging

- /health endpoint: DB connectivity, Graph config, last-collection freshness
  (200 healthy/degraded, 503 when DB unreachable) for Docker/k8s probes
- Audit hardening: capture client IP + User-Agent, SHA-256 tamper-evident
  hash chain, CSV export + chain-verify endpoints, sign-in audit events;
  UI: IP column, Export CSV + Verify integrity on the Activity Log card
- Data retention: nightly prune worker with configurable Retention section
  (resolved alerts, triggered alerts, notification/collection/trend/audit)
- Role-claim caching: 60s IMemoryCache in RoleClaimsTransformation, evicted
  on role change/user removal/first sign-in
- Structured logging: JSON console outside Development + X-Correlation-Id
  middleware with per-request logging scope
- Favicon (shield SVG) + description/theme-color/Open Graph meta
- Fix pre-existing tsc errors in AlertCenterPage + RecommendationsPage
  (swapped showToast args, invalid Badge/KpiTile/EmptyState props)
- Tests: 34 passing (hash chain, retention pruning, role cache TTL/eviction)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ention, logging, cache, favicon)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CRITICAL: authorization was non-functional. RequireAdmin/RequireAnalyst were
RequireAssertion(_ => true) stubs (passing even for anonymous callers) and
FallbackPolicy was null, leaving ~35 endpoints fully anonymous (user mgmt,
audit export, notification settings with decrypted webhook URLs, all
dashboard/tenant data).

- Role policies now RequireAuthenticatedUser + RequireRole (Analyst policy
  admits Admin); FallbackPolicy requires an authenticated user everywhere;
  only /health, /api/auth/config and the SPA fallback stay anonymous
- /api/collector/run: Analyst+, 400 when Graph unconfigured (no more
  fabricated Completed runs feeding /health), 409 on concurrent run via
  SemaphoreSlim gate in GraphCollector
- /api/alert-policies/evaluate: Analyst+ (dispatches real notifications)
- Demo-data honesty: securescore / security-incidents / defender-alerts
  return configured=false instead of fabricated payloads; sample-alert
  seeding gated behind Seed:DemoData (default false) and only when Graph
  is unconfigured; configured installs purge lingering seed alerts by
  ExternalId prefix at startup (fixes fake alerts commingled with real
  tenant data, skewed MFA %, and demo-driven notification firing)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Redesign — "easy to see what is happening in M365":
- Overview leads with a Needs Attention triage feed: open critical/high
  security alerts + unacknowledged policy alerts, ranked by severity then
  recency, one click to the alert or Alert Center
- Service-health advisories fully separated from security signal: excluded
  from /api/dashboard/overview counts/trends (new serviceAdvisories field),
  own "M365 Service Advisories" card on Overview, removed from the incidents
  sidebar badge, and the repeated every-page yellow banner is gone
- Removed redundant Top Active Alerts / Recent High Alerts cards; collection
  health moved up; single app-wide severity->tone mapping (utils.sevTone)

Fixed broken pages (were written in Tailwind classes with no Tailwind
installed, or hardcoded dark-mockup styles):
- RecommendationsPage rebuilt on the styles.css design system (KPI row,
  category filters, expandable guidance rows, keyboard accessible)
- Alert Center Coverage tab rebuilt (was white-on-white in light mode,
  dead data-table class)

Honesty + bug fixes:
- recApi no longer swallows API failures as "all healthy"; error states added
- Removed fake claims: 99.9% SLA uptime column, "Real-time from Graph API",
  "All 12 data sources", 5-minute cadence text, mail-flow-derived "Defender
  Status" KPI; Service Health healthy-count math fixed; one shared service-
  advisory matcher (grid and table no longer disagree)
- Overview banner no longer says "No collection yet" while a run is in
  progress or failed (branches on run status)
- sevClass() now emits the sev-dot base class (severity dots were invisible)
- Dead CSS classes replaced (btn-primary/secondary, filter-input,
  ctrl-status, borderless search-input misuse) + new .form-input class
- Trends: float-rounding garbage in deltas, undefined --color-bg-alt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backend hardening:
- GraphApiClient: bound 429 retries (max 3 per page) so a throttling tenant
  fails cleanly instead of hanging the request
- CORS origins now config-driven (Cors:AllowedOrigins) with localhost fallback
- Added fixed-window rate limiter (300 req/min/IP) on the API

Trust / credibility:
- Compliance no longer goes green on missing data: each control declares
  hasData; unconfigured sources render "NOT ASSESSED" and are excluded from
  framework scores (frameworks show N/A when nothing is measurable, plus an
  "X of N controls assessed" line). This was the biggest demo-killer.
- Removed storage-implementation leak in the alert UI: "DB CRITICAL/HIGH" ->
  "CRITICAL/HIGH ALERTS", "Security DB" tab -> "Vigil365 Alerts"
- Frontend defaults to Viewer (not Admin) when /api/auth/me fails, so
  Admin-only controls never flash for unprivileged users
- Version chip (Vigil365 v1.0.0) in the sidebar

Analyst triage loop:
- Incidents & Alerts unified feed now sorts by severity then recency, so the
  queue leads with the worst item regardless of source
- Alert detail modal shows "Related open alerts for this user/device" — click
  to pivot between alerts touching the same entity (investigation context)

Accessibility:
- DetailModal: role=dialog, aria-modal, focus-move-in, Tab focus trap, focus
  return on close
- Toasts: role=status aria-live=polite; error icon now colored
- Sidebar nav: aria-current on active page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 8 sections

Routing (URLs are now first-class):
- Hash router: #/{pageId} with optional ?alert={id}; page state initialises
  from the URL, back/forward navigate pages, refresh keeps you where you were
- Alert permalinks: the open alert detail is reflected in the URL and a shared
  link reopens that alert once data loads — makes email/Teams notification
  links actually useful

Information architecture (veteran-critique #2/#7):
- Sidebar: 17 flat items -> 8 sections: Overview · Alerts (Queue + Alert
  Center) · Identity (Overview + Sign-in Locations + Conditional Access +
  Audit Log) · Devices · Email · Posture (Compliance + Recommendations +
  Trends) · M365 Health (Service Health + Connectivity) · Administration
  (Licenses & Users + User Management + Setup)
- Multi-page sections render a tab bar (reuses ac-tab styling, role=tablist);
  section badge aggregates member-page unread counts (capped at 99+);
  sections remember the last tab you were on
- All original page ids survive as tabs, so crossNavigate deep links and the
  seen-count logic work unchanged; header title shows "Section · Tab"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cure-score cliff

- Needs Attention showed one row per policy FIRING (513 rows of the same 3
  policies) — now one row per policy with the latest firing time and an open-
  occurrence count, so real M365 alerts are visible again
- Secure Score trend painted a cliff to 0 at the end: Graph trend points with
  maxScore=0 (missing data) were mapped to 0% — they are now treated as gaps
  and filtered out

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Foundations:
- Defined 7 missing CSS variables that were silently dropping declarations
  (--color-surface, --color-bg-alt, --font-mono) and fixed call sites using
  undefined tokens (--border, --accent, --color-good/error) — repairs real
  rendering defects: transparent Trends buttons, unstyled StateMessage
  background, harsh currentColor borders, Setup banner in dark mode
- Sticky header: fixed min-height + subtle elevation shadow; hdr-sub no
  longer wraps (kept every below-header sticky offset honest); global
  scroll-margin-top so anchored scrolls clear the header (fixes clipped
  card titles)
- :focus-visible outlines + :disabled states for all button classes; new
  .btn-danger for destructive actions; deleted the unused shimmer skeleton
  system and dead .page-title

Shared sweep:
- Removed ~55 hardcoded icon hex props (#d1d5db empty states, #94a3b8
  search icons) across 14 files — icons now inherit themed colors, fixing
  ~40 dark-mode defects in one pass; .search-box svg themed centrally
- Date formatting unified: fmtDate/fmtShort add the year when not current,
  en-US pinned everywhere; relTime fallback no longer system-locale

Pages:
- Overview: fabricated "+N pts" improvement badge replaced with honest open
  count; dead act-clickable hover class fixed to al-clickable; footer grid
  auto-fits (no more orphaned half-width card)
- Alert Center: donut center readable in dark mode (was white-on-white);
  Clear button now also clears the date filter (was unremovable); raw
  "auto_resolved" enum humanized; KPI sub no longer raw toDateString()
- User Management: Remove is now a red danger button with confirmation (was
  green, no confirm); Add is primary / Cancel secondary (was inverted);
  failure toasts show as errors (were green success)
- Compliance: 3 duplicated wrong severity mappings -> shared sevTone()
- FilterPresets: text ✕ glyphs -> X icons with aria-labels; Save is
  primary, cancel secondary (was inverted)
- Setup: Save & Test is now the primary button

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…try chips, mobile notice

- Login page rebuilt on scoped .login-* CSS classes (keeps the deliberate
  dark brand look): no more inline-style hover mutation, system-ui font, or
  lock emoji (lucide Lock icon); focus-visible on the sign-in button; version
  shown in the footer; brand panel hides below 900px instead of squeezing
- Toasts follow the app theme (card surface + border + status-token accents)
  instead of always-dark slate in light mode
- Country flag emoji (render as bare letters on Windows) replaced with
  neutral ISO-code chips (.flag-emoji restyled); emoji removed from selects
- Permanent "sign-in map" placeholder gradient deleted (was advertising an
  unbuilt feature)
- Mobile: below 600px a notice explains navigation is limited instead of the
  sidebar silently disappearing
- Sign out uses the real LogOut icon (was a rotated LogIn)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t domain

- Needs Attention card removed from Overview per owner decision — the KPI
  pulse row leads the page again; Alerts section remains the triage surface
- LineChart domain now pads below the data minimum so a real dip (e.g.
  Secure Score 51% -> 38%) reads as a dip, not a crash to zero: the lowest
  point never touches the plot floor; removed stale "as per Microsoft" comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pass

Alert engine (behavior change, owner-approved):
- AlertEvaluator now keeps ONE open alert per policy: while the breach
  persists the open alert is updated in place (current metric value,
  affected entities, last-evaluated) instead of stacking a new row every
  cycle; legacy duplicates are collapsed automatically on the next
  evaluation (newest kept, rest retired as auto_resolved); a fresh alert
  + notification only fires after the previous one reached a terminal
  state. 3 new tests lock in the contract (37 total).

Card UX QA:
- Incidents: severity pills unified to tinted classes (defender/incident/
  advisory rows all render the same pill; advisories no longer hardcoded
  "medium"); icon-only portal links got aria-labels + tooltips
- Identity: "#EXT#" implementation string no longer injected into the
  search box from the Guest KPI; doubled badge+count in MFA card head
  merged into one badge
- Email: KPI tiles scroll to their card instead of injecting magic search
  strings that silently zeroed every list
- User menu: outside-click + Escape close, aria-haspopup/expanded, CSS
  classes instead of inline hover mutation
- Error boundary: theme-aware friendly card; stack trace behind a
  "Technical details" disclosure instead of dumped raw
- Card titles are now h2 (heading semantics; styled identically);
  Card accepts id for scroll anchors; th scope="col" across 12 files;
  remaining #dc2626/#22c55e/#6b7280 icon hexes -> status tokens

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…edupe, UX passes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sameerk27 and others added 30 commits August 1, 2026 17:09
Third QA batch.

- An Admin could demote themselves with a single change on the role dropdown.
  It applied instantly and removed the admin UI needed to undo it. The server
  already blocks removing the LAST admin, but with other admins present this was
  one mis-click. Self-demotion now confirms, naming exactly what disappears.
- Vigil365's own collected alerts were called three different things across
  three surfaces: "Vigil365 Alerts" in the queue filter, "SecurityDB" in the
  CSV export, "In-App DB" in the coverage scorecard. One name everywhere.

tsc clean; 49 frontend + 199 backend tests; build green; deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A notification deep-link for an alert not in the loaded set was consumed
  silently: click through from a Teams card or email for an alert that has since
  been resolved and you landed on the queue with no explanation. It now says the
  alert is no longer active and shows the queue.
- countryFlag() had a hardcoded ~25-country map, so a sign-in from Poland,
  Kenya, Malaysia or anywhere else rendered "—" beside the country name —
  visually identical to missing data when the data was fine. Coverage is now
  generated from Intl across all ISO 3166-1 alpha-2 codes, with aliases for the
  forms Intl does not use (USA, UK, UAE). Unresolvable countries render no chip
  at all rather than a dash, since the country name is already shown next to it.

The generated table needed two corrections, both caught by tests rather than
review:
  - Intl resolves the non-standard "UK" to "United Kingdom" as well as "GB",
    and the loop overwrote GB with UK — not a valid ISO code.
  - "First writer wins" then failed the same way in reverse: "DD" (East
    Germany) also resolves to "Germany" and sorts before "DE".
  Deprecated aliases are now rejected by canonicalising the region subtag —
  und-DD canonicalises to und-DE, so a code that does not survive
  canonicalisation is not the current one for that country. Verified against
  DD/UK/SU/CS.

73 frontend tests (was 49); tsc + build clean; deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lete

- The Email page had one search input sitting in the MDO card header that also
  filtered the Quarantined, Mail flow and Malware cards. Typing to narrow MDO
  alerts silently emptied three unrelated cards. Scoped to MDO, matching the
  per-card pattern Devices already uses. Nothing deep-links a search into this
  page, so no navigation depended on the shared state.
- Exports on paged or capped tables silently contained a subset while the card
  badge advertised the full count — a CSV that looks complete and is not is
  worse than no export. ExportDropdown takes an optional scopeTotal; when the
  loaded rows are fewer, the menu says what will actually be exported and the
  toast reports "N of M". Wired to Tenant Activity (server-paged, 50/page) and
  the unified queue (Vigil365 alerts capped at 200 — the total there is the
  filtered set plus the alerts the cap hid, since the other sources are fully
  loaded).

Exporting the full server-side set needs a dedicated endpoint per page; this
states the limit honestly rather than pretending it is not there.

tsc clean; 73 frontend + 199 backend tests; build green; deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…roke

An automated inline-style extraction had replaced every style={{...}} with a
data-inline-style attribute backed by a generated CSS rule. That is correct for
static styles, but 51 of them carried per-render values and a static class
cannot reproduce a computed one.

The damage:
- 20 had no generated rule at all, so the styling was simply gone. ProgressBar
  (used across many pages) and the setup-checklist progress bar rendered
  permanently blank — a computed width became an attribute with no width.
- The other 31 were worse: frozen to one hardcoded value, so gauges, chart
  geometry, Trends metric colours and Compliance score bars rendered a single
  wrong value rather than an obviously missing one.

Restored all 51 from HEAD via a hunk-based revert, keeping the 351 legitimate
static extractions. Line counts had shifted in five files because the tool
collapsed multi-line style objects, so positional mapping was unsafe — only
hunks that introduced a dynamic- reference were reverted. Then removed the 43
CSS rules left orphaned (31 dynamic, 12 unused static).

Verified: every one of the 197 remaining attributes resolves to a rule, no
dynamic- reference survives in any TSX, tsc clean, 74 frontend + 199 backend
tests, build and smoke green, deployed.

Note this does NOT get the app to a CSP without style-src 'unsafe-inline':
per-render values legitimately need the style attribute. Reaching that would
mean moving each dynamic value to a CSS custom property, which is still set
inline — so the goal itself needs rethinking rather than more extraction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy.ps1 targets a local install — hosts alias, self-signed cert, high port.
This is the public counterpart for running on a real domain over 443.

Preflight refuses to change anything unless it passes: elevation (443 and
firewall rules both need it), the hostname's A record actually resolving to this
connection's public IP, and 443 having no LISTENER — checked via
Get-NetTCPConnection -State Listen, because netstat also lists outbound :443
connections and would false-positive.

Then: writes appsettings.Production.json binding 0.0.0.0 (127.0.0.1 would be
unreachable from outside) while preserving the existing connection string and
AzureAd block, sets Auth:RedirectUri and CORS to the public URL, and adds an
inbound firewall rule.

Accepts a real -PfxPath; falls back to self-signed only so the plumbing can be
verified, and says loudly that visitors will get a trust warning. Points at
win-acme/certbot rather than downloading anything itself.

Prints the three things the script cannot do: router port-forward, adding the
SPA redirect URI in Entra (sign-in fails with AADSTS50011 without it), and
supplying a real certificate. Also warns that a residential IP will drift and
strand the A record.

The header states plainly that publishing this changes the threat model — the
README's "no inbound exposure by default" stops holding, /health and
/api/auth/config answer anonymously, and there has been no third-party pen test.

Verified by dry run: correctly failed on elevation and changed nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This connection is behind carrier-grade NAT on IPv4 — the traceroute shows the
RFC 7335 service-continuity address (192.0.0.1) for four hops, so 152.59.38.32
is a shared carrier address and no inbound IPv4 can reach this machine. IPv6 is
natively routable here and is the only path that accepts inbound connections.

Kestrel now binds [::], which is dual-stack on Windows, instead of 0.0.0.0
which would have listened on the one protocol that cannot receive traffic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ime claim

Found while verifying the public deployment. tsc had 13 errors and three test
files were failing to load — the Vite build stayed green throughout because it
does not type-check, so none of this was visible from a successful build.

- services/utils.ts read localStorage at MODULE LOAD for the new timezone
  preference. utils is imported almost everywhere, so in any environment
  without storage that is a blank application rather than a missing preference
  — it had already taken down 3 test files (51 tests). Guarded with the same
  defensive shape as public/display-prefs.js; the toggle still works for the
  session when storage is blocked.
- MdiAlertsData had been renamed to MdoAlertsData, which conflated Defender for
  IDENTITY with Defender for Office 365 — two different products. The renamed
  type was orphaned (no consumer) and referenced an MdoAlert that does not
  exist, while main.tsx and IdentityPage both wanted MDI. Restored.
  MY MISTAKE: this entered the tree in 56cc8c4, one of my own commits — I used
  git add -A and only dry-ran the deploy script that turn instead of re-running
  tsc, so I committed a regression I had not authored and did not notice.
- IncidentsPage referenced EmptyState without importing it: a ReferenceError
  the moment that branch rendered, not merely a type error.
- Badge was being passed an icon it did not accept. Supported it rather than
  stripping the icons — granted/missing/unknown reads faster with a glyph — plus
  the .badge-icon style, marked aria-hidden since the label carries the meaning.
- Login page still advertised "Real-time visibility" and "Live Security
  Monitoring". Collection is a scheduled poll (15 min default); this is the same
  overclaim already removed from the README, spotted in the deployed UI.

74 frontend + 199 backend tests, tsc 0 errors, build green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reads the PFX password out of appsettings.Production.json so it does not have
to be handled by hand, verifies the certificate is actually self-signed before
touching the Root store (an issued cert that still warns has a chain problem,
not a trust problem — installing it would hide that), and picks LocalMachine or
CurrentUser based on elevation.

States plainly that this only makes ONE machine stop warning, and that leaving a
self-signed certificate on a security product trains people to click through TLS
warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy-public.ps1 falls back to a self-signed certificate, which makes every
visitor click through a TLS warning on a security product. Nothing in the repo
actually walked you from that placeholder to a trusted certificate.

request-cert.ps1 drives lego and hands deploy-public.ps1 the resulting .pfx.
DNS-01 is the default because this deployment sits behind CGNAT with no A
record — it needs no inbound ports and no firewall changes. -Method http is
there for when port 80 is reachable and renewals should be automatable.

Captures two things that are easy to get wrong:

  - lego 5.x flag placement. Only --help/--version/--log.*/--config are global;
    --email, --domains, --dns and friends belong to the `run` subcommand.
    `lego --email ... run` fails with "flag provided but not defined: -email".
  - --pfx.format defaults to RC2, which modern Windows resists loading, so the
    script pins SHA256.

Also drops the win-acme/certbot pointers from deploy-public.ps1: the winget
package name no longer resolves, so that guidance dead-ends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first real run failed with NXDOMAIN for _acme-challenge.vigil365.in. Two
separate problems, only one of which was visible.

The TXT record never reached the zone — both authoritative nameservers returned
NXDOMAIN, not an empty answer, so this was not propagation. The likeliest cause
is pasting the FQDN into GoDaddy's Name field, which appends the zone and yields
_acme-challenge.vigil365.in.vigil365.in.

The second problem would have bitten even with a correctly saved record:
lego's manual provider polls for 60s by default, while GoDaddy's minimum TTL is
600s. The default loses the race and reports "time limit exceeded", which reads
as a DNS fault rather than a too-short timeout. Now raised to 600s, tunable via
-PropagationTimeout.

Adds -CheckTxt, which queries the zone's authoritative nameservers directly so
"not saved" is distinguishable from "not propagated yet" — a recursive resolver
caches NXDOMAIN and makes those two look identical. Run it in a second terminal
to know when to press Enter instead of guessing.

Adds -Method godaddy, which drives GoDaddy's API so there is no manual step and
renewals are unattended. Gated on GODADDY_API_KEY/GODADDY_API_SECRET, and it
fails fast when they are missing rather than spending a failed validation to
find out. GoDaddy limits that API to accounts with 10+ domains or a Discount
Domain Club plan, so -Method dns stays the default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wizard asked for a public URL, wrote it into the Entra redirect URI and the
CORS origin, and then bound the service to http://127.0.0.1:8080 regardless. So
any hostname a customer typed was advertised everywhere and served nowhere. The
only address that actually worked was the literal loopback URL — the one case
Entra exempts from its HTTPS rule.

There was no certificate step at all, and Security:RequireHttps was hardcoded
false, so sign-in ran in the clear. Nothing in the product got a customer from
"installed" to "reachable over HTTPS"; the README told them to go put a proxy in
front of it.

The wizard now asks where the certificate comes from, with three options that
match how organisations actually hold them: one already in LocalMachine\My
(listed, with hostname matches flagged), a .pfx file, or one Vigil365 generates
and trusts on that server. The generated option is the default so nobody has to
make a decision to get started, and the finish screen says plainly that other
machines will still warn until it is replaced.

The certificate is resolved before anything is installed — a wrong .pfx password
discovered after the service is registered leaves a half-built install and an
event-log entry to go hunting for.

Also grants the service account read access to the private key. Windows keeps
key ACLs separate from the certificate, and an admin importing one grants
themselves access, not LOCAL SERVICE. Without this the service installs cleanly
and then dies on startup, which is the most common way a perfectly good
certificate still produces a dead site.

And opens the firewall for the port, without which the site is reachable from
the server itself and nowhere else.

Verified by building the config in all four shapes (self-signed on 443,
self-signed on a non-default port, store certificate, http loopback) and parsing
each: the bind URL matches the advertised one, RedirectUri omits :443 but keeps
:8443 since Entra matches redirect URIs by exact string, RequireHttps tracks the
scheme, and the generated certificate carries the right subject, a SAN, and a
usable private key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…stions

Auditing every step turned up three faults that each produce an install that
looks successful and is not.

The service runs as LOCAL SERVICE with Trusted_Connection, but SQL Express was
installed granting sysadmin to BUILTIN\ADMINISTRATORS only. No login existed for
the service account at all, so it could never open a connection. The installer
now creates the login and the database while it still holds administrator
rights, and grants db_owner on that one database rather than server-wide
dbcreator.

DataProtection keys were written under C:\Program Files\Vigil365\keys, which
LOCAL SERVICE cannot write. The keyring could never persist, so every restart
invalidated anything protected with it — including the Graph client secret saved
on the Setup page. Keys now live in ProgramData with an explicit ACL.

az ad app create ran unconditionally, minting a new Entra registration on every
run. Since re-running the wizard is the documented way to replace a certificate,
following our own instructions littered the tenant and stranded whichever
registration was configured last. It now reuses an existing Vigil365 app.

On the UX the user asked for: the wizard now opens with "who needs to reach
this". Evaluating on one box binds loopback, skips certificates entirely (Entra
allows http for loopback redirect URIs), and touches no firewall rule — so that
path asks nothing beyond confirming the administrator. The hostname and
certificate questions only appear for a network install, where they are load
bearing.

The remaining answerable questions are now answered: the first administrator is
prefilled from the Azure CLI session, and an existing SQL instance is found in
the registry and reused instead of installing a second one over it.

Verified by generating the config for all four shapes and parsing each, with a
regression assertion that KeyPath is never under Program Files, plus a runtime
smoke test that the reworked XAML loads without a parse exception.

Not fixed, and worth knowing: this still builds from the source tree with npm and
dotnet publish, so it is a guided build rather than a self-contained installer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… server

The wizard ran npm install, npm run build and dotnet publish against the source
tree at install time. That made it a guided build, not an installer: the customer
needed the repo, Node and the .NET SDK on their production server, and got
whatever the toolchain resolved that day rather than the build we tested.

The build moves to release time. scripts/build-installer.ps1 builds the SPA from
the lockfile, publishes the API self-contained for win-x64, compresses it and
embeds it in the installer, which is itself published self-contained and
single-file. The result is one 121 MB Vigil365-Setup.exe that needs nothing on
the target — no source, no Node, no .NET, not even the runtime.

Install is now extraction. The service is stopped first, because an upgrade over
a running service holds locks on the files being replaced and the resulting
failure reads as corruption rather than "it is still running". Entries are
checked against the install root before extraction; a zip is an untrusted format
even when we produced it.

Prerequisites drop from four to one. .NET and Node are gone because nothing is
compiled on the server. Azure CLI stays, and only because the wizard registers
the Entra application. The step now also verifies the payload is present, so a
mis-built installer says so before installing SQL Express and registering an
application.

Removes GetRepoRoot and RunCommandAsync, dead once nothing shells out to a build.

Verified end to end: the script produces a single file, the payload is embedded
and readable at 50.8 MB, publish output contains wwwroot and hostfxr (so the SPA
ships and the runtime travels with it), and the shipped exe launches.

Not addressed: Azure CLI remains a dependency for Entra registration. Removing it
means device-code auth and Graph REST calls from the installer, and would also
let installs proceed where the operator lacks rights to create app registrations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
request-cert.ps1 writes the ACME account key and issued certificates here.
Committing either would publish a private key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three problems, all found by actually running the installer.

The database step failed with "Incorrect syntax near 'QUOTENAME'". That was
mine: EXEC() concatenates only string literals and variables, never function
calls, so hardening the earlier working concatenation with QUOTENAME turned it
into a syntax error. Rebuilt on sp_executesql, which takes a prepared variable
and so combines with QUOTENAME. The statements move into constants purely so
their syntax can be checked against a real server without executing them; they
now pass SET PARSEONLY on SQL Express.

The certificate list offered an Entra MS-Organization-P2P-Access certificate.
Those carry Server Authentication and a private key, so every structural test
passed, and they are still useless — the subject is a device GUID, no browser
trusts the issuer, and they rotate about daily. Windows issues several such
certificates to any Entra-joined or Intune-managed machine. Server
Authentication is now required rather than merely not-prohibited (an absent EKU
no longer counts as unrestricted), machine identity certificates are rejected by
issuer and GUID subject, and a certificate that does not cover the address is
labelled as such and never preselected.

Failure left the wizard stuck on step three showing a raw exception. It now
names what failed in plain language, gives specific steps for the database,
certificate, Entra and payload cases, and offers Back to Configuration — which
resets the run so a retry starts clean rather than resuming half-applied state.
Configuration gains a Back button to Prerequisites, and there is a Copy details
button for the log.

Verified: all three SQL batches parse on the local SQL Express under PARSEONLY,
the real certificate store now yields no device certificates, config still
generates correctly for all four shapes, and the rebuilt 121 MB installer
launches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An install reported "Installation Complete!" and left nothing running. The
service had never been created.

sc was invoked through cmd.exe, which re-parses quotes, so the quoted executable
path inside the quoted binPath value came apart. sc received:

    argv[3] = <C:\Program>
    argv[4] = <Files\Vigil365\M365SecurityDashboard.Api.exe --environment Production>

binPath=C:\Program, plus a stray argument. It fails, and RunCommand discards
exit codes, so nothing noticed and the wizard advanced to the success screen.
The firewall rule and the ProgramData key directory from the same step were both
created, which is what makes this a silent failure rather than an obvious one.

sc.exe is now invoked directly with the inner quotes escaped, giving

    argv[3] = <"C:\Program Files\...\M365SecurityDashboard.Api.exe" --environment Production>

and its exit code is checked. stop and delete stay unchecked because they
legitimately fail on a clean machine.

Creating the service is still not proof of anything, so the install now waits
for it to report RUNNING and fails with the likely causes if it does not. A
service that starts and dies a second later returns success from sc start, so
the exit code alone never established that this worked.

Verified with a stand-in binary that prints its argv, since sc opens the service
manager before parsing arguments and so cannot be used to test quoting without
administrator rights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service installed and then died on startup:

    SqlException: Cannot insert explicit value for identity column in table
    'NotificationSettings' when IDENTITY_INSERT is set to OFF.

NotificationSettings and GraphConfig are singleton rows whose model defaults the
key to 1, but InitialCreate made both keys identity columns by EF convention.
Assigning a key EF believes the store generates makes it send that key in the
INSERT, and SQL Server refuses. NotificationSettings is seeded during startup, so
every fresh install crashed before serving a request; GraphConfig would have
failed the moment anyone saved Graph credentials on the setup page. Existing
installs were unaffected because the legacy DDL creates those columns without
identity and their rows already exist — which is why this only ever appeared on
new machines.

Both keys are now configured ValueGeneratedNever, matching the model and the
legacy DDL. The migration is hand-written: the scaffolded AlterColumn emits a
plain ALTER COLUMN and SQL Server rejects it with "To change the IDENTITY
property of a column, the column needs to be dropped and recreated". It rebuilds
via a temporary column rather than from a column list, so later migrations adding
columns cannot silently rot it.

Verified against a real SQL Express database: migrations apply with no retries
and /health returns 200 with database ok. The first attempt looked like it had
worked because the process stayed alive — it was actually spinning in the
30-attempt "database not ready" loop.

Also, from the same install attempt:

Begin Installation appeared to hang on Configuration and then jump to a
part-finished progress bar. Every step ran on the UI thread, so the panel switch
could not paint until the first await. The step now yields before starting and
the blocking work runs on background threads, with the values it needs captured
from the controls first. The same fix applies to the Azure CLI lookup that
stalled the move to Configuration.

The administrator email is no longer pre-filled. Who gets full access is a real
decision, and a pre-filled value is the one people click past.

build-installer.ps1 failed on a second consecutive run because Move-Item -Force
does not reliably replace an existing destination; it now clears the target
first, and names the still-running installer instead of reporting access denied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sign-in button did nothing and the page showed "interaction_in_progress"
with a link to the MSAL docs.

MSAL records that an interactive sign-in is underway in sessionStorage the
moment a redirect starts, and clears it when the redirect returns. If it never
returns — the tab was reloaded mid-flight, the user backed out of the Microsoft
page, or a TLS warning interrupted the round trip — the flag stays set and every
later attempt throws rather than navigating anywhere. Nothing in the app cleared
it, so the only way out was clearing site data by hand, which is not something
to ask of someone signing in to a dashboard.

Sign-in now clears the stale flag and retries once. Startup also clears it when
handleRedirectPromise has settled with no account, since at that point nothing
can genuinely be in flight — so a reload fixes it too, and the button works on
the first click rather than the second.

Re-running the installer can register a different application, which leaves the
previous client id's keys behind; the cleanup matches on the interaction-status
key rather than a specific client id so that debris goes as well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t one

The wizard registered the Entra application in whichever tenant the Azure CLI
happened to be signed into, and never mentioned which that was. On the machine
this was found on, three tenants were in play: the administrator's
(infrassistsandbox.onmicrosoft.com), the operator's Azure sign-in (softvan.in),
and the one the CLI was actually pointed at — a third, unrelated directory.

The application was therefore created in the wrong directory as a single-tenant
registration, so the people it was installed for could never sign in. The
install reported success and the failure only surfaced at the login page.

The tenant is now part of the configuration, defaulted from the administrator's
email domain and editable for tenants whose mail domain differs. Before anything
else runs it is resolved to a directory id through the public OpenID discovery
document, which needs no sign-in — so a misspelt tenant fails immediately rather
than after SQL Server has been touched. The CLI is then signed in to that
specific tenant, with --allow-no-subscriptions because a Microsoft 365 tenant
often has no Azure subscription and the CLI otherwise refuses a valid sign-in.
Afterwards the directory id is verified, and a mismatch stops the install and
says which tenant it landed in.

Registration reads that verified id rather than re-reading the ambient context,
which is what allowed the two to diverge.

Verified against the live endpoint: the two named tenants resolve to distinct
directory ids, both different from the CLI's current one, and an invalid tenant
is rejected with a 400 rather than being accepted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two executables in there (146 MB and 64 MB) exceeded GitHub's 100 MB file limit
and blocked every push. They are build artifacts, superseded by dist/, which was
already ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tenant check worked — it correctly caught the CLI being signed in to a
different directory — and then the install hung on "Signing in to …" with an
empty log and nothing to click.

RunCommand hides the console and redirects its streams. That is right for every
other command it runs and fatal for this one: az login is interactive, opening a
browser and sometimes falling back to printing a device code. With the window
hidden there is nothing to read and nothing to answer, so it blocked
indefinitely.

Sign-in now runs in a visible console, and the log says a window has opened
before it appears. A non-zero exit gives the exact az command to run by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
master fixed three real problems in the manual build instructions — the clone
URL still said YOUR_ORG, a Copy-Item step copied a dist folder Vite no longer
produces, and the production connection string lacked Encrypt/TrustServerCertificate.

This branch had rewritten that whole part of the README around the new setup
wizard, and in doing so dropped the Setup, Development and Production Deployment
sections entirely — while Prerequisites still advertised a "Building from source
(Option 3)" path that no longer existed. Resolving in favour of either side
would have lost something real.

Both are kept: the wizard remains the primary install, and master's sections
return as "Build from source (Option 3)" with all three of its fixes intact.

SECURITY.md, docs/THREAT_MODEL.md and docs/PROJECT_SUMMARY.md merged cleanly and
are taken from master unchanged.
The installer registered only the sign-in application. A new install could
authenticate people and then show them an empty dashboard: every collector
failed on authorization because the app had no Graph permissions, and there was
no client secret for app-only collection, so the Setup page demanded one the
operator had to mint in the portal by hand. The README pointed at a
register-app.ps1 for this; that script does not exist.

The wizard now, against the tenant it just signed in to:

  - Adds the 14 required Graph application permissions (plus attack-simulation
    as optional). Names are resolved against the tenant's own Graph service
    principal rather than hard-coded GUIDs, so a permission the tenant does not
    offer is reported instead of failing as an opaque "invalid value".
  - Grants admin consent, retrying with a backoff. Consent routinely fails on
    the first try because the permissions and service principal we just created
    have not replicated yet; the old code reported consent as granted even when
    it failed, because RunCommand discarded the exit code.
  - Creates a client secret and writes it into the Graph config, so collection
    starts on first run with nothing further to do.

Adds RunCommandChecked, which actually reports success. Several az steps
branched on RunCommand, which only tells you the process ran, not that it
worked. The app PATCH that sets permissions and the SPA redirect URI now stops
the install on failure instead of continuing into a broken sign-in.

Config values that can contain arbitrary characters (the secret, the connection
string) are emitted through JsonSerializer rather than hand-escaping two
characters, so every character class round-trips.

CI: the solution now includes the WPF installer, which does not build on Linux
(NETSDK1100). The Linux job builds the API and tests directly and only restores
the solution for the vulnerability audit (EnableWindowsTargeting makes that
resolve); a new windows-latest job compiles the installer so a break there fails
CI.

Verified: all 15 permissions resolve against a real 707-role Graph SP and
produce valid PATCH JSON (15 unique Role GUIDs); the full appsettings parses in
every install shape; the secret round-trips through JSON across quote,
backslash, control and unicode characters; installer builds clean; ci.yml is
valid YAML with both jobs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Once the CI build was fixed, the NuGet vulnerability audit ran to completion and
flagged four High-severity advisories in transitively-pulled packages across all
three projects:

  API         System.Security.Cryptography.Xml 8.0.3  GHSA-g8r8-53c2-pm3f
  Installer   System.Formats.Asn1 5.0.0               GHSA-447r-wph3-92pm
  Tests       System.Net.Http 4.3.0                   GHSA-7jgj-8wvc-jh57
  Tests       System.Text.RegularExpressions 4.3.0    GHSA-cmhx-cq75-c4mj

Each is pinned to a patched version via an explicit top-level PackageReference —
the standard NuGet remediation for a vulnerable transitive dependency. The test
project ships nothing, but a security product should not carry known-vulnerable
packages even in its test tree, and the audit covers the whole solution.

Verified: the solution audit reports no vulnerable packages, all three projects
build, and all 199 .NET tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Certificate-thumbprint auth threw a cryptic CryptographicException on Linux
instead of the clear "thumbprint not found" message. Opening LocalMachine\My
read-only succeeds on Windows but throws on Linux, where that store does not
exist — and the exception escaped the loop, so it also masked a certificate that
was present in CurrentUser\My.

This is the Docker deployment path, not a test-only concern: a Linux host using
certificate auth would have hit the cryptic error. Each store open is now
guarded, so an unavailable store is skipped and lookup continues to the other
one and then to the clear error.

Surfaced by CI: the test asserting the clear error only ran on Linux once the
build and audit steps were fixed, and it caught this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-running the wizard against an existing Vigil365 registration failed with:

  CannotDeleteOrUpdateEnabledEntitlement: Permission (scope or role) cannot be
  deleted or updated unless disabled first.

The PATCH always sent an api.oauth2PermissionScopes array carrying a freshly
generated GUID for access_as_user. On a reused app that already exposes that
scope (enabled), Entra reads a new GUID as replacing an enabled entitlement and
refuses — so the whole registration PATCH failed, taking Graph permissions and
the redirect URI down with it, and the install stopped at "could not register
with Microsoft Entra".

The scope is now defined only when it is genuinely absent: a new app always, a
reused app only if it does not already expose access_as_user. When it is already
present the PATCH omits the api block entirely and just sets the SPA redirect URI
and the requested Graph permissions, which carry no enabled-entitlement
constraint.

Verified both PATCH shapes are valid JSON: scope-defined (new app) includes the
api block, scope-omitted (reused app) does not, and both always carry
spa.redirectUris and requiredResourceAccess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Setup page used <EmptyState/> but never imported it, so opening
localhost:8080/#/setup threw "ReferenceError: EmptyState is not defined" and the
error boundary took over the page.

It shipped because the build does not type-check: vite/rollup bundles an
undefined identifier without complaint, and the tsc step that would have caught
it runs in CI against committed code — while this usage lived in an uncommitted
change CI never saw. tsc now passes across the whole client, and the Setup page
renders the Graph-credentials form (verified in a browser: form present, console
clean).

Committing so CI type-checks this file going forward. This also carries the
setup-form validation work that was uncommitted alongside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rolls up the post-checkpoint working-tree changes and makes the whole tree
type-check and build, so it is releasable as one unit.

Features:
- Executive digest now carries each alert's Category, Status and Assigned To in
  both the HTML and CSV, so a digest reader can triage without opening the app
  (DigestBuilder, ReportsPage preview, types).
- Policy dry-run gains a look-back window selector and a clear action
  (PolicyDryRun).
- Licenses page distinguishes "request timed out / API unreachable" from a
  permission error, instead of showing one generic message (LicensesPage).

Fixes required to build the checkpointed work (it was never type-checked — the
checkpoint commit was never pushed, so CI never ran tsc on it):
- AlertCenterPage used lowercase policy severities ("medium") against the
  capitalised AlertPolicy.severity union. The app deliberately keeps two
  conventions — policies/alerts are capitalised (the AlertSeverity enum),
  triggered alerts are lowercased by the backend (PolicyBuilder .ToLowerInvariant)
  — so only the policy-side literals and the severity <select> options were
  capitalised; the triggered-alert filters stay lowercase, which is correct.
- EntityPage now accepts the fromAlertId prop that main.tsx passes for
  drill-down-from-alert navigation.
- DigestPdfRendererTests constructed TopAlert without the new Category/Status/
  AssignedTo fields, breaking the test build.

Verified: tsc clean, 74 client tests, 199 .NET tests, client and .NET builds,
and both the NuGet and npm vulnerability audits pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Folds the Unreleased notes and the old 1.0.0 stub into a single dated 1.0.0
section, and adds this release's installer, Entra-provisioning, digest-
categorisation, vulnerability-patch and first-run-fix entries. README now links
the exe to the GitHub Releases page rather than saying "download" with no source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant