diff --git a/README.md b/README.md index 2b78d6a..06b3c6e 100644 --- a/README.md +++ b/README.md @@ -32,17 +32,20 @@ and mitigation vertical slice: or excluded replacements without changing the organization baseline - Selectable graph nodes, original/mitigation toggling, and an accessible text representation of every relationship path -- Flyway-managed relational schema and Harborline Commerce seed +- Flyway-managed relational schema with published Northstar demo data - Backend unit and real-PostgreSQL integration tests - Frontend unit tests -## Seeded Harborline baseline +## Published Northstar demo -The version 1 baseline contains 25 employees, five teams, eight roles, six -applications, 23 permissions, ten capabilities, four workflows, and eleven -workflow steps. It includes the primary Priya Sharma role-revocation scenario, -Bob Chen as a potential mitigation candidate, and supporting refund and release -coverage scenarios. +The public version 1 demo models Northstar Medical Supplies with six employees, +two teams, four roles, and the critical Vendor Payment Run. Daniel Brooks is the +only Bank Payment Releaser, so removing that assignment blocks the bank-release +step. Nia Kapoor is ranked first as a safe replacement because she is active, +works in the same Treasury Operations team, already has Bank Portal access, and +restores the workflow without worsening another process. The published snapshot +is read-only. The older Harborline seed remains available for regression coverage +but is no longer presented as the product demo. ## Prerequisites @@ -74,10 +77,9 @@ npm --prefix frontend install npm --prefix frontend run dev ``` -Open . Run the Priya Sharma scenario, inspect the red and -amber impact paths, review Bob Chen's recommendation, and test the mitigation to -see the restored green relationship graph alongside the Low residual access -impact. +Open . Choose **Explore the live demo**, inspect the +Northstar dependency graph, test Daniel Brooks's access removal, and verify Nia +Kapoor's mitigation to see the restored green relationship graph. Editable organizations can be reopened from the homepage using their public ID with or without the displayed hyphen. Anyone with an organization ID or link can @@ -86,7 +88,7 @@ personal data. ## Frontend routes -- `/example` opens the read-only Harborline example. +- `/example` opens the read-only, graph-first Northstar continuity demo. - `/organizations/{publicCode}/map` opens an editable organization map. - `/organizations/{publicCode}/inventory` opens its detailed inventory. - `/organizations/{publicCode}/impact` opens a fresh impact-testing screen. @@ -100,7 +102,7 @@ Production hosting must rewrite non-asset, non-API routes to ## API endpoints -- `GET /api/v1/dashboard` loads the default Harborline Commerce dashboard. +- `GET /api/v1/dashboard` loads the default Northstar Medical Supplies dashboard. - `GET /api/v1/dashboard?organization={slug}` loads another organization or returns `404` when the slug does not exist. - `POST /api/v1/simulations` runs and saves a revoke-role simulation. diff --git a/backend/src/main/java/com/roleimpact/dashboard/api/DashboardController.java b/backend/src/main/java/com/roleimpact/dashboard/api/DashboardController.java index f581ca4..d30c844 100644 --- a/backend/src/main/java/com/roleimpact/dashboard/api/DashboardController.java +++ b/backend/src/main/java/com/roleimpact/dashboard/api/DashboardController.java @@ -19,7 +19,7 @@ public DashboardController(DashboardQueryService dashboardQueryService) { @GetMapping public DashboardResponse getDashboard( - @RequestParam(name = "organization", defaultValue = "harborline-commerce") String organizationSlug) { + @RequestParam(name = "organization", defaultValue = "northstar-public-demo") String organizationSlug) { return dashboardQueryService.getDashboard(organizationSlug); } } diff --git a/backend/src/main/java/com/roleimpact/workspace/application/WorkspaceService.java b/backend/src/main/java/com/roleimpact/workspace/application/WorkspaceService.java index 9058a51..5438221 100644 --- a/backend/src/main/java/com/roleimpact/workspace/application/WorkspaceService.java +++ b/backend/src/main/java/com/roleimpact/workspace/application/WorkspaceService.java @@ -41,11 +41,13 @@ public WorkspaceResource getEditableByCode(String rawCode) { @Transactional public WorkspaceResource createBlank(WorkspaceRequest request) { String name = request.name().trim(); - String slug = resolveSlug(request, name); - if (workspaceRepository.existsBySlug(slug)) { - throw slugConflict(slug); + String baseSlug = resolveSlug(request, name); + boolean generatedSlug = request.slug() == null; + if (!generatedSlug && workspaceRepository.existsBySlug(baseSlug)) { + throw slugConflict(baseSlug); } + String slug = baseSlug; for (int attempt = 0; attempt < 5; attempt++) { UUID workspaceId = UUID.randomUUID(); String publicCode = workspaceCode.generate(name); @@ -53,12 +55,20 @@ public WorkspaceResource createBlank(WorkspaceRequest request) { return get(workspaceId); } if (workspaceRepository.existsBySlug(slug)) { - throw slugConflict(slug); + if (!generatedSlug) { + throw slugConflict(slug); + } + slug = collisionSafeSlug(baseSlug, workspaceId); } } throw new WorkspaceConflictException("A unique organization ID could not be generated; try again"); } + private String collisionSafeSlug(String baseSlug, UUID workspaceId) { + String suffix = "-" + workspaceId.toString().substring(0, 8); + return baseSlug.substring(0, Math.min(baseSlug.length(), 80 - suffix.length())) + suffix; + } + private WorkspaceConflictException slugConflict(String slug) { return new WorkspaceConflictException("A workspace with slug '" + slug + "' already exists"); } diff --git a/backend/src/main/resources/db/migration/V11__seed_northstar_public_demo.sql b/backend/src/main/resources/db/migration/V11__seed_northstar_public_demo.sql new file mode 100644 index 0000000..c00ae04 --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__seed_northstar_public_demo.sql @@ -0,0 +1,97 @@ +INSERT INTO organizations ( + id, slug, name, current_version, created_at, workspace_status, updated_at, public_code +) VALUES ( + '00000000-0000-0000-0000-000000000002', + 'northstar-public-demo', + 'Northstar Medical Supplies', + 1, + '2026-09-16T12:00:00Z', + 'PUBLISHED', + '2026-09-16T12:00:00Z', + 'NMS-NQ7K4P9D8XM2QR6W' +); + +INSERT INTO teams (id, organization_id, name, department) VALUES +('11000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', 'Accounts Payable', 'Finance'), +('11000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', 'Treasury Operations', 'Treasury'); + +INSERT INTO employees (id, organization_id, team_id, employee_no, name, email, status, region, shift) VALUES +('22000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', '11000000-0000-0000-0000-000000000001', 'NMS-01', 'Maya Singh', 'maya.singh@northstar.demo', 'ACTIVE', 'NORTH_AMERICA', 'DAY'), +('22000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', '11000000-0000-0000-0000-000000000001', 'NMS-02', 'Arjun Mehta', 'arjun.mehta@northstar.demo', 'ACTIVE', 'NORTH_AMERICA', 'DAY'), +('22000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002', '11000000-0000-0000-0000-000000000001', 'NMS-03', 'Priya Sharma', 'priya.sharma@northstar.demo', 'ACTIVE', 'NORTH_AMERICA', 'DAY'), +('22000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000002', '11000000-0000-0000-0000-000000000001', 'NMS-04', 'Sarah Chen', 'sarah.chen@northstar.demo', 'ACTIVE', 'NORTH_AMERICA', 'DAY'), +('22000000-0000-0000-0000-000000000005', '00000000-0000-0000-0000-000000000002', '11000000-0000-0000-0000-000000000002', 'NMS-05', 'Daniel Brooks', 'daniel.brooks@northstar.demo', 'ACTIVE', 'NORTH_AMERICA', 'DAY'), +('22000000-0000-0000-0000-000000000006', '00000000-0000-0000-0000-000000000002', '11000000-0000-0000-0000-000000000002', 'NMS-06', 'Nia Kapoor', 'nia.kapoor@northstar.demo', 'ACTIVE', 'NORTH_AMERICA', 'DAY'); + +UPDATE teams SET manager_employee_id = '22000000-0000-0000-0000-000000000003' +WHERE id = '11000000-0000-0000-0000-000000000001'; +UPDATE teams SET manager_employee_id = '22000000-0000-0000-0000-000000000005' +WHERE id = '11000000-0000-0000-0000-000000000002'; + +INSERT INTO roles (id, organization_id, name, description, sensitivity, owner_employee_id) VALUES +('33000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', 'Invoice Processor', 'Validates supplier invoices before payment approval.', 'MEDIUM', '22000000-0000-0000-0000-000000000003'), +('33000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', 'Payment Approver', 'Approves validated supplier payments.', 'HIGH', '22000000-0000-0000-0000-000000000003'), +('33000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002', 'Bank Payment Releaser', 'Releases approved payment batches to the bank.', 'CRITICAL', '22000000-0000-0000-0000-000000000005'), +('33000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000002', 'Treasury Viewer', 'Reviews the bank release queue without release authority.', 'LOW', '22000000-0000-0000-0000-000000000005'); + +INSERT INTO employee_roles (employee_id, role_id, assigned_at, assigned_by) VALUES +('22000000-0000-0000-0000-000000000001', '33000000-0000-0000-0000-000000000001', '2026-09-16T12:00:00Z', 'seed:northstar-demo'), +('22000000-0000-0000-0000-000000000002', '33000000-0000-0000-0000-000000000001', '2026-09-16T12:00:00Z', 'seed:northstar-demo'), +('22000000-0000-0000-0000-000000000003', '33000000-0000-0000-0000-000000000002', '2026-09-16T12:00:00Z', 'seed:northstar-demo'), +('22000000-0000-0000-0000-000000000004', '33000000-0000-0000-0000-000000000002', '2026-09-16T12:00:00Z', 'seed:northstar-demo'), +('22000000-0000-0000-0000-000000000005', '33000000-0000-0000-0000-000000000003', '2026-09-16T12:00:00Z', 'seed:northstar-demo'), +('22000000-0000-0000-0000-000000000006', '33000000-0000-0000-0000-000000000004', '2026-09-16T12:00:00Z', 'seed:northstar-demo'); + +INSERT INTO applications (id, organization_id, name, category, owner_employee_id) VALUES +('44000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', 'Accounts Payable Hub', 'Finance', '22000000-0000-0000-0000-000000000003'), +('44000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', 'Bank Portal', 'Treasury', '22000000-0000-0000-0000-000000000005'); + +INSERT INTO resources (id, application_id, name, resource_type) VALUES +('55000000-0000-0000-0000-000000000001', '44000000-0000-0000-0000-000000000001', 'Supplier Invoices', 'invoice_queue'), +('55000000-0000-0000-0000-000000000002', '44000000-0000-0000-0000-000000000001', 'Payment Batches', 'payment_batch'), +('55000000-0000-0000-0000-000000000003', '44000000-0000-0000-0000-000000000002', 'Bank Release Queue', 'bank_queue'); + +INSERT INTO permissions (id, organization_id, application_id, resource_id, action, sensitivity) VALUES +('66000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', '44000000-0000-0000-0000-000000000001', '55000000-0000-0000-0000-000000000001', 'invoice.validate', 'MEDIUM'), +('66000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', '44000000-0000-0000-0000-000000000001', '55000000-0000-0000-0000-000000000002', 'payment.approve', 'HIGH'), +('66000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002', '44000000-0000-0000-0000-000000000002', '55000000-0000-0000-0000-000000000003', 'payment.release', 'CRITICAL'), +('66000000-0000-0000-0000-000000000004', '00000000-0000-0000-0000-000000000002', '44000000-0000-0000-0000-000000000002', '55000000-0000-0000-0000-000000000003', 'payment.read', 'LOW'); + +INSERT INTO role_permissions (role_id, permission_id) VALUES +('33000000-0000-0000-0000-000000000001', '66000000-0000-0000-0000-000000000001'), +('33000000-0000-0000-0000-000000000002', '66000000-0000-0000-0000-000000000002'), +('33000000-0000-0000-0000-000000000003', '66000000-0000-0000-0000-000000000003'), +('33000000-0000-0000-0000-000000000004', '66000000-0000-0000-0000-000000000004'); + +INSERT INTO capabilities (id, organization_id, name, description) VALUES +('77000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', 'Validate supplier invoice', 'Validate invoice details before payment approval.'), +('77000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000002', 'Approve supplier payment', 'Approve a validated supplier payment batch.'), +('77000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002', 'Release bank payment', 'Release the approved batch to the bank.'); + +INSERT INTO capability_permissions (capability_id, permission_id) VALUES +('77000000-0000-0000-0000-000000000001', '66000000-0000-0000-0000-000000000001'), +('77000000-0000-0000-0000-000000000002', '66000000-0000-0000-0000-000000000002'), +('77000000-0000-0000-0000-000000000003', '66000000-0000-0000-0000-000000000003'); + +INSERT INTO workflows (id, organization_id, name, criticality, owner_employee_id) VALUES +('88000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000002', 'Vendor Payment Run', 'CRITICAL', '22000000-0000-0000-0000-000000000003'); + +INSERT INTO workflow_steps ( + id, workflow_id, step_key, name, position, required_capability_id, + minimum_actors, resilience_target, required_department, required_region, + required_shift, required_application_id +) VALUES +('99000000-0000-0000-0000-000000000001', '88000000-0000-0000-0000-000000000001', 'VALIDATE_INVOICE', 'Validate supplier invoice', 1, '77000000-0000-0000-0000-000000000001', 1, 2, 'Finance', NULL, NULL, '44000000-0000-0000-0000-000000000001'), +('99000000-0000-0000-0000-000000000002', '88000000-0000-0000-0000-000000000001', 'APPROVE_PAYMENT', 'Approve supplier payment', 2, '77000000-0000-0000-0000-000000000002', 1, 2, 'Finance', NULL, NULL, '44000000-0000-0000-0000-000000000001'), +('99000000-0000-0000-0000-000000000003', '88000000-0000-0000-0000-000000000001', 'RELEASE_PAYMENT', 'Release bank payment', 3, '77000000-0000-0000-0000-000000000003', 1, 1, 'Treasury', NULL, NULL, '44000000-0000-0000-0000-000000000002'); + +INSERT INTO workflow_constraints (id, workflow_id, type, parameters) VALUES +('aa000000-0000-0000-0000-000000000001', '88000000-0000-0000-0000-000000000001', 'DIFFERENT_ACTORS', '{"description":"Invoice validation, payment approval, and bank release must be performed by different people."}'); + +INSERT INTO workflow_step_constraints (workflow_step_id, constraint_id) VALUES +('99000000-0000-0000-0000-000000000001', 'aa000000-0000-0000-0000-000000000001'), +('99000000-0000-0000-0000-000000000002', 'aa000000-0000-0000-0000-000000000001'), +('99000000-0000-0000-0000-000000000003', 'aa000000-0000-0000-0000-000000000001'); + +INSERT INTO organization_versions (organization_id, version, content_hash, created_at) VALUES +('00000000-0000-0000-0000-000000000002', 1, '9ae6989ed3265701f96e57c20d5704223d830abdfeeb93593077c4a694716cb5', '2026-09-16T12:00:00Z'); diff --git a/backend/src/test/java/com/roleimpact/catalog/persistence/CatalogMigrationIntegrationTest.java b/backend/src/test/java/com/roleimpact/catalog/persistence/CatalogMigrationIntegrationTest.java index d84d9ff..a4ed931 100644 --- a/backend/src/test/java/com/roleimpact/catalog/persistence/CatalogMigrationIntegrationTest.java +++ b/backend/src/test/java/com/roleimpact/catalog/persistence/CatalogMigrationIntegrationTest.java @@ -39,6 +39,10 @@ class CatalogMigrationIntegrationTest { private static final UUID PRIYA_ID = UUID.fromString("20000000-0000-0000-0000-000000000001"); private static final UUID BOB_ID = UUID.fromString("20000000-0000-0000-0000-000000000002"); private static final UUID FINANCE_APPROVER_ID = UUID.fromString("30000000-0000-0000-0000-000000000002"); + private static final UUID NORTHSTAR_ID = UUID.fromString("00000000-0000-0000-0000-000000000002"); + private static final UUID DANIEL_ID = UUID.fromString("22000000-0000-0000-0000-000000000005"); + private static final UUID NIA_ID = UUID.fromString("22000000-0000-0000-0000-000000000006"); + private static final UUID BANK_PAYMENT_RELEASER_ID = UUID.fromString("33000000-0000-0000-0000-000000000003"); @Container @ServiceConnection @@ -88,24 +92,25 @@ SELECT COUNT(*) """).query(Integer.class).single(); assertThat(publicCodes).isNotEmpty() - .allSatisfy(code -> assertThat(code).matches("[A-HJ-NP-Z2-9]{3}-[A-F0-9]{32}")); + .allSatisfy(code -> assertThat(code).matches( + "[A-HJ-NP-Z2-9]{3}-(?:[A-HJ-NP-Z2-9]{16}|[A-F0-9]{32})")); assertThat(publicCodes.stream().map(String::toUpperCase).distinct().count()) .isEqualTo(publicCodes.size()); assertThat(cloneColumnCount).isZero(); } @Test - void appliesSchemaAndLoadsTheCompleteHarborlineBaseline() { - assertThat(count("organizations")).isEqualTo(1); - assertThat(count("teams")).isEqualTo(5); - assertThat(count("employees")).isEqualTo(25); - assertThat(count("roles")).isEqualTo(8); - assertThat(count("applications")).isEqualTo(6); - assertThat(count("permissions")).isEqualTo(23); - assertThat(count("capabilities")).isEqualTo(10); - assertThat(count("workflows")).isEqualTo(4); - assertThat(count("workflow_steps")).isEqualTo(11); - assertThat(count("workflow_constraints")).isEqualTo(3); + void appliesSchemaAndLoadsBothPublishedBaselines() { + assertThat(count("organizations")).isEqualTo(2); + assertThat(count("teams")).isEqualTo(7); + assertThat(count("employees")).isEqualTo(31); + assertThat(count("roles")).isEqualTo(12); + assertThat(count("applications")).isEqualTo(8); + assertThat(count("permissions")).isEqualTo(27); + assertThat(count("capabilities")).isEqualTo(13); + assertThat(count("workflows")).isEqualTo(5); + assertThat(count("workflow_steps")).isEqualTo(14); + assertThat(count("workflow_constraints")).isEqualTo(4); var successfulMigrations = jdbcClient.sql(""" SELECT COUNT(*) @@ -115,7 +120,7 @@ SELECT COUNT(*) .query(Integer.class) .single(); - assertThat(successfulMigrations).isEqualTo(10); + assertThat(successfulMigrations).isEqualTo(11); var employeeNumberNullable = jdbcClient.sql(""" SELECT is_nullable FROM information_schema.columns @@ -270,21 +275,51 @@ void assemblesAnImmutableVersionedOrganizationSnapshot() { } @Test - void servesTheSeededDashboardFromTheSnapshotBoundary() throws Exception { + void servesNorthstarAsTheDefaultPublishedDemo() throws Exception { mockMvc.perform(get("/api/v1/dashboard")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.organization.slug").value("harborline-commerce")) + .andExpect(jsonPath("$.organization.slug").value("northstar-public-demo")) + .andExpect(jsonPath("$.organization.name").value("Northstar Medical Supplies")) .andExpect(jsonPath("$.organization.baselineVersion").value(1)) - .andExpect(jsonPath("$.counts.employees").value(25)) - .andExpect(jsonPath("$.counts.activeEmployees").value(24)) - .andExpect(jsonPath("$.counts.roles").value(8)) - .andExpect(jsonPath("$.counts.applications").value(6)) - .andExpect(jsonPath("$.counts.permissions").value(23)) - .andExpect(jsonPath("$.counts.workflows").value(4)) - .andExpect(jsonPath("$.workflows[0].name").value("Production Deployment")) + .andExpect(jsonPath("$.counts.employees").value(6)) + .andExpect(jsonPath("$.counts.activeEmployees").value(6)) + .andExpect(jsonPath("$.counts.roles").value(4)) + .andExpect(jsonPath("$.counts.workflows").value(1)) + .andExpect(jsonPath("$.workflows[0].name").value("Vendor Payment Run")) .andExpect(jsonPath("$.workflows[0].criticality").value("CRITICAL")); } + @Test + void blocksTheCanonicalNorthstarPaymentRunAndRanksNiaFirst() throws Exception { + mockMvc.perform(post("/api/v1/simulations") + .header("Idempotency-Key", "test-northstar-public-demo") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "schemaVersion":"1.0", + "organizationId":"%s", + "baselineVersion":1, + "change":{"type":"REVOKE_EMPLOYEE_ROLE","employeeId":"%s","roleId":"%s"} + } + """.formatted(NORTHSTAR_ID, DANIEL_ID, BANK_PAYMENT_RELEASER_ID))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.result.overallSeverity").value("CRITICAL")) + .andExpect(jsonPath("$.result.executiveSummary.workflowsBlocked").value(1)) + .andExpect(jsonPath("$.result.workflowImpacts[0].workflowName").value("Vendor Payment Run")) + .andExpect(jsonPath("$.result.workflowImpacts[0].scenarioStatus").value("BLOCKED")) + .andExpect(jsonPath("$.result.recommendations[0].candidate.id").value(NIA_ID.toString())) + .andExpect(jsonPath("$.result.recommendations[0].candidate.name").value("Nia Kapoor")); + } + + @Test + void rejectsCatalogChangesToThePublishedNorthstarDemo() throws Exception { + mockMvc.perform(post("/api/v1/workspaces/{workspaceId}/catalog/teams", NORTHSTAR_ID) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Injected team\",\"department\":\"Demo\"}")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("DRAFT_CATALOG_CONFLICT")); + } + @Test void returnsNotFoundForAnUnknownDashboardOrganization() throws Exception { mockMvc.perform(get("/api/v1/dashboard").param("organization", "missing-company")) diff --git a/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java b/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java index 5000eaf..88268e9 100644 --- a/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java +++ b/backend/src/test/java/com/roleimpact/workspace/WorkspaceIntegrationTest.java @@ -52,11 +52,11 @@ class WorkspaceIntegrationTest { @Test void createsAndResolvesAnEditableDraftByPublicCode() throws Exception { var blankResponse = mockMvc.perform(post("/api/v1/workspaces") - .contentType(MediaType.APPLICATION_JSON) - .content(workspaceRequest("Northstar Medical Supplies", null))) + .contentType(MediaType.APPLICATION_JSON) + .content(workspaceRequest("Northwind Medical Supplies", null))) .andExpect(status().isCreated()) .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/api/v1/workspaces/by-code/"))) - .andExpect(jsonPath("$.slug").value("northstar-medical-supplies")) + .andExpect(jsonPath("$.slug").value("northwind-medical-supplies")) .andExpect(jsonPath("$.status").value("DRAFT")) .andExpect(jsonPath("$.currentVersion").value(0)) .andExpect(jsonPath("$.publicCode") @@ -77,6 +77,28 @@ void createsAndResolvesAnEditableDraftByPublicCode() throws Exception { assertThat(workspaceState(blankId)).isEqualTo("DRAFT|0"); } + @Test + void createsIndependentOrganizationsWithTheSameCompanyName() throws Exception { + var firstResponse = mockMvc.perform(post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content(workspaceRequest("Twin Peak Logistics", null))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Twin Peak Logistics")) + .andReturn(); + var secondResponse = mockMvc.perform(post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content(workspaceRequest("Twin Peak Logistics", null))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Twin Peak Logistics")) + .andReturn(); + + var first = objectMapper.readTree(firstResponse.getResponse().getContentAsString()); + var second = objectMapper.readTree(secondResponse.getResponse().getContentAsString()); + assertThat(second.path("id").asText()).isNotEqualTo(first.path("id").asText()); + assertThat(second.path("publicCode").asText()).isNotEqualTo(first.path("publicCode").asText()); + assertThat(second.path("slug").asText()).isNotEqualTo(first.path("slug").asText()); + } + @Test void hidesPublishedAndUnknownOrganizationsFromPublicCodeLookup() throws Exception { String harborlinePublicCode = jdbcClient.sql("SELECT public_code FROM organizations WHERE id = :id") diff --git a/frontend/src/App.css b/frontend/src/App.css index 4e96317..3c09db7 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -18,12 +18,11 @@ display: flex; align-items: center; justify-content: space-between; - min-height: 4.75rem; + min-height: 4rem; padding: 0 4vw; border-bottom: 1px solid #242836; } -.workspace-home-status, .draft-status { display: flex; align-items: center; @@ -34,24 +33,38 @@ .workspace-home-main, .draft-main { - width: min(100% - 2rem, 76rem); + width: min(100% - 2rem, 88rem); margin: 0 auto; - padding: clamp(3.5rem, 8vw, 7rem) 0 4rem; +} + +.workspace-home-main { + padding: clamp(2.5rem, 5vw, 4.5rem) 0 3rem; +} + +.draft-main { + width: min(100% - 2rem, 96rem); + padding: clamp(1.5rem, 3vw, 2.5rem) 0 2rem; } .workspace-hero { max-width: 64rem; - margin-bottom: clamp(3rem, 6vw, 5.5rem); + margin-bottom: clamp(2.25rem, 4vw, 3.5rem); } -.workspace-hero h1, -.draft-main > h1 { +.workspace-hero h1 { margin-bottom: 1.35rem; - font-size: clamp(3rem, 7vw, 6.5rem); + font-size: clamp(2.75rem, 5.5vw, 5.25rem); letter-spacing: -0.067em; line-height: 0.94; } +.draft-main > h1 { + margin-bottom: 0.85rem; + font-size: clamp(2.25rem, 4vw, 3.75rem); + letter-spacing: -0.055em; + line-height: 0.98; +} + .workspace-hero > p:last-child, .draft-lede { max-width: 46rem; @@ -69,7 +82,7 @@ .workspace-choice { display: flex; - min-height: 31rem; + min-height: 25rem; flex-direction: column; padding: 1.5rem; border: 1px solid #2b3040; @@ -89,7 +102,7 @@ display: inline-grid; width: 2.35rem; height: 2.35rem; - margin-bottom: 2rem; + margin-bottom: 1.35rem; place-items: center; border: 1px solid #393e50; border-radius: 50%; @@ -258,7 +271,21 @@ } .draft-editor { - margin: 4rem 0 2rem; + margin: 2.25rem 0 1.5rem; +} + +.impact-main { + width: min(100% - 2rem, 100rem); + padding-top: 1rem; +} + +.impact-main .draft-editor { + margin-top: 0; +} + +.impact-editor-heading { + justify-content: flex-end; + margin-bottom: 0.75rem; } .editor-summary { @@ -709,7 +736,7 @@ position: relative; display: grid; grid-template-columns: 12.5rem minmax(32rem, 1fr) 16rem; - min-height: 42rem; + min-height: 0; overflow: hidden; border: 1px solid #2b3040; border-radius: 1rem; @@ -863,7 +890,8 @@ .canvas-dropzone { position: relative; - min-height: 38rem; + height: clamp(28rem, calc(100vh - 11rem), 36rem); + min-height: 0; background: #0d1017; } @@ -1555,7 +1583,7 @@ } .full-impact-map-canvas { - height: 40rem; + height: clamp(28rem, calc(100vh - 10rem), 38rem); overflow: hidden; border-top: 1px solid #2f3444; border-bottom: 1px solid #2f3444; @@ -1858,8 +1886,7 @@ grid-template-columns: 1fr; } - .impact-testing-shell, - .impact-explorer { grid-template-columns: 1fr; } + .impact-testing-shell { grid-template-columns: 1fr; } .impact-object-panel { grid-template-columns: 1fr; } .workflow-choice-panel { display: grid; } @@ -1870,11 +1897,16 @@ margin-top: 1rem; } +} + +@media (max-width: 48rem) { + .impact-explorer, .canvas-builder { grid-template-columns: 1fr; } + .canvas-toolbox { border-right: 0; border-bottom: 1px solid #2b3040; } .canvas-palette { grid-template-columns: repeat(4, 1fr); } .canvas-help { display: none; } - .canvas-dropzone { min-height: 32rem; } + .canvas-dropzone { height: 30rem; } } @media (max-width: 36rem) { @@ -1941,7 +1973,7 @@ display: flex; align-items: center; justify-content: space-between; - min-height: 4.75rem; + min-height: 4rem; padding: 0 4vw; border-bottom: 1px solid #242836; background: rgb(13 15 22 / 82%); @@ -2083,9 +2115,9 @@ } .dashboard-main { - width: min(100% - 2rem, 76rem); + width: min(100% - 2rem, 88rem); margin: 0 auto; - padding: clamp(3.5rem, 8vw, 7.5rem) 0 2rem; + padding: clamp(2.25rem, 4vw, 4rem) 0 2rem; } .intro { @@ -2093,7 +2125,7 @@ grid-template-columns: minmax(0, 1.7fr) minmax(16rem, 0.7fr); gap: clamp(2rem, 6vw, 6rem); align-items: end; - margin-bottom: clamp(4rem, 8vw, 7rem); + margin-bottom: clamp(2.5rem, 5vw, 4.5rem); } .eyebrow, @@ -2116,7 +2148,7 @@ p { h1 { max-width: 53rem; margin-bottom: 1.5rem; - font-size: clamp(3rem, 7vw, 6.25rem); + font-size: clamp(2.75rem, 5.5vw, 5.25rem); letter-spacing: -0.065em; line-height: 0.96; } diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 989c867..a6c1a26 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -36,7 +36,7 @@ const editableInventoryRoute = `/organizations/${editableCode}/inventory` const editableImpactRoute = `/organizations/${editableCode}/impact` describe('App', () => { - it('renders the seeded organization dashboard', async () => { + it('introduces the Northstar continuity demo without Harborline branding', async () => { const user = userEvent.setup() vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const url = input.toString() @@ -44,9 +44,9 @@ describe('App', () => { if (url.endsWith('/api/v1/dashboard')) { return jsonResponse({ organization: { - id: '00000000-0000-0000-0000-000000000001', - slug: 'harborline-commerce', - name: 'Harborline Commerce', + id: '00000000-0000-0000-0000-000000000002', + slug: 'northstar-medical-supplies', + name: 'Northstar Medical Supplies', baselineVersion: 1, contentHash: 'dbafb569ae3beaa13277897a7700ab32867675e31ee90cad74a9dc544d5c1fb4', }, @@ -76,15 +76,17 @@ describe('App', () => { renderApp() - expect(await screen.findByRole('heading', { name: 'Explore Harborline' })).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: 'Explore the example' })) - expect(await screen.findByText(/Harborline Commerce/)).toBeInTheDocument() + expect(await screen.findByRole('heading', { name: 'Explore a live continuity risk' })).toBeInTheDocument() + expect(screen.queryByText('Local workspace')).not.toBeInTheDocument() + expect(screen.queryByText(/Harborline/i)).not.toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Explore the live demo' })) + expect(await screen.findByText(/Northstar Medical Supplies/)).toBeInTheDocument() expect(screen.getByText('24')).toBeInTheDocument() expect(screen.getAllByRole('heading', { name: 'Month-End Close' })).not.toHaveLength(0) expect(screen.getByText('Immutable snapshot')).toBeInTheDocument() }) - it('runs the Priya scenario and verifies the recommended mitigation branch', async () => { + it('shows the Northstar dependency graph before running the Daniel access test', async () => { const user = userEvent.setup() vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const url = input.toString() @@ -95,7 +97,7 @@ describe('App', () => { return jsonResponse(dashboardFixture) } if (url.endsWith('/api/v1/simulations')) { - return jsonResponse(simulationFixture, 201) + return jsonResponse(northstarSimulationFixture, 201) } if (url.endsWith('/branches')) { return jsonResponse(mitigationFixture, 201) @@ -103,36 +105,38 @@ describe('App', () => { return new Response(null, { status: 404 }) }) - renderApp() + renderApp(['/example']) + + expect(await screen.findByRole( + 'heading', + { name: 'Northstar dependency graph' }, + { timeout: 3_000 }, + )).toBeInTheDocument() + expect(screen.getAllByText('Daniel Brooks')).not.toHaveLength(0) + expect(screen.getAllByText('Vendor Payment Run')).not.toHaveLength(0) + expect(screen.queryByRole('heading', { name: 'Critical business impact' })).not.toBeInTheDocument() - await user.click(await screen.findByRole('button', { name: 'Explore the example' })) - await user.click(await screen.findByRole('button', { name: 'Run impact analysis' })) + await user.click(screen.getByRole('button', { name: "Test removing Daniel's access" })) expect(await screen.findByRole('heading', { name: 'Critical business impact' })).toBeInTheDocument() - expect(screen.getAllByRole('heading', { name: 'Vendor Payment' })).not.toHaveLength(0) - expect(screen.getAllByRole('heading', { name: 'Month-End Close' })).not.toHaveLength(0) + expect(screen.getAllByRole('heading', { name: 'Vendor Payment Run' })).not.toHaveLength(0) expect(screen.getAllByText('BLOCKED')).not.toHaveLength(0) - expect(screen.getAllByText('DEGRADED')).not.toHaveLength(0) - expect(screen.getAllByText('payment.approve', { selector: 'code' })).not.toHaveLength(0) - expect(screen.getAllByText('ledger.close', { selector: 'code' })).not.toHaveLength(0) - expect(screen.getAllByText('Priya Sharma')).not.toHaveLength(0) + expect(screen.getAllByText('payment.release', { selector: 'code' })).not.toHaveLength(0) + expect(screen.getAllByText('Daniel Brooks')).not.toHaveLength(0) expect(await screen.findByRole('heading', { name: 'Focused impact graph' })).toBeInTheDocument() expect(screen.getByLabelText('Graph state legend')).toHaveTextContent('Blocked') expect(screen.getByText('Read the relationship path as text')).toBeInTheDocument() expect(screen.getByRole('heading', { - name: 'Restore the workflow without reversing Priya’s change', + name: 'Restore the workflow without reversing Daniel’s change', })).toBeInTheDocument() - expect(screen.getByText('Assign Finance Approver to Bob Chen')).toBeInTheDocument() + expect(screen.getByText('Assign Bank Payment Releaser to Nia Kapoor')).toBeInTheDocument() - await user.click(screen.getByRole('button', { name: "Test Bob's mitigation" })) + await user.click(screen.getByRole('button', { name: "Test Nia's mitigation" })) expect(await screen.findByRole('heading', { name: 'Workflow disruption resolved' })).toBeInTheDocument() expect(screen.getByRole('table', { name: 'Original and mitigated workflow comparison', - })).toHaveTextContent('Vendor Payment') - expect(screen.getByRole('table', { - name: 'Original and mitigated workflow comparison', - })).toHaveTextContent('Month-End Close') + })).toHaveTextContent('Vendor Payment Run') expect(screen.getAllByText('OPERATIONAL')).not.toHaveLength(0) expect(screen.getByText('Saved as a child simulation')).toBeInTheDocument() expect(screen.getByRole('button', { name: 'With mitigation' })).toHaveAttribute('aria-pressed', 'true') @@ -1111,30 +1115,102 @@ const customDraftMitigationPreviewFixture: DraftMitigationPreview = { }, } -const mitigationFixture = { +const northstarSimulationFixture = { ...simulationFixture, - id: 'b0000000-0000-0000-0000-000000000003', - parentSimulationId: simulationFixture.id, + organizationId: '00000000-0000-0000-0000-000000000002', result: { ...simulationFixture.result, - overallSeverity: 'LOW', executiveSummary: { ...simulationFixture.result.executiveSummary, + permissionsLost: 1, + workflowsDegraded: 0, + }, + changeSet: { + ...simulationFixture.result.changeSet, + employee: { id: '22000000-0000-0000-0000-000000000005', name: 'Daniel Brooks' }, + role: { id: '33000000-0000-0000-0000-000000000003', name: 'Bank Payment Releaser' }, + }, + technicalImpact: { + ...simulationFixture.result.technicalImpact, + lostPermissions: [{ + id: '44000000-0000-0000-0000-000000000003', + action: 'payment.release', + sensitivity: 'CRITICAL', + application: { id: 'bank-portal', name: 'Bank Portal' }, + resource: { id: 'release-queue', name: 'Bank Release Queue' }, + }], + }, + workflowImpacts: [{ + ...simulationFixture.result.workflowImpacts[0], + workflowId: '66000000-0000-0000-0000-000000000001', + workflowName: 'Vendor Payment Run', + failures: ['Release bank payment has no eligible actor.'], + }], + explanationPaths: [{ + ...simulationFixture.result.explanationPaths[0], + workflowId: '66000000-0000-0000-0000-000000000001', + reason: 'No eligible bank payment releaser remains.', + nodes: [ + { type: 'EMPLOYEE', id: 'daniel', label: 'Daniel Brooks' }, + { type: 'ROLE', id: 'releaser', label: 'Bank Payment Releaser' }, + { type: 'PERMISSION', id: 'release', label: 'payment.release' }, + { type: 'CAPABILITY', id: 'release-capability', label: 'Release bank payment' }, + { type: 'WORKFLOW_STEP', id: 'release-step', label: 'Release bank payment' }, + { type: 'WORKFLOW', id: 'vendor-payment-run', label: 'Vendor Payment Run' }, + ], + }], + graphDiff: { + nodes: simulationFixture.result.graphDiff.nodes.map((node) => ({ + ...node, + label: node.type === 'EMPLOYEE' ? 'Daniel Brooks' + : node.type === 'ROLE' ? 'Bank Payment Releaser' + : node.type === 'PERMISSION' ? 'payment.release' + : node.type === 'CAPABILITY' || node.type === 'WORKFLOW_STEP' ? 'Release bank payment' + : 'Vendor Payment Run', + })), + edges: simulationFixture.result.graphDiff.edges, + }, + recommendations: [{ + ...simulationFixture.result.recommendations[0], + candidate: { id: '22000000-0000-0000-0000-000000000006', name: 'Nia Kapoor' }, + role: { id: '33000000-0000-0000-0000-000000000003', name: 'Bank Payment Releaser' }, + gainedPermissions: [{ + id: '44000000-0000-0000-0000-000000000003', + action: 'payment.release', + sensitivity: 'CRITICAL', + application: { id: 'bank-portal', name: 'Bank Portal' }, + resource: { id: 'release-queue', name: 'Bank Release Queue' }, + }], + existingApplicationAccess: [{ id: 'bank-portal', name: 'Bank Portal' }], + restoredWorkflows: [{ id: '66000000-0000-0000-0000-000000000001', name: 'Vendor Payment Run' }], + }], + }, +} + +const mitigationFixture = { + ...northstarSimulationFixture, + id: 'b0000000-0000-0000-0000-000000000003', + parentSimulationId: northstarSimulationFixture.id, + result: { + ...northstarSimulationFixture.result, + overallSeverity: 'LOW', + executiveSummary: { + ...northstarSimulationFixture.result.executiveSummary, workflowsBlocked: 0, workflowsDegraded: 0, messageKey: 'simulation.revoke-role.low', }, changeSet: { - ...simulationFixture.result.changeSet, + ...northstarSimulationFixture.result.changeSet, type: 'REVOKE_EMPLOYEE_ROLE_AND_ASSIGN_REPLACEMENT', - replacementEmployee: { id: '20000000-0000-0000-0000-000000000002', name: 'Bob Chen' }, + replacementEmployee: { id: '22000000-0000-0000-0000-000000000006', name: 'Nia Kapoor' }, }, technicalImpact: { - ...simulationFixture.result.technicalImpact, - assignedRoles: [simulationFixture.result.changeSet.role], - gainedPermissions: simulationFixture.result.recommendations[0].gainedPermissions, + ...northstarSimulationFixture.result.technicalImpact, + assignedRoles: [northstarSimulationFixture.result.changeSet.role], + gainedPermissions: northstarSimulationFixture.result.recommendations[0].gainedPermissions, }, - workflowImpacts: simulationFixture.result.workflowImpacts.map((workflow) => ({ + workflowImpacts: northstarSimulationFixture.result.workflowImpacts.map((workflow) => ({ ...workflow, scenarioStatus: 'OPERATIONAL', failures: [], @@ -1142,30 +1218,30 @@ const mitigationFixture = { explanationPaths: [], graphDiff: { nodes: [ - ...simulationFixture.result.graphDiff.nodes.map((node) => ( + ...northstarSimulationFixture.result.graphDiff.nodes.map((node) => ( node.type === 'EMPLOYEE' ? node : { ...node, state: 'RESTORED', detail: `${node.label} is restored by the replacement assignment.` } )), { - id: 'employee:bob', + id: 'employee:nia', type: 'EMPLOYEE', - entityId: 'bob', - label: 'Bob Chen', + entityId: 'nia', + label: 'Nia Kapoor', state: 'ADDED', detail: 'Replacement employee selected by the tested mitigation.', }, ], edges: [ - simulationFixture.result.graphDiff.edges[0], + northstarSimulationFixture.result.graphDiff.edges[0], { - id: 'bob-role', - sourceNodeId: 'employee:bob', + id: 'nia-role', + sourceNodeId: 'employee:nia', targetNodeId: 'role:finance-approver', relationship: 'ASSIGNED_ROLE', state: 'ADDED', }, - ...simulationFixture.result.graphDiff.edges.slice(1).map((edge) => ({ + ...northstarSimulationFixture.result.graphDiff.edges.slice(1).map((edge) => ({ ...edge, state: 'RESTORED', })), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7b47896..9b35ad8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react' +import { lazy, Suspense, useState } from 'react' import { useMutation, useQuery } from '@tanstack/react-query' import { Navigate, Route, Routes, useNavigate, useParams } from 'react-router-dom' import { fetchDashboard } from './api/dashboard' @@ -13,7 +13,7 @@ export default function App() { return ( } /> - } /> + } /> } /> } /> } /> @@ -26,14 +26,19 @@ function OrganizationIndexRedirect() { return } -function HarborlineDashboard() { +function NorthstarDemo() { const navigate = useNavigate() + const [showResults, setShowResults] = useState(false) const dashboardQuery = useQuery({ - queryKey: ['dashboard', 'harborline-commerce'], + queryKey: ['dashboard', 'northstar-public-demo'], queryFn: ({ signal }) => fetchDashboard(signal), retry: 1, }) - const simulationMutation = useMutation({ mutationFn: runPrimarySimulation }) + const simulationQuery = useQuery({ + queryKey: ['simulation', 'northstar-live-demo'], + queryFn: runPrimarySimulation, + retry: 1, + }) const mitigationMutation = useMutation({ mutationFn: ({ simulationId, recommendationId }: { simulationId: string; recommendationId: string }) => runMitigationBranch(simulationId, recommendationId), @@ -43,7 +48,7 @@ function HarborlineDashboard() { return (
) } @@ -92,71 +97,73 @@ function HarborlineDashboard() {

Access change impact simulator

See the blast radius before access changes go live.

- Test a proposed access change against Harborline’s people, permissions, - and business workflows before anyone is affected. + Follow one real business process from people and roles to the critical + workflow they keep running.

Primary scenario - Remove Finance Approver from Priya Sharma -

Check which permissions disappear and whether Harborline can still complete critical work.

+ Remove Bank Payment Releaser from Daniel Brooks +

See whether Northstar can still release its vendor payments—and who can safely take over.

- {simulationMutation.isError && ( + {simulationQuery.isError && (

- The analysis could not run. Check that the local API is available and try again. + The analysis could not run: {simulationQuery.error.message}

)}
- {simulationMutation.data && ( + {!showResults && simulationQuery.data && ( + Preparing the dependency graph…}> + + + )} + + {showResults && simulationQuery.data && (

Simulation complete

- {sentenceCase(simulationMutation.data.result.overallSeverity)} business impact + {sentenceCase(simulationQuery.data.result.overallSeverity)} business impact

- Removing {simulationMutation.data.result.changeSet.role.name} from{' '} - {simulationMutation.data.result.changeSet.employee.name} blocks a critical + Removing {simulationQuery.data.result.changeSet.role.name} from{' '} + {simulationQuery.data.result.changeSet.employee.name} blocks a critical payment path and reduces month-end resilience.

- - {simulationMutation.data.result.overallSeverity} + + {simulationQuery.data.result.overallSeverity}
Role removed - {simulationMutation.data.result.executiveSummary.rolesRemoved} + {simulationQuery.data.result.executiveSummary.rolesRemoved}
Permissions lost - {simulationMutation.data.result.executiveSummary.permissionsLost} + {simulationQuery.data.result.executiveSummary.permissionsLost}
Workflows blocked - {simulationMutation.data.result.executiveSummary.workflowsBlocked} + {simulationQuery.data.result.executiveSummary.workflowsBlocked}
Workflows degraded - {simulationMutation.data.result.executiveSummary.workflowsDegraded} + {simulationQuery.data.result.executiveSummary.workflowsDegraded}
@@ -170,7 +177,7 @@ function HarborlineDashboard() { Before → after
- {simulationMutation.data.result.workflowImpacts + {simulationQuery.data.result.workflowImpacts .filter((workflow) => workflow.baselineStatus !== workflow.scenarioStatus) .map((workflow) => (
@@ -198,7 +205,7 @@ function HarborlineDashboard() {
- {simulationMutation.data.result.technicalImpact.lostPermissions.map((permission) => ( + {simulationQuery.data.result.technicalImpact.lostPermissions.map((permission) => (
{permission.action} {permission.application.name} · {permission.resource.name} @@ -214,10 +221,10 @@ function HarborlineDashboard() {

Why this happens

Traceable explanation paths

- {simulationMutation.data.result.diagnostics.resultHash.slice(0, 12)} + {simulationQuery.data.result.diagnostics.resultHash.slice(0, 12)}
- {simulationMutation.data.result.explanationPaths.map((path) => ( + {simulationQuery.data.result.explanationPaths.map((path) => (
{path.outcome}
@@ -235,23 +242,23 @@ function HarborlineDashboard() { Preparing the impact graph…
}> - {simulationMutation.data.result.recommendations.length > 0 && ( + {simulationQuery.data.result.recommendations.length > 0 && (

Recommended mitigation

-

Restore the workflow without reversing Priya’s change

+

Restore the workflow without reversing Daniel’s change

Ranked by least additional access
- {simulationMutation.data.result.recommendations.map((recommendation) => ( + {simulationQuery.data.result.recommendations.map((recommendation) => (
{String(recommendation.rank).padStart(2, '0')} @@ -279,7 +286,7 @@ function HarborlineDashboard() { type="button" disabled={mitigationMutation.isPending} onClick={() => mitigationMutation.mutate({ - simulationId: simulationMutation.data.id, + simulationId: simulationQuery.data.id, recommendationId: recommendation.id, })} > @@ -299,11 +306,11 @@ function HarborlineDashboard() {

)} - {simulationMutation.data.result.excludedCandidateReasons.length > 0 && ( + {simulationQuery.data.result.excludedCandidateReasons.length > 0 && (
Why other employees were not recommended
- {simulationMutation.data.result.excludedCandidateReasons.map((exclusion) => ( + {simulationQuery.data.result.excludedCandidateReasons.map((exclusion) => (

{exclusion.candidate.name}:{' '} {exclusion.reasons.map((reason) => reason.detail).join(' ')} @@ -339,7 +346,7 @@ function HarborlineDashboard() { Original scenario With mitigation

- {simulationMutation.data.result.workflowImpacts + {simulationQuery.data.result.workflowImpacts .filter((workflow) => workflow.baselineStatus !== workflow.scenarioStatus) .map((workflow) => { const mitigated = mitigationMutation.data.result.workflowImpacts diff --git a/frontend/src/api/simulations.ts b/frontend/src/api/simulations.ts index ec8ea2b..26147d0 100644 --- a/frontend/src/api/simulations.ts +++ b/frontend/src/api/simulations.ts @@ -144,19 +144,22 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '' const primaryScenario = { schemaVersion: '1.0', - organizationId: '00000000-0000-0000-0000-000000000001', + organizationId: '00000000-0000-0000-0000-000000000002', baselineVersion: 1, change: { type: 'REVOKE_EMPLOYEE_ROLE', - employeeId: '20000000-0000-0000-0000-000000000001', - roleId: '30000000-0000-0000-0000-000000000002', + employeeId: '22000000-0000-0000-0000-000000000005', + roleId: '33000000-0000-0000-0000-000000000003', }, } as const export async function runPrimarySimulation(): Promise { const response = await fetch(`${apiBaseUrl}/api/v1/simulations`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'Idempotency-Key': 'northstar-public-demo-v1', + }, body: JSON.stringify(primaryScenario), }) diff --git a/frontend/src/components/ImpactGraph.tsx b/frontend/src/components/ImpactGraph.tsx index a796a3a..c84f75c 100644 --- a/frontend/src/components/ImpactGraph.tsx +++ b/frontend/src/components/ImpactGraph.tsx @@ -16,6 +16,7 @@ import type { GraphNodeType, GraphState, Simulation } from '../api/simulations' type ImpactGraphProps = { original: Simulation mitigation?: Simulation + title?: string } type ImpactNodeData = { @@ -48,7 +49,7 @@ const stateColors: Record = { RESTORED: '#4ade80', } -export default function ImpactGraph({ original, mitigation }: ImpactGraphProps) { +export default function ImpactGraph({ original, mitigation, title = 'Focused impact graph' }: ImpactGraphProps) { const [view, setView] = useState<'impact' | 'mitigation'>('impact') const [selectedNodeId, setSelectedNodeId] = useState(null) const mitigationId = mitigation?.id @@ -77,7 +78,7 @@ export default function ImpactGraph({ original, mitigation }: ImpactGraphProps)

Visual blast radius

-

Focused impact graph

+

{title}

Follow the changed assignment through permissions and capabilities to the business workflows it affects.

diff --git a/frontend/src/components/workspaces/WorkspaceWelcome.tsx b/frontend/src/components/workspaces/WorkspaceWelcome.tsx index bb219b9..39cf839 100644 --- a/frontend/src/components/workspaces/WorkspaceWelcome.tsx +++ b/frontend/src/components/workspaces/WorkspaceWelcome.tsx @@ -15,7 +15,7 @@ export default function WorkspaceWelcome() { const [blankName, setBlankName] = useState('') const [codeError, setCodeError] = useState(null) const dashboardQuery = useQuery({ - queryKey: ['dashboard', 'harborline-commerce'], + queryKey: ['dashboard', 'northstar-public-demo'], queryFn: ({ signal }) => fetchDashboard(signal), retry: 1, }) @@ -59,10 +59,6 @@ export default function WorkspaceWelcome() { RoleImpact - -
@@ -79,9 +75,9 @@ export default function WorkspaceWelcome() {
01
-

Learn from the example

-

Explore Harborline

-

See Priya’s role change travel through a complete, realistic business workflow.

+

Guided live demo

+

Explore a live continuity risk

+

See how one missing bank-payment role blocks a critical workflow—and how RoleImpact finds a safe replacement.

Members
{counts?.employees ?? '—'}
@@ -89,7 +85,7 @@ export default function WorkspaceWelcome() {
Workflows
{counts?.workflows ?? '—'}
{dashboardQuery.isError ? (

Example statistics are temporarily unavailable.