From 8d2bb14f99696c6cc687dd0375aea18fe5e88300 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:29:26 +0100 Subject: [PATCH 01/13] Add portal user management and access control (#6913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Portal access control + user management What this does - Adds server-side portal access enforcement: a ResourceGrant ACL (owner → admin → grant → default policy) gates the portal via @resourceAccess.canUsePortal(), so access is authoritative on the backend, not just hidden in the UI. - New proprietary/access module: ResourceAccessService + ResourceAccessSecurity, PrincipalResolver (default + SaaS + team-lead lookup), OwnershipService, ResourceGrantController, and a SecretMasker for safe config display. - Exposes an authoritative portalAccess flag on /me (AuthController / AdminUserSummary); drops the old org-principal shortcut. - Full portal Users page: team + member management (members table, invite, move-to-team, new/rename team, reset password, access controls, confirm modals) wired to real user/team/grant endpoints. - Per-flavor capabilities seam (usersCapabilities): self-hosted org-admin gets everything; SaaS is trimmed to what a team leader can do (no ROLE_ADMIN ever surfaced). SaaS blockers (separate follow-up PR) The portal Users page works on self-hosted but 403s on SaaS (it calls the admin API hasRole('ADMIN'), and SaaS users are ROLE_USER). To ship the portal on SaaS: - Add a @app/portal/usersBackend seam and point the SaaS build at the existing SaasTeamController (no new backend). - Resolve the leader's team-id on SaaS and map member/invitation shapes to the portal Member type. - Add pending-invitation management (list + cancel) - the parity gap vs the editor. - Re-enable the roster remove action on SaaS against SaasTeamController's remove-member endpoint. image image --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details. --------- Co-authored-by: aikido-pr-checks[bot] <169896070+aikido-pr-checks[bot]@users.noreply.github.com> --- .../access/config/AccessConfig.java | 9 + .../controller/ResourceGrantController.java | 37 +- .../access/model/OwnedResource.java | 11 + .../access/model/PrincipalRef.java | 20 + .../access/model/PrincipalType.java | 2 +- .../repository/ResourceGrantRepository.java | 16 + .../security/ResourceAccessSecurity.java | 7 +- .../service/DefaultPrincipalResolver.java | 32 ++ .../service/MembershipTeamLeadLookup.java | 33 ++ .../access/service/OwnershipService.java | 8 +- .../access/service/PrincipalResolver.java | 28 + .../access/service/ResourceAccessService.java | 71 ++- .../access/service/SecretMasker.java | 27 +- .../api/ProprietaryUIDataController.java | 53 +- .../IntegrationConfigController.java | 4 +- .../crypto/CredentialEncryption.java | 25 +- .../IntegrationConfigRepository.java | 9 + .../service/IntegrationConfigService.java | 11 + .../proprietary}/model/TeamMembership.java | 3 +- .../security/InitialSecuritySetup.java | 3 + .../controller/api/AuthController.java | 15 +- .../controller/api/TeamController.java | 70 +++ .../controller/api/UserController.java | 3 + .../security/model/dto/AdminUserSummary.java | 8 + .../repository/TeamMembershipRepository.java | 26 +- .../service/TeamMembershipService.java | 117 +++++ .../security/service/UserService.java | 25 + .../security/ResourceAccessSecurityTest.java | 61 +++ .../service/DefaultPrincipalResolverTest.java | 54 ++ .../service/MembershipTeamLeadLookupTest.java | 55 ++ .../access/service/OwnershipServiceTest.java | 22 + .../service/ResourceAccessServiceTest.java | 117 ++++- .../access/service/SecretMaskerTest.java | 17 + .../ProprietaryUIDataControllerMoreTest.java | 8 +- .../api/ProprietaryUIDataControllerTest.java | 8 +- .../service/IntegrationConfigServiceTest.java | 61 ++- .../model/TeamMembershipTest.java | 3 +- .../security/InitialSecuritySetupTest.java | 5 +- .../api/AuthControllerLoginTest.java | 5 +- .../api/AuthControllerMoreTest.java | 5 +- .../api/UserControllerMoreTest.java | 5 +- .../controller/api/UserControllerTest.java | 5 +- .../service/TeamMembershipServiceTest.java | 159 ++++++ .../security/service/UserServiceMoreTest.java | 6 + .../security/service/UserServiceTest.java | 8 + .../accountlink/AccountLinkController.java | 4 +- .../saas/controller/SaasTeamController.java | 4 +- .../saas/payg/api/PaygInvoicesController.java | 4 +- .../payg/api/PaygPaymentMethodController.java | 4 +- .../saas/payg/api/PaygWalletController.java | 4 +- .../api/ProcurementController.java | 4 +- .../service/ProcurementService.java | 4 +- .../SaasPortalAuditScopeResolver.java | 2 +- .../saas/security/SaasPrincipalResolver.java | 30 ++ .../security/TeamSecurityExpressions.java | 2 +- .../saas/service/SaasTeamService.java | 18 +- .../AccountLinkControllerTest.java | 4 +- .../controller/SaasTeamControllerTest.java | 4 +- .../payg/api/PaygInvoicesControllerTest.java | 4 +- .../api/PaygPaymentMethodControllerTest.java | 4 +- .../payg/api/PaygWalletControllerTest.java | 4 +- .../security/SaasPrincipalResolverTest.java | 39 ++ .../TeamSecurityExpressionsMoreTest.java | 4 +- .../security/TeamSecurityExpressionsTest.java | 4 +- .../saas/service/SaasTeamServiceTest.java | 12 +- frontend/.storybook/preview.tsx | 15 + .../public/locales/en-US/translation.toml | 219 +++++--- frontend/editor/src/core/ui/Avatar.css | 11 +- frontend/editor/src/core/ui/Avatar.tsx | 4 +- frontend/editor/src/core/ui/Chip.css | 9 + frontend/editor/src/core/ui/Chip.stories.tsx | 8 + frontend/editor/src/core/ui/Chip.tsx | 4 + frontend/editor/src/portal/api/access.ts | 61 +++ frontend/editor/src/portal/api/http.ts | 19 + frontend/editor/src/portal/api/teams.ts | 76 +++ frontend/editor/src/portal/api/users.ts | 369 +++++++++++++- .../src/portal/api/usersCapabilities.ts | 53 ++ .../users/AccessControls.stories.tsx | 26 - .../components/users/AccessControls.tsx | 199 -------- .../components/users/ConfirmModal.stories.tsx | 32 ++ .../portal/components/users/ConfirmModal.tsx | 54 ++ .../users/InviteMemberModal.stories.tsx | 60 ++- .../users/InviteMemberModal.test.tsx | 72 +++ .../components/users/InviteMemberModal.tsx | 441 ++++++++++++++-- .../components/users/MembersTable.stories.tsx | 29 -- .../portal/components/users/MembersTable.tsx | 151 ------ .../users/MoveToTeamModal.stories.tsx | 38 ++ .../components/users/MoveToTeamModal.tsx | 94 ++++ .../components/users/NewTeamModal.stories.tsx | 18 + .../portal/components/users/NewTeamModal.tsx | 158 ++++++ .../users/RenameTeamModal.stories.tsx | 19 + .../components/users/RenameTeamModal.tsx | 83 +++ .../users/ResetPasswordModal.stories.tsx | 34 ++ .../components/users/ResetPasswordModal.tsx | 211 ++++++++ .../components/users/RolesGrid.stories.tsx | 15 - .../src/portal/components/users/RolesGrid.tsx | 43 -- .../users/UsersDirectory.stories.tsx | 324 ++++++++++++ .../components/users/UsersDirectory.tsx | 478 ++++++++++++++++++ .../users/UsersSummaryStrip.stories.tsx | 30 -- .../components/users/UsersSummaryStrip.tsx | 39 -- .../src/portal/components/users/directory.ts | 50 ++ .../src/portal/components/users/format.ts | 17 +- .../editor/src/portal/mocks/handlers/users.ts | 29 +- frontend/editor/src/portal/mocks/users.ts | 197 +++++--- frontend/editor/src/portal/views/Users.css | 263 ++++++++++ frontend/editor/src/portal/views/Users.tsx | 370 ++++++++++++-- .../proprietary/auth/supabase/UseSession.tsx | 51 ++ frontend/editor/src/proprietary/auth/types.ts | 4 + .../proprietary/portal/usersCapabilities.ts | 24 + .../src/saas/portal/usersCapabilities.ts | 25 + frontend/eslint.config.mjs | 11 + 111 files changed, 5081 insertions(+), 892 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalRef.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultPrincipalResolver.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookup.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/access/service/PrincipalResolver.java rename app/{saas/src/main/java/stirling/software/saas => proprietary/src/main/java/stirling/software/proprietary}/model/TeamMembership.java (96%) rename app/{saas/src/main/java/stirling/software/saas => proprietary/src/main/java/stirling/software/proprietary/security}/repository/TeamMembershipRepository.java (77%) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/access/security/ResourceAccessSecurityTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/access/service/DefaultPrincipalResolverTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookupTest.java rename app/{saas/src/test/java/stirling/software/saas => proprietary/src/test/java/stirling/software/proprietary}/model/TeamMembershipTest.java (98%) create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java create mode 100644 app/saas/src/main/java/stirling/software/saas/security/SaasPrincipalResolver.java create mode 100644 app/saas/src/test/java/stirling/software/saas/security/SaasPrincipalResolverTest.java create mode 100644 frontend/editor/src/portal/api/access.ts create mode 100644 frontend/editor/src/portal/api/teams.ts create mode 100644 frontend/editor/src/portal/api/usersCapabilities.ts delete mode 100644 frontend/editor/src/portal/components/users/AccessControls.stories.tsx delete mode 100644 frontend/editor/src/portal/components/users/AccessControls.tsx create mode 100644 frontend/editor/src/portal/components/users/ConfirmModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/ConfirmModal.tsx create mode 100644 frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx delete mode 100644 frontend/editor/src/portal/components/users/MembersTable.stories.tsx delete mode 100644 frontend/editor/src/portal/components/users/MembersTable.tsx create mode 100644 frontend/editor/src/portal/components/users/MoveToTeamModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/MoveToTeamModal.tsx create mode 100644 frontend/editor/src/portal/components/users/NewTeamModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/NewTeamModal.tsx create mode 100644 frontend/editor/src/portal/components/users/RenameTeamModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/RenameTeamModal.tsx create mode 100644 frontend/editor/src/portal/components/users/ResetPasswordModal.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/ResetPasswordModal.tsx delete mode 100644 frontend/editor/src/portal/components/users/RolesGrid.stories.tsx delete mode 100644 frontend/editor/src/portal/components/users/RolesGrid.tsx create mode 100644 frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx create mode 100644 frontend/editor/src/portal/components/users/UsersDirectory.tsx delete mode 100644 frontend/editor/src/portal/components/users/UsersSummaryStrip.stories.tsx delete mode 100644 frontend/editor/src/portal/components/users/UsersSummaryStrip.tsx create mode 100644 frontend/editor/src/portal/components/users/directory.ts create mode 100644 frontend/editor/src/proprietary/portal/usersCapabilities.ts create mode 100644 frontend/editor/src/saas/portal/usersCapabilities.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java index b2d6d3d12d..d763c146ee 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/config/AccessConfig.java @@ -4,7 +4,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import stirling.software.proprietary.access.service.DefaultPrincipalResolver; import stirling.software.proprietary.access.service.DefaultTeamLeadLookup; +import stirling.software.proprietary.access.service.PrincipalResolver; import stirling.software.proprietary.access.service.TeamLeadLookup; /** Access-layer bean wiring. */ @@ -17,4 +19,11 @@ public class AccessConfig { TeamLeadLookup defaultTeamLeadLookup() { return new DefaultTeamLeadLookup(); } + + /** USER/TEAM projection unless another bean is defined (e.g. the saas resolver). */ + @Bean + @ConditionalOnMissingBean(PrincipalResolver.class) + PrincipalResolver defaultPrincipalResolver() { + return new DefaultPrincipalResolver(); + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java index 5b69c6878e..cc66020670 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/controller/ResourceGrantController.java @@ -25,7 +25,9 @@ import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; import stirling.software.proprietary.access.model.ResourceType; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamRepository; /** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */ @RestController @@ -36,6 +38,8 @@ import stirling.software.proprietary.security.model.User; public class ResourceGrantController { private final ResourceAccessService accessService; + private final UserRepository userRepository; + private final TeamRepository teamRepository; @GetMapping("/grants") public ResponseEntity list( @@ -45,6 +49,14 @@ public class ResourceGrantController { return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); } + @GetMapping("/grants/by-principal") + public ResponseEntity listByPrincipal( + @RequestParam PrincipalType principalType, @RequestParam Long principalId) { + List grants = + accessService.listGrantsForPrincipal(principalType, principalId); + return ResponseEntity.ok(grants.stream().map(this::toDto).toList()); + } + @PostMapping("/grants") public ResponseEntity create( @RequestBody GrantRequest request, @AuthenticationPrincipal User admin) { @@ -57,17 +69,26 @@ public class ResourceGrantController { "error", "resourceType, principalType and principalId are required")); } + // PORTAL is a singleton (empty resourceId); every other type must name a resource. + boolean portal = request.resourceType() == ResourceType.PORTAL; + if (!portal && (request.resourceId() == null || request.resourceId().isBlank())) { + return ResponseEntity.badRequest() + .body(Map.of("error", "resourceId is required for " + request.resourceType())); + } + Long principalId = request.principalId(); + String principalError = validatePrincipalExists(request.principalType(), principalId); + if (principalError != null) { + return ResponseEntity.badRequest().body(Map.of("error", principalError)); + } AccessPermission permission = request.permission() == null ? AccessPermission.USE : request.permission(); - // PORTAL is a singleton resource; its grants always target the whole type. - String resourceId = - request.resourceType() == ResourceType.PORTAL ? "" : request.resourceId(); + String resourceId = portal ? "" : request.resourceId(); ResourceGrant grant = accessService.grant( request.resourceType(), resourceId, request.principalType(), - request.principalId(), + principalId, permission, admin); return ResponseEntity.ok(toDto(grant)); @@ -79,6 +100,14 @@ public class ResourceGrantController { return ResponseEntity.ok(Map.of("message", "Grant revoked")); } + // Rejects grants to nonexistent principals (dead rows otherwise). + private String validatePrincipalExists(PrincipalType type, Long id) { + return switch (type) { + case USER -> userRepository.existsById(id) ? null : "User " + id + " does not exist"; + case TEAM -> teamRepository.existsById(id) ? null : "Team " + id + " does not exist"; + }; + } + private Map toDto(ResourceGrant g) { Map m = new HashMap<>(); m.put("id", g.getId()); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java index 1bca14a420..46d6e17602 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/OwnedResource.java @@ -54,4 +54,15 @@ public abstract class OwnedResource { public Long getOwnerTeamId() { return ownerTeam != null ? ownerTeam.getId() : null; } + + /** Owner as a principal ref; null when server-owned (admin-only ownership). */ + public PrincipalRef getOwnerRef() { + if (getOwnerUserId() != null) { + return PrincipalRef.user(getOwnerUserId()); + } + if (getOwnerTeamId() != null) { + return PrincipalRef.team(getOwnerTeamId()); + } + return null; + } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalRef.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalRef.java new file mode 100644 index 0000000000..e66da7a435 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalRef.java @@ -0,0 +1,20 @@ +package stirling.software.proprietary.access.model; + +import java.util.Locale; + +/** A (type, id) principal pair; the atom grants and ownership are expressed in. */ +public record PrincipalRef(PrincipalType type, Long id) { + + public static PrincipalRef user(Long id) { + return new PrincipalRef(PrincipalType.USER, id); + } + + public static PrincipalRef team(Long id) { + return new PrincipalRef(PrincipalType.TEAM, id); + } + + /** Canonical engine wire form, e.g. "user:12". */ + public String token() { + return type.name().toLowerCase(Locale.ROOT) + ":" + id; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java index 9019b32845..b29b1df4c4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/model/PrincipalType.java @@ -1,6 +1,6 @@ package stirling.software.proprietary.access.model; -/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */ +/** Who a {@link ResourceGrant} is granted to. */ public enum PrincipalType { USER, TEAM diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java index 76209c1abe..654a8e0854 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/repository/ResourceGrantRepository.java @@ -3,11 +3,15 @@ package stirling.software.proprietary.access.repository; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.security.model.User; @Repository public interface ResourceGrantRepository extends JpaRepository { @@ -18,8 +22,20 @@ public interface ResourceGrantRepository extends JpaRepository findByResourceTypeAndPrincipalTypeAndPrincipalId( ResourceType resourceType, PrincipalType principalType, Long principalId); + /** All grants held by a principal, across resource types (for the manage-access view). */ + List findByPrincipalTypeAndPrincipalId( + PrincipalType principalType, Long principalId); + void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId); + /** Removes every grant held by a principal; used when the user/team behind it is deleted. */ + void deleteByPrincipalTypeAndPrincipalId(PrincipalType principalType, Long principalId); + + // Detach issued grants so deleting the granting user does not hit the FK. + @Modifying + @Query("update ResourceGrant g set g.grantedBy = null where g.grantedBy = :user") + void clearGrantedBy(@Param("user") User user); + boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId( ResourceType resourceType, String resourceId, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java index 5189b703a1..0f35965ad4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/security/ResourceAccessSecurity.java @@ -11,7 +11,12 @@ import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.UserService; -/** {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. */ +/** + * {@code @PreAuthorize} bean for portal-access checks. Active in self-hosted and saas. Convention: + * every portal-exclusive endpoint is gated with + * {@code @PreAuthorize("@resourceAccess.canUsePortal()")}; endpoints shared with the editor (e.g. + * the policies API) must NOT be. + */ @Component("resourceAccess") @RequiredArgsConstructor public class ResourceAccessSecurity { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultPrincipalResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultPrincipalResolver.java new file mode 100644 index 0000000000..61a70a7336 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/DefaultPrincipalResolver.java @@ -0,0 +1,32 @@ +package stirling.software.proprietary.access.service; + +import java.util.HashSet; +import java.util.Set; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.security.model.User; + +/** + * Self-hosted projection: the user and their team. One deployment = one org, so ORG_ALL is open. + */ +public class DefaultPrincipalResolver implements PrincipalResolver { + + @Override + public Set principalsOf(User user) { + if (user == null) { + return Set.of(); + } + Set principals = new HashSet<>(); + principals.add(PrincipalRef.user(user.getId())); + if (user.getTeam() != null) { + principals.add(PrincipalRef.team(user.getTeam().getId())); + } + return principals; + } + + // Self-hosted is a single deployment-wide org, so ORG_ALL admits every authenticated user. + @Override + public boolean allowsDeploymentWideAccess() { + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookup.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookup.java new file mode 100644 index 0000000000..9a5bece4ca --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookup.java @@ -0,0 +1,33 @@ +package stirling.software.proprietary.access.service; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +/** Real lookup backed by team_memberships LEADER rows; wins over the no-op default bean. */ +@Component +@RequiredArgsConstructor +public class MembershipTeamLeadLookup implements TeamLeadLookup { + + private final TeamMembershipRepository memberships; + + @Override + public boolean isAnyTeamLeader(User user) { + return user != null + && user.getId() != null + && memberships.existsByUserIdAndRole(user.getId(), TeamRole.LEADER); + } + + @Override + public boolean isLeaderOfTeam(User user, Long teamId) { + return user != null + && user.getId() != null + && teamId != null + && memberships.existsByTeamIdAndUserIdAndRole( + teamId, user.getId(), TeamRole.LEADER); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java index 8db87f2c0e..5b62d8e890 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/OwnershipService.java @@ -36,15 +36,19 @@ public class OwnershipService { return accessService.canUseResource( type, String.valueOf(resource.getId()), - resource.getOwnerUserId(), + resource.getOwnerRef(), resource.getDefaultAccess(), user); } /** Whether the user may manage the resource. */ public boolean canManage(ResourceType type, OwnedResource resource, User user) { + // Disabled resources bypass grants for MANAGE too: admin/owner only. + if (!resource.isEnabled()) { + return isAdmin(user) || isOwner(resource, user); + } return accessService.canManageResource( - type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user); + type, String.valueOf(resource.getId()), resource.getOwnerRef(), user); } /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/PrincipalResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/PrincipalResolver.java new file mode 100644 index 0000000000..94fdd990da --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/PrincipalResolver.java @@ -0,0 +1,28 @@ +package stirling.software.proprietary.access.service; + +import java.util.Set; +import java.util.stream.Collectors; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.security.model.User; + +/** Projects a user onto the set of principals they act as. */ +public interface PrincipalResolver { + + /** Every principal the user acts as; empty for a null user. */ + Set principalsOf(User user); + + /** + * Whether this deployment treats every authenticated user as one org, so the {@code ORG_ALL} + * default policy admits anyone. Self-hosted: true. Multi-tenant saas: false, so an {@code + * ORG_ALL} resource can't leak across tenants. Defaults to false (deny) for safety. + */ + default boolean allowsDeploymentWideAccess() { + return false; + } + + /** Canonical wire tokens for the engine, e.g. "user:12". */ + default Set principalTokens(User user) { + return principalsOf(user).stream().map(PrincipalRef::token).collect(Collectors.toSet()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java index ada4c95290..72159829a4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/ResourceAccessService.java @@ -14,6 +14,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.access.model.AccessPermission; import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalRef; import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; import stirling.software.proprietary.access.model.ResourceType; @@ -29,6 +30,7 @@ public class ResourceAccessService { private final ResourceGrantRepository grantRepository; private final TeamLeadLookup teamLeadLookup; + private final PrincipalResolver principalResolver; @Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}") private DefaultAccessPolicy portalDefaultPolicy; @@ -44,28 +46,28 @@ public class ResourceAccessService { public boolean canUseResource( ResourceType type, String resourceId, - Long ownerUserId, + PrincipalRef owner, DefaultAccessPolicy defaultPolicy, User user) { if (user == null) { return false; } - if (isOwner(ownerUserId, user) || isAdmin(user)) { + if (isOwner(owner, user) || isAdmin(user)) { return true; } if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) { return true; } - return matchesDefault(defaultPolicy, user); + return matchesDefault(defaultPolicy, owner, user); } /** Whether the user may manage (edit/delete/share) a resource. No default-policy fallback. */ public boolean canManageResource( - ResourceType type, String resourceId, Long ownerUserId, User user) { + ResourceType type, String resourceId, PrincipalRef owner, User user) { if (user == null) { return false; } - if (isOwner(ownerUserId, user) || isAdmin(user)) { + if (isOwner(owner, user) || isAdmin(user)) { return true; } return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE); @@ -110,21 +112,22 @@ public class ResourceAccessService { return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId)); } - /** Resource ids of the given type that this user (or their team) holds any grant on. */ + /** Every grant a principal holds, for the per-user/per-team manage-access view. */ + public List listGrantsForPrincipal( + PrincipalType principalType, Long principalId) { + return grantRepository.findByPrincipalTypeAndPrincipalId(principalType, principalId); + } + + /** Resource ids of the given type that any of the user's principals holds a grant on. */ public Set grantedResourceIds(ResourceType type, User user) { if (user == null) { return Set.of(); } Set ids = new HashSet<>(); - for (ResourceGrant g : - grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( - type, PrincipalType.USER, user.getId())) { - ids.add(g.getResourceId()); - } - if (user.getTeam() != null) { + for (PrincipalRef principal : principalResolver.principalsOf(user)) { for (ResourceGrant g : grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( - type, PrincipalType.TEAM, user.getTeam().getId())) { + type, principal.type(), principal.id())) { ids.add(g.getResourceId()); } } @@ -135,18 +138,12 @@ public class ResourceAccessService { private boolean hasGrant( ResourceType type, String resourceId, User user, AccessPermission required) { - Long teamId = user.getTeam() != null ? user.getTeam().getId() : null; + Set principals = principalResolver.principalsOf(user); for (ResourceGrant g : grantRepository.findByResourceTypeAndResourceId(type, resourceId)) { if (!permissionSatisfies(g.getPermission(), required)) { continue; } - if (g.getPrincipalType() == PrincipalType.USER - && g.getPrincipalId().equals(user.getId())) { - return true; - } - if (g.getPrincipalType() == PrincipalType.TEAM - && teamId != null - && g.getPrincipalId().equals(teamId)) { + if (principals.contains(new PrincipalRef(g.getPrincipalType(), g.getPrincipalId()))) { return true; } } @@ -161,20 +158,40 @@ public class ResourceAccessService { return held == AccessPermission.MANAGE; } - private boolean matchesDefault(DefaultAccessPolicy policy, User user) { + private boolean matchesDefault(DefaultAccessPolicy policy, PrincipalRef owner, User user) { if (policy == null) { return false; } return switch (policy) { - case ORG_ALL -> true; - // Admins already pass above; only team leads here. - case ADMINS_AND_TEAM_LEADS -> teamLeadLookup.isAnyTeamLeader(user); + // Deployment-wide only where the resolver treats everyone as one org; saas resolvers + // return false, so ORG_ALL cannot leak a tenant's resource to another tenant's users. + case ORG_ALL -> principalResolver.allowsDeploymentWideAccess(); + // Admins already pass above; only team leads here, scoped to the owning team. + case ADMINS_AND_TEAM_LEADS -> matchesTeamLeadDefault(owner, user); case EXPLICIT_ONLY -> false; }; } - private boolean isOwner(Long ownerUserId, User user) { - return ownerUserId != null && ownerUserId.equals(user.getId()); + // Portal (no owner) admits any team lead; a team-owned resource admits only that team's + // leads; a user-owned resource admits no extra leads. + private boolean matchesTeamLeadDefault(PrincipalRef owner, User user) { + if (owner == null) { + return teamLeadLookup.isAnyTeamLeader(user); + } + return owner.type() == PrincipalType.TEAM + && owner.id() != null + && teamLeadLookup.isLeaderOfTeam(user, owner.id()); + } + + // Team owners are the owning team's leaders; plain members are not. + private boolean isOwner(PrincipalRef owner, User user) { + if (owner == null || owner.id() == null) { + return false; + } + return switch (owner.type()) { + case USER -> owner.id().equals(user.getId()); + case TEAM -> teamLeadLookup.isLeaderOfTeam(user, owner.id()); + }; } private boolean isAdmin(User user) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java index 917c180116..f160b92759 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/access/service/SecretMasker.java @@ -18,15 +18,26 @@ public class SecretMasker { // Cap recursion so a pathologically nested payload cannot overflow the stack. private static final int MAX_DEPTH = 32; + // Key-name substrings that mark a value sensitive. Over-masking a non-secret is + // safe; leaking a secret is not, so this errs broad - but a per-type schema + // whitelist would be a stronger boundary for free-form config (follow-up). private static final Set SENSITIVE_HINTS = Set.of( "secret", "password", + "passphrase", + "pwd", "token", "apikey", "accesskey", "credential", - "privatekey"); + "privatekey", + "authorization", + "cookie", + "session", + "connectionstring", + "bearer", + "signature"); /** Replace sensitive values with the mask (recursively) for safe display. */ public Map mask(Map config) { @@ -73,18 +84,24 @@ public class SecretMasker { private Map merge( Map stored, Map incoming, int depth) { - Map out = new LinkedHashMap<>(stored); + // Replace semantics (PUT): the result is the incoming document, except a redacted secret + // keeps its stored value. Keys absent from incoming are dropped, so edits can remove them. + Map out = new LinkedHashMap<>(); for (Map.Entry e : incoming.entrySet()) { String key = e.getKey(); Object value = e.getValue(); if (isSensitive(key)) { - if (!isRedacted(value, depth)) { + if (isRedacted(value, depth)) { + if (stored.containsKey(key)) { + out.put(key, stored.get(key)); // keep the stored secret + } + } else { out.put(key, value); // a real new secret replaces the stored one } - continue; // redacted (blank / mask) -> keep stored + continue; } if (depth < MAX_DEPTH - && out.get(key) instanceof Map s + && stored.get(key) instanceof Map s && value instanceof Map i) { out.put(key, merge(castMap(s), castMap(i), depth + 1)); } else { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 161c1d7055..8db5f48174 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -5,6 +5,7 @@ import static stirling.software.common.util.ProviderUtils.validateProvider; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; +import java.util.stream.Collectors; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.ResponseEntity; @@ -28,13 +29,16 @@ import stirling.software.common.model.ApplicationProperties.Security.OAUTH2.Clie import stirling.software.common.model.ApplicationProperties.Security.SAML2; import stirling.software.common.model.FileInfo; import stirling.software.common.model.enumeration.Role; +import stirling.software.common.model.enumeration.TeamRole; import stirling.software.common.model.oauth2.GitHubProvider; import stirling.software.common.model.oauth2.GoogleProvider; import stirling.software.common.model.oauth2.KeycloakProvider; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.model.dto.TeamWithUserCountDTO; import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.security.config.EnterpriseEndpoint; @@ -44,6 +48,7 @@ import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.SessionEntity; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.model.dto.AdminUserSummary; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.service.DatabaseServiceInterface; @@ -65,6 +70,7 @@ public class ProprietaryUIDataController { private final SessionPersistentRegistry sessionPersistentRegistry; private final UserRepository userRepository; private final TeamRepository teamRepository; + private final TeamMembershipRepository teamMembershipRepository; private final SessionRepository sessionRepository; private final DatabaseServiceInterface databaseService; private final boolean runningEE; @@ -73,6 +79,7 @@ public class ProprietaryUIDataController { private final PersistentAuditEventRepository auditRepository; private final MfaService mfaService; private final LoginAttemptService loginAttemptService; + private final ResourceAccessService resourceAccessService; public ProprietaryUIDataController( ApplicationProperties applicationProperties, @@ -80,6 +87,7 @@ public class ProprietaryUIDataController { SessionPersistentRegistry sessionPersistentRegistry, UserRepository userRepository, TeamRepository teamRepository, + TeamMembershipRepository teamMembershipRepository, SessionRepository sessionRepository, DatabaseServiceInterface databaseService, ObjectMapper objectMapper, @@ -87,12 +95,14 @@ public class ProprietaryUIDataController { UserLicenseSettingsService licenseSettingsService, PersistentAuditEventRepository auditRepository, MfaService mfaService, - LoginAttemptService loginAttemptService) { + LoginAttemptService loginAttemptService, + ResourceAccessService resourceAccessService) { this.applicationProperties = applicationProperties; this.auditConfig = auditConfig; this.sessionPersistentRegistry = sessionPersistentRegistry; this.userRepository = userRepository; this.teamRepository = teamRepository; + this.teamMembershipRepository = teamMembershipRepository; this.sessionRepository = sessionRepository; this.databaseService = databaseService; this.objectMapper = objectMapper; @@ -101,6 +111,7 @@ public class ProprietaryUIDataController { this.auditRepository = auditRepository; this.mfaService = mfaService; this.loginAttemptService = loginAttemptService; + this.resourceAccessService = resourceAccessService; } /** @@ -370,8 +381,11 @@ public class ProprietaryUIDataController { boolean premiumEnabled = applicationProperties.getPremium().isEnabled(); // Convert User entities to AdminUserSummary DTOs to exclude sensitive fields + Set leaderUserIds = leaderUserIds(); List userSummaries = - sortedUsers.stream().map(this::convertUserToSummary).toList(); + sortedUsers.stream() + .map(user -> convertUserToSummary(user, leaderUserIds)) + .toList(); AdminSettingsData data = new AdminSettingsData(); data.setUsers(userSummaries); @@ -390,6 +404,10 @@ public class ProprietaryUIDataController { data.setLicenseMaxUsers(licenseMaxUsers); data.setPremiumEnabled(premiumEnabled); data.setMailEnabled(applicationProperties.getMail().isEnabled()); + // Email invites need the invites toggle AND SMTP on; matches the inviteUsers precondition. + data.setEmailInvitesEnabled( + applicationProperties.getMail().isEnableInvites() + && applicationProperties.getMail().isEnabled()); data.setUserSettings(userSettings); data.setLockedUsers(loginAttemptService.getAllBlockedUsers()); @@ -468,9 +486,18 @@ public class ProprietaryUIDataController { teamLastRequest.put(teamId, lastActivity); } + Map> teamOwners = new HashMap<>(); + for (TeamMembership row : + teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER)) { + teamOwners + .computeIfAbsent(row.getTeam().getId(), id -> new ArrayList<>()) + .add(row.getUser().getUsername()); + } + TeamsData data = new TeamsData(); data.setTeamsWithCounts(teamsWithCounts); data.setTeamLastRequest(teamLastRequest); + data.setTeamOwners(teamOwners); return ResponseEntity.ok(data); } @@ -510,11 +537,17 @@ public class ProprietaryUIDataController { userLastRequest.put(username, lastRequest); } + Set ownerUserIds = + teamMembershipRepository.findByTeamIdAndRole(id, TeamRole.LEADER).stream() + .map(row -> row.getUser().getId()) + .collect(Collectors.toSet()); + TeamDetailsData data = new TeamDetailsData(); data.setTeam(team); data.setTeamUsers(teamUsers); data.setAvailableUsers(availableUsers); data.setUserLastRequest(userLastRequest); + data.setOwnerUserIds(ownerUserIds); return ResponseEntity.ok(data); } @@ -535,13 +568,24 @@ public class ProprietaryUIDataController { return ResponseEntity.ok(data); } + /** User ids holding a LEADER membership on any team. */ + private Set leaderUserIds() { + return teamMembershipRepository.findByRoleFetchingUserAndTeam(TeamRole.LEADER).stream() + .map(row -> row.getUser().getId()) + .collect(Collectors.toSet()); + } + /** * Convert User entity to AdminUserSummary DTO, excluding sensitive fields like password and * apiKey. */ - private AdminUserSummary convertUserToSummary(User user) { + private AdminUserSummary convertUserToSummary(User user, Set leaderUserIds) { AdminUserSummary summary = new AdminUserSummary(); summary.setId(user.getId()); + summary.setTeamLead(leaderUserIds.contains(user.getId())); + // Authoritative portal access, same call /me uses, so the roster honors the configured + // policy instead of the frontend guessing from role/team-leadership. + summary.setPortalAccess(resourceAccessService.canAccessPortal(user)); summary.setUsername(user.getUsername()); summary.setEmail(user.getUsername()); // Use username as email for consistency summary.setRoleName(user.getRoleName()); @@ -609,6 +653,7 @@ public class ProprietaryUIDataController { private int licenseMaxUsers; private boolean premiumEnabled; private boolean mailEnabled; + private boolean emailInvitesEnabled; private Map> userSettings; private List lockedUsers; } @@ -629,6 +674,7 @@ public class ProprietaryUIDataController { public static class TeamsData { private List teamsWithCounts; private Map teamLastRequest; + private Map> teamOwners; } @Data @@ -637,6 +683,7 @@ public class ProprietaryUIDataController { private List teamUsers; private List availableUsers; private Map userLastRequest; + private Set ownerUserIds; } @Data diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java index 220c4e8d65..4e79fc5e7a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/controller/IntegrationConfigController.java @@ -29,7 +29,9 @@ import stirling.software.proprietary.security.model.User; @RestController @RequestMapping("/api/v1/integrations") @RequiredArgsConstructor -@PreAuthorize("isAuthenticated()") +// Portal-exclusive: server-side portal-access boundary, not just isAuthenticated. Per-config +// ownership is still enforced in the service layer. +@PreAuthorize("@resourceAccess.canUsePortal()") @Tag(name = "Integrations", description = "Manage S3/MCP/API integration configurations") public class IntegrationConfigController { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java index 1fff2dd321..96e20f55cb 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/crypto/CredentialEncryption.java @@ -1,12 +1,16 @@ package stirling.software.proprietary.integration.crypto; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; import java.security.GeneralSecurityException; import java.security.SecureRandom; import java.util.Arrays; import java.util.Base64; +import java.util.EnumSet; import javax.crypto.Cipher; import javax.crypto.KeyGenerator; @@ -74,7 +78,7 @@ public class CredentialEncryption { generator.init(256); SecretKey generated = generator.generateKey(); Files.createDirectories(path.getParent()); - Files.writeString(path, Base64.getEncoder().encodeToString(generated.getEncoded())); + writeOwnerOnly(path, Base64.getEncoder().encodeToString(generated.getEncoded())); log.warn( "Generated a new credential encryption key at {}. Back this file up: losing it" + " makes stored integration secrets unrecoverable.", @@ -85,6 +89,25 @@ public class CredentialEncryption { } } + // The master key decrypts every stored integration secret, so create it 0600 + // (owner-only) atomically. On non-POSIX filesystems (Windows) the config-dir + // ACL is the protection; we still create the file, just without POSIX perms. + private static void writeOwnerOnly(Path path, String content) throws IOException { + EnumSet ownerOnly = + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE); + try { + Files.createFile(path, PosixFilePermissions.asFileAttribute(ownerOnly)); + } catch (UnsupportedOperationException e) { + Files.createFile(path); + } + Files.writeString(path, content); + try { + Files.setPosixFilePermissions(path, ownerOnly); + } catch (UnsupportedOperationException ignored) { + // Non-POSIX filesystem: nothing to tighten here. + } + } + public static String encrypt(String plaintext) { if (plaintext == null) { return null; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java index 6efb45d745..30556060d4 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/repository/IntegrationConfigRepository.java @@ -18,4 +18,13 @@ public interface IntegrationConfigRepository extends JpaRepository findByOwnerTeam(Team ownerTeam); List findByScope(OwnerScope scope); + + // Nested path: OwnedResource has a getOwnerTeamId() convenience getter but no such persistent + // attribute, so the plain "...OwnerTeamId" derivation resolves to a phantom property and throws + // UnknownPathException. The underscore forces the real ownerTeam.id association path. + boolean existsByOwnerTeam_Id(Long teamId); + + void deleteByOwnerUser(User ownerUser); + + void deleteByOwnerTeam_Id(Long teamId); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java index b384cc48e6..9e44b35523 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -16,6 +16,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.access.model.DefaultAccessPolicy; import stirling.software.proprietary.access.model.OwnerScope; import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; import stirling.software.proprietary.access.service.OwnershipService; import stirling.software.proprietary.access.service.SecretMasker; import stirling.software.proprietary.integration.dto.IntegrationConfigRequest; @@ -41,6 +42,7 @@ public class IntegrationConfigService { private final IntegrationConfigRepository repository; private final OwnershipService ownership; private final SecretMasker secretMasker; + private final ResourceGrantRepository grantRepository; // ---- commands ---- @@ -49,6 +51,13 @@ public class IntegrationConfigService { OwnerScope scope = request.scope() == null ? OwnerScope.USER : request.scope(); IntegrationConfig cfg = new IntegrationConfig(); cfg.setIntegrationType(require(request.integrationType(), "integrationType")); + // S3 is infrastructure, not self-serve: no personal S3 for regular users. TEAM/SERVER + // scopes are already restricted to admins/team owners by assignOwnership. + if (cfg.getIntegrationType() == IntegrationType.S3 + && scope == OwnerScope.USER + && !ownership.isAdmin(currentUser)) { + throw forbidden("S3 connections can only be created by administrators or team owners"); + } cfg.setName(require(request.name(), "name")); cfg.setEnabled(request.enabled() == null || request.enabled()); cfg.setLocked(request.locked() != null && request.locked()); @@ -104,6 +113,8 @@ public class IntegrationConfigService { if (!ownership.canManage(TYPE, cfg, currentUser)) { throw forbidden("You cannot manage this integration"); } + // Drop grants sharing this config so they do not dangle as dead rows. + grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId())); repository.delete(cfg); } diff --git a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java similarity index 96% rename from app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java rename to app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java index 4221643432..de121939bb 100644 --- a/app/saas/src/main/java/stirling/software/saas/model/TeamMembership.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/TeamMembership.java @@ -1,4 +1,4 @@ -package stirling.software.saas.model; +package stirling.software.proprietary.model; import java.io.Serializable; import java.time.LocalDateTime; @@ -15,7 +15,6 @@ import lombok.Setter; import lombok.ToString; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; /** diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index 94dc41a231..5d68bc99e0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -22,6 +22,7 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.DatabaseServiceInterface; import stirling.software.proprietary.security.service.SaveUserRequest; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.service.UserLicenseSettingsService; @@ -40,6 +41,7 @@ public class InitialSecuritySetup { private final DatabaseServiceInterface databaseService; private final UserLicenseSettingsService licenseSettingsService; private final Environment environment; + private final TeamMembershipService teamMembershipService; /** * SaaS manages identity in Supabase and billing via PAYG, so the self-host bootstrap steps that @@ -114,6 +116,7 @@ public class InitialSecuritySetup { } userService.saveAll(usersWithoutTeam); // batch save + usersWithoutTeam.forEach(teamMembershipService::syncMembership); if (usersWithoutTeam != null && !usersWithoutTeam.isEmpty()) { log.info( "Assigned {} user(s) without a team to the default team.", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java index dcea7807b1..86a1c5fe0c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AuthController.java @@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.constants.JwtConstants; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.access.service.TeamLeadLookup; import stirling.software.proprietary.audit.AuditEventType; import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.audit.Audited; @@ -66,6 +67,7 @@ public class AuthController { private final ApplicationProperties applicationProperties; private final AiUserDataService aiUserDataService; private final ResourceAccessService resourceAccessService; + private final TeamLeadLookup teamLeadLookup; /** * Login endpoint - replaces Supabase signInWithPassword @@ -265,8 +267,11 @@ public class AuthController { .body(Map.of("error", "Not authenticated")); } - UserDetails userDetails = (UserDetails) auth.getPrincipal(); - User user = (User) userDetails; + // Anonymous SaaS sessions carry a raw Jwt principal; treat them as unauthenticated + if (!(auth.getPrincipal() instanceof User user)) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(Map.of("error", "Not authenticated")); + } return ResponseEntity.ok(Map.of("user", buildUserResponse(user))); @@ -631,6 +636,12 @@ public class AuthController { userMap.put("role", user.getRolesAsString()); userMap.put("enabled", user.isEnabled()); userMap.put("portalAccess", resourceAccessService.canAccessPortal(user)); + userMap.put("teamLead", teamLeadLookup.isAnyTeamLeader(user)); + // Expose the caller's team so non-admin team owners can scope their own team's resources. + if (user.getTeam() != null) { + userMap.put( + "team", Map.of("id", user.getTeam().getId(), "name", user.getTeam().getName())); + } userMap.put( "authenticationType", user.getAuthenticationType()); // Expose authentication type for SSO detection diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java index d9e43fa848..32a429ea72 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/TeamController.java @@ -14,11 +14,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.TeamApi; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.config.PremiumEndpoint; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; @TeamApi @@ -29,6 +33,9 @@ public class TeamController { private final TeamRepository teamRepository; private final UserRepository userRepository; + private final ResourceGrantRepository resourceGrantRepository; + private final IntegrationConfigRepository integrationConfigRepository; + private final TeamMembershipService teamMembershipService; @PreAuthorize("hasRole('ADMIN')") @PostMapping("/create") @@ -96,10 +103,72 @@ public class TeamController { "Team must be empty before deletion. Please remove all members first.")); } + if (integrationConfigRepository.existsByOwnerTeam_Id(teamId)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body( + Map.of( + "error", + "Team still owns integration configurations. Delete or reassign them first.")); + } + + // Team grants and membership rows would dangle once the team row is gone + resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId(PrincipalType.TEAM, teamId); + teamMembershipService.deleteAllForTeam(teamId); teamRepository.delete(team); return ResponseEntity.ok(Map.of("message", "Team deleted successfully")); } + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/setOwner") + @Transactional + public ResponseEntity setTeamOwner( + @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) { + return mutateOwner(teamId, userId, true); + } + + @PreAuthorize("hasRole('ADMIN')") + @PostMapping("/removeOwner") + @Transactional + public ResponseEntity removeTeamOwner( + @RequestParam("teamId") Long teamId, @RequestParam("userId") Long userId) { + return mutateOwner(teamId, userId, false); + } + + private ResponseEntity mutateOwner(Long teamId, Long userId, boolean owner) { + Optional teamOpt = teamRepository.findById(teamId); + if (teamOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Team not found.")); + } + Team team = teamOpt.get(); + + // System teams have no owners + if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName()) + || TeamService.DEFAULT_TEAM_NAME.equals(team.getName())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "System teams cannot have owners.")); + } + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "User not found.")); + } + User user = userOpt.get(); + + if (user.getTeam() == null || !user.getTeam().getId().equals(teamId)) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "User must be a member of the team.")); + } + + if (owner) { + teamMembershipService.setOwner(team, user); + return ResponseEntity.ok(Map.of("message", "Team owner assigned successfully")); + } + teamMembershipService.removeOwner(team, user); + return ResponseEntity.ok(Map.of("message", "Team owner removed successfully")); + } + @PreAuthorize("hasRole('ADMIN')") @PostMapping("/addUser") @Transactional @@ -138,6 +207,7 @@ public class TeamController { // Assign user to team user.setTeam(team); userRepository.save(user); + teamMembershipService.syncMembership(user); return ResponseEntity.ok(Map.of("message", "User added to team successfully")); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index d8500e475f..b19c052ff1 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -50,6 +50,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.LoginAttemptService; import stirling.software.proprietary.security.service.SaveUserRequest; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -69,6 +70,7 @@ public class UserController { private final Optional emailService; private final UserLicenseSettingsService licenseSettingsService; private final LoginAttemptService loginAttemptService; + private final TeamMembershipService teamMembershipService; @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") @@ -644,6 +646,7 @@ public class UserController { user.setTeam(team); userRepository.save(user); + teamMembershipService.syncMembership(user); } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java index 2a3e7d2af6..5a503e9436 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/dto/AdminUserSummary.java @@ -50,6 +50,14 @@ public class AdminUserSummary { @Schema(description = "Team membership (if any)") private TeamSummary team; + @Schema(description = "Whether the user owns (leads) any team") + private boolean teamLead; + + @Schema( + description = + "Whether the user may access the portal, per the server-side access policy") + private boolean portalAccess; + @Schema(description = "User account creation timestamp") private LocalDateTime createdAt; diff --git a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java similarity index 77% rename from app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java rename to app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java index 36c04f09c3..0662edd67b 100644 --- a/app/saas/src/main/java/stirling/software/saas/repository/TeamMembershipRepository.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/TeamMembershipRepository.java @@ -1,15 +1,17 @@ -package stirling.software.saas.repository; +package stirling.software.proprietary.security.repository; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.model.User; @Repository public interface TeamMembershipRepository extends JpaRepository { @@ -106,4 +108,24 @@ public interface TeamMembershipRepository extends JpaRepository findByRoleFetchingUserAndTeam(@Param("role") TeamRole role); + + void deleteByTeamId(Long teamId); + + void deleteByUserId(Long userId); + + // Detach invitation references so deleting the inviting user does not hit the FK. + @Modifying + @Query("update TeamMembership tm set tm.invitedBy = null where tm.invitedBy = :user") + void clearInvitedBy(@Param("user") User user); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java new file mode 100644 index 0000000000..e8fca904b6 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/TeamMembershipService.java @@ -0,0 +1,117 @@ +package stirling.software.proprietary.security.service; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +/** + * Keeps team_memberships in step with users.team_id on self-hosted admin flows and holds the + * team-owner (LEADER role) mutations. SaaS owns membership lifecycle (seats, personal teams, + * last-leader guards) via SaasTeamService, so these mutations no-op on the saas profile to avoid + * corrupting its accounting. + */ +@Service +@RequiredArgsConstructor +public class TeamMembershipService { + + private final TeamMembershipRepository membershipRepository; + private final Environment environment; + + private boolean isSaas() { + return Arrays.asList(environment.getActiveProfiles()).contains("saas"); + } + + /** Reflects users.team_id into membership rows, preserving an existing role on the team. */ + @Transactional + public void syncMembership(User user) { + if (isSaas() || user == null || user.getId() == null) { + return; + } + Long teamId = user.getTeam() != null ? user.getTeam().getId() : null; + boolean present = false; + for (TeamMembership row : membershipRepository.findByUserId(user.getId())) { + if (teamId != null && teamId.equals(row.getTeam().getId())) { + present = true; + } else { + membershipRepository.delete(row); + } + } + if (teamId != null && !present) { + membershipRepository.save(newRow(user.getTeam(), user, TeamRole.MEMBER)); + } + } + + /** Promotes a member to team owner, creating the membership row if it is missing. */ + @Transactional + public void setOwner(Team team, User user) { + if (isSaas()) { + return; + } + Optional existing = + membershipRepository.findByTeamIdAndUserId(team.getId(), user.getId()); + if (existing.isPresent()) { + existing.get().setRole(TeamRole.LEADER); + membershipRepository.save(existing.get()); + } else { + membershipRepository.save(newRow(team, user, TeamRole.LEADER)); + } + } + + /** Demotes a team owner back to member; keeps the membership row. */ + @Transactional + public void removeOwner(Team team, User user) { + if (isSaas()) { + return; + } + membershipRepository + .findByTeamIdAndUserId(team.getId(), user.getId()) + .ifPresent( + row -> { + row.setRole(TeamRole.MEMBER); + membershipRepository.save(row); + }); + } + + /** Owner user ids for a team. */ + @Transactional(readOnly = true) + public List ownerUserIds(Long teamId) { + return membershipRepository.findByTeamIdAndRole(teamId, TeamRole.LEADER).stream() + .map(row -> row.getUser().getId()) + .toList(); + } + + @Transactional + public void deleteAllForTeam(Long teamId) { + membershipRepository.deleteByTeamId(teamId); + } + + @Transactional + public void deleteAllForUser(User user) { + membershipRepository.deleteByUserId(user.getId()); + membershipRepository.clearInvitedBy(user); + } + + private TeamMembership newRow(Team team, User user, TeamRole role) { + TeamMembership row = new TeamMembership(); + row.setTeam(team); + row.setUser(user); + row.setRole(role); + // Self-hosted rows are admin-assigned, not invitation-driven. + row.setInvitedAt(LocalDateTime.now()); + row.setAcceptedAt(LocalDateTime.now()); + return row; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java index a7aaa573fd..45532978f3 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/UserService.java @@ -39,6 +39,11 @@ import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; import stirling.software.common.service.UserServiceInterface; import stirling.software.common.util.RegexPatternUtils; +import stirling.software.proprietary.access.model.PrincipalType; +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; @@ -87,6 +92,9 @@ public class UserService implements UserServiceInterface { private final StorageCleanupEntryRepository storageCleanupEntryRepository; private final FileShareRepository fileShareRepository; private final FileShareAccessRepository fileShareAccessRepository; + private final ResourceGrantRepository resourceGrantRepository; + private final IntegrationConfigRepository integrationConfigRepository; + private final TeamMembershipService teamMembershipService; @Transactional public void processSSOPostLogin( @@ -249,6 +257,21 @@ public class UserService implements UserServiceInterface { private void deleteUserRelatedData(User user) { log.info("Deleting all associated data for user: {}", user.getUsername()); + // Drop ACL grants held by this user and detach grants they issued + resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId( + PrincipalType.USER, user.getId()); + resourceGrantRepository.clearGrantedBy(user); + + // Integration configs owned by this user FK the users row; drop them and their grants + for (IntegrationConfig cfg : integrationConfigRepository.findByOwnerUser(user)) { + resourceGrantRepository.deleteByResourceTypeAndResourceId( + ResourceType.INTEGRATION_CONFIG, String.valueOf(cfg.getId())); + } + integrationConfigRepository.deleteByOwnerUser(user); + + // Membership rows and invitation references would dangle once the user row is gone + teamMembershipService.deleteAllForUser(user); + // Delete server certificate (non-nullable OneToOne → User) userServerCertificateService.deleteUserCertificate(user.getId()); @@ -412,6 +435,7 @@ public class UserService implements UserServiceInterface { } user.setTeam(team); userRepository.save(user); + teamMembershipService.syncMembership(user); databaseService.exportDatabase(); } @@ -523,6 +547,7 @@ public class UserService implements UserServiceInterface { // Save user userRepository.save(user); + teamMembershipService.syncMembership(user); // Export database databaseService.exportDatabase(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/security/ResourceAccessSecurityTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/security/ResourceAccessSecurityTest.java new file mode 100644 index 0000000000..09c0fbf1f7 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/security/ResourceAccessSecurityTest.java @@ -0,0 +1,61 @@ +package stirling.software.proprietary.access.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +@ExtendWith(MockitoExtension.class) +class ResourceAccessSecurityTest { + + @Mock private ResourceAccessService accessService; + @Mock private UserService userService; + + @InjectMocks private ResourceAccessSecurity security; + + @AfterEach + void clearContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void userPrincipalDelegatesToPortalCheck() { + User user = new User(); + user.setId(5L); + authenticate(user); + when(accessService.canAccessPortal(user)).thenReturn(true); + + assertThat(security.canUsePortal()).isTrue(); + } + + @Test + void deniedWithoutAuthentication() { + assertThat(security.canUsePortal()).isFalse(); + } + + @Test + void nonUserPrincipalWithoutBackingRowIsDenied() { + // Mirrors an anonymous SaaS session: principal is not a User and resolves to nothing. + authenticate("anonymousUser"); + + assertThat(security.canUsePortal()).isFalse(); + } + + private void authenticate(Object principal) { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + principal, null, java.util.List.of())); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/DefaultPrincipalResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/DefaultPrincipalResolverTest.java new file mode 100644 index 0000000000..94e99bdbc9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/DefaultPrincipalResolverTest.java @@ -0,0 +1,54 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +class DefaultPrincipalResolverTest { + + private final DefaultPrincipalResolver resolver = new DefaultPrincipalResolver(); + + @Test + void userWithoutTeamProjectsUser() { + assertThat(resolver.principalsOf(user(5, null))) + .containsExactlyInAnyOrder(PrincipalRef.user(5L)); + } + + @Test + void userWithTeamProjectsUserAndTeam() { + assertThat(resolver.principalsOf(user(5, 7L))) + .containsExactlyInAnyOrder(PrincipalRef.user(5L), PrincipalRef.team(7L)); + } + + @Test + void nullUserProjectsNothing() { + assertThat(resolver.principalsOf(null)).isEmpty(); + assertThat(resolver.principalTokens(null)).isEmpty(); + } + + @Test + void tokensUseTheCanonicalWireForm() { + assertThat(resolver.principalTokens(user(5, 7L))) + .containsExactlyInAnyOrder("user:5", "team:7"); + } + + @Test + void selfHostedAllowsDeploymentWideAccess() { + assertThat(resolver.allowsDeploymentWideAccess()).isTrue(); + } + + private User user(long id, Long teamId) { + User u = new User(); + u.setId(id); + if (teamId != null) { + Team t = new Team(); + t.setId(teamId); + u.setTeam(t); + } + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookupTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookupTest.java new file mode 100644 index 0000000000..f485bcec52 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/MembershipTeamLeadLookupTest.java @@ -0,0 +1,55 @@ +package stirling.software.proprietary.access.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +@ExtendWith(MockitoExtension.class) +class MembershipTeamLeadLookupTest { + + @Mock private TeamMembershipRepository memberships; + + @InjectMocks private MembershipTeamLeadLookup lookup; + + @Test + void leaderMembershipMakesTeamLeader() { + when(memberships.existsByTeamIdAndUserIdAndRole(7L, 5L, TeamRole.LEADER)).thenReturn(true); + assertThat(lookup.isLeaderOfTeam(user(5), 7L)).isTrue(); + } + + @Test + void memberOnlyIsNotTeamLeader() { + when(memberships.existsByTeamIdAndUserIdAndRole(7L, 5L, TeamRole.LEADER)).thenReturn(false); + assertThat(lookup.isLeaderOfTeam(user(5), 7L)).isFalse(); + } + + @Test + void anyLeadershipDetectedAcrossTeams() { + when(memberships.existsByUserIdAndRole(5L, TeamRole.LEADER)).thenReturn(true); + assertThat(lookup.isAnyTeamLeader(user(5))).isTrue(); + } + + @Test + void nullInputsNeverQueryAndDeny() { + assertThat(lookup.isAnyTeamLeader(null)).isFalse(); + assertThat(lookup.isLeaderOfTeam(null, 7L)).isFalse(); + assertThat(lookup.isLeaderOfTeam(user(5), null)).isFalse(); + verifyNoInteractions(memberships); + } + + private User user(long id) { + User u = new User(); + u.setId(id); + return u; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java index 3f70058d0b..f2d86e783d 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/OwnershipServiceTest.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.access.service; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.Optional; @@ -150,6 +151,27 @@ class OwnershipServiceTest { assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // not a lead } + @Test + void disabledResourceManageableOnlyByOwnerOrAdmin() { + TestResource r = new TestResource(1L); + r.setEnabled(false); + r.setOwnerUser(user(7)); + + assertThat(ownership.canManage(TYPE, r, user(7))).isTrue(); // owner + assertThat(ownership.canManage(TYPE, r, user(8))).isFalse(); // would-be MANAGE grantee + assertThat(ownership.canManage(TYPE, r, admin(2))).isTrue(); // admin + // Grants are bypassed entirely while disabled. + verifyNoInteractions(accessService); + } + + @Test + void enabledResourceManageDelegatesToTheAcl() { + TestResource r = new TestResource(1L); + when(accessService.canManageResource(any(), any(), any(), any())).thenReturn(true); + + assertThat(ownership.canManage(TYPE, r, user(7))).isTrue(); + } + // ---- helpers ---- private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java index 230c207eb0..baa6755493 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/ResourceAccessServiceTest.java @@ -1,20 +1,22 @@ package stirling.software.proprietary.access.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import java.lang.reflect.Field; import java.util.List; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.access.model.AccessPermission; import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.PrincipalRef; import stirling.software.proprietary.access.model.PrincipalType; import stirling.software.proprietary.access.model.ResourceGrant; import stirling.software.proprietary.access.model.ResourceType; @@ -32,13 +34,20 @@ class ResourceAccessServiceTest { @Mock private ResourceGrantRepository grantRepository; @Mock private TeamLeadLookup teamLeadLookup; - @InjectMocks private ResourceAccessService service; + private ResourceAccessService service; @BeforeEach - void setPortalDefault() throws Exception { + void setUp() throws Exception { + service = newService(new DefaultPrincipalResolver()); + } + + private ResourceAccessService newService(PrincipalResolver resolver) throws Exception { + ResourceAccessService s = + new ResourceAccessService(grantRepository, teamLeadLookup, resolver); Field f = ResourceAccessService.class.getDeclaredField("portalDefaultPolicy"); f.setAccessible(true); - f.set(service, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + f.set(s, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS); + return s; } // ---- owner / admin short-circuits ---- @@ -55,15 +64,56 @@ class ResourceAccessServiceTest { void ownerMayUseEvenWithExplicitOnly() { assertThat( service.canUseResource( - TYPE, RID, 5L, DefaultAccessPolicy.EXPLICIT_ONLY, user(5))) + TYPE, + RID, + PrincipalRef.user(5L), + DefaultAccessPolicy.EXPLICIT_ONLY, + user(5))) .isTrue(); } @Test void nullUserIsAlwaysDenied() { - assertThat(service.canUseResource(TYPE, RID, 5L, DefaultAccessPolicy.ORG_ALL, null)) + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.user(5L), + DefaultAccessPolicy.ORG_ALL, + null)) + .isFalse(); + assertThat(service.canManageResource(TYPE, RID, PrincipalRef.user(5L), null)).isFalse(); + } + + // ---- owner refs ---- + + @Test + void teamOwnerRefAllowsLeaderOfThatTeam() { + User leader = userInTeam(5, 7); + when(teamLeadLookup.isLeaderOfTeam(leader, 7L)).thenReturn(true); + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.team(7L), + DefaultAccessPolicy.EXPLICIT_ONLY, + leader)) + .isTrue(); + assertThat(service.canManageResource(TYPE, RID, PrincipalRef.team(7L), leader)).isTrue(); + } + + @Test + void teamOwnerRefDeniesPlainTeamMember() { + stubGrants(); + User member = userInTeam(5, 7); + assertThat( + service.canUseResource( + TYPE, + RID, + PrincipalRef.team(7L), + DefaultAccessPolicy.EXPLICIT_ONLY, + member)) .isFalse(); - assertThat(service.canManageResource(TYPE, RID, 5L, null)).isFalse(); } // ---- explicit grants ---- @@ -124,15 +174,60 @@ class ResourceAccessServiceTest { assertThat(service.canManageResource(TYPE, RID, null, user(5))).isTrue(); } + // ---- upsert semantics ---- + + @Test + void grantOverwritesPermissionEvenDowngrading() { + // Upsert key is (type, resourceId, principalType, principalId); permission is overwritten. + ResourceGrant existing = grant(PrincipalType.USER, 5L, AccessPermission.MANAGE); + stubGrants(existing); + when(grantRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + ResourceGrant saved = + service.grant(TYPE, RID, PrincipalType.USER, 5L, AccessPermission.USE, null); + + assertThat(saved).isSameAs(existing); + assertThat(saved.getPermission()).isEqualTo(AccessPermission.USE); + } + + // ---- granted resource ids ---- + + @Test + void grantedResourceIdsCollectsAcrossAllPrincipals() { + when(grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + TYPE, PrincipalType.USER, 5L)) + .thenReturn(List.of(grantOn("a"))); + when(grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId( + TYPE, PrincipalType.TEAM, 7L)) + .thenReturn(List.of(grantOn("b"))); + + assertThat(service.grantedResourceIds(TYPE, userInTeam(5, 7))) + .containsExactlyInAnyOrder("a", "b"); + } + // ---- default policies ---- @Test - void orgAllDefaultAllowsAnyUser() { + void orgAllDefaultAllowsAnyUserWhenDeploymentWide() { + // Self-hosted resolver allows deployment-wide access, so ORG_ALL admits anyone. stubGrants(); assertThat(service.canUseResource(TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5))) .isTrue(); } + @Test + void orgAllDefaultDeniedWhenNotDeploymentWide() throws Exception { + // Mirrors the saas resolver (USER/TEAM only, no deployment-wide access): ORG_ALL must not + // leak a resource to a tenant's users, so an ungranted user is denied. + ResourceAccessService tenantScoped = + newService(u -> u == null ? Set.of() : Set.of(PrincipalRef.user(u.getId()))); + stubGrants(); + assertThat( + tenantScoped.canUseResource( + TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5))) + .isFalse(); + } + @Test void explicitOnlyDefaultDeniesUngrantedUser() { stubGrants(); @@ -194,6 +289,12 @@ class ResourceAccessServiceTest { return g; } + private ResourceGrant grantOn(String resourceId) { + ResourceGrant g = grant(PrincipalType.USER, 5L, AccessPermission.USE); + g.setResourceId(resourceId); + return g; + } + private User user(long id) { User u = new User(); u.setId(id); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java index ea5597cb12..4f76b06b5c 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/access/service/SecretMaskerTest.java @@ -49,6 +49,23 @@ class SecretMaskerTest { assertThat(merged.get("secretKey")).isEqualTo("NEW"); } + @Test + void mergeDropsKeysAbsentFromIncoming() { + // PUT/replace semantics: a key removed in the edit is removed from storage. + Map stored = new java.util.LinkedHashMap<>(); + stored.put("bucket", "b"); + stored.put("endpoint", "https://old"); + stored.put("secretKey", "REAL"); + Map incoming = new java.util.LinkedHashMap<>(); + incoming.put("bucket", "b"); + incoming.put("secretKey", SecretMasker.MASK); + + Map merged = masker.merge(stored, incoming); + + assertThat(merged).doesNotContainKey("endpoint"); + assertThat(merged.get("secretKey")).isEqualTo("REAL"); // masked secret retained + } + @Test void sanitizeDropsBlankSecretsOnCreate() { Map incoming = new LinkedHashMap<>(); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java index 3ddd9e9a1e..585edc6e66 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerMoreTest.java @@ -24,6 +24,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AdminSettingsData; @@ -39,6 +40,7 @@ import stirling.software.proprietary.security.database.repository.SessionReposit import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal; import stirling.software.proprietary.security.service.DatabaseServiceInterface; @@ -57,12 +59,14 @@ class ProprietaryUIDataControllerMoreTest { @Mock private SessionPersistentRegistry sessionPersistentRegistry; @Mock private UserRepository userRepository; @Mock private TeamRepository teamRepository; + @Mock private TeamMembershipRepository teamMembershipRepository; @Mock private SessionRepository sessionRepository; @Mock private DatabaseServiceInterface databaseService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private PersistentAuditEventRepository auditRepository; @Mock private MfaService mfaService; @Mock private LoginAttemptService loginAttemptService; + @Mock private ResourceAccessService resourceAccessService; private ApplicationProperties applicationProperties; private AuditConfigurationProperties auditConfig; @@ -87,6 +91,7 @@ class ProprietaryUIDataControllerMoreTest { sessionPersistentRegistry, userRepository, teamRepository, + teamMembershipRepository, sessionRepository, databaseService, objectMapper, @@ -94,7 +99,8 @@ class ProprietaryUIDataControllerMoreTest { licenseSettingsService, auditRepository, mfaService, - loginAttemptService); + loginAttemptService, + resourceAccessService); } private static User normalUser(Long id, String username) { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java index b8552e274c..0b01c9bcf5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/controller/api/ProprietaryUIDataControllerTest.java @@ -18,6 +18,7 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.access.service.ResourceAccessService; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData; import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData; @@ -27,6 +28,7 @@ import stirling.software.proprietary.security.database.repository.SessionReposit import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.DatabaseService; import stirling.software.proprietary.security.service.LoginAttemptService; @@ -43,12 +45,14 @@ class ProprietaryUIDataControllerTest { @Mock private SessionPersistentRegistry sessionPersistentRegistry; @Mock private UserRepository userRepository; @Mock private TeamRepository teamRepository; + @Mock private TeamMembershipRepository teamMembershipRepository; @Mock private SessionRepository sessionRepository; @Mock private DatabaseService databaseService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private PersistentAuditEventRepository auditRepository; @Mock private MfaService mfaService; @Mock private LoginAttemptService loginAttemptService; + @Mock private ResourceAccessService resourceAccessService; private ApplicationProperties applicationProperties; private AuditConfigurationProperties auditConfig; @@ -75,6 +79,7 @@ class ProprietaryUIDataControllerTest { sessionPersistentRegistry, userRepository, teamRepository, + teamMembershipRepository, sessionRepository, databaseService, objectMapper, @@ -82,7 +87,8 @@ class ProprietaryUIDataControllerTest { licenseSettingsService, auditRepository, mfaService, - loginAttemptService); + loginAttemptService, + resourceAccessService); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java index 9f3b2d84af..8e0ef200bb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -49,6 +49,9 @@ class IntegrationConfigServiceTest { @Mock private OwnershipService ownership; @Mock private SecretMasker secretMasker; + @Mock + private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository; + @InjectMocks private IntegrationConfigService service; @Test @@ -58,9 +61,9 @@ class IntegrationConfigServiceTest { User user = user(7); IntegrationConfig created = - service.create(request(IntegrationType.S3, OwnerScope.USER, null), user); + service.create(request(IntegrationType.API, OwnerScope.USER, null), user); - assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.S3); + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.API); assertThat(created.getName()).isEqualTo("name"); verify(ownership) .assignOwnership(eq(created), eq(OwnerScope.USER), isNull(), eq(user), any()); @@ -156,6 +159,60 @@ class IntegrationConfigServiceTest { .isEqualTo(HttpStatus.FORBIDDEN); } + // ---- S3 type policy ---- + + @Test + void s3PersonalCreateForbiddenForRegularUser() { + User user = user(7); + when(ownership.isAdmin(user)).thenReturn(false); + + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.S3, OwnerScope.USER, null), user)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void s3PersonalCreateAllowedForAdmin() { + when(secretMasker.sanitize(any())).thenReturn(Map.of("bucket", "b")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + User admin = user(1); + when(ownership.isAdmin(admin)).thenReturn(true); + + IntegrationConfig created = + service.create(request(IntegrationType.S3, OwnerScope.USER, null), admin); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.S3); + } + + @Test + void s3TeamScopeCreateDelegatesLeadershipToOwnership() { + // TEAM scope skips the personal-S3 gate; assignOwnership enforces admin/team-owner. + when(secretMasker.sanitize(any())).thenReturn(Map.of("bucket", "b")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + User leader = user(7); + + IntegrationConfig created = + service.create(request(IntegrationType.S3, OwnerScope.TEAM, 3L), leader); + + verify(ownership) + .assignOwnership(eq(created), eq(OwnerScope.TEAM), eq(3L), eq(leader), any()); + } + + @Test + void mcpPersonalCreateAllowedForRegularUser() { + when(secretMasker.sanitize(any())).thenReturn(Map.of("token", "t")); + when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationConfig created = + service.create(request(IntegrationType.MCP, OwnerScope.USER, null), user(7)); + + assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.MCP); + } + // ---- helpers ---- private IntegrationConfig config(long id) { diff --git a/app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamMembershipTest.java similarity index 98% rename from app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java rename to app/proprietary/src/test/java/stirling/software/proprietary/model/TeamMembershipTest.java index a94e8b9373..653fcd0ef1 100644 --- a/app/saas/src/test/java/stirling/software/saas/model/TeamMembershipTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/model/TeamMembershipTest.java @@ -1,4 +1,4 @@ -package stirling.software.saas.model; +package stirling.software.proprietary.model; import static org.assertj.core.api.Assertions.assertThat; @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; /** Constructor, accessor, equals/hashCode/toString, and role-helper tests for TeamMembership. */ diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java index adf9486094..3e4c75f645 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/InitialSecuritySetupTest.java @@ -26,6 +26,7 @@ import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.DatabaseServiceInterface; import stirling.software.proprietary.security.service.SaveUserRequest; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.service.UserLicenseSettingsService; @@ -38,6 +39,7 @@ class InitialSecuritySetupTest { @Mock private DatabaseServiceInterface databaseService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private Environment environment; + @Mock private TeamMembershipService teamMembershipService; private ApplicationProperties applicationProperties; private InitialSecuritySetup initialSecuritySetup; @@ -63,7 +65,8 @@ class InitialSecuritySetupTest { applicationProperties, databaseService, licenseSettingsService, - environment); + environment, + teamMembershipService); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java index 6a27b4d578..6586fc11b6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerLoginTest.java @@ -29,6 +29,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.access.service.TeamLeadLookup; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -60,6 +61,7 @@ class AuthControllerLoginTest { @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; @Mock private ResourceAccessService resourceAccessService; + @Mock private TeamLeadLookup teamLeadLookup; @BeforeEach void setUp() { @@ -83,7 +85,8 @@ class AuthControllerLoginTest { securityProperties, applicationProperties, new stirling.software.proprietary.service.AiUserDataService(null), - resourceAccessService); + resourceAccessService, + teamLeadLookup); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java index 611929b5b9..aaf931f597 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AuthControllerMoreTest.java @@ -29,6 +29,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.proprietary.access.service.ResourceAccessService; +import stirling.software.proprietary.access.service.TeamLeadLookup; import stirling.software.proprietary.security.model.AuthenticationType; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; @@ -61,6 +62,7 @@ class AuthControllerMoreTest { @Mock private TotpService totpService; @Mock private RefreshRateLimitService refreshRateLimitService; @Mock private ResourceAccessService resourceAccessService; + @Mock private TeamLeadLookup teamLeadLookup; @BeforeEach void setUp() { @@ -84,7 +86,8 @@ class AuthControllerMoreTest { securityProperties, applicationProperties, new stirling.software.proprietary.service.AiUserDataService(null), - resourceAccessService); + resourceAccessService, + teamLeadLookup); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java index 75c23b4630..ac2219efa3 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerMoreTest.java @@ -30,6 +30,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -46,6 +47,7 @@ class UserControllerMoreTest { @Mock private EmailService emailService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private LoginAttemptService loginAttemptService; + @Mock private TeamMembershipService teamMembershipService; private ApplicationProperties applicationProperties; private MockMvc mockMvc; @@ -64,7 +66,8 @@ class UserControllerMoreTest { userRepository, Optional.of(emailService), licenseSettingsService, - loginAttemptService); + loginAttemptService, + teamMembershipService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java index 7bcc0da9f4..b3a1e8de93 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/UserControllerTest.java @@ -33,6 +33,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.LoginAttemptService; +import stirling.software.proprietary.security.service.TeamMembershipService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; @@ -53,6 +54,7 @@ class UserControllerTest { @Mock private EmailService emailService; @Mock private UserLicenseSettingsService licenseSettingsService; @Mock private LoginAttemptService loginAttemptService; + @Mock private TeamMembershipService teamMembershipService; private ApplicationProperties applicationProperties; private MockMvc mockMvc; @@ -72,7 +74,8 @@ class UserControllerTest { userRepository, Optional.of(emailService), licenseSettingsService, - loginAttemptService); + loginAttemptService, + teamMembershipService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java new file mode 100644 index 0000000000..68bd2d1727 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/TeamMembershipServiceTest.java @@ -0,0 +1,159 @@ +package stirling.software.proprietary.security.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; + +@ExtendWith(MockitoExtension.class) +class TeamMembershipServiceTest { + + @Mock private TeamMembershipRepository membershipRepository; + @Mock private org.springframework.core.env.Environment environment; + + @InjectMocks private TeamMembershipService service; + + @org.junit.jupiter.api.BeforeEach + void notSaas() { + org.mockito.Mockito.lenient() + .when(environment.getActiveProfiles()) + .thenReturn(new String[] {}); + } + + @Test + void syncCreatesMemberRowForUsersTeam() { + User user = userInTeam(5, 7); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of()); + + service.syncMembership(user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(captor.capture()); + assertThat(captor.getValue().getTeam().getId()).isEqualTo(7L); + assertThat(captor.getValue().getRole()).isEqualTo(TeamRole.MEMBER); + assertThat(captor.getValue().getInvitedAt()).isNotNull(); + } + + @Test + void syncMovesRowsWhenUserChangedTeam() { + User user = userInTeam(5, 8); + TeamMembership oldRow = row(7, user, TeamRole.LEADER); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(oldRow)); + + service.syncMembership(user); + + verify(membershipRepository).delete(oldRow); + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(captor.capture()); + assertThat(captor.getValue().getTeam().getId()).isEqualTo(8L); + assertThat(captor.getValue().getRole()).isEqualTo(TeamRole.MEMBER); + } + + @Test + void syncPreservesLeaderRoleOnSameTeam() { + User user = userInTeam(5, 7); + TeamMembership existing = row(7, user, TeamRole.LEADER); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(existing)); + + service.syncMembership(user); + + verify(membershipRepository, never()).delete(any()); + verify(membershipRepository, never()).save(any()); + } + + @Test + void syncRemovesAllRowsWhenUserHasNoTeam() { + User user = new User(); + user.setId(5L); + TeamMembership stale = row(7, user, TeamRole.MEMBER); + when(membershipRepository.findByUserId(5L)).thenReturn(List.of(stale)); + + service.syncMembership(user); + + verify(membershipRepository).delete(stale); + verify(membershipRepository, never()).save(any()); + } + + @Test + void setOwnerPromotesExistingRow() { + User user = userInTeam(5, 7); + TeamMembership existing = row(7, user, TeamRole.MEMBER); + when(membershipRepository.findByTeamIdAndUserId(7L, 5L)).thenReturn(Optional.of(existing)); + + service.setOwner(user.getTeam(), user); + + assertThat(existing.getRole()).isEqualTo(TeamRole.LEADER); + verify(membershipRepository).save(existing); + } + + @Test + void setOwnerCreatesLeaderRowWhenMissing() { + User user = userInTeam(5, 7); + when(membershipRepository.findByTeamIdAndUserId(7L, 5L)).thenReturn(Optional.empty()); + + service.setOwner(user.getTeam(), user); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TeamMembership.class); + verify(membershipRepository).save(captor.capture()); + assertThat(captor.getValue().getRole()).isEqualTo(TeamRole.LEADER); + } + + @Test + void removeOwnerDemotesToMemberAndKeepsRow() { + User user = userInTeam(5, 7); + TeamMembership existing = row(7, user, TeamRole.LEADER); + when(membershipRepository.findByTeamIdAndUserId(7L, 5L)).thenReturn(Optional.of(existing)); + + service.removeOwner(user.getTeam(), user); + + assertThat(existing.getRole()).isEqualTo(TeamRole.MEMBER); + verify(membershipRepository).save(existing); + verify(membershipRepository, never()).delete(any()); + } + + @Test + void deleteAllForUserDropsRowsAndInvitationRefs() { + User user = userInTeam(5, 7); + + service.deleteAllForUser(user); + + verify(membershipRepository).deleteByUserId(5L); + verify(membershipRepository).clearInvitedBy(user); + } + + private User userInTeam(long userId, long teamId) { + User u = new User(); + u.setId(userId); + Team t = new Team(); + t.setId(teamId); + u.setTeam(t); + return u; + } + + private TeamMembership row(long teamId, User user, TeamRole role) { + Team t = new Team(); + t.setId(teamId); + TeamMembership m = new TeamMembership(); + m.setTeam(t); + m.setUser(user); + m.setRole(role); + return m; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java index cfb0f2e885..bc516172a6 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceMoreTest.java @@ -71,6 +71,12 @@ class UserServiceMoreTest { @Mock private FileShareRepository fileShareRepository; @Mock private FileShareAccessRepository fileShareAccessRepository; + @Mock + private stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + + @Mock private TeamMembershipService teamMembershipService; + @InjectMocks private UserService userService; @AfterEach diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java index 713fd4b9c3..95ec72d962 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/service/UserServiceTest.java @@ -24,6 +24,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.exception.UnsupportedProviderException; +import stirling.software.proprietary.access.repository.ResourceGrantRepository; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.security.database.repository.AuthorityRepository; import stirling.software.proprietary.security.database.repository.PersistentLoginRepository; @@ -63,6 +64,13 @@ class UserServiceTest { @Mock private StorageCleanupEntryRepository storageCleanupEntryRepository; @Mock private FileShareRepository fileShareRepository; @Mock private FileShareAccessRepository fileShareAccessRepository; + @Mock private ResourceGrantRepository resourceGrantRepository; + + @Mock + private stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + + @Mock private TeamMembershipService teamMembershipService; @Spy @InjectMocks private UserService userService; diff --git a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java index 91974a6d04..490b3d5db1 100644 --- a/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java +++ b/app/saas/src/main/java/stirling/software/saas/accountlink/AccountLinkController.java @@ -20,10 +20,10 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java b/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java index 6253d64651..9346e18b4b 100644 --- a/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java +++ b/app/saas/src/main/java/stirling/software/saas/controller/SaasTeamController.java @@ -20,15 +20,15 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.annotations.api.TeamApi; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.TeamSecurityExpressions; import stirling.software.saas.service.SaasTeamExtensionService; import stirling.software.saas.service.SaasTeamService; diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java index 6b8bb2c883..80c20902ce 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygInvoicesController.java @@ -20,13 +20,13 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripeInvoiceDao; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java index 724d6715d3..89ab18230f 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygPaymentMethodController.java @@ -18,13 +18,13 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripePaymentMethodDao; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java index b79d57117e..90616bea55 100644 --- a/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java +++ b/app/saas/src/main/java/stirling/software/saas/payg/api/PaygWalletController.java @@ -32,9 +32,10 @@ import jakarta.validation.constraints.Min; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.WalletSnapshotResponse.ActivityRow; import stirling.software.saas.payg.api.WalletSnapshotResponse.CategoryBreakdown; import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; @@ -51,7 +52,6 @@ import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.repository.WalletPolicyRepository; import stirling.software.saas.payg.wallet.WalletLedgerEntry; import stirling.software.saas.payg.wallet.WalletPolicy; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java index f92267da33..57f7e6a3a3 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/api/ProcurementController.java @@ -25,16 +25,16 @@ import io.swagger.v3.oas.annotations.Hidden; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; import stirling.software.saas.procurement.model.ProcurementDeal; import stirling.software.saas.procurement.model.ProcurementQuote; import stirling.software.saas.procurement.pricing.QuoteConfig; import stirling.software.saas.procurement.pricing.QuoteLineItem; import stirling.software.saas.procurement.service.ProcurementService; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java index 57f530cc9a..225e0327e8 100644 --- a/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java +++ b/app/saas/src/main/java/stirling/software/saas/procurement/service/ProcurementService.java @@ -17,7 +17,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.enumeration.TeamRole; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.procurement.config.ProcurementConfigurationProperties; import stirling.software.saas.procurement.license.EnterpriseLicenseService; import stirling.software.saas.procurement.license.LicenseEntitlements; @@ -28,7 +29,6 @@ import stirling.software.saas.procurement.pricing.QuoteBreakdown; import stirling.software.saas.procurement.pricing.QuoteConfig; import stirling.software.saas.procurement.repository.ProcurementDealRepository; import stirling.software.saas.procurement.repository.ProcurementQuoteRepository; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a diff --git a/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java index 00c7c42757..0d0df35688 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java +++ b/app/saas/src/main/java/stirling/software/saas/security/SaasPortalAuditScopeResolver.java @@ -11,7 +11,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.proprietary.audit.PortalAuditScope; import stirling.software.proprietary.audit.PortalAuditScopeResolver; -import stirling.software.saas.repository.TeamMembershipRepository; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; /** SaaS audit visibility: admins see the server; team LEADERs see their team (by member email). */ @Component diff --git a/app/saas/src/main/java/stirling/software/saas/security/SaasPrincipalResolver.java b/app/saas/src/main/java/stirling/software/saas/security/SaasPrincipalResolver.java new file mode 100644 index 0000000000..d064c7b59d --- /dev/null +++ b/app/saas/src/main/java/stirling/software/saas/security/SaasPrincipalResolver.java @@ -0,0 +1,30 @@ +package stirling.software.saas.security; + +import java.util.HashSet; +import java.util.Set; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.access.service.PrincipalResolver; +import stirling.software.proprietary.security.model.User; + +/** USER/TEAM only: SaaS has no org concept, so an ORG grant must never match across tenants. */ +@Component +@Profile("saas") +public class SaasPrincipalResolver implements PrincipalResolver { + + @Override + public Set principalsOf(User user) { + if (user == null) { + return Set.of(); + } + Set principals = new HashSet<>(); + principals.add(PrincipalRef.user(user.getId())); + if (user.getTeam() != null) { + principals.add(PrincipalRef.team(user.getTeam().getId())); + } + return principals; + } +} diff --git a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java index 3dfdf541cc..d93eacbafa 100644 --- a/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java +++ b/app/saas/src/main/java/stirling/software/saas/security/TeamSecurityExpressions.java @@ -12,8 +12,8 @@ import lombok.RequiredArgsConstructor; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Security expressions for team-based authorization in saas mode. Wired into diff --git a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java index 6d3e42e806..94996ca01c 100644 --- a/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java +++ b/app/saas/src/main/java/stirling/software/saas/service/SaasTeamService.java @@ -16,17 +16,17 @@ import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.SaasTeamExtensionsRepository; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; /** SaaS-only team management: invitations, personal teams, seat caps, paid-subscription gating. */ @Service @@ -48,6 +48,17 @@ public class SaasTeamService { private final SaasTeamExtensionsRepository saasTeamExtensionsRepository; private final LinkedInstanceRepository linkedInstanceRepository; private final stirling.software.proprietary.security.service.UserService userService; + private final stirling.software.proprietary.access.repository.ResourceGrantRepository + resourceGrantRepository; + private final stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + + // Team-owned integration configs + team grants FK the teams row; purge before deleting a team. + private void purgeTeamOwnedResources(Long teamId) { + integrationConfigRepository.deleteByOwnerTeam_Id(teamId); + resourceGrantRepository.deleteByPrincipalTypeAndPrincipalId( + stirling.software.proprietary.access.model.PrincipalType.TEAM, teamId); + } public static final String DEFAULT_TEAM_NAME = "Default"; public static final String INTERNAL_TEAM_NAME = "Internal"; @@ -347,6 +358,7 @@ public class SaasTeamService { "Deleting empty personal team {} after user {} joined another team", teamToDelete.getId(), acceptingUser.getUsername()); + purgeTeamOwnedResources(teamToDelete.getId()); teamRepository.delete(teamToDelete); } @@ -437,6 +449,7 @@ public class SaasTeamService { if (!saasTeamExtensionService.isPersonal(team) && membershipRepository.countByTeamId(teamId) == 0) { log.info("Deleting empty non-personal team {} after last member removed", teamId); + purgeTeamOwnedResources(team.getId()); teamRepository.delete(team); } @@ -551,6 +564,7 @@ public class SaasTeamService { if (!saasTeamExtensionService.isPersonal(team) && membershipRepository.countByTeamId(teamId) == 0) { log.info("Deleting empty non-personal team {} after last member left", teamId); + purgeTeamOwnedResources(team.getId()); teamRepository.delete(team); } diff --git a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java index 0de790cac0..e12888edb3 100644 --- a/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/accountlink/AccountLinkControllerTest.java @@ -19,12 +19,12 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.accountlink.AccountLinkController.RegisterRequest; import stirling.software.saas.accountlink.AccountLinkController.RegisterResponse; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java b/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java index ea8721a4c3..4dd8a9670d 100644 --- a/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/controller/SaasTeamControllerTest.java @@ -36,8 +36,10 @@ import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; @@ -45,9 +47,7 @@ import stirling.software.saas.controller.SaasTeamController.InviteUserRequest; import stirling.software.saas.controller.SaasTeamController.RenameTeamRequest; import stirling.software.saas.controller.SaasTeamController.UpdateSeatsRequest; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.TeamSecurityExpressions; import stirling.software.saas.service.SaasTeamExtensionService; import stirling.software.saas.service.SaasTeamService; diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java index 9266b02336..dc29ceb22d 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygInvoicesControllerTest.java @@ -23,14 +23,14 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.PaygInvoicesController.InvoiceResponse; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripeInvoiceDao; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.util.AuthenticationUtils; /** diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java index c4c0780b86..b7b7561a88 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygPaymentMethodControllerTest.java @@ -24,15 +24,15 @@ import org.springframework.security.oauth2.jwt.Jwt; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.PaygPaymentMethodController.PaymentMethodResponse; import stirling.software.saas.payg.policy.PaygTeamExtensions; import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.stripe.StripePaymentMethodDao; import stirling.software.saas.payg.stripe.StripePaymentMethodDao.CardSummary; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.EnhancedJwtAuthenticationToken; /** diff --git a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java index 6cc97d916b..0e02c0ce50 100644 --- a/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java +++ b/app/saas/src/test/java/stirling/software/saas/payg/api/PaygWalletControllerTest.java @@ -31,9 +31,10 @@ import org.springframework.security.oauth2.jwt.Jwt; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.User; -import stirling.software.saas.model.TeamMembership; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.saas.payg.api.PaygWalletController.UpdateCapRequest; import stirling.software.saas.payg.api.WalletSnapshotResponse.MemberRow; import stirling.software.saas.payg.billing.TeamBillingContext; @@ -50,7 +51,6 @@ import stirling.software.saas.payg.repository.PaygTeamExtensionsRepository; import stirling.software.saas.payg.repository.WalletLedgerRepository; import stirling.software.saas.payg.repository.WalletPolicyRepository; import stirling.software.saas.payg.wallet.WalletPolicy; -import stirling.software.saas.repository.TeamMembershipRepository; import stirling.software.saas.security.EnhancedJwtAuthenticationToken; /** diff --git a/app/saas/src/test/java/stirling/software/saas/security/SaasPrincipalResolverTest.java b/app/saas/src/test/java/stirling/software/saas/security/SaasPrincipalResolverTest.java new file mode 100644 index 0000000000..20acc83e5b --- /dev/null +++ b/app/saas/src/test/java/stirling/software/saas/security/SaasPrincipalResolverTest.java @@ -0,0 +1,39 @@ +package stirling.software.saas.security; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.access.model.PrincipalRef; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.User; + +class SaasPrincipalResolverTest { + + private final SaasPrincipalResolver resolver = new SaasPrincipalResolver(); + + @Test + void projectsUserAndTeamOnlyNeverOrg() { + User u = new User(); + u.setId(5L); + Team t = new Team(); + t.setId(7L); + u.setTeam(t); + + assertThat(resolver.principalsOf(u)) + .containsExactlyInAnyOrder(PrincipalRef.user(5L), PrincipalRef.team(7L)); + } + + @Test + void userWithoutTeamProjectsUserOnly() { + User u = new User(); + u.setId(5L); + + assertThat(resolver.principalsOf(u)).containsExactly(PrincipalRef.user(5L)); + } + + @Test + void nullUserProjectsNothing() { + assertThat(resolver.principalsOf(null)).isEmpty(); + } +} diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java index 580623c878..364db2855d 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsMoreTest.java @@ -25,10 +25,10 @@ import org.springframework.security.oauth2.jwt.Jwt; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Additional branch coverage for {@link TeamSecurityExpressions}: the JWT resolution path, the diff --git a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java index 23226e4467..c698daccca 100644 --- a/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java +++ b/app/saas/src/test/java/stirling/software/saas/security/TeamSecurityExpressionsTest.java @@ -20,10 +20,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.service.UserService; -import stirling.software.saas.model.TeamMembership; -import stirling.software.saas.repository.TeamMembershipRepository; /** * {@link TeamSecurityExpressions#isCurrentUserTeamLeader()} — used to gate policy editing on SaaS. diff --git a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java index ac2a4cecba..5f1083cb3f 100644 --- a/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java +++ b/app/saas/src/test/java/stirling/software/saas/service/SaasTeamServiceTest.java @@ -30,18 +30,18 @@ import stirling.software.common.model.enumeration.InvitationStatus; import stirling.software.common.model.enumeration.Role; import stirling.software.common.model.enumeration.TeamRole; import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.model.TeamMembership; import stirling.software.proprietary.security.database.repository.UserRepository; import stirling.software.proprietary.security.model.Authority; import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.repository.TeamMembershipRepository; import stirling.software.proprietary.security.repository.TeamRepository; import stirling.software.saas.accountlink.LinkedInstanceRepository; import stirling.software.saas.billing.repository.BillingSubscriptionRepository; import stirling.software.saas.config.SupabaseConfigurationProperties; import stirling.software.saas.model.TeamInvitation; -import stirling.software.saas.model.TeamMembership; import stirling.software.saas.repository.SaasTeamExtensionsRepository; import stirling.software.saas.repository.TeamInvitationRepository; -import stirling.software.saas.repository.TeamMembershipRepository; /** * Unit tests for {@link SaasTeamService}. @@ -68,6 +68,14 @@ class SaasTeamServiceTest { @Mock private LinkedInstanceRepository linkedInstanceRepository; @Mock private stirling.software.proprietary.security.service.UserService userService; + @Mock + private stirling.software.proprietary.access.repository.ResourceGrantRepository + resourceGrantRepository; + + @Mock + private stirling.software.proprietary.integration.repository.IntegrationConfigRepository + integrationConfigRepository; + @InjectMocks private SaasTeamService service; private static final UUID SUPABASE_ID = UUID.fromString("11111111-2222-3333-4444-555555555555"); diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 76463001fe..5ec17efb9a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -19,11 +19,26 @@ import { UIProvider } from "@portal/contexts/UIContext"; import { SuiProvider } from "@portal/theme/SuiProvider"; import { handlers } from "@portal/mocks/handlers"; import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient"; +import i18next from "i18next"; +import { initReactI18next } from "react-i18next"; import "@mantine/core/styles.css"; import "@core/tokens/tokens.css"; import "@core/tokens/base.css"; +// Storybook-only: init react-i18next so t(key, fallback, vars) interpolates its +// English fallback (there's no backend here to load locale files). Without this, +// the default t() returns raw templates like "{{count}} people · led by {{owner}}". +if (!i18next.isInitialized) { + void i18next.use(initReactI18next).init({ + lng: "en", + fallbackLng: "en", + resources: { en: { translation: {} } }, + interpolation: { escapeValue: false }, + react: { useSuspense: false }, + }); +} + // Start MSW once. Storybook runs in a browser so this uses the service worker. initialize({ onUnhandledRequest: "bypass" }, handlers); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index e81f5eaebb..242c3885e3 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -2812,6 +2812,7 @@ back = "Back" cancel = "Cancel" close = "Close" collapse = "Collapse" +confirm = "Confirm" continue = "Continue" copied = "Copied!" copy = "Copy" @@ -6462,9 +6463,6 @@ componentSpendMtd = "Component spend (MTD)" embedsThisMonth = "Embeds this month" inBeta = "In beta" -[portal.common] -inviteMember = "Invite member" - [portal.componentsView] subtitle = "Embeddable SDK widgets you drop into your own app — a viewer, an e-sign flow, an AI review panel. Each is metered per action. Click a card for install, usage and props." title = "Components" @@ -8027,83 +8025,8 @@ eyebrow = "TRAINING DATA" title = "Turn PDFs into training data" [portal.users] -subtitle = "The people in your organization and the access they hold — roles, status and security controls." title = "Users" -[portal.users.access] -subtitle = "Seats, authentication and provisioning for your organization." -title = "Access & security" - -[portal.users.access.auth] -title = "Authentication" - -[portal.users.access.auth.requireMfa] -description = "Members must set up a second factor to sign in." -enforced = "Enforced org-wide on this plan." -label = "Require MFA" - -[portal.users.access.auth.shortSessions] -description = "Sign members out after inactivity (currently {{timeout}})." -label = "Short-lived sessions" - -[portal.users.access.scim] -active = "Active" -directory = "Directory" -lastSync = "Last sync" -note = "Members are created, updated and deactivated automatically from your identity provider." -off = "Off" -title = "SCIM provisioning" - -[portal.users.access.seats] -title = "Seats" -unlimited = "Your plan includes unlimited seats." -usedLabel = "{{used}} of {{limit}} seats used" - -[portal.users.access.sso] -connected = "Connected" -domains = "Domains" -manage = "Manage connection" -notConfigured = "Not configured" -provider = "Provider" -title = "SSO / SAML" - -[portal.users.access.upgrade] -action = "Upgrade plan" -title = "Unlock team access controls" - -[portal.users.empty] -description = "Invite your team to start collaborating on documents and pipelines." -title = "No members yet" - -[portal.users.invite] -cancel = "Cancel" -email = "Email" -emailError = "Enter a valid email address" -emailPlaceholder = "teammate@acme.com" -role = "Role" -roleHelper = "Determines what the member can do once they join." -send = "Send invite" -subtitle = "They'll receive an email to join your organization." - -[portal.users.roles] -subtitle = "Every role exists on every plan — what each one can do is fixed across the org." -title = "Roles" - -[portal.users.summary] -members = "Members" -pendingInvites = "Pending invites" -seatsUsed = "Seats used" - -[portal.users.table] -actionsFor = "Actions for {{name}}" -changeRole = "Change role" -lastActive = "Last active" -member = "Member" -remove = "Remove from org" -role = "Role" -status = "Status" -suspend = "Suspend" - [portal.welcome] ariaLabel = "Welcome to Stirling PDF" badge = "Open-source" @@ -10017,6 +9940,146 @@ title = "Detailed Statistics" unknownEndpoint = "Unknown endpoint" visits = "Visits" +[users] +lastActive = "Last active" +learnMore = "Learn more about roles and access." +locked = "Locked" +roleFor = "Role for {{name}}" +rowActions = "Actions for {{name}}" +showAll = "Show all {{count}}" +showLess = "Show less" +subtitle2 = "Your people, teams, and access levels." +suspended = "Suspended" +teamActions = "Team actions" +title = "Users" +you = "(you)" + +[users.action] +deleteTeam = "Delete team" +disableMfa = "Reset MFA" +move = "Move to team" +reinstate = "Reinstate" +remove = "Remove from org" +rename = "Rename team" +resetPw = "Reset password" +suspend = "Suspend" +unlock = "Unlock account" + +[users.cap] +addProcessor = "+ Processor" +approver = "Approves policy" +editor = "Editor" +processor = "Processor" + +[users.confirm] +deleteTeamBody = "Delete the {{name}} team? The team must be empty first - move its members to another team, and it can't still own any integration configs." +deleteTeamTitle = "Delete team" +disableMfaBody = "Remove {{name}}'s MFA enrolment? They'll set it up again on next login if required." +disableMfaTitle = "Reset MFA" +removeBody = "Permanently remove {{name}} from the organization? This cannot be undone." +removeTitle = "Remove member" + +[users.empty] +description = "Invite your team to start collaborating." +title = "No members yet" + +[users.group] +addToTeam = "Add to team" +guestCount = "{{count}} guest" +guests = "Guests" +guestsDesc = "External collaborators, scoped to what you shared. Editor only." +ledBy = "led by {{owner}}" +org = "Organization" +orgDesc = "Owners with org-wide authority and policy approval" +owners = "{{count}} owner" +team = "{{name}} team" +teamMeta = "{{count}} people" + +[users.invite] +access = "Access" +action = "Invite people" +authOauth = "OAuth2 / SSO" +authSaml = "SAML 2.0" +authType = "Sign-in method" +authWeb = "Password" +create = "Create account" +createSubtitle = "Create a self-hosted account with a password or SSO." +createTitle = "Create account" +editorDesc = "Edit PDFs in the Stirling PDF Editor. Everyone gets this." +email = "Email address" +emailError = "Enter a valid email address" +emailPlaceholder2 = "name@company.com" +forceChange = "Require a password change on first login" +forceMfa = "Require MFA setup on first login" +method = "How to add them" +methodDirect = "Create account directly" +methodEmail = "Invite by email" +password = "Password" +passwordError = "Password must be at least 8 characters" +processorDeferred = "Invite sent, but Processor access couldn't be granted yet - set it from the roster once they've joined." +processorDesc = "The governance surface, run pipelines, agents, and the API." +role = "Role" +send2 = "Send invite" +subtitle2 = "They'll get an email to join your Stirling workspace." +team = "Team" +title = "Invite people" +username = "Username" +usernameError = "Username must be at least 3 characters" +usernamePlaceholder = "jsmith" + +[users.loadError] +description = "Something went wrong reaching the backend, or you don't have access. Try again." +title = "Couldn't load members" + +[users.moveTeam] +apply = "Move" +team = "Team" +title = "Move to team" + +[users.newTeam] +action = "+ New team" +create = "Create team" +emailError = "Enter a valid email address" +name = "Team name" +namePlaceholder = "e.g. Finance" +nameRequired = "Team name is required" +owner = "Team Owner" +ownerFailed = "Team created, but the owner couldn't be invited. Assign one from the roster." +ownerHelper = "Every team has a leader. They'll be invited as Team Owner, with the Processor on, and can add the rest. You can reassign later." +ownerPlaceholder = "owner@company.com" +subtitle = "Group people under a Team Owner who manages their access." +title = "New team" + +[users.renameTeam] +apply = "Rename" +name = "Team name" +title = "Rename team" + +[users.resetPw] +apply = "Reset password" +confirm = "Confirm password" +copied = "Copied to clipboard" +copyHint = "Copy this now - it won't be shown again." +email = "Email the user about the reset" +forceChange = "Require a password change on next login" +generate = "Generate a secure password" +includePw = "Include the new password in the email" +mismatch = "Passwords do not match" +newPassword = "New password" +regen = "Regenerate" +title = "Reset password" +tooShort = "Password must be at least 8 characters" + +[users.role] +guest = "Guest" +member = "Member" +orgOwner = "Org Owner" +teamOwner = "Team Owner" + +[users.team] +grantProcessor = "Grant Processor to team" +revokeProcessor = "Revoke Processor from team" + [validateSignature] date = "Date" downloadCsv = "Download CSV" diff --git a/frontend/editor/src/core/ui/Avatar.css b/frontend/editor/src/core/ui/Avatar.css index 07187c439a..21089db8f0 100644 --- a/frontend/editor/src/core/ui/Avatar.css +++ b/frontend/editor/src/core/ui/Avatar.css @@ -3,14 +3,21 @@ align-items: center; justify-content: center; border-radius: 50%; - font-weight: 600; + font-weight: 700; color: var(--color-text-on-accent); font-family: var(--font-sans); - letter-spacing: 0.01em; overflow: hidden; flex-shrink: 0; } +/* line-height:1 + centred tracking keeps initials optically centred at small sizes. */ +.sui-avatar__initials { + display: block; + line-height: 1; + letter-spacing: 0.02em; + text-indent: 0.02em; +} + .sui-avatar--interactive { cursor: pointer; transition: transform var(--motion-fast); diff --git a/frontend/editor/src/core/ui/Avatar.tsx b/frontend/editor/src/core/ui/Avatar.tsx index e848217e88..c7cfac501b 100644 --- a/frontend/editor/src/core/ui/Avatar.tsx +++ b/frontend/editor/src/core/ui/Avatar.tsx @@ -56,7 +56,9 @@ export function Avatar({ const content = src ? ( {ariaLabel ) : ( - {initialsOf(name)} + + {initialsOf(name)} + ); if (onClick) { diff --git a/frontend/editor/src/core/ui/Chip.css b/frontend/editor/src/core/ui/Chip.css index c341bb8e5c..f4b83cb853 100644 --- a/frontend/editor/src/core/ui/Chip.css +++ b/frontend/editor/src/core/ui/Chip.css @@ -21,6 +21,15 @@ .sui-chip--interactive { cursor: pointer; } +/* "box-cut" add/placeholder affordance: no fill, dashed outline. */ +.sui-chip--dashed.mantine-Pill-root { + background: transparent; + border-style: dashed; + border-color: var(--_bd); +} +.sui-chip--dashed.sui-chip--interactive:hover { + background: var(--_tint); +} .sui-chip--loading { opacity: 0.6; pointer-events: none; diff --git a/frontend/editor/src/core/ui/Chip.stories.tsx b/frontend/editor/src/core/ui/Chip.stories.tsx index 0d54da3891..22a9f554c8 100644 --- a/frontend/editor/src/core/ui/Chip.stories.tsx +++ b/frontend/editor/src/core/ui/Chip.stories.tsx @@ -29,6 +29,7 @@ const meta: Meta = { variant: { control: "inline-radio", options: ["primary", "secondary"] }, size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] }, showDot: { control: "boolean" }, + dashed: { control: "boolean" }, onClick: { action: "clicked" }, onRemove: { action: "removed" }, }, @@ -55,6 +56,13 @@ export const Accents: Story = { ), }; +/** "box-cut" add affordance: dashed outline, no fill, clickable. */ +export const DashedAdd: Story = { + args: { children: "+ Processor", dashed: true, accent: "neutral" }, + argTypes: { onClick: { action: "clicked" } }, + render: (args) => {}} />, +}; + export const InContext_OpChain: Story = { render: () => (
void; /** Leading status dot. Use for status-style chips. */ showDot?: boolean; + /** Dashed "box-cut" outline with no fill - for add/placeholder affordances. */ + dashed?: boolean; style?: CSSProperties; children?: ReactNode; className?: string; @@ -52,6 +54,7 @@ export function Chip({ onRemove, onClick, showDot, + dashed, style, children, className, @@ -63,6 +66,7 @@ export function Chip({ `sui-chip--${variant}`, onClick ? "sui-chip--interactive" : "", loading ? "sui-chip--loading" : "", + dashed ? "sui-chip--dashed" : "", className ?? "", ] .filter(Boolean) diff --git a/frontend/editor/src/portal/api/access.ts b/frontend/editor/src/portal/api/access.ts new file mode 100644 index 0000000000..cec165d68f --- /dev/null +++ b/frontend/editor/src/portal/api/access.ts @@ -0,0 +1,61 @@ +import { apiClient } from "@portal/api/http"; + +/** + * Access-control service layer: the ResourceGrant ACL the portal admin drives. + * + * Grants sit on top of the default policy (admins + team leads may enter the + * portal; owners/admins may use their configs). A grant widens that: a PORTAL + * grant lets a specific user/team into the processor; an INTEGRATION_CONFIG + * grant shares one stored config with a user/team at USE or MANAGE. + * + * Backend: /api/v1/admin/access/grants (admin only). + */ + +export type PrincipalType = "USER" | "TEAM"; +export type ResourceType = "PORTAL" | "INTEGRATION_CONFIG"; +export type AccessPermission = "USE" | "MANAGE"; + +/** One ACL row. `resourceId` is "" for the singleton PORTAL resource. */ +export interface ResourceGrant { + id: number; + resourceType: ResourceType; + resourceId: string; + principalType: PrincipalType; + principalId: number; + permission: AccessPermission; + createdAt?: string; +} + +/** Create body; `permission` defaults to USE, `resourceId` empty for PORTAL. */ +export interface GrantRequest { + resourceType: ResourceType; + resourceId?: string; + principalType: PrincipalType; + principalId: number; + permission?: AccessPermission; +} + +const BASE = "/api/v1/admin/access/grants"; + +/** GET /grants: every grant on one resource (PORTAL, or one config by id). */ +export async function fetchGrants( + resourceType: ResourceType, + resourceId = "", +): Promise { + const q = new URLSearchParams({ resourceType }); + if (resourceId) q.set("resourceId", resourceId); + return apiClient.local.json(`${BASE}?${q.toString()}`); +} + +/** POST /grants: grant (or, for a new permission, re-grant) access. */ +export async function createGrant(req: GrantRequest): Promise { + return apiClient.local.json(BASE, { + method: "POST", + body: req, + }); +} + +/** DELETE /grants/{id}: revoke a single grant row. */ +export async function revokeGrant(id: number): Promise { + await apiClient.local.json(`${BASE}/${id}`, { method: "DELETE" }); +} diff --git a/frontend/editor/src/portal/api/http.ts b/frontend/editor/src/portal/api/http.ts index d0398470eb..b479a226c7 100644 --- a/frontend/editor/src/portal/api/http.ts +++ b/frontend/editor/src/portal/api/http.ts @@ -188,6 +188,24 @@ async function localBlob( return res.blob(); } +/** POST an application/x-www-form-urlencoded body (Spring @RequestParam endpoints), + * via the localBackend seam — same base + auth as localJson. */ +async function localForm( + path: string, + params: Record, + method: "POST" | "PUT" | "DELETE" = "POST", +): Promise { + const res = await fetch(`${localBaseUrl()}${path}`, { + method, + headers: { Accept: "application/json", ...(await localAuthHeader()) }, + body: new URLSearchParams(params), + }); + if (res.status === 401) { + onLocalUnauthorized(); + } + return unwrap(res); +} + // ──────────────────────────────────────────────────────────────────────────── // saas — hosted SaaS Java, admin's Supabase JWT // ──────────────────────────────────────────────────────────────────────────── @@ -269,6 +287,7 @@ export const apiClient = { /** Local backend (this instance). Spring admin bearer auto-attached. */ local: { json: localJson, + form: localForm, blob: localBlob, }, /** Hosted SaaS Java. Admin's Supabase JWT auto-attached. */ diff --git a/frontend/editor/src/portal/api/teams.ts b/frontend/editor/src/portal/api/teams.ts new file mode 100644 index 0000000000..8ce2e36d30 --- /dev/null +++ b/frontend/editor/src/portal/api/teams.ts @@ -0,0 +1,76 @@ +import { apiClient } from "@portal/api/http"; + +/** + * Teams service layer. The roster groups people under teams, each with a Team + * Owner (a LEADER membership). Backed by the proprietary team endpoints + * (/api/v1/team/*) plus the admin ui-data teams summary. + */ + +export interface Team { + id: number; + name: string; + userCount: number; + /** Usernames of the team's owners (LEADER memberships). */ + owners: string[]; +} + +interface TeamsDto { + teamsWithCounts: { id: number; name: string; userCount: number }[]; + teamOwners: Record; +} + +/** GET the teams summary and fold the owners map onto each team. */ +export async function fetchTeams(): Promise { + const data = await apiClient.local.json( + "/api/v1/proprietary/ui-data/teams", + ); + return (data.teamsWithCounts ?? []).map((t) => ({ + id: t.id, + name: t.name, + userCount: t.userCount, + owners: data.teamOwners?.[String(t.id)] ?? [], + })); +} + +/** POST /api/v1/team/create. */ +export async function createTeam(name: string): Promise { + await apiClient.local.form("/api/v1/team/create", { name }); +} + +/** POST /api/v1/team/addUser. */ +export async function addUserToTeam( + teamId: number, + userId: string, +): Promise { + await apiClient.local.form("/api/v1/team/addUser", { + teamId: String(teamId), + userId, + }); +} + +/** POST /api/v1/team/setOwner. */ +export async function setTeamOwner( + teamId: number, + userId: string, +): Promise { + await apiClient.local.form("/api/v1/team/setOwner", { + teamId: String(teamId), + userId, + }); +} + +/** POST /api/v1/team/rename. */ +export async function renameTeam( + teamId: number, + newName: string, +): Promise { + await apiClient.local.form("/api/v1/team/rename", { + teamId: String(teamId), + newName, + }); +} + +/** POST /api/v1/team/delete (blocked by the backend if the team still has members/configs). */ +export async function deleteTeam(teamId: number): Promise { + await apiClient.local.form("/api/v1/team/delete", { teamId: String(teamId) }); +} diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts index 4c0fa5dcb6..0987f659ec 100644 --- a/frontend/editor/src/portal/api/users.ts +++ b/frontend/editor/src/portal/api/users.ts @@ -1,11 +1,13 @@ import { apiClient } from "@portal/api/http"; -import type { UsersResponse } from "@portal/mocks/users"; +import { ROLES } from "@portal/mocks/users"; +import type { Member, RoleId, UsersResponse } from "@portal/mocks/users"; import type { Tier } from "@portal/contexts/TierContext"; export type { AccessControls, Member, MemberStatus, + PortalAccessState, Role, RoleId, UsersResponse, @@ -13,14 +15,373 @@ export type { } from "@portal/mocks/users"; export { MEMBER_STATUS_TONE, + PORTAL_ACCESS_TONE, ROLES, ROLE_LABEL, ROLE_TONE, } from "@portal/mocks/users"; -/** GET /v1/users?tier=… — summary strip, members table, role catalogue, access. */ +/** Roles an admin can assign from the portal; guest is derived, not assigned. */ +export const ASSIGNABLE_ROLES: RoleId[] = ["admin", "team_owner", "member"]; + +/* ── backend payload (subset of AdminSettingsData) ─────────────────────── */ + +interface AdminUserSummaryDto { + id: number; + username: string; + email?: string; + rolesAsString?: string; + enabled: boolean; + teamLead?: boolean; + team?: { id: number; name: string }; + authenticationType?: string; + /** Authoritative server-side portal access (honors the configured default policy). */ + portalAccess?: boolean; +} + +interface AdminSettingsDto { + users: AdminUserSummaryDto[]; + userLastRequest?: Record; + userSettings?: Record>; + lockedUsers?: string[]; + mailEnabled?: boolean; + emailInvitesEnabled?: boolean; + totalUsers?: number; + maxAllowedUsers?: number; + currentUsername?: string; +} + +function roleIdFor(u: AdminUserSummaryDto): RoleId { + const role = u.rolesAsString ?? ""; + if (role.includes("ROLE_ADMIN")) return "admin"; + if (u.teamLead) return "team_owner"; + // Guest (web-only/demo) is hidden from the UI for now; surface as a member. + return "member"; +} + +/** A member's last-seen time as plain language; "Never" when no session is tracked. */ +function relativeTime(value: number | string | undefined): string { + if (value === undefined || value === null) return "Never"; + const ts = typeof value === "string" ? Date.parse(value) : value; + if (!Number.isFinite(ts) || ts <= 0) return "Never"; + const mins = Math.max(0, Math.round((Date.now() - ts) / 60000)); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 7) return `${days}d ago`; + const weeks = Math.round(days / 7); + if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`; + const months = Math.round(days / 30); + if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`; + const years = Math.round(days / 365); + return years <= 1 ? "1 year ago" : `${years} years ago`; +} + +/** 0 / huge sentinel license values mean "no seat limit". */ +function normalizeSeatLimit(max: number | undefined): number | null { + if (!max || max <= 0 || max >= 100000) return null; + return max; +} + +/** + * GET /api/v1/proprietary/ui-data/admin-settings adapted onto the portal's + * UsersResponse. Role = stored authority + team leadership; the role + * catalogue is client copy. `tier` shapes only the access card. + */ export async function fetchUsers(tier: Tier): Promise { - return apiClient.local.json( - `/v1/users?tier=${encodeURIComponent(tier)}`, + const data = await apiClient.local.json( + "/api/v1/proprietary/ui-data/admin-settings", + ); + const locked = new Set(data.lockedUsers ?? []); + const members: Member[] = (data.users ?? []).map((u) => ({ + id: String(u.id), + name: u.username, + email: u.email ?? u.username, + username: u.username, + teamId: u.team?.id, + teamName: u.team?.name, + role: roleIdFor(u), + teamLead: u.teamLead === true, + canAccessPortal: u.portalAccess === true, + isSelf: !!data.currentUsername && data.currentUsername === u.username, + status: u.enabled ? "active" : "suspended", + lastActive: relativeTime(data.userLastRequest?.[u.username]), + locked: locked.has(u.username), + mfaEnabled: data.userSettings?.[u.username]?.mfaEnabled === "true", + authType: u.authenticationType, + authority: u.rolesAsString, + })); + const seatLimit = normalizeSeatLimit(data.maxAllowedUsers); + const seatsUsed = data.totalUsers ?? members.length; + return { + summary: { + totalMembers: members.length, + pendingInvites: 0, + seatsUsed, + seatLimit, + }, + members, + roles: ROLES, + access: { tier, seatsUsed, seatLimit }, + mailEnabled: data.mailEnabled === true, + emailInvitesEnabled: data.emailInvitesEnabled === true, + }; +} + +/* ── row actions (Spring @RequestParam endpoints) ──────────────────────── */ + +async function setAuthority(username: string, role: string): Promise { + await apiClient.local.form("/api/v1/user/admin/changeRole", { + username, + role, + }); +} + +/** + * Reassign a member's canonical role. Admin/member map onto the stored ROLE_* + * authority; team owner is a LEADER membership on the user's team. Leadership is + * assigned/removed on `teamLead` (independent of the displayed role), and for + * team_owner the ownership call runs FIRST so a rejection can't strand a demote. + */ +export async function changeMemberRole( + member: Member, + target: RoleId, +): Promise { + if (!member.username) throw new Error("Member has no backend identity"); + if (target === member.role) return; + + const holdsAdmin = member.role === "admin"; + const holdsLeader = member.teamLead === true; + // A stored web-only/demo authority must be lifted to ROLE_USER for member/team_owner. + // roleIdFor() surfaces a web-only account as "member", so read the raw authority. + const holdsWebOnly = (member.authority ?? "").includes("ROLE_WEB_ONLY_USER"); + + if (target === "guest") { + // Demote to web-only; drop any leadership first so no team is left ownerless-by-a-guest. + if (holdsLeader && member.teamId) { + await apiClient.local.form("/api/v1/team/removeOwner", { + teamId: String(member.teamId), + userId: member.id, + }); + } + await setAuthority(member.username, "ROLE_WEB_ONLY_USER"); + return; + } + + if (target === "admin") { + if (!holdsAdmin) await setAuthority(member.username, "ROLE_ADMIN"); + return; // any LEADER membership is harmless; an admin owns everything anyway + } + + if (target === "team_owner") { + if (!member.teamId) { + throw new Error("Member must belong to a team to become its owner"); + } + // Assign ownership first so a 400 (e.g. system team) leaves the user unchanged. + await apiClient.local.form("/api/v1/team/setOwner", { + teamId: String(member.teamId), + userId: member.id, + }); + if (holdsAdmin || holdsWebOnly) + await setAuthority(member.username, "ROLE_USER"); + return; + } + + // target === "member": drop any leadership, and normalise a non-ROLE_USER authority. + if (holdsLeader && member.teamId) { + await apiClient.local.form("/api/v1/team/removeOwner", { + teamId: String(member.teamId), + userId: member.id, + }); + } + if (holdsAdmin || holdsWebOnly) + await setAuthority(member.username, "ROLE_USER"); +} + +export async function setMemberSuspended( + member: Member, + suspended: boolean, +): Promise { + if (!member.username) throw new Error("Member has no backend identity"); + await apiClient.local.form( + `/api/v1/user/admin/changeUserEnabled/${encodeURIComponent(member.username)}`, + { enabled: String(!suspended) }, + ); +} + +export async function removeMember(member: Member): Promise { + if (!member.username) throw new Error("Member has no backend identity"); + await apiClient.local.json( + `/api/v1/user/admin/deleteUser/${encodeURIComponent(member.username)}`, + { method: "POST" }, + ); +} + +export interface ResetPasswordOptions { + /** The new password (client-generated or admin-typed). */ + newPassword: string; + forcePasswordChange?: boolean; + sendEmail?: boolean; + includePassword?: boolean; +} + +/** Admin reset of a member's password (cannot target yourself). */ +export async function resetMemberPassword( + member: Member, + opts: ResetPasswordOptions, +): Promise { + if (!member.username) throw new Error("Member has no backend identity"); + const params: Record = { + username: member.username, + newPassword: opts.newPassword, + }; + if (opts.forcePasswordChange) params.forcePasswordChange = "true"; + if (opts.sendEmail) params.sendEmail = "true"; + if (opts.includePassword) params.includePassword = "true"; + await apiClient.local.form( + "/api/v1/user/admin/changePasswordForUser", + params, + ); +} + +/** Unlock an account locked after failed logins. */ +export async function unlockMember(member: Member): Promise { + if (!member.username) throw new Error("Member has no backend identity"); + await apiClient.local.json( + `/api/v1/user/admin/unlockUser/${encodeURIComponent(member.username)}`, + { method: "POST" }, + ); +} + +/** Reset (disable) a member's MFA enrolment. */ +export async function disableMemberMfa(member: Member): Promise { + if (!member.username) throw new Error("Member has no backend identity"); + await apiClient.local.json( + `/api/v1/auth/mfa/disable/admin/${encodeURIComponent(member.username)}`, + { method: "POST" }, + ); +} + +/** + * Move a member to a different team, keeping their role. The backend changeRole + * endpoint requires a single role, so we resolve one canonical authority from the + * member's stored roles (which may be a CSV) - preserving a web-only account and + * never silently promoting one to ROLE_USER. + */ +export async function moveMemberToTeam( + member: Member, + teamId: number, +): Promise { + if (!member.username) throw new Error("Member has no backend identity"); + const role = canonicalAuthority(member); + await apiClient.local.form("/api/v1/user/admin/changeRole", { + username: member.username, + role, + teamId: String(teamId), + }); +} + +/** + * The member's single canonical ROLE_* authority. `authority` is the raw stored + * rolesAsString, which may be a CSV; match on substrings so a compound value maps + * to one role. Web-only is preserved; team_owner/member both store as ROLE_USER + * (leadership is a separate membership, not an authority). + */ +function canonicalAuthority(member: Member): string { + const stored = member.authority ?? ""; + if (stored.includes("ROLE_ADMIN")) return "ROLE_ADMIN"; + if (stored.includes("ROLE_WEB_ONLY_USER")) return "ROLE_WEB_ONLY_USER"; + if (stored.includes("ROLE_USER")) return "ROLE_USER"; + // Authority absent/unrecognized: fall back to the displayed role, never upgrading. + return member.role === "admin" ? "ROLE_ADMIN" : "ROLE_USER"; +} + +/* ── direct account creation (self-hosted / password auth only) ────────── */ + +interface LoginConfigDto { + enableLogin?: boolean; + loginMethod?: string; + providerList?: Record; +} + +export interface AdminAuthConfig { + /** + * True only on a self-hosted instance with username/password login (loginMethod + * all|normal). SaaS (Supabase-authed) has no password accounts, so direct + * create is hidden there - and the portal can only reach a self-hosted backend + * anyway (it logs in via /api/v1/auth/login, which SaaS doesn't expose). + */ + canDirectCreate: boolean; + hasOauth: boolean; + hasSaml: boolean; +} + +/** Probe the login config to decide whether direct account creation is offered. */ +export async function fetchAuthConfig(): Promise { + const d = await apiClient.local.json( + "/api/v1/proprietary/ui-data/login", + ); + const method = (d.loginMethod ?? "all").toLowerCase(); + const keys = Object.keys(d.providerList ?? {}); + return { + canDirectCreate: + d.enableLogin === true && (method === "all" || method === "normal"), + hasOauth: keys.some((k) => k.includes("oauth2")), + hasSaml: keys.some((k) => k.includes("saml")), + }; +} + +export type AuthType = "WEB" | "OAUTH2" | "SAML2"; + +export interface CreateMemberParams { + username: string; + /** Required for WEB (password) accounts; omitted for OAUTH2/SAML2. */ + password?: string; + role: Extract; + teamId?: number; + authType: AuthType; + forceChange?: boolean; + forceMFA?: boolean; +} + +/** Create an account directly (self-hosted). Returns the created username. */ +export async function createMember(p: CreateMemberParams): Promise { + const params: Record = { + username: p.username, + role: p.role === "admin" ? "ROLE_ADMIN" : "ROLE_USER", + authType: p.authType, + }; + if (p.password) params.password = p.password; + if (p.teamId != null) params.teamId = String(p.teamId); + if (p.forceChange) params.forceChange = "true"; + if (p.forceMFA) params.forceMFA = "true"; + await apiClient.local.form("/api/v1/user/admin/saveUser", params); + return p.username; +} + +export interface InviteResult { + successCount?: number; + failureCount?: number; + message?: string; + errors?: string; + error?: string; +} + +/** Email invite; creates the account and mails a join link (mail required). */ +export async function inviteMember( + email: string, + role: Extract, + teamId?: number, +): Promise { + const params: Record = { + emails: email, + role: role === "admin" ? "ROLE_ADMIN" : "ROLE_USER", + }; + if (teamId != null) params.teamId = String(teamId); + return apiClient.local.form( + "/api/v1/user/admin/inviteUsers", + params, ); } diff --git a/frontend/editor/src/portal/api/usersCapabilities.ts b/frontend/editor/src/portal/api/usersCapabilities.ts new file mode 100644 index 0000000000..151c4c1df4 --- /dev/null +++ b/frontend/editor/src/portal/api/usersCapabilities.ts @@ -0,0 +1,53 @@ +/** + * Which user/team admin actions the current build's backend supports. + * + * The Users page UI and its data endpoints are shared across flavors; only this + * capability set differs. The endpoints are identical - the build flavor just + * selects the client + credential (`apiClient.local` in @portal/api/http, via the + * localBackend seam: self-hosted -> local Spring bearer, SaaS -> SaaS-backend + * Supabase bearer). Resolved at build time via the `@app/*` alias - see + * `src/proprietary/portal/usersCapabilities.ts` + * (self-hosted, org-admin: everything) and `src/saas/portal/usersCapabilities.ts` + * (SaaS, team-leader scoped: invite / rename / remove / seats only). + */ +export interface UsersCapabilities { + /** Show the "Organization" owners group (a single-org deployment). */ + orgGroup: boolean; + /** Let an admin reassign roles at all (the role Select). */ + changeRole: boolean; + /** + * Whether the "Org Owner" (ROLE_ADMIN) role can be held/assigned. Always false + * on SaaS - no SaaS user is ever ROLE_ADMIN, so it's dropped from the picker. + */ + adminRole: boolean; + /** Create a team from the roster ("+ New team"). */ + createTeam: boolean; + /** Delete a team. */ + deleteTeam: boolean; + /** Rename a team. */ + renameTeam: boolean; + /** Invite by email. */ + emailInvite: boolean; + /** Create an account directly with a password (self-hosted password login). */ + directCreate: boolean; + /** Admin password reset. */ + resetPassword: boolean; + /** Unlock a locked account. */ + unlock: boolean; + /** Reset (disable) a member's MFA. */ + resetMfa: boolean; + /** Suspend / reinstate an account. */ + suspend: boolean; + /** Move a member between teams. */ + moveTeam: boolean; + /** Per-team seat usage / limits (SaaS billing). */ + seats: boolean; + /** + * Manage Processor (portal) access grants. The grant endpoints are ADMIN-only, + * so this is off on SaaS (team leaders aren't admins) - the +Processor / grant + * controls are hidden there rather than shown as permanently-403 dead controls. + */ + manageGrants: boolean; + /** Whether "remove" takes the member out of the whole org or just the team. */ + removeScope: "org" | "team"; +} diff --git a/frontend/editor/src/portal/components/users/AccessControls.stories.tsx b/frontend/editor/src/portal/components/users/AccessControls.stories.tsx deleted file mode 100644 index 45f69ec8a3..0000000000 --- a/frontend/editor/src/portal/components/users/AccessControls.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { accessFor } from "@portal/mocks/users"; -import { AccessControls } from "@portal/components/users/AccessControls"; - -const meta: Meta = { - title: "Portal/Users/AccessControls", - component: AccessControls, - parameters: { layout: "padded" }, -}; -export default meta; -type Story = StoryObj; - -/** Free: seat limit + upgrade nudge only. */ -export const Free: Story = { - args: { access: accessFor("free") }, -}; - -/** Pro: adds self-service MFA + session toggles. */ -export const Pro: Story = { - args: { access: accessFor("pro") }, -}; - -/** Enterprise: SSO/SAML, SCIM provisioning and enforced MFA. */ -export const Enterprise: Story = { - args: { access: accessFor("enterprise") }, -}; diff --git a/frontend/editor/src/portal/components/users/AccessControls.tsx b/frontend/editor/src/portal/components/users/AccessControls.tsx deleted file mode 100644 index 9744e13141..0000000000 --- a/frontend/editor/src/portal/components/users/AccessControls.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Banner, - Button, - Card, - ProgressBar, - StatTile, - StatusBadge, - ToggleSwitch, -} from "@app/ui"; -import type { AccessControls as Access } from "@portal/api/users"; -import { seatsLabel } from "@portal/components/users/format"; -import "@portal/views/Users.css"; - -interface AccessControlsProps { - access: Access; -} - -/** - * Access posture for the org, scaling by tier: - * free — seat limit + upgrade nudge only - * pro — adds self-service MFA + session timeout toggles - * enterprise — adds SSO/SAML, SCIM provisioning and enforced MFA - * - * Toggles hold local state only; persisting them is a backend wiring task. - */ -export function AccessControls({ access }: AccessControlsProps) { - const { t } = useTranslation(); - const [mfaEnforced, setMfaEnforced] = useState(access.mfaEnforced ?? false); - const [shortSessions, setShortSessions] = useState(false); - - const seatPct = - access.seatLimit === null - ? 0 - : Math.min(1, access.seatsUsed / access.seatLimit); - - return ( -
-
-

- {t("portal.users.access.title")} -

-

- {t("portal.users.access.subtitle")} -

-
- -
- {/* Seats — shown on every tier. */} - -
-

- {t("portal.users.access.seats.title")} -

- - {seatsLabel(access.seatsUsed, access.seatLimit)} - -
- {access.seatLimit === null ? ( -

- {t("portal.users.access.seats.unlimited")} -

- ) : ( - - )} -
- - {/* Pro+: MFA + sessions self-service. */} - {access.mfaAvailable && ( - -

- {t("portal.users.access.auth.title")} -

-
-
- { - setMfaEnforced(v); - // TODO(backend): PATCH /v1/users/access { mfaEnforced } - }} - label={t("portal.users.access.auth.requireMfa.label")} - description={ - access.mfaEnforced - ? t("portal.users.access.auth.requireMfa.enforced") - : t("portal.users.access.auth.requireMfa.description") - } - disabled={access.mfaEnforced} - /> -
-
- { - setShortSessions(v); - // TODO(backend): PATCH /v1/users/access { sessionTimeout } - }} - label={t("portal.users.access.auth.shortSessions.label")} - description={t( - "portal.users.access.auth.shortSessions.description", - { - timeout: access.sessionTimeout, - }, - )} - /> -
-
-
- )} - - {/* Enterprise: SSO. */} - {access.sso && ( - -
-

- {t("portal.users.access.sso.title")} -

- - {access.sso.status === "connected" - ? t("portal.users.access.sso.connected") - : t("portal.users.access.sso.notConfigured")} - -
-
- - -
- -
- )} - - {/* Enterprise: SCIM provisioning. */} - {access.scim && ( - -
-

- {t("portal.users.access.scim.title")} -

- - {access.scim.enabled - ? t("portal.users.access.scim.active") - : t("portal.users.access.scim.off")} - -
-
- - -
-

- {t("portal.users.access.scim.note")} -

-
- )} -
- - {/* Free: upgrade nudge spans the section. */} - {access.upgradeHint && ( - - {t("portal.users.access.upgrade.action")} - - } - /> - )} -
- ); -} diff --git a/frontend/editor/src/portal/components/users/ConfirmModal.stories.tsx b/frontend/editor/src/portal/components/users/ConfirmModal.stories.tsx new file mode 100644 index 0000000000..92eeca3f42 --- /dev/null +++ b/frontend/editor/src/portal/components/users/ConfirmModal.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ConfirmModal } from "@portal/components/users/ConfirmModal"; + +const meta: Meta = { + title: "Portal/Users/ConfirmModal", + component: ConfirmModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + title: "Remove member", + body: "Permanently remove Sarah Kowalski from the organization? This cannot be undone.", + confirmLabel: "Remove from org", + danger: true, + onConfirm: () => {}, + onCancel: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Destructive confirm (red button). */ +export const Danger: Story = {}; + +/** Neutral confirm. */ +export const Neutral: Story = { + args: { + title: "Reset MFA", + body: "Remove this member's MFA enrolment?", + confirmLabel: "Reset MFA", + danger: false, + }, +}; diff --git a/frontend/editor/src/portal/components/users/ConfirmModal.tsx b/frontend/editor/src/portal/components/users/ConfirmModal.tsx new file mode 100644 index 0000000000..183b61bde3 --- /dev/null +++ b/frontend/editor/src/portal/components/users/ConfirmModal.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import { Button, Modal } from "@app/ui"; +import "@portal/views/Users.css"; + +interface ConfirmModalProps { + open: boolean; + title: string; + body: string; + confirmLabel: string; + /** Red confirm button for destructive actions. */ + danger?: boolean; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** Small reusable confirm dialog for destructive/irreversible actions. */ +export function ConfirmModal({ + open, + title, + body, + confirmLabel, + danger, + busy, + onConfirm, + onCancel, +}: ConfirmModalProps) { + const { t } = useTranslation(); + return ( + + + +
+ } + > +

{body}

+ + ); +} diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.stories.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.stories.tsx index cffa89d54d..9d1127d8a5 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.stories.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.stories.tsx @@ -1,16 +1,72 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; +import type { Team } from "@portal/api/teams"; + +const TEAMS: Team[] = [ + { id: 1, name: "Default", userCount: 4, owners: [] }, + { id: 2, name: "Engineering", userCount: 3, owners: ["tom"] }, + { id: 3, name: "Compliance", userCount: 2, owners: ["dana"] }, +]; const meta: Meta = { title: "Portal/Users/InviteMemberModal", component: InviteMemberModal, parameters: { layout: "fullscreen" }, - args: { open: true, onClose: () => {} }, + args: { + open: true, + onClose: () => {}, + onInvited: () => {}, + teams: TEAMS, + // Admin build: the Processor access option is offered (gated on grant management). + manageGrants: true, + }, }; export default meta; type Story = StoryObj; -/** Email + role; Send invite validates locally then closes (demo shell). */ +/** Invite by email with a role, team, and starting access level. */ export const Open: Story = {}; +/** Opened from a team's "Add to team" — the team is preselected. */ +export const ScopedToTeam: Story = { + args: { defaultTeamId: 2 }, +}; + +/** Self-hosted with mail configured: the email/create toggle is offered; opens in + * create mode (the default when account creation is available). */ +export const SelfHostedDirectCreate: Story = { + args: { + canDirectCreate: true, + canEmailInvite: true, + hasOauth: true, + hasSaml: true, + }, +}; + +/** Self-hosted without SMTP/invites: no "Invite by email" option at all - just the + * create-account form. */ +export const SelfHostedNoMail: Story = { + args: { + canDirectCreate: true, + canEmailInvite: false, + hasOauth: true, + hasSaml: true, + }, +}; + +/** The direct "Create account" form: username, password, sign-in method, force-MFA. */ +export const CreateAccountForm: Story = { + args: { + canDirectCreate: true, + hasOauth: true, + hasSaml: true, + initialMode: "direct", + }, +}; + +/** SaaS: no "admin" (Org Owner) option and no Processor grant (both admin-only). */ +export const NoAdminRole: Story = { + args: { adminRole: false, manageGrants: false }, +}; + export const Closed: Story = { args: { open: false } }; diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx new file mode 100644 index 0000000000..cf43743817 --- /dev/null +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.test.tsx @@ -0,0 +1,72 @@ +import type { ComponentProps } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +// Deterministic i18n: render the English fallback so assertions read naturally. +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, fallback?: string) => fallback ?? key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +vi.mock("@portal/contexts/TierContext", () => ({ + useTier: () => ({ tier: "pro" }), +})); + +// The modal only calls these on submit; stub so imports resolve and no fetch fires. +vi.mock("@portal/api/users", () => ({ + createMember: vi.fn(), + fetchUsers: vi.fn().mockResolvedValue({ members: [] }), + inviteMember: vi.fn(), + ROLE_LABEL: { member: "Member", admin: "Admin" }, +})); +vi.mock("@portal/api/access", () => ({ createGrant: vi.fn() })); + +import { InviteMemberModal } from "@portal/components/users/InviteMemberModal"; +import type { Team } from "@portal/api/teams"; + +const TEAMS: Team[] = [{ id: 1, name: "Default", userCount: 1, owners: [] }]; + +function renderModal(props: Partial>) { + return render( + + {}} teams={TEAMS} {...props} /> + , + ); +} + +describe("InviteMemberModal — add-user method gating", () => { + it("SaaS (email only): no method toggle, opens to the email field", () => { + renderModal({ canDirectCreate: false, canEmailInvite: true }); + expect(screen.queryByText("How to add them")).not.toBeInTheDocument(); + expect(screen.getByText("Email address")).toBeInTheDocument(); + expect(screen.queryByText("Username")).not.toBeInTheDocument(); + }); + + it("self-hosted with mail: offers the toggle and defaults to create-account", () => { + renderModal({ canDirectCreate: true, canEmailInvite: true }); + expect(screen.getByText("How to add them")).toBeInTheDocument(); + // Default mode is create-account (username field, not email). + expect(screen.getByText("Username")).toBeInTheDocument(); + expect(screen.queryByText("Email address")).not.toBeInTheDocument(); + }); + + it("self-hosted without SMTP: no email option at all, create-account only", () => { + renderModal({ canDirectCreate: true, canEmailInvite: false }); + expect(screen.queryByText("How to add them")).not.toBeInTheDocument(); + expect(screen.getByText("Username")).toBeInTheDocument(); + expect(screen.queryByText("Email address")).not.toBeInTheDocument(); + }); + + it("clamps initialMode=email to create-account when email is unavailable", () => { + renderModal({ + canDirectCreate: true, + canEmailInvite: false, + initialMode: "email", + }); + expect(screen.getByText("Username")).toBeInTheDocument(); + expect(screen.queryByText("Email address")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx index fe90105cf5..305bcc622d 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx @@ -1,54 +1,253 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Button, FormField, Input, Modal, Select } from "@app/ui"; -import { type RoleId, ROLES } from "@portal/api/users"; +import { Button, Checkbox, FormField, Input, Modal, Select } from "@app/ui"; +import { + createMember, + fetchUsers, + inviteMember, + ROLE_LABEL, + type AuthType, +} from "@portal/api/users"; +import { createGrant } from "@portal/api/access"; +import { errorMessage } from "@portal/api/http"; +import type { Team } from "@portal/api/teams"; +import { useTier } from "@portal/contexts/TierContext"; import "@portal/views/Users.css"; interface InviteMemberModalProps { open: boolean; onClose: () => void; + onInvited?: () => void; + teams: Team[]; + defaultTeamId?: number | null; + /** Self-hosted with password login: enables the "Create account" mode. */ + canDirectCreate?: boolean; + /** Whether "Invite by email" is offered. Off when SMTP/invites aren't configured + * (self-hosted); always on for SaaS. */ + canEmailInvite?: boolean; + hasOauth?: boolean; + hasSaml?: boolean; + /** Whether the "admin" (Org Owner) role can be assigned. Off on SaaS. */ + adminRole?: boolean; + /** Whether portal-access grants can be created (ADMIN-only). Gates the Processor option. */ + manageGrants?: boolean; + /** Non-blocking notice back to the parent (e.g. a deferred Processor grant). */ + onNotice?: (message: string) => void; + /** Force the initial mode (mainly for Storybook). Defaults to "direct" when account + * creation is available, else "email". */ + initialMode?: "email" | "direct"; } -const ROLE_SELECT_OPTIONS = ROLES.map((r) => ({ - value: r.id, - label: r.label, -})); +type InviteRole = "member" | "admin"; +type Mode = "email" | "direct"; -/** Org Owner is reserved for transfer flows — invites default to Developer. */ -const DEFAULT_ROLE: RoleId = "developer"; +const ROLE_SELECT_OPTIONS: { value: InviteRole; label: string }[] = [ + { value: "member", label: ROLE_LABEL.member }, + { value: "admin", label: ROLE_LABEL.admin }, +]; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -/** - * Invite-by-email shell. Submitting validates locally then closes without - * sending — wiring the submit to the backend dispatches the invitation. - */ -export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { +export function InviteMemberModal({ + open, + onClose, + onInvited, + teams, + defaultTeamId, + canDirectCreate = false, + canEmailInvite = true, + hasOauth = false, + hasSaml = false, + adminRole = true, + manageGrants = false, + onNotice, + initialMode, +}: InviteMemberModalProps) { const { t } = useTranslation(); + const { tier } = useTier(); + const [mode, setMode] = useState("email"); const [email, setEmail] = useState(""); - const [role, setRole] = useState(DEFAULT_ROLE); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [authType, setAuthType] = useState("WEB"); + const [forceChange, setForceChange] = useState(true); + const [forceMFA, setForceMFA] = useState(false); + const [role, setRole] = useState("member"); + const [teamId, setTeamId] = useState(""); + const [processor, setProcessor] = useState(false); const [touched, setTouched] = useState(false); + const [sending, setSending] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // Which add-user modes this flavor/config offers. Self-hosted offers direct create; + // SaaS offers email; self-hosted also offers email once SMTP + invites are configured. + const directAvailable = canDirectCreate; + const emailAvailable = canEmailInvite; + const preferredMode: Mode = directAvailable ? "direct" : "email"; + + useEffect(() => { + if (!open) return; + // Honor an explicit initialMode only when that mode is actually available. + const requested: Mode = + initialMode && + (initialMode === "email" ? emailAvailable : directAvailable) + ? initialMode + : preferredMode; + setMode(requested); + setTeamId( + defaultTeamId != null + ? String(defaultTeamId) + : teams.length > 0 + ? String(teams[0].id) + : "", + ); + }, [ + open, + defaultTeamId, + teams, + directAvailable, + emailAvailable, + preferredMode, + initialMode, + ]); + + // A server-side submit error must not outlive the input that caused it. + useEffect(() => { + setSubmitError(null); + }, [email, username, password]); + + // Drop the "admin" (Org Owner) option where it can't be assigned (SaaS). + const roleOptions = adminRole + ? ROLE_SELECT_OPTIONS + : ROLE_SELECT_OPTIONS.filter((o) => o.value !== "admin"); + + const authTypeOptions: { value: AuthType; label: string }[] = [ + { value: "WEB", label: t("users.invite.authWeb", "Password") }, + ...(hasOauth + ? [ + { + value: "OAUTH2" as AuthType, + label: t("users.invite.authOauth", "OAuth2 / SSO"), + }, + ] + : []), + ...(hasSaml + ? [ + { + value: "SAML2" as AuthType, + label: t("users.invite.authSaml", "SAML 2.0"), + }, + ] + : []), + ]; const emailValid = EMAIL_RE.test(email.trim()); + const usernameValid = username.trim().length >= 3; + const needsPassword = mode === "direct" && authType === "WEB"; + const passwordValid = !needsPassword || password.length >= 8; + const error = - touched && !emailValid ? t("portal.users.invite.emailError") : undefined; + (touched && mode === "email" && !emailValid + ? t("users.invite.emailError", "Enter a valid email address") + : undefined) ?? + (touched && mode === "direct" && !usernameValid + ? t( + "users.invite.usernameError", + "Username must be at least 3 characters", + ) + : undefined) ?? + (touched && mode === "direct" && !passwordValid + ? t( + "users.invite.passwordError", + "Password must be at least 8 characters", + ) + : undefined) ?? + submitError ?? + undefined; function close() { onClose(); - // Reset for the next open, after the close transition has finished. setTimeout(() => { setEmail(""); - setRole(DEFAULT_ROLE); + setUsername(""); + setPassword(""); + setAuthType("WEB"); + setForceChange(true); + setForceMFA(false); + setRole("member"); + setProcessor(false); setTouched(false); + setSubmitError(null); }, 200); } - function submit() { + // Grant Processor to the just-added user. Returns false when it couldn't be applied + // (an email invitee usually isn't a resolvable user yet, or the grant call is denied). + async function grantProcessor( + match: (m: { email: string; username?: string }) => boolean, + ): Promise { + const { members } = await fetchUsers(tier); + const user = members.find(match); + if (!user) return false; + await createGrant({ + resourceType: "PORTAL", + resourceId: "", + principalType: "USER", + principalId: Number(user.id), + permission: "USE", + }); + return true; + } + + async function submit() { setTouched(true); - if (!emailValid) return; - // TODO(backend): POST /v1/users/invitations { email, role } — send the - // invite, then close on success and refetch the members list. - close(); + setSubmitError(null); + if (sending) return; + const teamNum = teamId ? Number(teamId) : undefined; + setSending(true); + try { + let processorApplied = true; + if (mode === "email") { + if (!emailValid) return; + const result = await inviteMember(email.trim(), role, teamNum); + if (result?.error || result?.errors) { + setSubmitError(result.error ?? result.errors ?? null); + return; + } + if (processor) + processorApplied = await grantProcessor( + (m) => m.email === email.trim() || m.username === email.trim(), + ).catch(() => false); + } else { + if (!usernameValid || !passwordValid) return; + const created = await createMember({ + username: username.trim(), + password: authType === "WEB" ? password : undefined, + role, + teamId: teamNum, + authType, + forceChange, + forceMFA, + }); + if (processor) + processorApplied = await grantProcessor( + (m) => m.username === created, + ).catch(() => false); + } + if (processor && !processorApplied) + onNotice?.( + t( + "users.invite.processorDeferred", + "Invite sent, but Processor access couldn't be granted yet - set it from the roster once they've joined.", + ), + ); + onInvited?.(); + close(); + } catch (e) { + setSubmitError(errorMessage(e)); + } finally { + setSending(false); + } } return ( @@ -56,44 +255,184 @@ export function InviteMemberModal({ open, onClose }: InviteMemberModalProps) { open={open} onClose={close} width="sm" - title={t("portal.common.inviteMember")} - subtitle={t("portal.users.invite.subtitle")} + title={ + mode === "direct" + ? t("users.invite.createTitle", "Create account") + : t("users.invite.title", "Invite people") + } + subtitle={ + mode === "direct" + ? t( + "users.invite.createSubtitle", + "Create a self-hosted account with a password or SSO.", + ) + : t( + "users.invite.subtitle2", + "They'll get an email to join your Stirling workspace.", + ) + } footer={
-
} >
- - setEmail(e.target.value)} - onBlur={() => setTouched(true)} + {directAvailable && emailAvailable && ( + + setEmail(e.target.value)} + onBlur={() => setTouched(true)} + /> + + ) : ( + <> + + setUsername(e.target.value)} + onBlur={() => setTouched(true)} + /> + + {authTypeOptions.length > 1 && ( + + setPassword(e.target.value)} + /> + + )} + + )} + +
+ + ({ + value: String(tm.id), + label: tm.name, + }))} + value={teamId} + onChange={(value) => setTeamId(value ?? "")} + /> + +
+ + {mode === "direct" && ( +
+ setForceChange(e.target.checked)} + label={t( + "users.invite.forceChange", + "Require a password change on first login", + )} + disabled={authType !== "WEB"} + /> + setForceMFA(e.target.checked)} + label={t( + "users.invite.forceMfa", + "Require MFA setup on first login", + )} + /> +
+ )} + +
+ + {t("users.invite.access", "Access")} + + - - - setTeamId(value ?? "")} + /> + + {error && ( +

+ {error} +

+ )} +
+ + ); +} diff --git a/frontend/editor/src/portal/components/users/NewTeamModal.stories.tsx b/frontend/editor/src/portal/components/users/NewTeamModal.stories.tsx new file mode 100644 index 0000000000..ce0b5409ce --- /dev/null +++ b/frontend/editor/src/portal/components/users/NewTeamModal.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NewTeamModal } from "@portal/components/users/NewTeamModal"; + +const meta: Meta = { + title: "Portal/Users/NewTeamModal", + component: NewTeamModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + onClose: () => {}, + onCreated: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Create a team and (optionally) invite its owner. */ +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/users/NewTeamModal.tsx b/frontend/editor/src/portal/components/users/NewTeamModal.tsx new file mode 100644 index 0000000000..cc7cf26e81 --- /dev/null +++ b/frontend/editor/src/portal/components/users/NewTeamModal.tsx @@ -0,0 +1,158 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, FormField, Input, Modal } from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { createTeam, fetchTeams, setTeamOwner } from "@portal/api/teams"; +import { fetchUsers, inviteMember } from "@portal/api/users"; +import { useTier } from "@portal/contexts/TierContext"; +import "@portal/views/Users.css"; + +interface NewTeamModalProps { + open: boolean; + onClose: () => void; + /** Called after a team is created so the roster refetches. */ + onCreated: () => void; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Create a team and optionally invite its owner. The team is created first, so + * even if the owner invite needs mail (unavailable) the team still lands and an + * owner can be assigned from the roster. + */ +export function NewTeamModal({ open, onClose, onCreated }: NewTeamModalProps) { + const { t } = useTranslation(); + const { tier } = useTier(); + const [name, setName] = useState(""); + const [ownerEmail, setOwnerEmail] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [note, setNote] = useState(null); + + function close() { + onClose(); + setTimeout(() => { + setName(""); + setOwnerEmail(""); + setError(null); + setNote(null); + }, 200); + } + + async function inviteOwner(teamName: string, email: string) { + const teams = await fetchTeams(); + const team = teams.find((tm) => tm.name === teamName); + if (!team) return; + await inviteMember(email, "member", team.id); + const { members } = await fetchUsers(tier); + const owner = members.find( + (m) => m.email === email || m.username === email, + ); + if (owner) await setTeamOwner(team.id, owner.id); + } + + async function submit() { + setError(null); + setNote(null); + if (!name.trim()) { + setError(t("users.newTeam.nameRequired", "Team name is required")); + return; + } + const email = ownerEmail.trim(); + if (email && !EMAIL_RE.test(email)) { + setError(t("users.newTeam.emailError", "Enter a valid email address")); + return; + } + setSaving(true); + try { + await createTeam(name.trim()); + let ownerFailed = false; + if (email) { + try { + await inviteOwner(name.trim(), email); + } catch { + // Team is created; keep the modal open so this recovery note stays + // readable (owner invite needs mail, can be redone from the roster). + ownerFailed = true; + setNote( + t( + "users.newTeam.ownerFailed", + "Team created, but the owner couldn't be invited. Assign one from the roster.", + ), + ); + } + } + onCreated(); + if (!ownerFailed) close(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSaving(false); + } + } + + return ( + + + +
+ } + > +
+ + setName(e.target.value)} + /> + + + setOwnerEmail(e.target.value)} + /> + + {note && ( +

+ {note} +

+ )} + {error && ( +

+ {error} +

+ )} +
+ + ); +} diff --git a/frontend/editor/src/portal/components/users/RenameTeamModal.stories.tsx b/frontend/editor/src/portal/components/users/RenameTeamModal.stories.tsx new file mode 100644 index 0000000000..6e8cba5aea --- /dev/null +++ b/frontend/editor/src/portal/components/users/RenameTeamModal.stories.tsx @@ -0,0 +1,19 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { RenameTeamModal } from "@portal/components/users/RenameTeamModal"; + +const meta: Meta = { + title: "Portal/Users/RenameTeamModal", + component: RenameTeamModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + teamId: 2, + currentName: "Engineering", + onClose: () => {}, + onDone: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/users/RenameTeamModal.tsx b/frontend/editor/src/portal/components/users/RenameTeamModal.tsx new file mode 100644 index 0000000000..d9f1f4b66c --- /dev/null +++ b/frontend/editor/src/portal/components/users/RenameTeamModal.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, FormField, Input, Modal } from "@app/ui"; +import { renameTeam } from "@portal/api/teams"; +import { errorMessage } from "@portal/api/http"; +import "@portal/views/Users.css"; + +interface RenameTeamModalProps { + open: boolean; + teamId: number | null; + currentName: string; + onClose: () => void; + onDone: () => void; +} + +/** Rename a team. */ +export function RenameTeamModal({ + open, + teamId, + currentName, + onClose, + onDone, +}: RenameTeamModalProps) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setName(currentName); + setError(null); + }, [open, currentName]); + + async function submit() { + if (teamId == null || !name.trim()) return; + setSaving(true); + setError(null); + try { + await renameTeam(teamId, name.trim()); + onDone(); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+ + setName(e.target.value)} /> + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/users/ResetPasswordModal.stories.tsx b/frontend/editor/src/portal/components/users/ResetPasswordModal.stories.tsx new file mode 100644 index 0000000000..24a6f58308 --- /dev/null +++ b/frontend/editor/src/portal/components/users/ResetPasswordModal.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ResetPasswordModal } from "@portal/components/users/ResetPasswordModal"; +import type { Member } from "@portal/api/users"; + +const MEMBER: Member = { + id: "3", + name: "Sarah Kowalski", + email: "sarah@stirlingpdf.com", + role: "member", + status: "active", + lastActive: "30m ago", + username: "sarah", +}; + +const meta: Meta = { + title: "Portal/Users/ResetPasswordModal", + component: ResetPasswordModal, + parameters: { layout: "fullscreen" }, + args: { + open: true, + member: MEMBER, + mailEnabled: false, + onClose: () => {}, + onDone: () => {}, + }, +}; +export default meta; +type Story = StoryObj; + +/** Auto-generate a secure password (copy + regenerate). */ +export const Default: Story = {}; + +/** With SMTP configured, the admin can email the reset. */ +export const WithEmail: Story = { args: { mailEnabled: true } }; diff --git a/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx b/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx new file mode 100644 index 0000000000..d1cfe9e739 --- /dev/null +++ b/frontend/editor/src/portal/components/users/ResetPasswordModal.tsx @@ -0,0 +1,211 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button, Checkbox, FormField, Input, Modal } from "@app/ui"; +import { resetMemberPassword, type Member } from "@portal/api/users"; +import { errorMessage } from "@portal/api/http"; +import "@portal/views/Users.css"; + +interface ResetPasswordModalProps { + open: boolean; + member: Member | null; + /** SMTP configured - gates the "email the password" options. */ + mailEnabled: boolean; + onClose: () => void; + onDone: () => void; +} + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Cryptographically secure password with rejection sampling (no modulo bias). */ +function generatePassword(len = 16): string { + const chars = + "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*"; + const max = Math.floor(256 / chars.length) * chars.length; + const out: string[] = []; + const buf = new Uint8Array(1); + while (out.length < len) { + crypto.getRandomValues(buf); + if (buf[0] < max) out.push(chars[buf[0] % chars.length]); + } + return out.join(""); +} + +/** Admin reset of a member's password: auto-generate (with copy) or set manually. */ +export function ResetPasswordModal({ + open, + member, + mailEnabled, + onClose, + onDone, +}: ResetPasswordModalProps) { + const { t } = useTranslation(); + const [autoGenerate, setAutoGenerate] = useState(true); + const [generated, setGenerated] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [forceChange, setForceChange] = useState(true); + const [sendEmail, setSendEmail] = useState(false); + const [includePassword, setIncludePassword] = useState(false); + const [copied, setCopied] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const canEmail = mailEnabled && EMAIL_RE.test(member?.email ?? ""); + + useEffect(() => { + if (!open) return; + setAutoGenerate(true); + setGenerated(generatePassword()); + setPassword(""); + setConfirm(""); + setForceChange(true); + setSendEmail(false); + setIncludePassword(false); + setCopied(false); + setError(null); + }, [open]); + + function copy() { + void navigator.clipboard?.writeText(generated)?.then(() => setCopied(true)); + } + + async function submit() { + if (!member) return; + setError(null); + const newPassword = autoGenerate ? generated : password; + if (!autoGenerate) { + if (newPassword.length < 8) { + setError( + t("users.resetPw.tooShort", "Password must be at least 8 characters"), + ); + return; + } + if (newPassword !== confirm) { + setError(t("users.resetPw.mismatch", "Passwords do not match")); + return; + } + } + setSaving(true); + try { + await resetMemberPassword(member, { + newPassword, + forcePasswordChange: forceChange, + sendEmail: canEmail && sendEmail, + includePassword: canEmail && sendEmail && includePassword, + }); + onDone(); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > +
+ setAutoGenerate(e.target.checked)} + label={t("users.resetPw.generate", "Generate a secure password")} + /> + + {autoGenerate ? ( + +
+ + + +
+
+ ) : ( + <> + + setPassword(e.target.value)} + /> + + + setConfirm(e.target.value)} + /> + + + )} + + setForceChange(e.target.checked)} + label={t( + "users.resetPw.forceChange", + "Require a password change on next login", + )} + /> + + {canEmail && ( + <> + setSendEmail(e.target.checked)} + label={t("users.resetPw.email", "Email the user about the reset")} + /> + setIncludePassword(e.target.checked)} + label={t( + "users.resetPw.includePw", + "Include the new password in the email", + )} + /> + + )} + + {error && ( +

+ {error} +

+ )} +
+
+ ); +} diff --git a/frontend/editor/src/portal/components/users/RolesGrid.stories.tsx b/frontend/editor/src/portal/components/users/RolesGrid.stories.tsx deleted file mode 100644 index 386e61fc99..0000000000 --- a/frontend/editor/src/portal/components/users/RolesGrid.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { ROLES } from "@portal/mocks/users"; -import { RolesGrid } from "@portal/components/users/RolesGrid"; - -const meta: Meta = { - title: "Portal/Users/RolesGrid", - component: RolesGrid, - parameters: { layout: "padded" }, - args: { roles: ROLES }, -}; -export default meta; -type Story = StoryObj; - -/** The five org roles, most → least privileged. */ -export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/users/RolesGrid.tsx b/frontend/editor/src/portal/components/users/RolesGrid.tsx deleted file mode 100644 index 24527f35be..0000000000 --- a/frontend/editor/src/portal/components/users/RolesGrid.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Card, Chip } from "@app/ui"; -import type { Role } from "@portal/api/users"; -import { chipAccentForRole } from "@portal/components/users/format"; -import "@portal/views/Users.css"; - -interface RolesGridProps { - roles: Role[]; -} - -/** Reference catalogue of the org roles and what each one can do. */ -export function RolesGrid({ roles }: RolesGridProps) { - const { t } = useTranslation(); - return ( -
-
-

- {t("portal.users.roles.title")} -

-

- {t("portal.users.roles.subtitle")} -

-
-
- {roles.map((role) => ( - -
- - {role.label} - -
-

{role.summary}

-
    - {role.permissions.map((perm) => ( -
  • {perm}
  • - ))} -
-
- ))} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx new file mode 100644 index 0000000000..b7fb277854 --- /dev/null +++ b/frontend/editor/src/portal/components/users/UsersDirectory.stories.tsx @@ -0,0 +1,324 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UsersDirectory } from "@portal/components/users/UsersDirectory"; +import type { Member } from "@portal/api/users"; +import type { Team } from "@portal/api/teams"; +import type { UsersCapabilities } from "@portal/api/usersCapabilities"; + +/** Self-hosted org-admin: the full action set. */ +const FULL_CAPS: UsersCapabilities = { + orgGroup: true, + changeRole: true, + adminRole: true, + createTeam: true, + deleteTeam: true, + renameTeam: true, + emailInvite: true, + directCreate: true, + resetPassword: true, + unlock: true, + resetMfa: true, + suspend: true, + moveTeam: true, + seats: false, + manageGrants: true, + removeScope: "org", +}; + +/** SaaS team-leader: invite / rename / remove-member only, no org group. */ +const SAAS_CAPS: UsersCapabilities = { + orgGroup: false, + changeRole: false, + adminRole: false, + createTeam: false, + deleteTeam: false, + renameTeam: true, + emailInvite: true, + directCreate: false, + resetPassword: false, + unlock: false, + resetMfa: false, + suspend: false, + moveTeam: false, + seats: true, + manageGrants: false, + removeScope: "team", +}; + +/** A full org: one org owner and two teams, each with a leader. */ +const MEMBERS: Member[] = [ + { + id: "1", + name: "Matt Joseph", + email: "matt@stirlingpdf.com", + role: "admin", + status: "active", + lastActive: "Now", + username: "matt", + teamId: 1, + teamName: "Default", + isSelf: true, + portalAccess: "admin", + }, + { + id: "2", + name: "Tom Reilly", + email: "tom@stirlingpdf.com", + role: "team_owner", + status: "active", + lastActive: "12m ago", + username: "tom", + teamId: 2, + teamName: "Engineering", + teamLead: true, + portalAccess: "role", + }, + { + id: "3", + name: "Sarah Kowalski", + email: "sarah@stirlingpdf.com", + role: "member", + status: "active", + lastActive: "30m ago", + username: "sarah", + teamId: 2, + teamName: "Engineering", + portalAccess: "granted", + portalGrantId: 10, + }, + { + id: "4", + name: "Priya Patel", + email: "priya@stirlingpdf.com", + role: "member", + status: "active", + lastActive: "Never", + username: "priya", + teamId: 2, + teamName: "Engineering", + portalAccess: "none", + }, + { + id: "5", + name: "Dana Okafor", + email: "dana@stirlingpdf.com", + role: "team_owner", + status: "active", + lastActive: "1h ago", + username: "dana", + teamId: 3, + teamName: "Compliance", + teamLead: true, + portalAccess: "role", + }, + { + id: "6", + name: "Lars Eriksson", + email: "lars@stirlingpdf.com", + role: "member", + status: "active", + lastActive: "3h ago", + username: "lars", + teamId: 3, + teamName: "Compliance", + portalAccess: "granted", + portalGrantId: 11, + }, +]; + +/** An external guest (parked concept, shown via showGuests). */ +const GUEST: Member = { + id: "7", + name: "Meridian Legal", + email: "legal@meridian-partners.com", + role: "guest", + status: "active", + lastActive: "2d ago", + username: "legal", + teamId: 1, + teamName: "Default", + portalAccess: "none", +}; + +/** Members exercising the status states: suspended, locked, and MFA-enrolled. */ +const STATE_MEMBERS: Member[] = [ + { + id: "1", + name: "Matt Joseph", + email: "matt@stirlingpdf.com", + role: "admin", + status: "active", + lastActive: "Now", + username: "matt", + teamId: 1, + teamName: "Default", + isSelf: true, + portalAccess: "admin", + }, + { + id: "s1", + name: "Nadia Costa", + email: "nadia@acme.com", + role: "member", + status: "suspended", + lastActive: "12 days ago", + username: "nadia", + teamId: 2, + teamName: "Engineering", + portalAccess: "none", + }, + { + id: "s2", + name: "Leo Fischer", + email: "leo@acme.com", + role: "member", + status: "active", + lastActive: "1h ago", + username: "leo", + teamId: 2, + teamName: "Engineering", + portalAccess: "granted", + portalGrantId: 20, + locked: true, + }, + { + id: "s3", + name: "Aisha Rahman", + email: "aisha@acme.com", + role: "team_owner", + status: "active", + lastActive: "3m ago", + username: "aisha", + teamId: 2, + teamName: "Engineering", + teamLead: true, + portalAccess: "role", + mfaEnabled: true, + }, +]; + +/** A big team (>8) to exercise the "Show all" / "Show less" expander. */ +const BIG_MEMBERS: Member[] = [ + { + id: "b0", + name: "Matt Joseph", + email: "matt@acme.com", + role: "admin", + status: "active", + lastActive: "Now", + username: "matt", + teamId: 1, + isSelf: true, + portalAccess: "admin", + }, + ...Array.from( + { length: 11 }, + (_, i): Member => ({ + id: `big-${i}`, + name: `Teammate ${i + 1}`, + email: `teammate${i + 1}@acme.com`, + role: i === 0 ? "team_owner" : "member", + status: "active", + lastActive: `${i + 1}h ago`, + username: `tm${i + 1}`, + teamId: 9, + teamName: "Platform", + teamLead: i === 0, + portalAccess: i === 0 ? "role" : "none", + }), + ), +]; + +const TEAMS: Team[] = [ + { id: 2, name: "Engineering", userCount: 3, owners: ["tom"] }, + { id: 3, name: "Compliance", userCount: 2, owners: ["dana"] }, +]; + +const meta: Meta = { + title: "Portal/Users/UsersDirectory", + component: UsersDirectory, + parameters: { layout: "padded" }, + args: { + members: MEMBERS, + teams: TEAMS, + capabilities: FULL_CAPS, + onChangeRole: () => {}, + onGrantProcessor: () => {}, + onRevokeProcessor: () => {}, + processorTeamIds: new Set(), + onGrantTeamProcessor: () => {}, + onRevokeTeamProcessor: () => {}, + onAddToTeam: () => {}, + onResetPassword: () => {}, + onMoveToTeam: () => {}, + onToggleEnabled: () => {}, + onUnlock: () => {}, + onDisableMfa: () => {}, + onRemove: () => {}, + onRenameTeam: () => {}, + onDeleteTeam: () => {}, + // Documents the intended "Approves policy" chip; hidden in the live app. + showApprover: true, + }, +}; +export default meta; +type Story = StoryObj; + +/** Organization owner plus two teams, each with a leader. */ +export const Default: Story = {}; + +/** + * SaaS build (team-leader scope): no Organization group, no role select, no + * password/suspend actions - just invite / rename / remove-from-team. + */ +export const SaasTeamLeader: Story = { + args: { + members: MEMBERS.filter((m) => m.role !== "admin"), + capabilities: SAAS_CAPS, + }, +}; + +/** A solo workspace: just the org owner, no teams yet. */ +export const OrgOnly: Story = { + args: { members: MEMBERS.filter((m) => m.role === "admin"), teams: [] }, +}; + +/** A team with a team-wide Processor grant: every member inherits it (solid, non-removable). */ +export const TeamWideProcessor: Story = { + args: { + members: MEMBERS.map((m) => + m.teamId === 2 && m.role === "member" + ? { ...m, portalAccess: "team" as const, portalGrantId: undefined } + : m, + ), + processorTeamIds: new Set([2]), + }, +}; + +/** One team, mixed Processor access (implicit, granted, and not granted). */ +export const SingleTeam: Story = { + args: { + members: MEMBERS.filter((m) => m.teamId === 2 || m.role === "admin"), + teams: TEAMS.filter((t) => t.id === 2), + }, +}; + +/** Guests group + "Guest" role option (parked in the live app; behind showGuests). */ +export const WithGuests: Story = { + args: { members: [...MEMBERS, GUEST], showGuests: true }, +}; + +/** Suspended / locked / MFA-enrolled members show inline tags and gated kebab actions. */ +export const MemberStates: Story = { + args: { + members: STATE_MEMBERS, + teams: TEAMS.filter((t) => t.id === 2), + }, +}; + +/** A team past the collapse limit surfaces the "Show all" expander. */ +export const LargeTeam: Story = { + args: { + members: BIG_MEMBERS, + teams: [{ id: 9, name: "Platform", userCount: 11, owners: ["tm1"] }], + }, +}; diff --git a/frontend/editor/src/portal/components/users/UsersDirectory.tsx b/frontend/editor/src/portal/components/users/UsersDirectory.tsx new file mode 100644 index 0000000000..1419026453 --- /dev/null +++ b/frontend/editor/src/portal/components/users/UsersDirectory.tsx @@ -0,0 +1,478 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Menu } from "@mantine/core"; +import PersonAddAltRounded from "@mui/icons-material/PersonAddAltRounded"; +import { Avatar, Chip, Select } from "@app/ui"; +import { type Member, type RoleId } from "@portal/api/users"; +import type { Team } from "@portal/api/teams"; +import type { UsersCapabilities } from "@portal/api/usersCapabilities"; +import { avatarToneForMember } from "@portal/components/users/format"; +import { + buildDirectory, + type TeamGroup, +} from "@portal/components/users/directory"; +import "@portal/views/Users.css"; + +/** Collapse a group's rows past this many, behind a "Show all" expander. */ +const COLLAPSED_LIMIT = 8; + +/** Teams that can't be renamed/deleted (system-managed). */ +const SYSTEM_TEAMS = new Set(["Default", "Internal"]); + +interface UsersDirectoryProps { + members: Member[]; + teams: Team[]; + /** Flavor-specific action set (self-hosted org-admin vs SaaS team-leader). */ + capabilities: UsersCapabilities; + onChangeRole: (member: Member, role: RoleId) => void; + onGrantProcessor: (member: Member) => void; + onRevokeProcessor: (member: Member) => void; + /** Team ids holding a team-wide Processor grant; members inherit it. */ + processorTeamIds: Set; + onGrantTeamProcessor: (team: TeamGroup) => void; + onRevokeTeamProcessor: (team: TeamGroup) => void; + onAddToTeam: (team: TeamGroup) => void; + // Per-member admin actions (the row kebab). + onResetPassword: (member: Member) => void; + onMoveToTeam: (member: Member) => void; + onToggleEnabled: (member: Member) => void; + onUnlock: (member: Member) => void; + onDisableMfa: (member: Member) => void; + onRemove: (member: Member) => void; + // Team actions (the team-header kebab). + onRenameTeam: (team: TeamGroup) => void; + onDeleteTeam: (team: TeamGroup) => void; + /** + * Show the "Approves policy" capability chip on org owners. Off in the live + * app (no backend for it yet); on in Storybook to document the intended design. + */ + showApprover?: boolean; + /** + * Show the Guests group + the "Guest" role option. Off in the live app (the + * Guest concept is parked); on in Storybook to document the intended design. + */ + showGuests?: boolean; +} + +/** + * The people roster, grouped like the org chart: Organization owners, then each + * team (with its leader), each row carrying capability chips, a role selector, + * and a kebab of admin actions. Long groups collapse behind a "Show all" so big + * orgs stay scannable. + */ +export function UsersDirectory({ + members, + teams, + capabilities, + onChangeRole, + onGrantProcessor, + onRevokeProcessor, + processorTeamIds, + onGrantTeamProcessor, + onRevokeTeamProcessor, + onAddToTeam, + onResetPassword, + onMoveToTeam, + onToggleEnabled, + onUnlock, + onDisableMfa, + onRemove, + onRenameTeam, + onDeleteTeam, + showApprover = false, + showGuests = false, +}: UsersDirectoryProps) { + const { t } = useTranslation(); + const dir = useMemo(() => buildDirectory(members, teams), [members, teams]); + const [expanded, setExpanded] = useState>(new Set()); + + const nameByUsername = useMemo(() => { + const m = new Map(); + for (const member of members) { + if (member.username) m.set(member.username, member.name); + } + return m; + }, [members]); + + const roleOptions: { value: RoleId; label: string }[] = [ + // No SaaS user is ever ROLE_ADMIN, so the Org Owner option is dropped there. + ...(capabilities.adminRole + ? [ + { + value: "admin" as RoleId, + label: t("users.role.orgOwner", "Org Owner"), + }, + ] + : []), + { value: "team_owner", label: t("users.role.teamOwner", "Team Owner") }, + { value: "member", label: t("users.role.member", "Member") }, + ...(showGuests + ? [{ value: "guest" as RoleId, label: t("users.role.guest", "Guest") }] + : []), + ]; + + function toggleExpand(key: string) { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + } + + function ownerNames(owners: string[]): string { + return owners.map((u) => nameByUsername.get(u) ?? u).join(", "); + } + + // Whether the team-header kebab has any actions (else it isn't rendered). + function teamKebabHasItems(team: TeamGroup): boolean { + return ( + capabilities.manageGrants || + (!SYSTEM_TEAMS.has(team.name) && + (capabilities.renameTeam || capabilities.deleteTeam)) + ); + } + + // Whether any action sits above the "Remove" item (so we render a divider). + function rowKebabHasUpperActions(m: Member): boolean { + return ( + capabilities.resetPassword || + capabilities.moveTeam || + capabilities.suspend || + (capabilities.unlock && !!m.locked) || + (capabilities.resetMfa && !!m.mfaEnabled) + ); + } + + function rowKebab(m: Member) { + // Removal is org-delete (admin-only) - the only backed path. SaaS "remove from + // team" has no endpoint, so don't offer a control that would 403 or org-delete. + const canRemove = capabilities.removeScope === "org"; + if (!rowKebabHasUpperActions(m) && !canRemove) return null; + return ( + + + + + + {capabilities.resetPassword && ( + onResetPassword(m)}> + {t("users.action.resetPw", "Reset password")} + + )} + {capabilities.moveTeam && ( + onMoveToTeam(m)}> + {t("users.action.move", "Move to team")} + + )} + {capabilities.suspend && ( + onToggleEnabled(m)}> + {m.status === "suspended" + ? t("users.action.reinstate", "Reinstate") + : t("users.action.suspend", "Suspend")} + + )} + {capabilities.unlock && m.locked && ( + onUnlock(m)}> + {t("users.action.unlock", "Unlock account")} + + )} + {capabilities.resetMfa && m.mfaEnabled && ( + onDisableMfa(m)}> + {t("users.action.disableMfa", "Reset MFA")} + + )} + {canRemove && rowKebabHasUpperActions(m) && } + {canRemove && ( + onRemove(m)} + > + {t("users.action.remove", "Remove from org")} + + )} + + + ); + } + + function renderRow(m: Member) { + const access = m.portalAccess ?? "none"; + return ( +
+
+ +
+ + {m.name} + {m.isSelf && ( + + {" "} + {t("users.you", "(you)")} + + )} + {m.status === "suspended" && ( + + {t("users.suspended", "Suspended")} + + )} + {m.locked && ( + + {t("users.locked", "Locked")} + + )} + + {m.email !== m.name && ( + {m.email} + )} +
+
+ +
+ + {t("users.cap.editor", "Editor")} + + {access === "granted" ? ( + onRevokeProcessor(m) + : undefined + } + > + {t("users.cap.processor", "Processor")} + + ) : access !== "none" ? ( + // admin / team-owner role / inherited from a team-wide grant + + {t("users.cap.processor", "Processor")} + + ) : capabilities.manageGrants ? ( + onGrantProcessor(m)} + > + {t("users.cap.addProcessor", "+ Processor")} + + ) : null} + {showApprover && m.role === "admin" && ( + ✓} + > + {t("users.cap.approver", "Approves policy")} + + )} +
+ + + {m.lastActive} + + + {capabilities.changeRole && ( +
+ { + setPending(e.target.value); + if (addError) setAddError(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + /> + +
+ )} + {addError &&

{addError}

} + + {value.length === 0 ? ( +

+ {emptyText ?? t("policies.labels.empty", "No labels yet.")} +

+ ) : grouped ? ( + + ) : ( +
+ {value.map(renderChip)} +
+ )} +
+ ); +} + +interface GroupedLabelsProps { + value: ClassificationLabel[]; + renderChip: (label: ClassificationLabel) => ReactNode; +} + +/** Chips laid out under collapsible parent categories (device-local structure). */ +function GroupedLabels({ value, renderChip }: GroupedLabelsProps) { + const { t } = useTranslation(); + const categories = useSyncExternalStore( + subscribeSidebarCategories, + getSidebarCategories, + ); + + // Category sections that actually contain labels from `value`, plus the leftovers. + const { sections, ungrouped } = useMemo(() => { + const byId = new Map(value.map((l) => [l.id, l])); + const claimed = new Set(); + const sections = categories + .map((category) => { + const members = category.labelKeys + .map((id) => byId.get(id)) + .filter((l): l is ClassificationLabel => !!l); + members.forEach((m) => claimed.add(m.id)); + return { category, members }; + }) + .filter((s) => s.members.length > 0); + const ungrouped = value.filter((l) => !claimed.has(l.id)); + return { sections, ungrouped }; + }, [value, categories]); + + // Only the first section starts expanded — a scannable overview. + const [expanded, setExpanded] = useState>( + () => new Set(sections.length > 0 ? [sections[0].category.id] : []), + ); + const toggle = (id: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + return ( +
+ {sections.map(({ category, members }) => { + const isOpen = expanded.has(category.id); + return ( +
+ + {isOpen && ( +
+ {members.map(renderChip)} +
+ )} +
+ ); + })} + {ungrouped.length > 0 && ( +
+

+ {t("policies.labels.ungrouped", "Ungrouped")} +

+
+ {ungrouped.map(renderChip)} +
+
+ )} +
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/LabelsEditorModal.tsx b/frontend/editor/src/proprietary/components/policies/LabelsEditorModal.tsx new file mode 100644 index 0000000000..9cafed3aeb --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/LabelsEditorModal.tsx @@ -0,0 +1,175 @@ +/** + * Full-screen ("fat") editor for the team's classification labels — the roomy + * view the settings summary's Edit button opens. Hosts the {@link LabelsEditor} + * chip grid plus an Import/Export toolbar and a footer holding the destructive + * actions (reset / start-from-scratch) and the Save/Cancel buttons. Editing is + * staged: nothing is persisted until Save, which stays disabled until the draft + * actually changes. The draft is owned by the caller so the settings summary + * reflects saved changes. + */ + +import { useRef } from "react"; +import { useTranslation } from "react-i18next"; +import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined"; +import FileUploadOutlinedIcon from "@mui/icons-material/FileUploadOutlined"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import DeleteSweepOutlinedIcon from "@mui/icons-material/DeleteSweepOutlined"; +import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; +import { Modal } from "@app/ui/Modal"; +import { Button } from "@app/ui/Button"; +import { Banner } from "@app/ui/Banner"; +import { LabelsEditor } from "@app/components/policies/LabelsEditor"; +import type { ClassificationLabel } from "@app/data/classificationLabels"; + +interface LabelsEditorModalProps { + open: boolean; + onClose: () => void; + draft: ClassificationLabel[]; + onDraftChange: (next: ClassificationLabel[]) => void; + onImportFile: (file: File) => void; + onExport: () => void; + /** Stage the built-in default into the draft. */ + onReset: () => void; + /** Stage an empty list into the draft (build from scratch). */ + onClear: () => void; + onSave: () => void; + dirty: boolean; + saving: boolean; + readOnly: boolean; + /** Save (server) or import (file) failure to surface, if any. */ + error: string | null; +} + +export function LabelsEditorModal({ + open, + onClose, + draft, + onDraftChange, + onImportFile, + onExport, + onReset, + onClear, + onSave, + dirty, + saving, + readOnly, + error, +}: LabelsEditorModalProps) { + const { t } = useTranslation(); + const fileInput = useRef(null); + + const handleFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + // Reset the input so picking the same file twice still fires onChange. + e.target.value = ""; + if (file) onImportFile(file); + }; + + return ( + +
+ {!readOnly && ( + <> + + + + )} +
+
+ + {!readOnly && ( + + )} +
+ + } + > +
+ {error && ( + } + description={error} + /> + )} + {!readOnly && ( +
+ + + +
+ )} + +
+
+ ); +} diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css index 37b92a5830..9c4683f0ac 100644 --- a/frontend/editor/src/proprietary/components/policies/Policies.css +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -103,6 +103,87 @@ .pol-row:focus-visible .sui-iconbadge { --ib-accent: var(--ib-base); } + +/* Processing indicator: a spinning ring around the category icon while the + policy has runs in flight. The ring carries the category accent; the badge + also shows its colour (not the neutral rest tint) so an active policy reads + clearly even before hover. */ +.pol-row-icon { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + --pol-ring: var(--color-blue); +} +.pol-row-icon[data-accent="purple"] { + --pol-ring: var(--color-purple); +} +.pol-row-icon[data-accent="green"] { + --pol-ring: var(--color-green); +} +.pol-row-icon[data-accent="amber"] { + --pol-ring: var(--color-amber); +} +.pol-row-icon[data-accent="red"] { + --pol-ring: var(--color-red); +} +.pol-row-icon[data-accent="orange"] { + --pol-ring: var(--color-orange); +} +.pol-row-icon.is-processing .sui-iconbadge { + --ib-accent: var(--ib-base); +} +.pol-row-ring { + position: absolute; + inset: -3px; + border-radius: 999px; + border: 2px solid color-mix(in srgb, var(--pol-ring) 20%, transparent); + border-top-color: var(--pol-ring); + animation: pol-ring-spin 0.7s linear infinite; + pointer-events: none; +} + +/* Global "Retry failed policies (N)" action under the panel header — one place + to resume every file the chain stranded, whichever step failed. Amber, so it + reads as "needs attention" without screaming error. */ +.pol-retry-failed { + display: flex; + align-items: center; + gap: var(--space-1_5); + width: calc(100% - 2 * var(--space-1_5)); + margin: 0 var(--space-1_5) var(--space-1); + padding: var(--space-1) var(--space-2); + border: none; + border-radius: var(--radius-lg); + font-family: inherit; + font-size: 0.75rem; + font-weight: 500; + text-align: left; + cursor: pointer; + color: var(--color-amber, #d97706); + background: color-mix(in srgb, var(--color-amber, #d97706) 12%, transparent); + transition: background var(--motion-fast); +} +.pol-retry-failed:hover { + background: color-mix(in srgb, var(--color-amber, #d97706) 20%, transparent); +} +.pol-retry-failed:focus-visible { + outline: 2px solid var(--color-amber, #d97706); + outline-offset: -2px; +} +@keyframes pol-ring-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .pol-row-ring { + animation-duration: 2s; + } +} + .pol-row-label { flex: 1; min-width: 0; @@ -110,6 +191,16 @@ font-weight: 500; color: var(--color-text-1); } + +.pol-row > .mantine-Button-inner { + width: 100%; +} +.pol-row > .mantine-Button-inner > .mantine-Button-label { + flex: 1; + min-width: 0; + justify-content: flex-start; + overflow: visible; +} .pol-row-trail { display: inline-flex; align-items: center; @@ -128,6 +219,81 @@ flex-shrink: 0; } +/* ── Policy settings: per-trigger run-order lists ── */ +.pol-reorder-section { + margin-top: var(--space-3); +} +.pol-reorder-list { + display: flex; + flex-direction: column; + margin-top: var(--space-1); +} +/* A reorder row: leading grip + tinted icon + label. Square (no radius) so the + drop line reads as one straight rule across the list. */ +.pol-reorder-row { + position: relative; + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1_5) var(--space-2); +} +.pol-reorder-row[data-dragging] { + opacity: 0.4; +} +/* Straight, full-width blue insertion line at the drop position (no curves). */ +.pol-reorder-row[data-drop="above"]::before, +.pol-reorder-row[data-drop="below"]::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 2px; + background: var(--color-blue); + pointer-events: none; +} +.pol-reorder-row[data-drop="above"]::before { + top: -1px; +} +.pol-reorder-row[data-drop="below"]::after { + bottom: -1px; +} +.pol-reorder-grip { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1.25rem; + color: var(--color-text-4); + cursor: grab; +} +.pol-reorder-grip:active { + cursor: grabbing; +} +.pol-reorder-label { + flex: 1; + min-width: 0; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-1); +} +/* The drag ghost (a cloned row): the whole row with a full blue outline, kept + translucent so the list shows through as it moves. */ +.pol-reorder-row--ghost { + border-radius: var(--radius-lg); + outline: 2px solid var(--color-blue); + outline-offset: -2px; + background: var(--color-surface); + box-shadow: var(--shadow-md); + opacity: 0.55; +} +/* Empty-state line for a trigger with no policies. */ +.pol-reorder-empty { + margin: var(--space-1) 0 0; + padding: var(--space-1_5) var(--space-2); + font-size: 0.8125rem; + color: var(--color-text-3); +} + /* Retry button on a failed activity row. */ /* Expandable error text in the activity feed — long backend errors are clamped and collapsed by default so they don't blow up the row. */ @@ -278,6 +444,37 @@ margin: 0 0 var(--space-2); } +/* A section label rendered as a collapse toggle (Recent Activity): strip the + button chrome but keep the .pol-section-label typography, chevron pushed right. */ +.pol-section-toggle { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; + font-family: inherit; +} + +.pol-section-chevron { + margin-left: auto; + color: var(--color-text-4); + transition: transform 0.15s ease; +} +.pol-section-chevron.is-open { + transform: rotate(180deg); +} + +/* Recent-activity feed: cap to ~4.5 rows (and never more than ~45% of the + viewport) then scroll, so a long history doesn't push the stats footer away. */ +.pol-activity-list { + max-height: min(22rem, 45vh); + overflow-y: auto; +} + /* Sub-section header inside a settings card (e.g. "Output filename"). The field directly below it carries data-first so the borders don't double up. */ .pol-subhead { diff --git a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx index 15b494c3f6..99d9e3169d 100644 --- a/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx +++ b/frontend/editor/src/proprietary/components/policies/PoliciesSidebar.tsx @@ -12,18 +12,39 @@ * collapsed; clicking an icon selects the policy and expands the rail. */ -import { useState, useEffect, useMemo, type ReactNode } from "react"; +import { + useState, + useEffect, + useMemo, + type DragEvent, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; +import { Menu } from "@mantine/core"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; -import LocalIcon from "@app/components/shared/LocalIcon"; +import ReplayRounded from "@mui/icons-material/ReplayRounded"; +import DragIndicatorRounded from "@mui/icons-material/DragIndicatorRounded"; +import MoreHorizRounded from "@mui/icons-material/MoreHorizRounded"; +import TuneRounded from "@mui/icons-material/TuneRounded"; +import InfoOutlined from "@mui/icons-material/InfoOutlined"; import { usePolicies } from "@app/hooks/usePolicies"; import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAuth } from "@app/auth/UseSession"; import { getPolicyAutomation } from "@app/services/policyFolders"; import { watchedFolderStorage } from "@app/services/watchedFolderStorage"; -import { runsToActivity, runsToStats } from "@app/services/policyLiveData"; -import { usePolicyRuns } from "@app/components/policies/policyRunStore"; +import { + runsToActivity, + runsToStats, + progressByCategory, + retryableFailedRuns, + EMPTY_RUN_PROGRESS, +} from "@app/services/policyLiveData"; +import { + removeRun, + usePolicyRuns, + usePolicyWaveStart, +} from "@app/components/policies/policyRunStore"; import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun"; import type { FileId } from "@app/types/file"; import type { @@ -46,12 +67,15 @@ import { SectionHeader } from "@app/ui/SectionHeader"; import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard"; import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel"; import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal"; -import type { PolicyConfigResult } from "@app/types/policies"; +import type { PolicyCategory, PolicyConfigResult } from "@app/types/policies"; +import { PanelHeader } from "@app/ui/PanelHeader"; import { usePolicySelection, selectPolicy, setPolicyDetailView, closePolicy, + openPolicySettings, + closePolicySettings, } from "@app/components/policies/policySelectionStore"; import "@app/components/policies/Policies.css"; @@ -97,8 +121,8 @@ function promptGuestSignup(): void { * place of the tool list. False when the feature is off or nothing is selected. */ export function usePolicyDetailActive(): boolean { - const { selectedId } = usePolicySelection(); - return POLICIES_ENABLED && selectedId != null; + const { selectedId, settingsOpen } = usePolicySelection(); + return POLICIES_ENABLED && (selectedId != null || settingsOpen); } /** The collapsible policy list, rendered above the Tools section. */ @@ -113,6 +137,39 @@ export function PoliciesSection({ const pol = usePolicies(); const { categories } = usePolicyCatalog(); const guestBlocked = usePolicyGuestBlocked(); + + // Live run tallies drive the per-row processing ring + the header summary, + // scoped to the current upload wave so they don't accumulate across the whole + // persisted run history. + const runs = usePolicyRuns(); + const waveStart = usePolicyWaveStart(); + const progress = useMemo( + () => progressByCategory(runs, waveStart), + [runs, waveStart], + ); + // Failed runs still worth retrying, across ALL policies — drives the single + // global "Retry failed policies" action. Deliberately not per-policy: a chain + // failure strands the file mid-pipeline whichever step failed, and retrying + // globally resumes every stranded file (successful steps are never re-run; + // completed runs chain onward automatically). NOT wave-scoped: failures from + // earlier uploads still count. + const retryableRuns = useMemo(() => retryableFailedRuns(runs), [runs]); + const retryAllFailed = () => { + // Same replace-in-place pattern as the queue-full auto-retry: drop the stale + // failed row, fire a fresh run. Queue-full rejections during the burst are + // absorbed by the existing backoff. + for (const failed of retryableRuns) { + const backendId = pol.policies[failed.categoryId]?.backendId; + if (!backendId) continue; + removeRun(failed.runId); + void runPolicyOnFile( + failed.categoryId, + backendId, + failed.fileId as FileId, + failed.fileName, + ); + } + }; // Persist the expand/collapse state across refreshes. const [expanded, setExpanded] = useState(() => { try { @@ -148,6 +205,13 @@ export function PoliciesSection({ (c) => pol.policies[c.id]?.configured, ).length; + // Rows render in execution order (defaults to catalog order until reordered + // on the Policy settings page). + const displayCategories = [...visibleCategories].sort( + (a, b) => + (pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0), + ); + return (
@@ -161,37 +225,70 @@ export function PoliciesSection({ expanded={expanded} onToggle={toggleExpanded} /> - - + + + + + + + {/* Hovering surfaces the same explanation the info tooltip used to show. */} + + } + > + {t("policies.sidebar.whatIsPolicy", "What is a policy?")} + + + {pol.canConfigure && ( + } + onClick={() => openPolicySettings()} + > + {t("policies.sidebar.policySettings", "Policy settings")} + )} - > - - - + +
+ {retryableRuns.length > 0 && !guestBlocked && ( + + )} + {expanded && ( <>
- {visibleCategories.map((cat) => { + {displayCategories.map((cat) => { if (cat.comingSoon) { return (
@@ -203,8 +300,9 @@ export function PoliciesSection({ ); })} @@ -269,11 +402,25 @@ export function PoliciesSection({ ); } +/** + * Takeover dispatcher: shows the policy-settings page (execution order), an open + * policy's detail, or nothing — whichever the selection store currently holds. + * The heavy per-policy hooks live in {@link PolicyOpenDetail}, so the settings + * page doesn't pay for (or trip over) them. + */ +export function PolicyDetailTakeover() { + const { selectedId, settingsOpen } = usePolicySelection(); + if (!POLICIES_ENABLED) return null; + if (settingsOpen && selectedId == null) return ; + if (selectedId == null) return null; + return ; +} + /** * The open-policy view — narrative detail, setup wizard, or edit-settings — * which replaces the Tools area while a policy is selected. */ -export function PolicyDetailTakeover() { +function PolicyOpenDetail() { const { t } = useTranslation(); const pol = usePolicies(); const { categories, configs, sources, docTypes } = usePolicyCatalog(); @@ -470,6 +617,232 @@ export function PolicyDetailTakeover() { ); } +/** + * The Policy settings takeover — reached from the section header's "…" menu. + * Each trigger (upload / export) gets its own run-order list, since a chain only + * spans policies that fire on the same trigger — reordering one never affects the + * other. Admin-only (the menu entry is gated on canConfigure). + */ +function PolicySettingsPanel() { + const { t } = useTranslation(); + const pol = usePolicies(); + const { categories } = usePolicyCatalog(); + + // Configured, live policies for a trigger, in execution order. + const inOrder = (trigger: "upload" | "export") => + categories + .filter( + (c) => + pol.policies[c.id]?.configured && + !c.comingSoon && + (pol.policies[c.id]?.runOn ?? "upload") === trigger, + ) + .sort( + (a, b) => + (pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0), + ); + + const uploadCats = inOrder("upload"); + const exportCats = inOrder("export"); + + // Order is one global sort key, so persist both groups together (upload first) + // to keep each group's members contiguous — the auto-run chain reads relative + // order within a trigger. + const persist = (uploadIds: string[], exportIds: string[]) => + pol.reorderPolicies([...uploadIds, ...exportIds]); + + return ( +
+ } + title={t("policies.settings.title", "Policy settings")} + onClose={() => closePolicySettings()} + closeLabel={t("policies.detail.close", "Close")} + /> +
+

+ {t( + "policies.settings.runOrderDesc", + "When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder.", + )} +

+ {/* Both triggers are always shown so the run order for each is explicit, + with an empty note when a trigger has no policies. */} + + persist( + ids, + exportCats.map((c) => c.id), + ) + } + /> + + persist( + uploadCats.map((c) => c.id), + ids, + ) + } + /> +
+
+ ); +} + +/** + * One trigger's run-order list. Rows drag to reorder (only when there's more than + * one to order); the drag ghost is the whole row with a blue outline, and a + * straight blue line marks where the policy will land. Reorders in isolation and + * hands the new id order back to the parent to persist. + */ +function PolicyReorderSection({ + title, + cats, + emptyText, + onReorder, +}: { + title: string; + cats: PolicyCategory[]; + emptyText: string; + onReorder: (orderedIds: string[]) => void; +}) { + const { t } = useTranslation(); + const [dragId, setDragId] = useState(null); + const [overId, setOverId] = useState(null); + // Whether the drop would land after (vs before) the hovered row. + const [overBelow, setOverBelow] = useState(false); + const draggable = cats.length >= 2; + + const clear = () => { + setDragId(null); + setOverId(null); + }; + + const handleDrop = (targetId: string) => { + if (!dragId || dragId === targetId) return clear(); + const ids = cats.map((c) => c.id); + const from = ids.indexOf(dragId); + let to = ids.indexOf(targetId) + (overBelow ? 1 : 0); + if (from < 0 || to < 0) return clear(); + ids.splice(from, 1); + if (from < to) to -= 1; + ids.splice(to, 0, dragId); + onReorder(ids); + clear(); + }; + + // The native drag image would be just the grip under the cursor; instead snapshot + // the whole row (a styled clone) so the ghost that follows the mouse is the full + // row with a blue outline. + const startDrag = (e: DragEvent, catId: string) => { + setDragId(catId); + e.dataTransfer.effectAllowed = "move"; + const row = (e.currentTarget as HTMLElement).closest(".pol-reorder-row"); + if (row instanceof HTMLElement) { + const clone = row.cloneNode(true) as HTMLElement; + clone.classList.add("pol-reorder-row--ghost"); + clone.style.width = `${row.offsetWidth}px`; + clone.style.position = "fixed"; + clone.style.top = "-1000px"; + clone.style.left = "-1000px"; + clone.style.pointerEvents = "none"; + document.body.appendChild(clone); + e.dataTransfer.setDragImage(clone, 24, row.offsetHeight / 2); + window.setTimeout(() => clone.remove(), 0); + } + }; + + if (cats.length === 0) { + return ( +
+

{title}

+

{emptyText}

+
+ ); + } + + return ( +
+

{title}

+
+ {cats.map((cat) => ( +
{ + if (!dragId) return; + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + setOverId(cat.id); + setOverBelow(e.clientY > rect.top + rect.height / 2); + } + : undefined + } + onDragLeave={ + draggable + ? () => setOverId((id) => (id === cat.id ? null : id)) + : undefined + } + onDrop={ + draggable + ? (e) => { + e.preventDefault(); + handleDrop(cat.id); + } + : undefined + } + > + {draggable && ( + startDrag(e, cat.id)} + onDragEnd={clear} + role="button" + tabIndex={-1} + aria-label={t( + "policies.settings.reorderHandle", + "Drag to reorder", + )} + > + + + )} + + {cat.icon} + + + {t(`policies.catalog.${cat.id}`, cat.label)} + +
+ ))} +
+
+ ); +} + /** * Collapsed-rail policy icons. Each tints blue when active and carries a small * status dot (green active / amber paused). Clicking selects the policy and diff --git a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx index 4afcf5891c..95f554edcc 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyDetailPanel.tsx @@ -7,6 +7,7 @@ import DescriptionIcon from "@mui/icons-material/Description"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import WarningAmberIcon from "@mui/icons-material/WarningAmber"; import AutorenewIcon from "@mui/icons-material/Autorenew"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; import LockIcon from "@mui/icons-material/Lock"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; import { PanelHeader } from "@app/ui/PanelHeader"; @@ -115,6 +116,7 @@ export function PolicyDetailPanel({ }: PolicyDetailPanelProps) { const { t } = useTranslation(); const isPaused = status === "paused"; + const [activityOpen, setActivityOpen] = useState(true); // Real configured steps drive the flow; fall back to the preset's rule labels. const enforceItems = steps && steps.length > 0 @@ -181,70 +183,91 @@ export function PolicyDetailPanel({ {/* Recent Activity */}
-

- {t("policies.detail.recentActivity", "Recent Activity")} -

- {activityItems.length > 0 ? ( - - {activityItems.map((item, i) => ( - 0} - leadingTone={ - item.status === "flagged" - ? "warning" - : item.status === "processing" - ? "info" - : "success" - } - leading={ - item.status === "flagged" ? ( - - ) : item.status === "processing" ? ( - - ) : ( - - ) - } - title={item.doc} - description={ - item.status === "flagged" ? ( - - ) : ( - item.action - ) - } - meta={item.time} - trailing={ - item.status === "flagged" && onRetry ? ( - - ) : undefined - } - /> - ))} - - ) : ( - - } - title={t("policies.detail.noActivityTitle", "No activity yet")} - description={t( - "policies.detail.noActivityDescription", - "Documents will appear here once this policy runs.", - )} + + {activityOpen && + (activityItems.length > 0 ? ( + +
+ {activityItems.map((item, i) => ( + 0} + leadingTone={ + item.status === "flagged" + ? "warning" + : item.status === "processing" + ? "info" + : "success" + } + leading={ + item.status === "flagged" ? ( + + ) : item.status === "processing" ? ( + + ) : ( + + ) + } + title={item.doc} + description={ + item.status === "flagged" ? ( + + ) : ( + item.action + ) + } + meta={item.time} + trailing={ + item.status === "flagged" && onRetry ? ( + + ) : undefined + } + /> + ))} +
+
+ ) : ( + + } + title={t( + "policies.detail.noActivityTitle", + "No activity yet", + )} + description={t( + "policies.detail.noActivityDescription", + "Documents will appear here once this policy runs.", + )} + /> + + ))}
{/* Stats — one grouped card with divided columns, intentionally diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index d096b8327e..a2b361a86f 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -36,6 +36,7 @@ import { } from "@app/components/policies/PolicyWorkflowStep"; import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep"; import { getPolicyToolChain } from "@app/components/policies/policyToolChains"; +import { ClassificationLabelsSection } from "@app/components/policies/ClassificationLabelsSection"; // Sources are always "editor" for this release, so the Sources step is dropped // from the flow (its panel code is kept below for when other sources return). @@ -103,6 +104,9 @@ export function PolicySetupWizard({ // Preset (tool-chain) policies render the locked tool config as their Workflow // step instead of the add/remove builder. const toolChain = getPolicyToolChain(category.id); + // A single-tool chain has nothing to toggle/configure, so its config UI is + // hidden (kept mounted so the submit trigger still emits that one tool). + const singleToolChain = toolChain != null && toolChain.length === 1; const { user } = useAuth(); const [step, setStep] = useState(1); const [fieldValues, setFieldValues] = useState(() => @@ -337,22 +341,29 @@ export function PolicySetupWizard({
{toolChain ? ( <> -

- {t( - "policies.wizard.toolChainDesc", - "Configure the tools this policy runs on each document.", - )} -

- + {/* Single-tool chains have nothing to configure — hide the prompt + and the toggle, but keep the step mounted (display:none) so the + final submit still emits that one tool. */} + {!singleToolChain && ( +

+ {t( + "policies.wizard.toolChainDesc", + "Configure the tools this policy runs on each document.", + )} +

+ )} +
+ +
) : ( <> @@ -371,6 +382,12 @@ export function PolicySetupWizard({ /> )} + {/* The Classification policy owns the editable label sets (team-shared + + personal) the classifier picks from. Kept on the first step + alongside the tool so it's not buried. */} + {category.id === "classification" && ( + + )}
{step === 2 && ( diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts index 2034866b87..498a688ac2 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.test.ts @@ -83,12 +83,81 @@ describe("policyRunStore", () => { expect(isDispatched("security", "f1")).toBe(true); }); - it("caps stored runs at 50, newest first", () => { - for (let i = 0; i < 55; i++) { - recordRunStart(rec({ runId: `r${i}`, fileId: `f${i}`, startedAt: i })); + it("never evicts in-flight runs, even past the soft cap", () => { + // A large upload batch can exceed the cap while still processing. Dropping a + // live run would orphan its polling/import and undercount progress, so every + // in-flight run is kept regardless of the cap. + for (let i = 0; i < 210; i++) { + recordRunStart( + rec({ + runId: `r${i}`, + fileId: `f${i}`, + status: "PENDING", + startedAt: i, + }), + ); } const runs = read("stirling-policy-runs").runs; - expect(runs).toHaveLength(50); - expect(runs[0].runId).toBe("r54"); // most recent + expect(runs).toHaveLength(210); + expect(runs[0].runId).toBe("r209"); // newest first + }); + + it("evicts the oldest TERMINAL runs first once over the cap", () => { + // Imported, since a COMPLETED-but-not-yet-imported run still counts as + // in-flight (see isRunInFlight) and must never be evicted. + for (let i = 0; i < 210; i++) { + recordRunStart( + rec({ + runId: `r${i}`, + fileId: `f${i}`, + status: "COMPLETED", + imported: true, + startedAt: i, + }), + ); + } + const runs = read("stirling-policy-runs").runs; + expect(runs).toHaveLength(200); // trimmed to MAX_RUNS + expect(runs[0].runId).toBe("r209"); // newest kept + expect(runs.some((r: PolicyRunRecord) => r.runId === "r0")).toBe(false); // oldest dropped + }); + + it("does not evict a COMPLETED run that hasn't been imported yet, even past the cap", () => { + for (let i = 0; i < 210; i++) { + recordRunStart( + rec({ + runId: `r${i}`, + fileId: `f${i}`, + status: "COMPLETED", + startedAt: i, + }), + ); + } + const runs = read("stirling-policy-runs").runs; + expect(runs).toHaveLength(210); + expect(runs.some((r: PolicyRunRecord) => r.runId === "r0")).toBe(true); + }); + + describe("processing wave (scopes the panel's progress counts to this upload)", () => { + it("begins a new wave when recording with nothing in flight", () => { + recordRunStart(rec({ runId: "a", fileId: "fa", startedAt: 500 })); + expect(read("stirling-policy-runs").waveStartedAt).toBe(500); + }); + + it("keeps the wave while earlier runs are still in flight", () => { + recordRunStart(rec({ runId: "a", fileId: "fa", startedAt: 100 })); + recordRunStart(rec({ runId: "b", fileId: "fb", startedAt: 200 })); + // b joined a's wave (a still PENDING) — the boundary stays at a. + expect(read("stirling-policy-runs").waveStartedAt).toBe(100); + }); + + it("starts a fresh wave once the prior batch has all finished", () => { + recordRunStart(rec({ runId: "a", fileId: "fa", startedAt: 100 })); + // Prior batch completes AND imports → no longer in flight. + updateRun("a", { status: "COMPLETED", imported: true }); + // A new upload after the lull resets the wave to itself. + recordRunStart(rec({ runId: "b", fileId: "fb", startedAt: 5000 })); + expect(read("stirling-policy-runs").waveStartedAt).toBe(5000); + }); }); }); diff --git a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts index 9485df3a9e..2fd91d215a 100644 --- a/frontend/editor/src/proprietary/components/policies/policyRunStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policyRunStore.ts @@ -59,11 +59,56 @@ export const POLICY_IN_FLIGHT_STATUSES: readonly PolicyRunStatus[] = [ interface RunState { runs: PolicyRunRecord[]; dispatched: string[]; + /** startedAt of the run that began the current processing "wave" — a burst of + * runs with no idle gap. Reset whenever a run is recorded while nothing is in + * flight. The panel's progress counts (X of Y processed) scope to this so they + * reflect the CURRENT upload, not the whole persisted history. */ + waveStartedAt: number; +} + +const IN_FLIGHT_STATUSES: ReadonlySet = new Set([ + "PENDING", + "RUNNING", + "WAITING_FOR_INPUT", +]); + +/** True while a run is still working: dispatched/running, in a retry backoff, or + * done on the backend but not yet imported into the workspace. Shared so the + * wave tracking, the panel progress, and the file-row spinner all agree. */ +export function isRunInFlight(run: PolicyRunRecord): boolean { + if (run.retrying) return true; + if (IN_FLIGHT_STATUSES.has(run.status)) return true; + return run.status === "COMPLETED" && !run.imported; } const STORAGE_KEY = "stirling-policy-runs"; -/** Cap stored runs so the activity log can't grow without bound. */ -const MAX_RUNS = 50; +/** Soft cap on stored runs so the activity log can't grow without bound. Only + * TERMINAL runs are ever evicted (see {@link capRuns}); in-flight runs are always + * kept, so a large upload batch (more files than this cap) still polls + imports + * every one — and the panel's processing count stays accurate. */ +const MAX_RUNS = 200; + +const TERMINAL: ReadonlySet = new Set([ + "COMPLETED", + "FAILED", + "CANCELLED", +]); + +/** + * Trim the run list toward MAX_RUNS by dropping the OLDEST terminal runs first, + * never in-flight ones. Runs are newest-first, so we walk from the tail. If more + * than MAX_RUNS runs are still in flight (a very large batch) they're all kept — + * dropping a live run would orphan its polling/import and undercount progress. + */ +function capRuns(runs: PolicyRunRecord[]): PolicyRunRecord[] { + if (runs.length <= MAX_RUNS) return runs; + const trimmed = [...runs]; + for (let i = trimmed.length - 1; i >= 0 && trimmed.length > MAX_RUNS; i--) { + const run = trimmed[i]; + if (TERMINAL.has(run.status) && !isRunInFlight(run)) trimmed.splice(i, 1); + } + return trimmed; +} function read(): RunState { try { @@ -88,12 +133,14 @@ function read(): RunState { })) : [], dispatched: Array.isArray(parsed.dispatched) ? parsed.dispatched : [], + waveStartedAt: + typeof parsed.waveStartedAt === "number" ? parsed.waveStartedAt : 0, }; } } catch { // Corrupt/unavailable storage — start empty. } - return { runs: [], dispatched: [] }; + return { runs: [], dispatched: [], waveStartedAt: 0 }; } let state: RunState = read(); @@ -134,11 +181,22 @@ function getSnapshot(): RunState { return state; } -const SERVER_SNAPSHOT: RunState = { runs: [], dispatched: [] }; +const SERVER_SNAPSHOT: RunState = { + runs: [], + dispatched: [], + waveStartedAt: 0, +}; function getServerSnapshot(): RunState { return SERVER_SNAPSHOT; } +/** Non-hook check for background work that should yield while a policy wave is + * running (e.g. the sidebar's category backfill) — reads the store without + * subscribing, so callers don't re-render on every poll tick. */ +export function hasInFlightPolicyRuns(): boolean { + return state.runs.some(isRunInFlight); +} + /** Key identifying a single (policy, file) run attempt. */ export function dispatchKey(categoryId: string, fileId: string): string { return `${categoryId}:${fileId}`; @@ -152,11 +210,18 @@ export function isDispatched(categoryId: string, fileId: string): boolean { /** Record a newly-dispatched run (marks it dispatched + adds the record). */ export function recordRunStart(record: PolicyRunRecord) { const key = dispatchKey(record.categoryId, record.fileId); + // A run recorded while nothing else is in flight begins a fresh wave, so the + // progress counts reset to this upload instead of accumulating across every + // past upload persisted in localStorage. + const waveStartedAt = state.runs.some(isRunInFlight) + ? state.waveStartedAt + : record.startedAt; state = { - runs: [record, ...state.runs].slice(0, MAX_RUNS), + runs: capRuns([record, ...state.runs]), dispatched: state.dispatched.includes(key) ? state.dispatched : [...state.dispatched, key], + waveStartedAt, }; emit(); } @@ -169,7 +234,7 @@ export function recordRunStart(record: PolicyRunRecord) { */ export function addReconciledRun(record: PolicyRunRecord) { if (state.runs.some((r) => r.runId === record.runId)) return; - state = { ...state, runs: [record, ...state.runs].slice(0, MAX_RUNS) }; + state = { ...state, runs: capRuns([record, ...state.runs]) }; emit(); } @@ -209,7 +274,7 @@ export function removeRun(runId: string) { /** Reset the store — used by tests to isolate it. */ export function resetPolicyRuns() { - state = { runs: [], dispatched: [] }; + state = { runs: [], dispatched: [], waveStartedAt: 0 }; emit(); } @@ -220,3 +285,13 @@ export function usePolicyRuns(): PolicyRunRecord[] { () => getServerSnapshot().runs, ); } + +/** startedAt of the current processing wave (see {@link RunState.waveStartedAt}). + * The panel scopes its progress counts to runs at/after this. */ +export function usePolicyWaveStart(): number { + return useSyncExternalStore( + subscribe, + () => getSnapshot().waveStartedAt, + () => getServerSnapshot().waveStartedAt, + ); +} diff --git a/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts index 451582b90e..e8b80c06c8 100644 --- a/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts +++ b/frontend/editor/src/proprietary/components/policies/policySelectionStore.ts @@ -15,9 +15,16 @@ import type { PolicyDetailView } from "@app/types/policies"; interface PolicySelection { selectedId: string | null; detailView: PolicyDetailView; + /** The policy-settings page (execution order) takes over the rail. Independent + * of {@link selectedId} — it's a section-level view, not tied to one policy. */ + settingsOpen: boolean; } -let state: PolicySelection = { selectedId: null, detailView: "detail" }; +let state: PolicySelection = { + selectedId: null, + detailView: "detail", + settingsOpen: false, +}; const listeners = new Set<() => void>(); function emit() { @@ -37,6 +44,7 @@ function getSnapshot(): PolicySelection { const SERVER_SNAPSHOT: PolicySelection = { selectedId: null, detailView: "detail", + settingsOpen: false, }; function getServerSnapshot(): PolicySelection { return SERVER_SNAPSHOT; @@ -44,7 +52,7 @@ function getServerSnapshot(): PolicySelection { /** Open a policy's detail (resets the sub-view to the narrative). */ export function selectPolicy(id: string | null) { - state = { selectedId: id, detailView: "detail" }; + state = { selectedId: id, detailView: "detail", settingsOpen: false }; emit(); } @@ -55,6 +63,19 @@ export function setPolicyDetailView(view: PolicyDetailView) { emit(); } +/** Open the policy-settings page (execution order). Clears any open policy. */ +export function openPolicySettings() { + state = { selectedId: null, detailView: "detail", settingsOpen: true }; + emit(); +} + +/** Close the policy-settings page and return to the list. */ +export function closePolicySettings() { + if (!state.settingsOpen) return; + state = { ...state, settingsOpen: false }; + emit(); +} + /** Close the open policy and return to the list. */ export function closePolicy() { selectPolicy(null); @@ -62,7 +83,7 @@ export function closePolicy() { /** Reset to the initial state — used by tests to isolate the module store. */ export function resetPolicySelection() { - state = { selectedId: null, detailView: "detail" }; + state = { selectedId: null, detailView: "detail", settingsOpen: false }; emit(); } diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts index e45d280654..5fbd779725 100644 --- a/frontend/editor/src/proprietary/components/policies/policyStatus.ts +++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts @@ -22,22 +22,23 @@ export const STATUS_LABEL: Record = { */ export const ROW_ACCENT: Record = { ingestion: "blue", + classification: "orange", security: "purple", compliance: "green", routing: "amber", retention: "red", }; -/** Accent name → the CSS colour var the policy badges tint with. */ -const ACCENT_VAR: Record = { - blue: "var(--color-blue)", - purple: "var(--color-purple)", - green: "var(--color-green)", - amber: "var(--color-amber)", - red: "var(--color-red)", -}; - -/** CSS colour var for a policy category's accent (blue for unknown categories). */ +/** + * CSS colour var for a policy category's accent (blue for unknown categories) — + * the tint used by the file badges and the enforcement overlay. + * + * Derived straight from the accent name (`--color-`), which is exactly + * the token {@link IconBadge} uses for the same accent. Deriving it (rather than + * keeping a second name→var map) means the badge tint can never drift from the + * sidebar's colour — previously `orange` was missing from that map, so the + * Classification badge/overlay rendered untinted while its sidebar row was orange. + */ export function policyAccentVar(categoryId: string): string { - return ACCENT_VAR[ROW_ACCENT[categoryId] ?? "blue"]; + return `var(--color-${ROW_ACCENT[categoryId] ?? "blue"})`; } diff --git a/frontend/editor/src/proprietary/components/policies/policyToolChains.ts b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts index e3cd231ea1..a7c224d1ea 100644 --- a/frontend/editor/src/proprietary/components/policies/policyToolChains.ts +++ b/frontend/editor/src/proprietary/components/policies/policyToolChains.ts @@ -8,6 +8,9 @@ export const POLICY_TOOL_CHAINS: Record = { // Security: redact PII + watermark + sanitize (strips JS). Which are enabled // by default comes from the preset's defaultOperations, not this list. security: ["redact", "watermark", "sanitize"], + // Classification: a single backend step that classifies the document and + // writes the result into its metadata. + classification: ["classify"], }; /** The configurable tool chain for a category, or null if it has none yet. */ diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx new file mode 100644 index 0000000000..1d1bec13e6 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.batch.test.tsx @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +/** + * Batch integration test for the policy auto-run orchestration, at the scale the + * user hit the bug: 61 files uploaded at once, two active upload policies + * (Classification → Security) chained. Drives the REAL policyRunStore + the REAL + * hook effects (dispatch → poll → import → chain), mocking only the IO boundaries + * (network, storage, thumbnail/stub creation). + * + * Proves the invariants the user asked for: + * - 61 files ⇒ exactly 122 runs (61 classification, then 61 security). + * - Delivery is SILENT + in place (consumeFiles called with { silent: true }), + * never adding a second copy — the workspace never grows past 61. + * - No runaway: if the loop guard regressed, the run count would blow past 122 + * (or the test would time out), so an exact 122 is a hard regression gate. + * - Closing all files mid-run does NOT re-open them: with the workspace emptied, + * outputs are delivered to storage (persistVersionedOutputs), never re-added + * to the workspace via consumeFiles. + */ + +const FILE_COUNT = 61; + +// A tiny mutable "workspace" the mocks share: the list of file stubs currently in +// the workbench, mirrored into useAllFiles. consumeFiles mutates it in place +// (input id → output id) exactly as the real silent reducer would. +const mocks = vi.hoisted(() => ({ + workspace: [] as Array<{ id: string }>, + consumeSilentCalls: 0, + consumeNonSilentCalls: 0, + persistCalls: 0, + addFilesCalls: 0, + stubCounter: 0, + backendOutCounter: 0, + bumpRevision: vi.fn(), + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + listPolicyRuns: vi.fn(), + downloadPolicyOutput: vi.fn(), + getStirlingFile: vi.fn(), + getStirlingFileStub: vi.fn(), + persistVersionedOutputs: vi.fn(), + updateFileMetadata: vi.fn(), + createStirlingFilesAndStubs: vi.fn(), + addFiles: vi.fn(), + updateStirlingFileStub: vi.fn(), + consumeFiles: vi.fn(), +})); + +vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true })); +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs: mocks.workspace }), + useFileManagement: () => ({ + addFiles: mocks.addFiles, + updateStirlingFileStub: mocks.updateStirlingFileStub, + }), + useFileContext: () => ({ consumeFiles: mocks.consumeFiles }), +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: mocks.bumpRevision }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + // Classification runs first (order 0), Security second (order 1). + classification: { + configured: true, + status: "active", + backendId: "backend-classification", + runOn: "upload", + order: 0, + outputMode: "new_version", + outputName: "", + }, + security: { + configured: true, + status: "active", + backendId: "backend-security", + runOn: "upload", + order: 1, + outputMode: "new_version", + outputName: "", + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: mocks.runStoredPolicy, + getPolicyRun: mocks.getPolicyRun, + listPolicyRuns: mocks.listPolicyRuns, + downloadPolicyOutput: mocks.downloadPolicyOutput, + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { + getStirlingFile: mocks.getStirlingFile, + getStirlingFileStub: mocks.getStirlingFileStub, + persistVersionedOutputs: mocks.persistVersionedOutputs, + updateFileMetadata: mocks.updateFileMetadata, + }, +})); +vi.mock("@app/services/fileStubHelpers", () => ({ + createStirlingFilesAndStubs: mocks.createStirlingFilesAndStubs, +})); +vi.mock("@app/services/fileClassification", () => ({ + readClassificationLabelsFromFile: vi.fn().mockResolvedValue(null), +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + usePolicyRuns, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; +import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; + +/** A stable snapshot of the store, read after the flow settles. */ +let latestRuns: PolicyRunRecord[] = []; +function Harness() { + usePolicyAutoRun(); + latestRuns = usePolicyRuns(); + return null; +} + +function replaceInWorkspace(inputIds: string[], outputIds: string[]) { + mocks.workspace = mocks.workspace + .filter((s) => !inputIds.includes(s.id)) + .concat(outputIds.map((id) => ({ id }))); +} + +beforeEach(() => { + localStorage.clear(); + resetPolicyRuns(); + vi.clearAllMocks(); + mocks.consumeSilentCalls = 0; + mocks.consumeNonSilentCalls = 0; + mocks.persistCalls = 0; + mocks.addFilesCalls = 0; + mocks.stubCounter = 0; + mocks.backendOutCounter = 0; + + mocks.workspace = Array.from({ length: FILE_COUNT }, (_, i) => ({ + id: `file-${i}`, + })); + + mocks.listPolicyRuns.mockResolvedValue([]); + // A run's bytes are always resolvable (input files + versioned children). + mocks.getStirlingFile.mockResolvedValue( + new File(["x"], "doc.pdf", { type: "application/pdf" }), + ); + mocks.getStirlingFileStub.mockResolvedValue(null); + mocks.persistVersionedOutputs.mockImplementation(async () => { + mocks.persistCalls += 1; + }); + mocks.updateFileMetadata.mockResolvedValue(false); + mocks.downloadPolicyOutput.mockResolvedValue( + new Blob(["x"], { type: "application/pdf" }), + ); + + // Each dispatch gets a unique run id; the run's single backend output likewise. + mocks.runStoredPolicy.mockImplementation( + async () => `run-${mocks.stubCounter++}`, + ); + mocks.getPolicyRun.mockImplementation(async (runId: string) => ({ + runId, + policyId: null, + status: "COMPLETED", + currentStep: 1, + stepCount: 1, + error: null, + outputs: [ + { + fileId: `backend-out-${mocks.backendOutCounter++}`, + fileName: "doc.pdf", + }, + ], + })); + // Deliver a unique workspace child stub per output, derived from the parent so + // the chain's second policy can find + version it. + mocks.createStirlingFilesAndStubs.mockImplementation( + async (files: File[], parentStub: { id: string }) => { + const stubs = files.map(() => ({ + id: `${parentStub.id}~${mocks.stubCounter++}`, + })); + return { stirlingFiles: files, stubs }; + }, + ); + mocks.addFiles.mockImplementation(async (files: File[]) => { + mocks.addFilesCalls += 1; + return files.map((_f, i) => ({ + fileId: `added-${mocks.stubCounter++}-${i}`, + })); + }); + mocks.consumeFiles.mockImplementation( + async ( + inputIds: string[], + _outputs: unknown[], + stubs: Array<{ id: string }>, + options?: { silent?: boolean }, + ) => { + if (options?.silent) mocks.consumeSilentCalls += 1; + else mocks.consumeNonSilentCalls += 1; + replaceInWorkspace( + inputIds, + stubs.map((s) => s.id), + ); + }, + ); +}); + +/** Drive the hook until the store shows the expected number of imported runs. */ +async function runUntilSettled(expectedRuns: number) { + renderHook(() => Harness()); + await act(async () => { + await vi.waitFor( + () => { + const imported = latestRuns.filter((r) => r.imported).length; + expect(imported).toBe(expectedRuns); + }, + { timeout: 8000, interval: 20 }, + ); + }); +} + +describe("policy auto-run — 61-file batch through a Classification → Security chain", () => { + it("produces exactly 122 runs (61 classification, then 61 security)", async () => { + await runUntilSettled(FILE_COUNT * 2); + + const classification = latestRuns.filter( + (r) => r.categoryId === "classification", + ); + const security = latestRuns.filter((r) => r.categoryId === "security"); + + expect(classification).toHaveLength(FILE_COUNT); + expect(security).toHaveLength(FILE_COUNT); + expect(latestRuns).toHaveLength(FILE_COUNT * 2); + }); + + it("delivers every output SILENTLY in place — workspace never grows past 61", async () => { + await runUntilSettled(FILE_COUNT * 2); + + // 122 deliveries, all silent (background), none via the disruptive path. + expect(mocks.consumeSilentCalls).toBe(FILE_COUNT * 2); + expect(mocks.consumeNonSilentCalls).toBe(0); + // Never added as brand-new files either. + expect(mocks.addFilesCalls).toBe(0); + // In-place versioning: each file replaced twice, count unchanged. + expect(mocks.workspace).toHaveLength(FILE_COUNT); + }); + + it("does NOT re-open files that were closed while their runs were in flight", async () => { + renderHook(() => Harness()); + // Close everything immediately — as if the user cleared the workbench mid-run. + // The inputs still persist in storage, so getStirlingFileStub resolves them. + mocks.getStirlingFileStub.mockResolvedValue({ + id: "storage", + versionNumber: 1, + }); + act(() => { + mocks.workspace = []; + }); + + await act(async () => { + await vi.waitFor( + () => { + const imported = latestRuns.filter((r) => r.imported).length; + expect(imported).toBe(FILE_COUNT * 2); + }, + { timeout: 8000, interval: 20 }, + ); + }); + + // Still fully processed (chain intact), but delivered to STORAGE, never + // re-added to the workbench — the workspace stays empty. + expect(latestRuns).toHaveLength(FILE_COUNT * 2); + expect(mocks.workspace).toHaveLength(0); + expect(mocks.consumeSilentCalls).toBe(0); + expect(mocks.persistCalls).toBeGreaterThan(0); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx new file mode 100644 index 0000000000..638c821669 --- /dev/null +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.chain.test.tsx @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; + +// Two active upload policies, so the auto-run should CHAIN them: fire the first on +// the upload, then the second on the first's output. Stub the contexts + network so +// we can drive the dispatch against the REAL run store. +vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true })); +const fileStubs: { id: string; name: string; derivedFromTool?: boolean }[] = []; +vi.mock("@app/contexts/FileContext", () => ({ + useAllFiles: () => ({ fileStubs }), + useFileManagement: () => ({ addFiles: vi.fn() }), + useFileContext: () => ({ consumeFiles: vi.fn() }), +})); +vi.mock("@app/hooks/usePolicies", () => ({ + usePolicies: () => ({ + policies: { + security: { + configured: true, + status: "active", + backendId: "backend-sec", + runOn: "upload", + order: 0, + }, + classification: { + configured: true, + status: "active", + backendId: "backend-cls", + runOn: "upload", + order: 1, + }, + }, + }), +})); +vi.mock("@app/services/policyApi", () => ({ + runStoredPolicy: vi.fn(), + getPolicyRun: vi.fn(), + downloadPolicyOutput: vi.fn(), + resolvePolicyRunTarget: () => "saas", +})); +vi.mock("@app/services/fileStorage", () => ({ + fileStorage: { getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn() }, +})); +vi.mock("@app/contexts/IndexedDBContext", () => ({ + useIndexedDB: () => ({ bumpRevision: vi.fn() }), +})); + +import { usePolicyAutoRun } from "@app/components/policies/usePolicyAutoRun"; +import { + recordRunStart, + updateRun, + resetPolicyRuns, +} from "@app/components/policies/policyRunStore"; +import { runStoredPolicy } from "@app/services/policyApi"; +import { fileStorage } from "@app/services/fileStorage"; + +const runStored = vi.mocked(runStoredPolicy); +const getFile = vi.mocked(fileStorage.getStirlingFile); + +/** Reset the shared file list between tests without swapping the array identity. */ +function setFileStubs(next: typeof fileStubs) { + fileStubs.length = 0; + fileStubs.push(...next); +} + +beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + resetPolicyRuns(); + setFileStubs([]); + runStored.mockReset(); + getFile.mockReset(); + getFile.mockResolvedValue({ size: 100 } as never); +}); +afterEach(() => vi.useRealTimers()); + +describe("auto-run ordered chaining", () => { + it("dispatches only the FIRST ordered policy on upload, not the whole set", async () => { + setFileStubs([{ id: "file-1", name: "doc.pdf" }]); + runStored.mockResolvedValue("run-sec"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + // The first policy (order 0) runs on the upload; the second waits for the chain. + expect(runStored).toHaveBeenCalledTimes(1); + expect(runStored).toHaveBeenCalledWith("backend-sec", [{ size: 100 }]); + }); + + it("chains the next policy onto a completed run's output", async () => { + // A first-policy run that has completed and imported its output as file-1-v2. + recordRunStart({ + runId: "run-sec", + categoryId: "security", + fileId: "file-1", + fileName: "doc.pdf", + fileSize: 100, + target: "saas", + status: "PENDING", + outputs: [], + error: null, + startedAt: 0, + }); + updateRun("run-sec", { + status: "COMPLETED", + imported: true, + outputFileIds: ["file-1-v2"], + }); + runStored.mockResolvedValue("run-cls"); + + renderHook(() => usePolicyAutoRun()); + await act(async () => { + await vi.advanceTimersByTimeAsync(1); + }); + + // The next policy (order 1) fires on the first policy's output, not the original. + expect(runStored).toHaveBeenCalledWith("backend-cls", [{ size: 100 }]); + }); +}); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx index 249b56ea33..b6e91186fd 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.import.test.tsx @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({ persistVersionedOutputs: vi.fn(), getStirlingFile: vi.fn(), getStirlingFileStub: vi.fn(), + updateFileMetadata: vi.fn(), downloadPolicyOutput: vi.fn(), listPolicyRuns: vi.fn(), createStirlingFilesAndStubs: vi.fn(), @@ -23,7 +24,10 @@ const mocks = vi.hoisted(() => ({ vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true })); vi.mock("@app/contexts/FileContext", () => ({ useAllFiles: () => ({ fileStubs: mocks.fileStubs }), - useFileManagement: () => ({ addFiles: mocks.addFiles }), + useFileManagement: () => ({ + addFiles: mocks.addFiles, + updateStirlingFileStub: vi.fn(), + }), useFileContext: () => ({ consumeFiles: mocks.consumeFiles }), })); vi.mock("@app/contexts/IndexedDBContext", () => ({ @@ -54,6 +58,7 @@ vi.mock("@app/services/fileStorage", () => ({ fileStorage: { getStirlingFile: mocks.getStirlingFile, getStirlingFileStub: mocks.getStirlingFileStub, + updateFileMetadata: mocks.updateFileMetadata, persistVersionedOutputs: mocks.persistVersionedOutputs, }, })); @@ -100,6 +105,7 @@ beforeEach(() => { mocks.listPolicyRuns.mockResolvedValue([]); mocks.getStirlingFileStub.mockResolvedValue(null); mocks.persistVersionedOutputs.mockResolvedValue(undefined); + mocks.updateFileMetadata.mockResolvedValue(true); mocks.consumeFiles.mockResolvedValue(undefined); mocks.addFiles.mockResolvedValue([{ fileId: "out-1" }]); mocks.downloadPolicyOutput.mockResolvedValue( @@ -133,17 +139,23 @@ describe("auto-run import: new-version output delivery", () => { expect(mocks.addFiles).not.toHaveBeenCalled(); }); - it("versions the input in the workspace when it's open (consumeFiles, not a storage write)", async () => { + it("versions the input in place SILENTLY when it's open (consumeFiles with silent, + sidebar bump)", async () => { mocks.fileStubs = [{ id: "file-1" }]; recordCompletedRun(); await runImport(); + // Background enforcement must not disturb the workbench: the silent consume + // replaces the file in place without auto-selecting / reordering / opening it. expect(mocks.consumeFiles).toHaveBeenCalledWith( ["file-1"], expect.any(Array), expect.any(Array), + { silent: true }, ); + // No redundant bump in the in-workspace path — the silent consume's own state + // update drives the sidebar refresh (avoids an O(n) IDB re-read per delivery). + expect(mocks.bumpRevision).not.toHaveBeenCalled(); expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled(); expect(mocks.addFiles).not.toHaveBeenCalled(); }); @@ -166,9 +178,12 @@ describe("auto-run import: new-version output delivery", () => { expect(mocks.consumeFiles).not.toHaveBeenCalled(); }); - it("adopts a server-only run, dating it from the server's createdAt (not now)", async () => { - // A run the client never recorded (true orphan): reconcile adopts it from the server. With no - // local input link it delivers as a new file, and its age comes from the server, not Date.now(). + it("adopts a server-only run for visibility, without delivering its outputs", async () => { + // A run the client never recorded (true orphan): reconcile adopts it from the server for the + // activity feed, dated from the server's createdAt (not Date.now()). It is adopted already + // `imported` — with no local input link a delivery would add its output as a NEW workspace + // file, and since cap-evicted runs are re-adopted on every refresh, that meant phantom + // duplicates opening onto the workbench after each reload. mocks.listPolicyRuns.mockResolvedValue([ { runId: "srv-1", @@ -188,10 +203,8 @@ describe("auto-run import: new-version output delivery", () => { }); expect(getRun("srv-1")?.startedAt).toBe(1000); - expect(mocks.addFiles).toHaveBeenCalledWith( - expect.any(Array), - expect.objectContaining({ derivedFromTool: true }), - ); + expect(mocks.addFiles).not.toHaveBeenCalled(); + expect(mocks.downloadPolicyOutput).not.toHaveBeenCalled(); expect(mocks.persistVersionedOutputs).not.toHaveBeenCalled(); }); }); diff --git a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts index 9ecdf62525..e17947f59e 100644 --- a/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts +++ b/frontend/editor/src/proprietary/components/policies/usePolicyAutoRun.ts @@ -1,9 +1,13 @@ /** - * Auto-run controller: every enabled policy enforces on every uploaded - * file. Watches the session's files and, for each (active policy × not-yet-run - * file), fires a real backend run (`POST /api/v1/policies/{id}/run`) and polls it - * to completion, recording progress in {@link policyRunStore} for the activity - * feed. + * Auto-run controller: every enabled policy enforces on every uploaded file. + * Watches the session's files and fires a real backend run + * (`POST /api/v1/policies/{id}/run`) per file, polling it to completion and + * recording progress in {@link policyRunStore} for the activity feed. + * + * When several policies enforce on the same trigger they run as an ordered chain: + * the first fires on the upload, and each subsequent policy fires on the previous + * one's output once it lands — so their effects accumulate in the admin-defined + * order rather than racing to fork the same version. * * Headless — call it from {@link PolicyAutoRunController}, which is mounted once * wherever the editor is open so enforcement happens regardless of whether the @@ -11,7 +15,7 @@ * in the run store), so re-renders and remounts don't re-fire. */ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useAllFiles, useFileManagement, @@ -35,6 +39,7 @@ import type { import { dispatchPaygLimitReached } from "@app/services/usageLimitBridge"; import type { FileId } from "@app/types/file"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; +import { readClassificationLabelsFromFile } from "@app/services/fileClassification"; import type { StirlingFile, StirlingFileStub } from "@app/types/fileContext"; import type { PoliciesByCategory } from "@app/types/policies"; import { usePolicies } from "@app/hooks/usePolicies"; @@ -120,11 +125,18 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); export function usePolicyAutoRun(): void { const { fileStubs } = useAllFiles(); - const { addFiles } = useFileManagement(); + const { addFiles, updateStirlingFileStub } = useFileManagement(); const { consumeFiles } = useFileContext(); const { bumpRevision } = useIndexedDB(); const { policies } = usePolicies(); const runs = usePolicyRuns(); + // Live view of the workspace files, read inside the import effect WITHOUT making + // it a dependency. The silent consume that delivers an output mutates fileStubs, + // so if the import effect depended on fileStubs it would re-fire on its own + // delivery — an infinite import cascade (and a bumpRevision storm that trips + // React's max-update-depth). The effect only needs to fire when `runs` changes. + const fileStubsRef = useRef(fileStubs); + fileStubsRef.current = fileStubs; // Keys (run ids / dispatch keys) currently in flight, so the effects never // double-fire across re-renders while their first async step is pending. const polling = useRef>(new Set()); @@ -141,6 +153,33 @@ export function usePolicyAutoRun(): void { // run so a folder-watch burst opens the modal once, not once per file. const firedLimitModal = useRef>(new Set()); + // Active upload policies in execution order. When several enforce on upload they + // run as a chain — the first fires on the upload, each subsequent one on the + // previous policy's output — so their effects accumulate in a defined order + // instead of racing to fork the same version. Mirrors the dispatch filter + // (incl. the editor-source gate) so the chain honours the same eligibility. + const orderedUploadCategories = useMemo( + () => + Object.entries(policies) + .filter( + ([, s]) => + s.configured && + s.status === "active" && + s.backendId && + (!s.sources || + s.sources.length === 0 || + s.sources.includes("editor")) && + (s.runOn ?? "upload") === "upload", + ) + .sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0)) + .map(([id]) => id), + [policies], + ); + + // Runs whose chain-continuation we've already handled this session, so the next + // policy is dispatched exactly once per completed run. + const chained = useRef>(new Set()); + // Latest policies, read from inside the stable retry callback (which has no deps). const policiesRef = useRef(policies); policiesRef.current = policies; @@ -204,50 +243,75 @@ export function usePolicyAutoRun(): void { [scheduleQueueRetry], ); - // Dispatch: for each active policy × each session file not yet run, fire a run. + // Dispatch: fire only the FIRST upload policy on each not-yet-run file. The rest + // of the chain is dispatched by the chaining effect below, each on the previous + // policy's output, so the policies apply cumulatively in order. useEffect(() => { if (!POLICIES_ENABLED) return; - const active = Object.entries(policies).filter( - ([, s]) => - s.configured && - s.status === "active" && - s.backendId && - // Only enforce in the editor when the policy includes "editor" as a source. - // runOn is an editor-specific parameter: "upload" fires here, "export" fires - // at export time via policyExport. Non-editor sources have their own triggers. - (!s.sources || - s.sources.length === 0 || - s.sources.includes("editor")) && - (s.runOn ?? "upload") === "upload", - ); - for (const [categoryId, s] of active) { - for (const stub of fileStubs) { - // Input-mode policies enforce only on files that actually entered the - // system as an upload — not on files a tool/automation produced in-app - // (versioned edits or independent artifacts like convert/split/merge). - // Those are enforced only by export-mode policies, at export time. - if (stub.derivedFromTool) continue; - const key = dispatchKey(categoryId, stub.id); - // Skip if already run (persisted) or a dispatch is in flight — the - // in-memory guard prevents double-firing during the async wait. - if (isDispatched(categoryId, stub.id) || dispatching.current.has(key)) { - continue; - } - dispatching.current.add(key); + const firstCategory = orderedUploadCategories[0]; + if (!firstCategory) return; + const backendId = policies[firstCategory]?.backendId; + if (!backendId) return; + for (const stub of fileStubs) { + // Input-mode policies enforce only on files that actually entered the + // system as an upload — not on files a tool/automation produced in-app + // (versioned edits or independent artifacts like convert/split/merge). + // Those are enforced only by export-mode policies, at export time. + if (stub.derivedFromTool) continue; + const key = dispatchKey(firstCategory, stub.id); + // Skip if already run (persisted) or a dispatch is in flight — the + // in-memory guard prevents double-firing during the async wait. + if ( + isDispatched(firstCategory, stub.id) || + dispatching.current.has(key) + ) { + continue; + } + dispatching.current.add(key); + void runPolicyOnFile(firstCategory, backendId, stub.id, stub.name) + .catch(() => { + // runPolicyOnFile handles its own failures; this is just a backstop + // so an unexpected rejection never becomes an unhandled rejection. + }) + .finally(() => dispatching.current.delete(key)); + } + }, [fileStubs, policies, orderedUploadCategories]); + + // Chain: once a run has completed AND its output landed in the workspace, fire the + // next upload policy on that output. Only chains on success (a failed run has no + // output), and only once per run. isDispatched guards re-dispatch across reloads. + useEffect(() => { + if (!POLICIES_ENABLED) return; + for (const run of runs) { + if (run.status !== "COMPLETED" || !run.imported) continue; + if (chained.current.has(run.runId)) continue; + const nextCategory = nextUploadCategory( + orderedUploadCategories, + run.categoryId, + ); + const outputIds = run.outputFileIds ?? []; + if (!nextCategory || outputIds.length === 0) { + // End of the chain (or nothing to chain onto): don't revisit this run. + chained.current.add(run.runId); + continue; + } + const backendId = policies[nextCategory]?.backendId; + // Next policy not ready yet (still reconciling) — retry when policies change. + if (!backendId) continue; + chained.current.add(run.runId); + // Chain onto EVERY output, not just the first — a run that produced multiple files (split, + // ZIP-unpacked) must apply the next policy to all of them, or outputs 2..N silently skip it. + for (const outputId of outputIds) { + if (isDispatched(nextCategory, outputId as FileId)) continue; void runPolicyOnFile( - categoryId, - s.backendId as string, - stub.id, - stub.name, - ) - .catch(() => { - // runPolicyOnFile handles its own failures; this is just a backstop - // so an unexpected rejection never becomes an unhandled rejection. - }) - .finally(() => dispatching.current.delete(key)); + nextCategory, + backendId, + outputId as FileId, + run.fileName, + ).catch(() => {}); } } - }, [fileStubs, policies]); + }, [runs, policies, orderedUploadCategories]); // Poll each in-flight run to a terminal state. useEffect(() => { @@ -280,18 +344,32 @@ export function usePolicyAutoRun(): void { const outputMode = policies[run.categoryId]?.outputMode ?? "new_version"; const outputName = policies[run.categoryId]?.outputName ?? ""; const outputNamePosition = policies[run.categoryId]?.outputNamePosition; - const parentStub = fileStubs.find((s) => (s.id as string) === run.fileId); + const parentStub = fileStubsRef.current.find( + (s) => (s.id as string) === run.fileId, + ); void importOutputs(run, { addFiles, consumeFiles, + updateStirlingFileStub, bumpRevision, outputMode, outputName, outputNamePosition, parentStub, + firstUploadCategory: orderedUploadCategories[0], }).finally(() => importing.current.delete(run.runId)); } - }, [runs, addFiles, consumeFiles, policies, fileStubs]); + // NB: fileStubs is intentionally NOT a dependency — it's read via a ref so a + // delivery's own workspace mutation can't re-trigger this effect (see the ref + // declaration above). The effect fires on run completions, which is all it needs. + }, [ + runs, + addFiles, + consumeFiles, + updateStirlingFileStub, + policies, + orderedUploadCategories, + ]); // Reconcile against the backend on load. The server owns runs (durable, user-scoped), // so a run started before this client recorded it, or before a refresh/crash, is @@ -315,7 +393,13 @@ interface ImportContext { inputFileIds: FileId[], outputs: StirlingFile[], stubs: StirlingFileStub[], + options?: { silent?: boolean }, ) => Promise; + /** Patch a workspace stub in place (used to stamp a new-file output's category). */ + updateStirlingFileStub: ( + fileId: FileId, + updates: Partial, + ) => void; /** Bump the IndexedDB revision so the file views re-read after a storage-only version write. */ bumpRevision: () => void; /** "new_file" adds the output as a separate file; "new_version" versions the input. */ @@ -327,13 +411,18 @@ interface ImportContext { outputNamePosition?: "prefix" | "suffix" | "auto-number"; /** The input file's stub — required to version it; absent if it's been removed. */ parentStub: StirlingFileStub | undefined; + /** The first upload policy in the chain — the only one the dispatch effect ever + * fires. Every policy output is marked dispatched for it so a downstream policy's + * output is never mistaken for a fresh upload and re-enforced (an endless loop). */ + firstUploadCategory: string | undefined; } /** * Pull the caller's server-side runs and fold them into the local store. For a run we already * track, patch its status/outputs (preserving local import progress + attribution); for one we - * don't, adopt it so the poll/import effects pick it up. Server-excluded ad-hoc runs and runs we - * can't map to a configured category are skipped. + * don't, adopt it for feed visibility (polled if still live, but never auto-imported — see the + * `imported` note below). Server-excluded ad-hoc runs and runs we can't map to a configured + * category are skipped. */ function applyOutputName( inputFileName: string, @@ -343,10 +432,21 @@ function applyOutputName( const dot = inputFileName.lastIndexOf("."); const base = dot > 0 ? inputFileName.slice(0, dot) : inputFileName; const ext = dot > 0 ? inputFileName.slice(dot) : ""; - if (position === "suffix") return `${base}_${outputName}${ext}`; - if (position === "prefix") return `${outputName}_${base}${ext}`; - // auto-number requires dedup state not available here — fall back to suffix. - return `${base}_${outputName}${ext}`; + // auto-number needs dedup state not available here, so it falls back to suffix. + return position === "prefix" + ? `${outputName}_${base}${ext}` + : `${base}_${outputName}${ext}`; +} + +/** The next upload policy after {@code categoryId} in the chain, or undefined if + * it's last or no longer in the ordered set (e.g. paused since it ran). */ +function nextUploadCategory( + orderedUploadCategories: string[], + categoryId: string, +): string | undefined { + const index = orderedUploadCategories.indexOf(categoryId); + if (index < 0) return undefined; + return orderedUploadCategories[index + 1]; } async function reconcileServerRuns( @@ -371,10 +471,8 @@ async function reconcileServerRuns( addReconciledRun({ runId: view.runId, categoryId, - // No local input link: a run rediscovered purely from the server (never recorded by this - // client) can't be tied back to a workspace/storage file, so its output is delivered as a - // new file rather than a version, and it isn't retried. The recorded-run path (real fileId) - // covers the common refresh case; this only bites true orphans (storage wipe / other device). + // No local input link: a run rediscovered purely from the server was never recorded by + // this client, so it can't be tied back to a workspace/storage file (and isn't retried). fileId: "", fileName: view.outputs[0]?.fileName ?? "", fileSize: 0, @@ -384,6 +482,8 @@ async function reconcileServerRuns( status: view.status, outputs: view.outputs, error: view.error, + // Adopted for feed visibility ONLY, never delivery: else a completed run evicted from the capped store gets re-adopted every refresh and re-delivered as a new file (no fileId → no parent), opening phantom duplicates forever. Client-recorded runs (real fileId) still deliver. + imported: true, // Use the server's creation time, not now, so a rediscovered run shows its real age. startedAt: view.createdAt, }); @@ -473,6 +573,19 @@ async function importOutputs( return; // transient/mixed: retry the lot later; permanent: already failed. } + // Mark a delivered output as already-handled so the auto-run never re-enforces + // a policy on its own output. Covers the producing policy AND the first upload + // policy (the only one the dispatch effect fires) — without the latter, a + // downstream policy's output looks like a fresh upload and the first policy + // re-runs on it, versioning/duplicating endlessly. Forward chaining is + // unaffected: it only ever fires categories AFTER the producer, never the first. + const markHandled = (id: string) => { + markDispatched(run.categoryId, id); + if (ctx.firstUploadCategory && ctx.firstUploadCategory !== run.categoryId) { + markDispatched(ctx.firstUploadCategory, id); + } + }; + // Deliver, then mark exactly those imported. If delivery throws we don't mark // them, so they retry (without having been added). const files = fetched.map((f) => f.file); @@ -489,6 +602,21 @@ async function importOutputs( (await fileStorage.getStirlingFileStub(run.fileId as FileId)) ?? undefined) : undefined; + + // Resolve each output's classification labels and put them ON the stub, so + // they ride through consume/persist to BOTH the workspace and storage — and + // every later version inherits them (createChildStub + the CONSUME_FILES + // reducer). This keeps files in their label groups instead of flashing into + // "Other" and waiting on a PDF re-read when a 2nd policy or a tool runs. + // Prefer the input's carried-forward labels (cheap) and only read the + // freshly-labelled file when there's nothing to inherit (the classification + // origin) — so a 60-file batch doesn't re-read every downstream output. + const parentLabels = parentStub?.classificationLabels; + const resolveLabels = async (file: File) => + (parentLabels && parentLabels.length > 0 ? parentLabels : undefined) ?? + (await readClassificationLabelsFromFile(file)) ?? + undefined; + if (parentStub) { // Replace the input file with a versioned child (preserves its history). // The version records "automate" as its origin tool — a policy is a @@ -498,25 +626,54 @@ async function importOutputs( parentStub, "automate", ); - // derivedFromTool is the durable cross-session guard; markDispatched is the - // belt-and-suspenders session guard. Both are needed: dispatched lives only - // in localStorage (wiped on clear / absent on a different device), while - // derivedFromTool is stamped on the stub itself. - for (const s of stubs) { - s.derivedFromTool = true; - markDispatched(run.categoryId, s.id); - } - deliveredIds = stubs.map((s) => s.id as string); + // Transitive provenance for the PERSISTED record, mirroring what the + // CONSUME_FILES reducer computes for workspace state: the output derives + // from its input plus everything that input derived from. Without this the + // stored lineage misses intermediate hops, and a closed file's policy + // badges can't resolve past the most recent run in a 3+-policy chain. + const lineage = Array.from( + new Set([run.fileId as FileId, ...(parentStub.sourceFileIds ?? [])]), + ); + // Stamp each output stub with: the resolved labels (createChildStub already + // inherited the parent's; this also captures the classification origin, + // where the parent had none but the labelled file does), the transitive + // lineage, and derivedFromTool — the durable cross-session guard that stops + // the auto-run ever re-enforcing a policy on its own output (survives a + // localStorage wipe / a different device, unlike the dispatched markers). + const categorized = await Promise.all( + stubs.map(async (s, i) => { + const labels = await resolveLabels(files[i]); + return { + ...s, + sourceFileIds: lineage, + derivedFromTool: true, + ...(labels ? { classificationLabels: labels } : {}), + }; + }), + ); + // Mark the outputs handled BEFORE adding them (belt-and-suspenders session + // guard on top of derivedFromTool) so the auto-run never enforces the policy + // on its own output — that would version endlessly in a loop. + for (const s of categorized) markHandled(s.id as string); + deliveredIds = categorized.map((s) => s.id as string); if (ctx.parentStub) { - // Input is in the active workspace: version it there (workspace + storage). - await ctx.consumeFiles([run.fileId as FileId], stirlingFiles, stubs); + // Input is in the active workspace: version it in place, silently — the + // output replaces the input in the same slot without being auto-selected, + // reordered to the top, or opened in the viewer. The category rides on the + // stub, so it lands in the right group instantly (no re-read, no flicker). + await ctx.consumeFiles( + [run.fileId as FileId], + stirlingFiles, + categorized, + { silent: true }, + ); } else { // Input is only in storage (run recovered after a reload): version it at the // storage layer, then refresh the file views. await fileStorage.persistVersionedOutputs( [run.fileId as FileId], stirlingFiles, - stubs, + categorized, ); ctx.bumpRevision(); } @@ -528,8 +685,28 @@ async function importOutputs( derivedFromTool: true, }); // Belt-and-suspenders session guard on top of derivedFromTool. - for (const f of added) markDispatched(run.categoryId, f.fileId); + for (const f of added) markHandled(f.fileId as string); deliveredIds = added.map((f) => f.fileId as string); + // Mark each new-file output as tool-derived (the versioned path gets this from the + // CONSUME_FILES reducer; the addFiles path doesn't). This is the real loop guard: the dispatch + // effect skips `derivedFromTool` files, so a policy output is never re-enforced as a fresh + // upload regardless of how upload policies are later reordered — unlike per-(category,file) + // markers keyed to whichever policy is currently first. Also stamp labels so it lands in the + // right sidebar group immediately (a new file has no parent to inherit from). + let mutated = false; + await Promise.all( + added.map(async (f, i) => { + const labels = await resolveLabels(files[i]); + const updates = { + derivedFromTool: true, + ...(labels ? { classificationLabels: labels } : {}), + }; + ctx.updateStirlingFileStub(f.fileId, updates); + const ok = await fileStorage.updateFileMetadata(f.fileId, updates); + if (ok) mutated = true; + }), + ); + if (mutated) ctx.bumpRevision(); } const importedFileIds = [...done, ...fetched.map((f) => f.fileId)]; const imported = run.outputs.every((out) => diff --git a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx index 973410aef3..92cc4a556f 100644 --- a/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx +++ b/frontend/editor/src/proprietary/components/shared/PolicyEnforcingOverlay.tsx @@ -20,6 +20,9 @@ interface PolicyEnforcingOverlayProps { zIndex?: number; /** When provided, an × button is shown and called on click. */ onDismiss?: () => void; + /** CSS colour var of the enforcing policy's accent (e.g. `var(--color-orange)`), + * so the icon/spinner match that policy's badge instead of a fixed blue. */ + accentVar?: string; } /** @@ -31,6 +34,7 @@ export function PolicyEnforcingOverlay({ progress, zIndex = 200, onDismiss, + accentVar, }: PolicyEnforcingOverlayProps) { const { t } = useTranslation(); if (!enforcing) return null; @@ -66,7 +70,23 @@ export function PolicyEnforcingOverlay({ )}
- + @@ -80,9 +100,10 @@ export function PolicyEnforcingOverlay({ value={progress} striped animated + color={accentVar} /> ) : ( - + )}
diff --git a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx index 53fb1a4733..75fa9cb952 100644 --- a/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx +++ b/frontend/editor/src/proprietary/components/viewer/PolicyEnforcementOverlay.tsx @@ -70,6 +70,7 @@ export function PolicyEnforcementOverlay({ runs }: Props) { zIndex={1100} progress={progress} onDismiss={() => setDismissed(true)} + accentVar={policyAccentVar(inFlight.categoryId)} /> ); } diff --git a/frontend/editor/src/proprietary/data/classificationLabels.json b/frontend/editor/src/proprietary/data/classificationLabels.json new file mode 100644 index 0000000000..d22fecc155 --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationLabels.json @@ -0,0 +1,1719 @@ +{ + "labels": [ + { + "id": "invoice", + "name": "Invoice", + "icon": "receipt-long" + }, + { + "id": "receipt", + "name": "Receipt", + "icon": "receipt" + }, + { + "id": "credit-note", + "name": "Credit note", + "icon": "currency-exchange" + }, + { + "id": "debit-note", + "name": "Debit note", + "icon": "attach-money" + }, + { + "id": "purchase-order", + "name": "Purchase order", + "icon": "shopping-cart" + }, + { + "id": "order-confirmation", + "name": "Order confirmation", + "icon": "shopping-cart" + }, + { + "id": "quote", + "name": "Quote", + "icon": "request-quote" + }, + { + "id": "estimate", + "name": "Estimate", + "icon": "price-check" + }, + { + "id": "proforma-invoice", + "name": "Proforma invoice", + "icon": "paid" + }, + { + "id": "bank-statement", + "name": "Bank statement", + "icon": "account-balance" + }, + { + "id": "financial-statement", + "name": "Financial statement", + "icon": "account-balance-wallet" + }, + { + "id": "balance-sheet", + "name": "Balance sheet", + "icon": "table-chart" + }, + { + "id": "income-statement", + "name": "Income statement", + "icon": "functions" + }, + { + "id": "cash-flow-statement", + "name": "Cash flow statement", + "icon": "query-stats" + }, + { + "id": "expense-report", + "name": "Expense report", + "icon": "credit-card" + }, + { + "id": "budget", + "name": "Budget", + "icon": "savings" + }, + { + "id": "financial-forecast", + "name": "Financial forecast", + "icon": "insights" + }, + { + "id": "payslip", + "name": "Payslip", + "icon": "payments" + }, + { + "id": "payroll-document", + "name": "Payroll document", + "icon": "wallet" + }, + { + "id": "tax-form", + "name": "Tax form", + "icon": "calculate" + }, + { + "id": "tax-return", + "name": "Tax return", + "icon": "percent" + }, + { + "id": "tax-statement", + "name": "Tax statement", + "icon": "calculate" + }, + { + "id": "remittance-advice", + "name": "Remittance advice", + "icon": "send" + }, + { + "id": "payment-reminder", + "name": "Payment reminder", + "icon": "schedule" + }, + { + "id": "statement-of-account", + "name": "Statement of account", + "icon": "inbox" + }, + { + "id": "dunning-letter", + "name": "Dunning letter", + "icon": "mail" + }, + { + "id": "audit-report", + "name": "Audit report", + "icon": "fact-check" + }, + { + "id": "annual-report", + "name": "Annual report", + "icon": "leaderboard" + }, + { + "id": "quarterly-report", + "name": "Quarterly report", + "icon": "bar-chart" + }, + { + "id": "pricing-sheet", + "name": "Pricing sheet", + "icon": "sell" + }, + { + "id": "price-list", + "name": "Price list", + "icon": "format-list-bulleted" + }, + { + "id": "loan-document", + "name": "Loan document", + "icon": "assured-workload" + }, + { + "id": "mortgage-document", + "name": "Mortgage document", + "icon": "home" + }, + { + "id": "investment-summary", + "name": "Investment summary", + "icon": "trending-up" + }, + { + "id": "donation-receipt", + "name": "Donation receipt", + "icon": "volunteer-activism" + }, + { + "id": "contract", + "name": "Contract", + "icon": "handshake" + }, + { + "id": "nda", + "name": "NDA", + "icon": "lock" + }, + { + "id": "service-agreement", + "name": "Service agreement", + "icon": "handshake" + }, + { + "id": "employment-contract", + "name": "Employment contract", + "icon": "work" + }, + { + "id": "lease-agreement", + "name": "Lease agreement", + "icon": "home-work" + }, + { + "id": "rental-agreement", + "name": "Rental agreement", + "icon": "home-work" + }, + { + "id": "license-agreement", + "name": "License agreement", + "icon": "verified" + }, + { + "id": "purchase-agreement", + "name": "Purchase agreement", + "icon": "shopping-cart" + }, + { + "id": "partnership-agreement", + "name": "Partnership agreement", + "icon": "diversity-3" + }, + { + "id": "loan-agreement", + "name": "Loan agreement", + "icon": "account-balance" + }, + { + "id": "settlement-agreement", + "name": "Settlement agreement", + "icon": "balance" + }, + { + "id": "vendor-agreement", + "name": "Vendor agreement", + "icon": "storefront" + }, + { + "id": "franchise-agreement", + "name": "Franchise agreement", + "icon": "business-center" + }, + { + "id": "non-compete-agreement", + "name": "Non-compete agreement", + "icon": "gavel" + }, + { + "id": "amendment", + "name": "Amendment", + "icon": "edit-document" + }, + { + "id": "addendum", + "name": "Addendum", + "icon": "edit-document" + }, + { + "id": "terms-and-conditions", + "name": "Terms and conditions", + "icon": "gavel" + }, + { + "id": "terms-of-service", + "name": "Terms of service", + "icon": "gavel" + }, + { + "id": "privacy-policy", + "name": "Privacy policy", + "icon": "privacy-tip" + }, + { + "id": "power-of-attorney", + "name": "Power of attorney", + "icon": "gavel" + }, + { + "id": "affidavit", + "name": "Affidavit", + "icon": "gavel" + }, + { + "id": "will", + "name": "Will", + "icon": "history-edu" + }, + { + "id": "trust-document", + "name": "Trust document", + "icon": "shield" + }, + { + "id": "deed", + "name": "Deed", + "icon": "home-work" + }, + { + "id": "court-filing", + "name": "Court filing", + "icon": "balance" + }, + { + "id": "legal-brief", + "name": "Legal brief", + "icon": "balance" + }, + { + "id": "legal-opinion", + "name": "Legal opinion", + "icon": "balance" + }, + { + "id": "legal-notice", + "name": "Legal notice", + "icon": "gavel" + }, + { + "id": "subpoena", + "name": "Subpoena", + "icon": "gavel" + }, + { + "id": "cease-and-desist", + "name": "Cease and desist", + "icon": "gavel" + }, + { + "id": "compliance-document", + "name": "Compliance document", + "icon": "rule" + }, + { + "id": "regulatory-filing", + "name": "Regulatory filing", + "icon": "rule" + }, + { + "id": "consent-form", + "name": "Consent form", + "icon": "fact-check" + }, + { + "id": "waiver", + "name": "Waiver", + "icon": "fact-check" + }, + { + "id": "memorandum-of-understanding", + "name": "Memorandum of understanding", + "icon": "handshake" + }, + { + "id": "letter-of-intent", + "name": "Letter of intent", + "icon": "draft" + }, + { + "id": "articles-of-incorporation", + "name": "Articles of incorporation", + "icon": "business-center" + }, + { + "id": "bylaws", + "name": "Bylaws", + "icon": "rule" + }, + { + "id": "shareholder-agreement", + "name": "Shareholder agreement", + "icon": "handshake" + }, + { + "id": "board-resolution", + "name": "Board resolution", + "icon": "groups" + }, + { + "id": "resume", + "name": "Resume", + "icon": "person" + }, + { + "id": "cv", + "name": "CV", + "icon": "person" + }, + { + "id": "cover-letter", + "name": "Cover letter", + "icon": "mail" + }, + { + "id": "job-description", + "name": "Job description", + "icon": "topic" + }, + { + "id": "job-application", + "name": "Job application", + "icon": "note-add" + }, + { + "id": "offer-letter", + "name": "Offer letter", + "icon": "work" + }, + { + "id": "onboarding-document", + "name": "Onboarding document", + "icon": "badge" + }, + { + "id": "employee-handbook", + "name": "Employee handbook", + "icon": "menu-book" + }, + { + "id": "hr-policy", + "name": "HR policy", + "icon": "policy" + }, + { + "id": "performance-review", + "name": "Performance review", + "icon": "monitoring" + }, + { + "id": "timesheet", + "name": "Timesheet", + "icon": "schedule" + }, + { + "id": "leave-request", + "name": "Leave request", + "icon": "event" + }, + { + "id": "resignation-letter", + "name": "Resignation letter", + "icon": "mail" + }, + { + "id": "termination-letter", + "name": "Termination letter", + "icon": "send" + }, + { + "id": "reference-letter", + "name": "Reference letter", + "icon": "contact-page" + }, + { + "id": "recommendation-letter", + "name": "Recommendation letter", + "icon": "how-to-reg" + }, + { + "id": "training-material", + "name": "Training material", + "icon": "school" + }, + { + "id": "organization-chart", + "name": "Organization chart", + "icon": "supervisor-account" + }, + { + "id": "benefits-summary", + "name": "Benefits summary", + "icon": "health-and-safety" + }, + { + "id": "hr-memo", + "name": "HR memo", + "icon": "sticky-note-2" + }, + { + "id": "letter", + "name": "Letter", + "icon": "mail" + }, + { + "id": "email-thread", + "name": "Email thread", + "icon": "alternate-email" + }, + { + "id": "memo", + "name": "Memo", + "icon": "sticky-note-2" + }, + { + "id": "meeting-minutes", + "name": "Meeting minutes", + "icon": "groups" + }, + { + "id": "meeting-agenda", + "name": "Meeting agenda", + "icon": "checklist" + }, + { + "id": "newsletter", + "name": "Newsletter", + "icon": "newspaper" + }, + { + "id": "announcement", + "name": "Announcement", + "icon": "campaign" + }, + { + "id": "notice", + "name": "Notice", + "icon": "campaign" + }, + { + "id": "public-notice", + "name": "Public notice", + "icon": "public" + }, + { + "id": "press-release", + "name": "Press release", + "icon": "campaign" + }, + { + "id": "complaint-letter", + "name": "Complaint letter", + "icon": "forum" + }, + { + "id": "demand-letter", + "name": "Demand letter", + "icon": "mail" + }, + { + "id": "confirmation-letter", + "name": "Confirmation letter", + "icon": "mail" + }, + { + "id": "report", + "name": "Report", + "icon": "bar-chart" + }, + { + "id": "progress-report", + "name": "Progress report", + "icon": "timeline" + }, + { + "id": "status-report", + "name": "Status report", + "icon": "monitoring" + }, + { + "id": "incident-report", + "name": "Incident report", + "icon": "emergency" + }, + { + "id": "inspection-report", + "name": "Inspection report", + "icon": "fact-check" + }, + { + "id": "survey-results", + "name": "Survey results", + "icon": "pie-chart" + }, + { + "id": "market-research", + "name": "Market research", + "icon": "insights" + }, + { + "id": "case-study", + "name": "Case study", + "icon": "article" + }, + { + "id": "white-paper", + "name": "White paper", + "icon": "library-books" + }, + { + "id": "research-paper", + "name": "Research paper", + "icon": "science" + }, + { + "id": "research-abstract", + "name": "Research abstract", + "icon": "text-snippet" + }, + { + "id": "feasibility-study", + "name": "Feasibility study", + "icon": "architecture" + }, + { + "id": "risk-assessment", + "name": "Risk assessment", + "icon": "security" + }, + { + "id": "analytics-report", + "name": "Analytics report", + "icon": "analytics" + }, + { + "id": "sales-report", + "name": "Sales report", + "icon": "trending-up" + }, + { + "id": "expense-summary", + "name": "Expense summary", + "icon": "pie-chart" + }, + { + "id": "board-report", + "name": "Board report", + "icon": "groups" + }, + { + "id": "sustainability-report", + "name": "Sustainability report", + "icon": "eco" + }, + { + "id": "standard-operating-procedure", + "name": "Standard operating procedure", + "icon": "rule" + }, + { + "id": "work-instruction", + "name": "Work instruction", + "icon": "checklist" + }, + { + "id": "manual", + "name": "Manual", + "icon": "menu-book" + }, + { + "id": "user-guide", + "name": "User guide", + "icon": "auto-stories" + }, + { + "id": "quick-start-guide", + "name": "Quick start guide", + "icon": "menu-book" + }, + { + "id": "checklist", + "name": "Checklist", + "icon": "checklist" + }, + { + "id": "inventory-list", + "name": "Inventory list", + "icon": "inventory-2" + }, + { + "id": "stock-report", + "name": "Stock report", + "icon": "warehouse" + }, + { + "id": "packing-slip", + "name": "Packing slip", + "icon": "package-2" + }, + { + "id": "delivery-note", + "name": "Delivery note", + "icon": "local-shipping" + }, + { + "id": "bill-of-lading", + "name": "Bill of lading", + "icon": "pallet" + }, + { + "id": "waybill", + "name": "Waybill", + "icon": "map" + }, + { + "id": "customs-declaration", + "name": "Customs declaration", + "icon": "flight" + }, + { + "id": "customs-form", + "name": "Customs form", + "icon": "assignment" + }, + { + "id": "freight-document", + "name": "Freight document", + "icon": "forklift" + }, + { + "id": "shipping-confirmation", + "name": "Shipping confirmation", + "icon": "send" + }, + { + "id": "supply-order", + "name": "Supply order", + "icon": "shopping-cart" + }, + { + "id": "work-order", + "name": "Work order", + "icon": "task" + }, + { + "id": "maintenance-log", + "name": "Maintenance log", + "icon": "build" + }, + { + "id": "service-report", + "name": "Service report", + "icon": "engineering" + }, + { + "id": "quality-report", + "name": "Quality report", + "icon": "verified" + }, + { + "id": "safety-data-sheet", + "name": "Safety data sheet", + "icon": "health-and-safety" + }, + { + "id": "safety-procedure", + "name": "Safety procedure", + "icon": "health-and-safety" + }, + { + "id": "warehouse-receipt", + "name": "Warehouse receipt", + "icon": "inventory-2" + }, + { + "id": "return-authorization", + "name": "Return authorization", + "icon": "redeem" + }, + { + "id": "proposal", + "name": "Proposal", + "icon": "slideshow" + }, + { + "id": "business-proposal", + "name": "Business proposal", + "icon": "business-center" + }, + { + "id": "sales-proposal", + "name": "Sales proposal", + "icon": "trending-up" + }, + { + "id": "pitch-deck", + "name": "Pitch deck", + "icon": "leaderboard" + }, + { + "id": "presentation", + "name": "Presentation", + "icon": "slideshow" + }, + { + "id": "brochure", + "name": "Brochure", + "icon": "image" + }, + { + "id": "flyer", + "name": "Flyer", + "icon": "palette" + }, + { + "id": "catalog", + "name": "Catalog", + "icon": "menu-book" + }, + { + "id": "product-sheet", + "name": "Product sheet", + "icon": "description" + }, + { + "id": "marketing-plan", + "name": "Marketing plan", + "icon": "campaign" + }, + { + "id": "campaign-brief", + "name": "Campaign brief", + "icon": "campaign" + }, + { + "id": "media-kit", + "name": "Media kit", + "icon": "photo-camera" + }, + { + "id": "promotional-material", + "name": "Promotional material", + "icon": "celebration" + }, + { + "id": "advertisement", + "name": "Advertisement", + "icon": "campaign" + }, + { + "id": "request-for-proposal", + "name": "Request for proposal", + "icon": "assignment" + }, + { + "id": "request-for-quotation", + "name": "Request for quotation", + "icon": "request-quote" + }, + { + "id": "tender-document", + "name": "Tender document", + "icon": "assignment" + }, + { + "id": "statement-of-work", + "name": "Statement of work", + "icon": "task" + }, + { + "id": "scope-of-work", + "name": "Scope of work", + "icon": "task" + }, + { + "id": "specification", + "name": "Specification", + "icon": "engineering" + }, + { + "id": "technical-specification", + "name": "Technical specification", + "icon": "code" + }, + { + "id": "requirements-document", + "name": "Requirements document", + "icon": "checklist" + }, + { + "id": "design-document", + "name": "Design document", + "icon": "design-services" + }, + { + "id": "architecture-document", + "name": "Architecture document", + "icon": "architecture" + }, + { + "id": "datasheet", + "name": "Datasheet", + "icon": "table-chart" + }, + { + "id": "schematic", + "name": "Schematic", + "icon": "bolt" + }, + { + "id": "blueprint", + "name": "Blueprint", + "icon": "construction" + }, + { + "id": "technical-drawing", + "name": "Technical drawing", + "icon": "engineering" + }, + { + "id": "floor-plan", + "name": "Floor plan", + "icon": "apartment" + }, + { + "id": "patent", + "name": "Patent", + "icon": "copyright" + }, + { + "id": "test-plan", + "name": "Test plan", + "icon": "checklist" + }, + { + "id": "test-report", + "name": "Test report", + "icon": "fact-check" + }, + { + "id": "release-notes", + "name": "Release notes", + "icon": "article" + }, + { + "id": "change-log", + "name": "Change log", + "icon": "edit-document" + }, + { + "id": "api-documentation", + "name": "API documentation", + "icon": "api" + }, + { + "id": "bill-of-materials", + "name": "Bill of materials", + "icon": "inventory-2" + }, + { + "id": "business-plan", + "name": "Business plan", + "icon": "business-center" + }, + { + "id": "project-plan", + "name": "Project plan", + "icon": "task" + }, + { + "id": "project-charter", + "name": "Project charter", + "icon": "task" + }, + { + "id": "roadmap", + "name": "Roadmap", + "icon": "trending-up" + }, + { + "id": "timeline", + "name": "Timeline", + "icon": "timeline" + }, + { + "id": "meeting-notes", + "name": "Meeting notes", + "icon": "sticky-note-2" + }, + { + "id": "action-plan", + "name": "Action plan", + "icon": "checklist" + }, + { + "id": "retrospective", + "name": "Retrospective", + "icon": "psychology" + }, + { + "id": "transcript", + "name": "Transcript", + "icon": "school" + }, + { + "id": "diploma", + "name": "Diploma", + "icon": "history-edu" + }, + { + "id": "certificate", + "name": "Certificate", + "icon": "verified" + }, + { + "id": "certificate-of-completion", + "name": "Certificate of completion", + "icon": "verified" + }, + { + "id": "course-syllabus", + "name": "Course syllabus", + "icon": "menu-book" + }, + { + "id": "lesson-plan", + "name": "Lesson plan", + "icon": "school" + }, + { + "id": "assignment-brief", + "name": "Assignment brief", + "icon": "assignment" + }, + { + "id": "exam-paper", + "name": "Exam paper", + "icon": "assignment" + }, + { + "id": "grade-report", + "name": "Grade report", + "icon": "school" + }, + { + "id": "thesis", + "name": "Thesis", + "icon": "science" + }, + { + "id": "dissertation", + "name": "Dissertation", + "icon": "science" + }, + { + "id": "study-guide", + "name": "Study guide", + "icon": "menu-book" + }, + { + "id": "academic-record", + "name": "Academic record", + "icon": "school" + }, + { + "id": "medical-report", + "name": "Medical report", + "icon": "medical-services" + }, + { + "id": "lab-report", + "name": "Lab report", + "icon": "science" + }, + { + "id": "radiology-report", + "name": "Radiology report", + "icon": "monitor-heart" + }, + { + "id": "pathology-report", + "name": "Pathology report", + "icon": "medical-information" + }, + { + "id": "prescription", + "name": "Prescription", + "icon": "medication" + }, + { + "id": "referral-letter", + "name": "Referral letter", + "icon": "stethoscope" + }, + { + "id": "discharge-summary", + "name": "Discharge summary", + "icon": "medical-services" + }, + { + "id": "immunization-record", + "name": "Immunization record", + "icon": "vaccines" + }, + { + "id": "medical-invoice", + "name": "Medical invoice", + "icon": "receipt-long" + }, + { + "id": "insurance-policy", + "name": "Insurance policy", + "icon": "shield" + }, + { + "id": "insurance-claim", + "name": "Insurance claim", + "icon": "security" + }, + { + "id": "insurance-certificate", + "name": "Insurance certificate", + "icon": "approval" + }, + { + "id": "explanation-of-benefits", + "name": "Explanation of benefits", + "icon": "medical-information" + }, + { + "id": "property-listing", + "name": "Property listing", + "icon": "real-estate-agent" + }, + { + "id": "appraisal-report", + "name": "Appraisal report", + "icon": "real-estate-agent" + }, + { + "id": "home-inspection-report", + "name": "Home inspection report", + "icon": "home" + }, + { + "id": "title-document", + "name": "Title document", + "icon": "home-work" + }, + { + "id": "closing-statement", + "name": "Closing statement", + "icon": "real-estate-agent" + }, + { + "id": "tenancy-agreement", + "name": "Tenancy agreement", + "icon": "home-work" + }, + { + "id": "eviction-notice", + "name": "Eviction notice", + "icon": "gavel" + }, + { + "id": "hoa-document", + "name": "HOA document", + "icon": "home-work" + }, + { + "id": "utility-bill", + "name": "Utility bill", + "icon": "bolt" + }, + { + "id": "permit", + "name": "Permit", + "icon": "approval" + }, + { + "id": "license", + "name": "License", + "icon": "verified" + }, + { + "id": "registration-form", + "name": "Registration form", + "icon": "how-to-reg" + }, + { + "id": "application-form", + "name": "Application form", + "icon": "assignment" + }, + { + "id": "government-notice", + "name": "Government notice", + "icon": "campaign" + }, + { + "id": "grant-application", + "name": "Grant application", + "icon": "assignment" + }, + { + "id": "grant-agreement", + "name": "Grant agreement", + "icon": "handshake" + }, + { + "id": "visa-document", + "name": "Visa document", + "icon": "public" + }, + { + "id": "immigration-document", + "name": "Immigration document", + "icon": "badge" + }, + { + "id": "legal-filing", + "name": "Legal filing", + "icon": "balance" + }, + { + "id": "itinerary", + "name": "Itinerary", + "icon": "map" + }, + { + "id": "travel-itinerary", + "name": "Travel itinerary", + "icon": "flight" + }, + { + "id": "booking-confirmation", + "name": "Booking confirmation", + "icon": "hotel" + }, + { + "id": "reservation", + "name": "Reservation", + "icon": "restaurant" + }, + { + "id": "ticket", + "name": "Ticket", + "icon": "confirmation-number" + }, + { + "id": "event-agenda", + "name": "Event agenda", + "icon": "calendar-month" + }, + { + "id": "event-program", + "name": "Event program", + "icon": "calendar-month" + }, + { + "id": "invitation", + "name": "Invitation", + "icon": "mail" + }, + { + "id": "registration-confirmation", + "name": "Registration confirmation", + "icon": "fact-check" + }, + { + "id": "form", + "name": "Form", + "icon": "assignment" + }, + { + "id": "questionnaire", + "name": "Questionnaire", + "icon": "assignment" + }, + { + "id": "survey-form", + "name": "Survey form", + "icon": "checklist" + }, + { + "id": "feedback-form", + "name": "Feedback form", + "icon": "forum" + }, + { + "id": "intake-form", + "name": "Intake form", + "icon": "assignment" + }, + { + "id": "order-form", + "name": "Order form", + "icon": "shopping-cart" + }, + { + "id": "claim-form", + "name": "Claim form", + "icon": "assignment" + }, + { + "id": "warranty-document", + "name": "Warranty document", + "icon": "assured-workload" + }, + { + "id": "membership-document", + "name": "Membership document", + "icon": "badge" + }, + { + "id": "subscription-confirmation", + "name": "Subscription confirmation", + "icon": "fact-check" + }, + { + "id": "gift-certificate", + "name": "Gift certificate", + "icon": "redeem" + }, + { + "id": "sponsorship-agreement", + "name": "Sponsorship agreement", + "icon": "handshake" + }, + { + "id": "petition", + "name": "Petition", + "icon": "history-edu" + }, + { + "id": "agenda", + "name": "Agenda", + "icon": "checklist" + }, + { + "id": "fact-sheet", + "name": "Fact sheet", + "icon": "summarize" + }, + { + "id": "faq-document", + "name": "FAQ document", + "icon": "chat" + }, + { + "id": "glossary", + "name": "Glossary", + "icon": "menu-book" + }, + { + "id": "index", + "name": "Index", + "icon": "format-list-bulleted" + }, + { + "id": "table-of-contents", + "name": "Table of contents", + "icon": "topic" + } + ], + "families": [ + { + "id": "finance", + "name": "Financial", + "icon": "payments", + "labelIds": [ + "invoice", + "receipt", + "credit-note", + "debit-note", + "purchase-order", + "order-confirmation", + "quote", + "estimate", + "proforma-invoice", + "bank-statement", + "financial-statement", + "balance-sheet", + "income-statement", + "cash-flow-statement", + "expense-report", + "budget", + "financial-forecast", + "payslip", + "payroll-document", + "tax-form", + "tax-return", + "tax-statement", + "remittance-advice", + "payment-reminder", + "statement-of-account", + "dunning-letter", + "audit-report", + "annual-report", + "quarterly-report", + "pricing-sheet", + "price-list", + "loan-document", + "mortgage-document", + "investment-summary", + "donation-receipt" + ] + }, + { + "id": "legal", + "name": "Legal", + "icon": "gavel", + "labelIds": [ + "contract", + "nda", + "service-agreement", + "employment-contract", + "lease-agreement", + "rental-agreement", + "license-agreement", + "purchase-agreement", + "partnership-agreement", + "loan-agreement", + "settlement-agreement", + "vendor-agreement", + "franchise-agreement", + "non-compete-agreement", + "amendment", + "addendum", + "terms-and-conditions", + "terms-of-service", + "privacy-policy", + "power-of-attorney", + "affidavit", + "will", + "trust-document", + "deed", + "court-filing", + "legal-brief", + "legal-opinion", + "legal-notice", + "subpoena", + "cease-and-desist", + "compliance-document", + "regulatory-filing", + "consent-form", + "waiver", + "memorandum-of-understanding", + "letter-of-intent", + "articles-of-incorporation", + "bylaws", + "shareholder-agreement", + "board-resolution" + ] + }, + { + "id": "hr", + "name": "HR", + "icon": "badge", + "labelIds": [ + "resume", + "cv", + "cover-letter", + "job-description", + "job-application", + "offer-letter", + "onboarding-document", + "employee-handbook", + "hr-policy", + "performance-review", + "timesheet", + "leave-request", + "resignation-letter", + "termination-letter", + "reference-letter", + "recommendation-letter", + "training-material", + "organization-chart", + "benefits-summary", + "hr-memo" + ] + }, + { + "id": "correspondence", + "name": "Correspondence", + "icon": "mail", + "labelIds": [ + "letter", + "email-thread", + "memo", + "meeting-minutes", + "meeting-agenda", + "newsletter", + "announcement", + "notice", + "public-notice", + "press-release", + "complaint-letter", + "demand-letter", + "confirmation-letter" + ] + }, + { + "id": "reports", + "name": "Reports", + "icon": "monitoring", + "labelIds": [ + "report", + "progress-report", + "status-report", + "incident-report", + "inspection-report", + "survey-results", + "market-research", + "case-study", + "white-paper", + "research-paper", + "research-abstract", + "feasibility-study", + "risk-assessment", + "analytics-report", + "sales-report", + "expense-summary", + "board-report", + "sustainability-report" + ] + }, + { + "id": "operations", + "name": "Operations", + "icon": "local-shipping", + "labelIds": [ + "standard-operating-procedure", + "work-instruction", + "manual", + "user-guide", + "quick-start-guide", + "checklist", + "inventory-list", + "stock-report", + "packing-slip", + "delivery-note", + "bill-of-lading", + "waybill", + "customs-declaration", + "customs-form", + "freight-document", + "shipping-confirmation", + "supply-order", + "work-order", + "maintenance-log", + "service-report", + "quality-report", + "safety-data-sheet", + "safety-procedure", + "warehouse-receipt", + "return-authorization" + ] + }, + { + "id": "sales", + "name": "Marketing", + "icon": "campaign", + "labelIds": [ + "proposal", + "business-proposal", + "sales-proposal", + "pitch-deck", + "presentation", + "brochure", + "flyer", + "catalog", + "product-sheet", + "marketing-plan", + "campaign-brief", + "media-kit", + "promotional-material", + "advertisement", + "request-for-proposal", + "request-for-quotation", + "tender-document", + "statement-of-work", + "scope-of-work" + ] + }, + { + "id": "engineering", + "name": "Engineering", + "icon": "engineering", + "labelIds": [ + "specification", + "technical-specification", + "requirements-document", + "design-document", + "architecture-document", + "datasheet", + "schematic", + "blueprint", + "technical-drawing", + "floor-plan", + "patent", + "test-plan", + "test-report", + "release-notes", + "change-log", + "api-documentation", + "bill-of-materials" + ] + }, + { + "id": "projects", + "name": "Projects", + "icon": "task", + "labelIds": [ + "business-plan", + "project-plan", + "project-charter", + "roadmap", + "timeline", + "meeting-notes", + "action-plan", + "retrospective" + ] + }, + { + "id": "education", + "name": "Education", + "icon": "school", + "labelIds": [ + "transcript", + "diploma", + "certificate", + "certificate-of-completion", + "course-syllabus", + "lesson-plan", + "assignment-brief", + "exam-paper", + "grade-report", + "thesis", + "dissertation", + "study-guide", + "academic-record" + ] + }, + { + "id": "health", + "name": "Medical", + "icon": "medical-services", + "labelIds": [ + "medical-report", + "lab-report", + "radiology-report", + "pathology-report", + "prescription", + "referral-letter", + "discharge-summary", + "immunization-record", + "medical-invoice", + "insurance-policy", + "insurance-claim", + "insurance-certificate", + "explanation-of-benefits" + ] + }, + { + "id": "property", + "name": "Property", + "icon": "real-estate-agent", + "labelIds": [ + "property-listing", + "appraisal-report", + "home-inspection-report", + "title-document", + "closing-statement", + "tenancy-agreement", + "eviction-notice", + "hoa-document", + "utility-bill" + ] + }, + { + "id": "government", + "name": "Government", + "icon": "account-balance", + "labelIds": [ + "permit", + "license", + "registration-form", + "application-form", + "government-notice", + "grant-application", + "grant-agreement", + "visa-document", + "immigration-document", + "legal-filing" + ] + }, + { + "id": "travel", + "name": "Travel", + "icon": "flight", + "labelIds": [ + "itinerary", + "travel-itinerary", + "booking-confirmation", + "reservation", + "ticket", + "event-agenda", + "event-program", + "invitation", + "registration-confirmation" + ] + }, + { + "id": "forms", + "name": "Forms", + "icon": "assignment", + "labelIds": [ + "form", + "questionnaire", + "survey-form", + "feedback-form", + "intake-form", + "order-form", + "claim-form", + "warranty-document", + "membership-document", + "subscription-confirmation", + "gift-certificate", + "sponsorship-agreement", + "petition", + "agenda", + "fact-sheet", + "faq-document", + "glossary", + "index", + "table-of-contents" + ] + } + ] +} diff --git a/frontend/editor/src/proprietary/data/classificationLabels.ts b/frontend/editor/src/proprietary/data/classificationLabels.ts new file mode 100644 index 0000000000..3695255c4e --- /dev/null +++ b/frontend/editor/src/proprietary/data/classificationLabels.ts @@ -0,0 +1,86 @@ +// Default classification labels. The SOURCE OF TRUTH is the co-located static +// JSON (`classificationLabels.json`), imported here and shaped into typed +// objects — edit THAT file, not this one. This is the ONLY copy of the label +// data: it seeds a team's editable set and drives the sidebar's grouping, +// icons, and display names. Neither the backend nor the engine keeps a copy. +// +// NOTE: this is only the built-in default vocabulary. A team's own +// (admin-editable) labels live in the backend store and are what the backend +// sends to the engine per classify request — the engine holds no vocabulary of +// its own. Edits to a team's set reach Python on the next run, independent of +// this file. +// +// `labels` is the flat set: each has a stable `id` (slug — the value on the wire, +// in storage and keyed on) and a human `name` (display, translatable via +// `classification.labels.`); `icon` is presentational only, the engine never +// sees it. `families` are presentational sidebar roll-ups (referencing labels by +// id) the classifier never sees. + +import labelsData from "@app/data/classificationLabels.json"; + +export interface ClassificationLabel { + /** Stable identity (slug) — the value on the wire, stored on the doc, and + * keyed on. Independent of the (translatable) display name. */ + id: string; + /** Human display name; the en-US default for `classification.labels.`. */ + name: string; + /** Material Symbols icon key (see `labelIcons.ts`). */ + icon?: string; +} + +export interface LabelFamily { + /** Stable identity for sidebar prefs — never rename once shipped. */ + id: string; + /** Group header text shown in the sidebar and the group picker. */ + name: string; + /** Material Symbols icon key (see `labelIcons.ts`). */ + icon: string; + /** The built-in labels this family rolls up in the sidebar. */ + labels: ClassificationLabel[]; +} + +/** Shape of `classificationLabels.json` — the flat label set plus the + * presentational family grouping (which references labels by id). */ +interface LabelsFile { + labels: ClassificationLabel[]; + families: { id: string; name: string; icon: string; labelIds: string[] }[]; +} + +const data = labelsData as LabelsFile; + +/** Flat default label set — file order, as the classifier/team-seed sees it. */ +export const DEFAULT_CLASSIFICATION_LABELS: ClassificationLabel[] = data.labels; + +const LABEL_BY_ID = new Map(data.labels.map((label) => [label.id, label])); + +/** + * Built-in families with their labels resolved from the flat set by id. Throws + * at module load if a family references an unknown id, so a bad hand-edit of the + * JSON fails fast rather than silently dropping a label from its group. + */ +export const LABEL_FAMILIES: LabelFamily[] = data.families.map((family) => ({ + id: family.id, + name: family.name, + icon: family.icon, + labels: family.labelIds.map((id) => { + const label = LABEL_BY_ID.get(id); + if (!label) { + throw new Error( + `Classification family "${family.id}" references unknown label id "${id}"`, + ); + } + return label; + }), +})); + +/** + * Stable slug id from a label's canonical (English) name — used to derive an id + * for a NEW custom label the user types. Built-in ids are authored in the JSON; + * this must stay in sync with the slug rule used to generate them. + */ +export function labelId(name: string): string { + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} diff --git a/frontend/editor/src/proprietary/data/labelDisplay.ts b/frontend/editor/src/proprietary/data/labelDisplay.ts new file mode 100644 index 0000000000..008f8786a7 --- /dev/null +++ b/frontend/editor/src/proprietary/data/labelDisplay.ts @@ -0,0 +1,17 @@ +// Proprietary override of the label-display seam: resolves a classification +// label id to its display name, translated via `classification.labels.` +// (the built-in English name is the en-US default). Custom team labels aren't in +// the built-in map, so they fall back to the id. + +import { useTranslation } from "react-i18next"; +import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels"; + +const NAME_BY_ID = new Map( + DEFAULT_CLASSIFICATION_LABELS.map((label) => [label.id, label.name]), +); + +/** Returns a resolver mapping a label id to its (translated) display name. */ +export function useLabelName(): (id: string) => string { + const { t } = useTranslation(); + return (id) => t(`classification.labels.${id}`, NAME_BY_ID.get(id) ?? id); +} diff --git a/frontend/editor/src/proprietary/data/labelIcons.ts b/frontend/editor/src/proprietary/data/labelIcons.ts new file mode 100644 index 0000000000..699f9ba61c --- /dev/null +++ b/frontend/editor/src/proprietary/data/labelIcons.ts @@ -0,0 +1,195 @@ +/** + * Curated palette of icons a classification label can use, shown in the label + * icon picker and rendered in the file sidebar's label groups. Keys are + * Material Symbols names rendered via {@link LocalIcon}. + * + * IMPORTANT: each entry is written as an `icon: "…"` literal so the icon-bundler + * (`scripts/generate-icons.js`, which regex-scans for `icon: "name"`) picks every + * one up and bundles it — otherwise a picked icon would fall back to the CDN and + * render blank offline. After adding/removing entries run `task frontend:prepare:icons`. + * + * Search is intentionally omitted for now (no synonyms to maintain); the palette + * is small enough to eyeball. + */ + +export interface LabelIconOption { + /** Material Symbols key (no `material-symbols:` prefix). */ + icon: string; + /** Short English name — the en-US default for the icon's tooltip/aria; the + * picker translates it via `policies.labels.iconName.`. */ + label: string; +} + +/** Fallback icon for a label with none set (and for the "Other" group). */ +export const DEFAULT_LABEL_ICON = "sell"; + +export const LABEL_ICON_OPTIONS: LabelIconOption[] = [ + // Money / finance + { icon: "receipt-long", label: "Receipt" }, + { icon: "request-quote", label: "Quote" }, + { icon: "payments", label: "Payments" }, + { icon: "account-balance", label: "Bank" }, + { icon: "savings", label: "Savings" }, + { icon: "credit-card", label: "Card" }, + { icon: "point-of-sale", label: "Point of sale" }, + { icon: "sell", label: "Label" }, + { icon: "shopping-cart", label: "Cart" }, + { icon: "calculate", label: "Calculate" }, + // Legal / compliance + { icon: "handshake", label: "Agreement" }, + { icon: "gavel", label: "Legal" }, + { icon: "balance", label: "Balance" }, + { icon: "policy", label: "Policy" }, + { icon: "verified", label: "Verified" }, + { icon: "shield", label: "Shield" }, + { icon: "health-and-safety", label: "Safety" }, + { icon: "lock", label: "Lock" }, + { icon: "fact-check", label: "Fact check" }, + { icon: "rule", label: "Rule" }, + // Documents / writing + { icon: "description", label: "Document" }, + { icon: "article", label: "Article" }, + { icon: "assignment", label: "Form" }, + { icon: "checklist", label: "Checklist" }, + { icon: "task", label: "Task" }, + { icon: "summarize", label: "Summary" }, + { icon: "sticky-note-2", label: "Note" }, + { icon: "edit-document", label: "Edit" }, + { icon: "draft", label: "Draft" }, + { icon: "folder", label: "Folder" }, + // People / comms + { icon: "mail", label: "Mail" }, + { icon: "campaign", label: "Campaign" }, + { icon: "contact-page", label: "Contact" }, + { icon: "badge", label: "Badge" }, + { icon: "groups", label: "Group" }, + { icon: "person", label: "Person" }, + { icon: "work", label: "Work" }, + { icon: "business-center", label: "Business" }, + { icon: "event", label: "Event" }, + { icon: "calendar-month", label: "Calendar" }, + // Charts / education / logistics / misc + { icon: "bar-chart", label: "Bar chart" }, + { icon: "monitoring", label: "Monitoring" }, + { icon: "pie-chart", label: "Pie chart" }, + { icon: "trending-up", label: "Trend" }, + { icon: "table-chart", label: "Table" }, + { icon: "school", label: "School" }, + { icon: "menu-book", label: "Book" }, + { icon: "science", label: "Science" }, + { icon: "medical-services", label: "Medical" }, + { icon: "local-shipping", label: "Shipping" }, + { icon: "slideshow", label: "Presentation" }, + { icon: "home-work", label: "Property" }, + { icon: "real-estate-agent", label: "Real estate" }, + { icon: "engineering", label: "Technical" }, + { icon: "inventory-2", label: "Inventory" }, + { icon: "image", label: "Image" }, + // Money / finance (extended) + { icon: "paid", label: "Paid" }, + { icon: "attach-money", label: "Money" }, + { icon: "currency-exchange", label: "Exchange" }, + { icon: "account-balance-wallet", label: "Wallet" }, + { icon: "price-check", label: "Price check" }, + { icon: "wallet", label: "Billfold" }, + { icon: "redeem", label: "Redeem" }, + { icon: "receipt", label: "Receipt (short)" }, + { icon: "percent", label: "Percent" }, + // Legal / security (extended) + { icon: "encrypted", label: "Encrypted" }, + { icon: "privacy-tip", label: "Privacy" }, + { icon: "security", label: "Security" }, + { icon: "assured-workload", label: "Assured" }, + { icon: "copyright", label: "Copyright" }, + { icon: "approval", label: "Approval" }, + { icon: "history-edu", label: "Quill" }, + // Documents (extended) + { icon: "topic", label: "Topic" }, + { icon: "note-add", label: "New note" }, + { icon: "text-snippet", label: "Snippet" }, + { icon: "library-books", label: "Library" }, + { icon: "auto-stories", label: "Open book" }, + { icon: "newspaper", label: "Newspaper" }, + { icon: "folder-open", label: "Open folder" }, + { icon: "format-list-bulleted", label: "List" }, + // People / HR (extended) + { icon: "manage-accounts", label: "Manage accounts" }, + { icon: "supervisor-account", label: "Supervisor" }, + { icon: "how-to-reg", label: "Registered" }, + { icon: "diversity-3", label: "Team" }, + { icon: "psychology", label: "Psychology" }, + { icon: "volunteer-activism", label: "Volunteer" }, + // Health (extended) + { icon: "medical-information", label: "Medical info" }, + { icon: "medication", label: "Medication" }, + { icon: "vaccines", label: "Vaccine" }, + { icon: "monitor-heart", label: "Heart monitor" }, + { icon: "emergency", label: "Emergency" }, + { icon: "stethoscope", label: "Stethoscope" }, + // Property / places (extended) + { icon: "home", label: "Home" }, + { icon: "apartment", label: "Apartment" }, + { icon: "storefront", label: "Storefront" }, + { icon: "location-on", label: "Location" }, + { icon: "map", label: "Map" }, + { icon: "public", label: "Globe" }, + // Logistics (extended) + { icon: "package-2", label: "Package" }, + { icon: "warehouse", label: "Warehouse" }, + { icon: "forklift", label: "Forklift" }, + { icon: "pallet", label: "Pallet" }, + { icon: "flight", label: "Flight" }, + { icon: "luggage", label: "Luggage" }, + // Technology / engineering (extended) + { icon: "code", label: "Code" }, + { icon: "terminal", label: "Terminal" }, + { icon: "database", label: "Database" }, + { icon: "cloud", label: "Cloud" }, + { icon: "api", label: "API" }, + { icon: "bug-report", label: "Bug" }, + { icon: "build", label: "Wrench" }, + { icon: "architecture", label: "Compass" }, + { icon: "design-services", label: "Design" }, + { icon: "precision-manufacturing", label: "Robotics" }, + { icon: "factory", label: "Factory" }, + { icon: "bolt", label: "Bolt" }, + { icon: "construction", label: "Construction" }, + // Charts / time (extended) + { icon: "analytics", label: "Analytics" }, + { icon: "insights", label: "Insights" }, + { icon: "query-stats", label: "Stats" }, + { icon: "leaderboard", label: "Leaderboard" }, + { icon: "functions", label: "Functions" }, + { icon: "schedule", label: "Clock" }, + { icon: "pending-actions", label: "Pending" }, + { icon: "timeline", label: "Timeline" }, + // Communication (extended) + { icon: "chat", label: "Chat" }, + { icon: "forum", label: "Forum" }, + { icon: "call", label: "Call" }, + { icon: "send", label: "Send" }, + { icon: "inbox", label: "Inbox" }, + { icon: "alternate-email", label: "At sign" }, + // Events / travel (extended) + { icon: "airplane-ticket", label: "Plane ticket" }, + { icon: "confirmation-number", label: "Ticket stub" }, + { icon: "hotel", label: "Hotel" }, + { icon: "restaurant", label: "Restaurant" }, + { icon: "celebration", label: "Celebration" }, + // Media / creative (extended) + { icon: "photo-camera", label: "Camera" }, + { icon: "videocam", label: "Video" }, + { icon: "music-note", label: "Music" }, + { icon: "palette", label: "Palette" }, + { icon: "brush", label: "Brush" }, + { icon: "movie", label: "Movie" }, + // Nature / misc (extended) + { icon: "eco", label: "Eco" }, + { icon: "recycling", label: "Recycling" }, + { icon: "agriculture", label: "Agriculture" }, +]; + +/** Set of valid palette keys, for validating a stored/imported icon. */ +export const LABEL_ICON_KEYS: ReadonlySet = new Set( + LABEL_ICON_OPTIONS.map((option) => option.icon), +); diff --git a/frontend/editor/src/proprietary/data/policyDefinitions.tsx b/frontend/editor/src/proprietary/data/policyDefinitions.tsx index ce12ee3380..837287cbdb 100644 --- a/frontend/editor/src/proprietary/data/policyDefinitions.tsx +++ b/frontend/editor/src/proprietary/data/policyDefinitions.tsx @@ -16,6 +16,7 @@ import PublicIcon from "@mui/icons-material/Public"; import CloudIcon from "@mui/icons-material/Cloud"; import EmailOutlinedIcon from "@mui/icons-material/EmailOutlined"; import FolderOpenIcon from "@mui/icons-material/FolderOpen"; +import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined"; import type { PolicyCategory, PolicyConfigDef, @@ -42,6 +43,14 @@ export const POLICY_CATEGORIES: PolicyCategory[] = [ icon: , desc: "Detect PII, encrypt, verify authenticity, control access, and certify documents.", }, + { + id: "classification", + label: "Classification", + icon: , + desc: "Identify each document's type on upload and tag its metadata for filing and search.", + // Needs the AI engine to classify; hidden from the policy list when it's off. + requiresAiEngine: true, + }, { id: "compliance", label: "Compliance", @@ -204,6 +213,16 @@ export const POLICY_CONFIG: Record = { // output naming + retries are set in the wizard. fields: [], }, + classification: { + summary: + "Classifies every uploaded document and writes the result to its metadata.", + rules: ["Classify", "Tag metadata"], + // Single backend step: classify the document via the AI engine and store the + // result in the document's StirlingPDFClassification metadata field. + defaultOperations: [{ operation: "classify", parameters: {} }], + scopeLabel: "All PDFs on this device", + fields: [], + }, compliance: { summary: "Validates documents against regulatory frameworks before they leave the system.", diff --git a/frontend/editor/src/proprietary/hooks/useClassificationLabels.ts b/frontend/editor/src/proprietary/hooks/useClassificationLabels.ts new file mode 100644 index 0000000000..5f034ad7fc --- /dev/null +++ b/frontend/editor/src/proprietary/hooks/useClassificationLabels.ts @@ -0,0 +1,85 @@ +/** + * Loads and persists the team's classification labels + * (`/api/v1/classification/labels`) — one server-truth set shared by the whole + * team. When the team has none, this shows the built-in default as a starting + * point for the editor/sidebar; note the classifier itself does NOT use that + * default — the backend only classifies against a team's saved set (an unsaved + * team is not classified). Editing is gated to team leaders / admins by the + * backend — callers pass `canConfigure` (the policy gate) to keep read-only + * users out of the save path. + * + * `teamLabels` is also what the sidebar/editor display uses — the team set, or + * the built-in default when the team has none. + */ + +import { useCallback, useEffect, useState } from "react"; +import { + DEFAULT_CLASSIFICATION_LABELS, + type ClassificationLabel, +} from "@app/data/classificationLabels"; +import { fetchTeamLabels, saveTeamLabels } from "@app/services/labelsBackend"; + +export interface UseClassificationLabels { + /** Server-truth team labels (or the built-in default when the team has none). */ + teamLabels: ClassificationLabel[]; + /** Whether the team has a stored set (vs. the built-in default). */ + isCustom: boolean; + loading: boolean; + saving: boolean; + /** Last save failure, cleared on the next attempt. */ + error: string | null; + /** Persist the team set; resolves once server state is updated. */ + saveTeam: (next: ClassificationLabel[]) => Promise; +} + +export function useClassificationLabels( + enabled: boolean, +): UseClassificationLabels { + const [teamLabels, setTeamLabels] = useState( + DEFAULT_CLASSIFICATION_LABELS, + ); + const [isCustom, setIsCustom] = useState(false); + const [loading, setLoading] = useState(enabled); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + setLoading(true); + void (async () => { + const team = await fetchTeamLabels().catch(() => null); + if (cancelled) return; + setTeamLabels(team ?? DEFAULT_CLASSIFICATION_LABELS); + setIsCustom(team != null); + setLoading(false); + })(); + return () => { + cancelled = true; + }; + }, [enabled]); + + const saveTeam = useCallback(async (next: ClassificationLabel[]) => { + setSaving(true); + setError(null); + try { + const saved = await saveTeamLabels(next); + setTeamLabels(saved); + setIsCustom(true); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't save the labels."); + throw e; + } finally { + setSaving(false); + } + }, []); + + return { + teamLabels, + isCustom, + loading, + saving, + error, + saveTeam, + }; +} diff --git a/frontend/editor/src/proprietary/hooks/usePolicies.ts b/frontend/editor/src/proprietary/hooks/usePolicies.ts index 356afa3164..be95079b3a 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicies.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicies.ts @@ -6,7 +6,7 @@ * IndexedDB backing folder still holds the editable automation + run state. */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useSaaSTeam } from "@app/contexts/SaaSTeamContext"; import { @@ -14,6 +14,7 @@ import { onPoliciesChange, updatePolicy, resetPolicy, + reorderPolicies as persistPolicyOrder, } from "@app/services/policyStorage"; import { loadPolicyCatalog } from "@app/services/policyCatalog"; import { @@ -33,6 +34,9 @@ import { setPolicyEnabled, removePolicy, } from "@app/services/policyBackend"; +import { reorderPolicies as reorderBackendPolicies } from "@app/services/policyApi"; +import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend"; +import { getPolicyToolChain } from "@app/components/policies/policyToolChains"; import type { PolicyToStore } from "@app/services/policyPipeline"; import type { PoliciesByCategory, @@ -40,6 +44,12 @@ import type { PolicyWizardResult, } from "@app/types/policies"; +/** Cold-start reconcile retry budget + capped backoff (≈0.5s→5s, ~1 min total), + * enough to outlast a backend that starts a little after the frontend. */ +const RECONCILE_MAX_ATTEMPTS = 15; +const reconcileRetryDelay = (attempt: number) => + Math.min(500 * 2 ** attempt, 5000); + /** Build the backend store-request for a category from a wizard result. */ function toStoreRequest( categoryId: string, @@ -65,23 +75,31 @@ function toStoreRequest( export function usePolicies() { const [policies, setPolicies] = useState(loadPolicies); - const { config } = useAppConfig(); + const { config, refetch: refetchAppConfig } = useAppConfig(); const { isTeamLeader } = useSaaSTeam(); useEffect(() => onPoliciesChange(() => setPolicies(loadPolicies())), []); - // Reconcile the local cache against the backend (the source of truth) on - // mount. Backend config wins; the locally-cached folderId (which the backend - // doesn't track) is preserved. If the backend is unreachable we keep the - // local cache as-is, so the surface still works offline. + // Latest refetch, read from inside the retry loop without re-triggering it. + const refetchAppConfigRef = useRef(refetchAppConfig); + refetchAppConfigRef.current = refetchAppConfig; + + // Reconcile local cache against the backend (source of truth), preserving the + // locally-cached folderId; retry with backoff since the backend may not be up yet. + // On recovery, also re-resolve app config in case its admin/team-leader flags settled false while down. useEffect(() => { let cancelled = false; - void (async () => { + let attempt = 0; + let timer: ReturnType | undefined; + + const reconcile = async () => { let byCategory; try { byCategory = await fetchPoliciesByCategory(); } catch { - return; // offline / backend down — local cache stands. + if (cancelled || attempt >= RECONCILE_MAX_ATTEMPTS) return; + timer = setTimeout(reconcile, reconcileRetryDelay(attempt++)); + return; } if (cancelled) return; const local = loadPolicies(); @@ -90,14 +108,24 @@ export function usePolicies() { const decoded = byCategory.get(cat.id); reconciled[cat.id] = decoded ? decodedToState(decoded, local[cat.id]?.folderId) - : { ...local[cat.id], configured: false, status: "default" }; + : { + ...local[cat.id], + configured: false, + status: "default", + backendId: undefined, + }; } for (const [id, state] of Object.entries(reconciled)) { updatePolicy(id, state); } - })(); + // The backend was down at first load (we retried) — re-resolve the app + // config so the admin/team-leader gate isn't stuck on its offline default. + if (attempt > 0) void refetchAppConfigRef.current(); + }; + void reconcile(); return () => { cancelled = true; + if (timer) clearTimeout(timer); }; }, []); @@ -111,6 +139,13 @@ export function usePolicies() { async (id: string, result: PolicyWizardResult) => { const category = loadPolicyCatalog().categories.find((c) => c.id === id); if (!category) throw new Error(`Unknown policy category: ${id}`); + // A policy whose chain classifies runs against the team's stored label set + // (the engine has no default). Seed it with the built-in defaults now so + // the very first enforced file has a vocabulary to classify against; no-op + // if the team already has a set (never clobbers admin edits). + if (getPolicyToolChain(id)?.includes("classify")) { + await seedTeamLabelsIfEmpty(); + } // One policy per category, ever: reuse any existing backend record. const existingBackendId = loadPolicies()[id]?.backendId ?? @@ -190,6 +225,12 @@ export function usePolicies() { async (id: string, result: PolicyConfigResult) => { const category = loadPolicyCatalog().categories.find((c) => c.id === id); if (!category) throw new Error(`Unknown policy category: ${id}`); + // Seed the team's default label set on first classification-policy setup + // (see enablePolicy) — the engine has no default, so the stored set is the + // only vocabulary. No-op once the team has any set. + if (getPolicyToolChain(id)?.includes("classify")) { + await seedTeamLabelsIfEmpty(); + } const current = loadPolicies()[id]; // One policy per category, ever: reuse the existing backend record (even // if the local link was lost) so a save never creates a duplicate. @@ -245,14 +286,42 @@ export function usePolicies() { const pausePolicy = useCallback(async (id: string) => { const current = loadPolicies()[id]; - if (current?.backendId) await setPolicyEnabled(current.backendId, false); + if (current?.backendId) { + await setPolicyEnabled(current.backendId, false).catch((err: unknown) => { + if ( + (err as { response?: { status?: number } })?.response?.status === 404 + ) { + updatePolicy(id, { + backendId: undefined, + configured: false, + status: "default", + }); + return; + } + throw err; + }); + } if (current?.folderId) await setPolicyFolderPaused(current.folderId, true); updatePolicy(id, { status: "paused" }); }, []); const resumePolicy = useCallback(async (id: string) => { const current = loadPolicies()[id]; - if (current?.backendId) await setPolicyEnabled(current.backendId, true); + if (current?.backendId) { + await setPolicyEnabled(current.backendId, true).catch((err: unknown) => { + if ( + (err as { response?: { status?: number } })?.response?.status === 404 + ) { + updatePolicy(id, { + backendId: undefined, + configured: false, + status: "default", + }); + return; + } + throw err; + }); + } if (current?.folderId) await setPolicyFolderPaused(current.folderId, false); updatePolicy(id, { status: "active" }); }, []); @@ -264,6 +333,26 @@ export function usePolicies() { resetPolicy(id); }, []); + /** + * Persist a new execution order for the given categories (in the sequence + * provided). The order is server-side and team-wide: it's mirrored to the + * backend (mapping each category to its stored policy id) so it survives a + * cleared browser and is shared by the whole team. The local cache is updated + * first for an instant re-render; the next reconcile re-reads the server order. + */ + const reorderPolicies = useCallback((orderedCategoryIds: string[]) => { + persistPolicyOrder(orderedCategoryIds); + const current = loadPolicies(); + const backendIds = orderedCategoryIds + .map((categoryId) => current[categoryId]?.backendId) + .filter((id): id is string => !!id); + if (backendIds.length > 0) { + // Fire-and-forget: on failure (offline / not a team leader) the optimistic + // local order stands until the next reconcile re-reads the server's order. + void reorderBackendPolicies(backendIds).catch(() => {}); + } + }, []); + /** * Ensure a configured policy has a *valid* backing folder (its editable * pipeline) and return its id. Self-heals a stale `folderId` — one that no @@ -315,6 +404,7 @@ export function usePolicies() { pausePolicy, resumePolicy, deletePolicy, + reorderPolicies, ensurePolicyFolder, }; } diff --git a/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts index 9d2bfbfa2c..c233333e39 100644 --- a/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts +++ b/frontend/editor/src/proprietary/hooks/usePolicyCatalog.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; import { loadPolicyCatalog, type PolicyCatalog, @@ -10,7 +11,20 @@ import { * directly. Memoised; when the catalog becomes a backend fetch, this hook is * where loading/error state would be introduced — its consumers already treat * it as the single source of definitions. + * + * Categories flagged {@link PolicyCategory.requiresAiEngine} are hidden while the + * AI engine is off, so a policy only appears where it can actually run. */ export function usePolicyCatalog(): PolicyCatalog { - return useMemo(() => loadPolicyCatalog(), []); + const aiEngineEnabled = useAiEngineEnabled(); + return useMemo(() => { + const catalog = loadPolicyCatalog(); + if (aiEngineEnabled) return catalog; + return { + ...catalog, + categories: catalog.categories.filter( + (category) => !category.requiresAiEngine, + ), + }; + }, [aiEngineEnabled]); } diff --git a/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts b/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts index 6c6a8029ce..18b028ab8e 100644 --- a/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts +++ b/frontend/editor/src/proprietary/hooks/useWatchedFolderUrlSync.ts @@ -12,6 +12,7 @@ import { } from "@app/contexts/NavigationContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders"; +import { slugify } from "@app/utils/slug"; // Inlined to avoid circular imports — must match WatchedFoldersRegistration.tsx const WATCHED_FOLDER_VIEW_ID = "watchedFolder"; @@ -20,13 +21,7 @@ const WATCHED_FOLDER_WORKBENCH_ID = "custom:watchedFolder"; const WATCHED_FOLDERS_BASE = "/watch-folders"; export function slugifyFolderName(name: string): string { - return ( - name - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, "") || "folder" - ); + return slugify(name) || "folder"; } function parseWatchedFolderRoute(): { diff --git a/frontend/editor/src/proprietary/services/fileSidebarCategories.ts b/frontend/editor/src/proprietary/services/fileSidebarCategories.ts new file mode 100644 index 0000000000..0ae8dac0db --- /dev/null +++ b/frontend/editor/src/proprietary/services/fileSidebarCategories.ts @@ -0,0 +1,196 @@ +// Device-local (localStorage) category structure for the Files sidebar: which parent +// categories exist, their name/icon/order, and which labels roll up into each. It's an editable +// override of the built-in LABEL_FAMILIES default — until the user customizes it, the default is +// used verbatim (so the store stays empty and the default can evolve). A label may sit in more +// than one category (multi-membership); a category with `hidden` set stays defined but isn't shown +// as a sidebar group. This is presentational only — the classifier never sees categories. + +import { LABEL_FAMILIES } from "@app/data/classificationLabels"; + +export interface SidebarCategory { + /** Stable id — built-ins reuse their family id; custom ones get `custom:`. */ + id: string; + name: string; + icon: string; + /** Label ids in this category (matches a file's stored classification ids). */ + labelKeys: string[]; + /** Defined but not rendered as a sidebar group. */ + hidden?: boolean; +} + +// v2: labelKeys hold label ids (was lower-cased names in v1); bumping discards +// stale name-keyed prefs so they don't silently stop matching. +const STORAGE_KEY = "stirling.fileSidebarCategories.v2"; + +/** The built-in default, derived from LABEL_FAMILIES. Fresh copy per call (callers may mutate). */ +export function defaultCategories(): SidebarCategory[] { + return LABEL_FAMILIES.map((family) => ({ + id: family.id, + name: family.name, + icon: family.icon, + labelKeys: family.labels.map((label) => label.id), + })); +} + +function readStorage(): SidebarCategory[] | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return null; + return parsed + .filter((c): c is SidebarCategory => { + const cat = c as Partial; + return ( + typeof cat.id === "string" && + typeof cat.name === "string" && + typeof cat.icon === "string" && + Array.isArray(cat.labelKeys) + ); + }) + .map((c) => ({ + id: c.id, + name: c.name, + icon: c.icon, + labelKeys: c.labelKeys.filter((k) => typeof k === "string"), + hidden: c.hidden === true, + })); + } catch { + return null; + } +} + +// null = using the built-in default (not yet customized). Cached so useSyncExternalStore sees a +// stable reference between writes. +let stored: SidebarCategory[] | null = readStorage(); +const listeners = new Set<() => void>(); + +// Effective list, recomputed only on write so its identity is stable for memo/useSyncExternalStore. +let effective: SidebarCategory[] = stored ?? defaultCategories(); + +function recompute() { + effective = stored ?? defaultCategories(); +} + +function write(next: SidebarCategory[]) { + stored = next; + recompute(); + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Quota/private-mode failures degrade to session-only categories. + } + for (const listener of listeners) listener(); +} + +/** Mutate the current effective list (snapshotting the default on first edit). */ +function mutate(fn: (categories: SidebarCategory[]) => SidebarCategory[]) { + write(fn(effective.map((c) => ({ ...c, labelKeys: [...c.labelKeys] })))); +} + +export function getSidebarCategories(): SidebarCategory[] { + return effective; +} + +export function subscribeSidebarCategories(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function isCustomized(): boolean { + return stored !== null; +} + +/** Map each label key to the ids of every VISIBLE category it belongs to. */ +export function labelCategoryMap( + categories: SidebarCategory[], +): Map { + const map = new Map(); + for (const category of categories) { + if (category.hidden) continue; + for (const key of category.labelKeys) { + const ids = map.get(key); + if (ids) ids.push(category.id); + else map.set(key, [category.id]); + } + } + return map; +} + +/** Set of every label key that belongs to any category (visible or not). */ +export function categorizedLabelKeys( + categories: SidebarCategory[], +): Set { + const keys = new Set(); + for (const category of categories) { + for (const key of category.labelKeys) keys.add(key); + } + return keys; +} + +// ---- editing ---- + +/** Create a new empty category; returns its id. */ +export function addCategory(name: string, icon: string): string { + const id = `custom:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${ + effective.length + }`; + mutate((categories) => [...categories, { id, name, icon, labelKeys: [] }]); + return id; +} + +export function renameCategory(id: string, name: string) { + mutate((categories) => + categories.map((c) => (c.id === id ? { ...c, name } : c)), + ); +} + +export function setCategoryIcon(id: string, icon: string) { + mutate((categories) => + categories.map((c) => (c.id === id ? { ...c, icon } : c)), + ); +} + +export function setCategoryHidden(id: string, hidden: boolean) { + mutate((categories) => + categories.map((c) => (c.id === id ? { ...c, hidden } : c)), + ); +} + +export function deleteCategory(id: string) { + mutate((categories) => categories.filter((c) => c.id !== id)); +} + +export function addLabelToCategory(id: string, labelId: string) { + mutate((categories) => + categories.map((c) => + c.id === id && !c.labelKeys.includes(labelId) + ? { ...c, labelKeys: [...c.labelKeys, labelId] } + : c, + ), + ); +} + +export function removeLabelFromCategory(id: string, labelId: string) { + mutate((categories) => + categories.map((c) => + c.id === id + ? { ...c, labelKeys: c.labelKeys.filter((k) => k !== labelId) } + : c, + ), + ); +} + +/** Restore the built-in default and clear the customized flag (undoes any prior `mutate`). */ +export function resetSidebarCategories() { + stored = null; + recompute(); + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Quota/private-mode failures degrade to session-only categories. + } + for (const listener of listeners) listener(); +} diff --git a/frontend/editor/src/proprietary/services/labelsBackend.test.ts b/frontend/editor/src/proprietary/services/labelsBackend.test.ts new file mode 100644 index 0000000000..d530364b3a --- /dev/null +++ b/frontend/editor/src/proprietary/services/labelsBackend.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import apiClient from "@app/services/apiClient"; +import { DEFAULT_CLASSIFICATION_LABELS } from "@app/data/classificationLabels"; +import { seedTeamLabelsIfEmpty } from "@app/services/labelsBackend"; + +vi.mock("@app/services/apiClient"); + +const get = vi.mocked(apiClient.get); +const put = vi.mocked(apiClient.put); + +// The service only reads `status`/`data` off the axios response; a partial shape +// is all these tests need, so cast the mock values through this helper. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const res = (value: object): any => value; + +// 204 No Content (nothing stored) comes back as an empty body. +const emptyResponse = res({ status: 204, data: "" }); +const storedResponse = res({ + status: 200, + data: { labels: [{ id: "invoice", name: "Invoice", icon: "receipt" }] }, +}); +const putEcho = res({ data: { labels: DEFAULT_CLASSIFICATION_LABELS } }); + +describe("seedTeamLabelsIfEmpty", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("seeds the built-in defaults when the team has no set", async () => { + get.mockResolvedValue(emptyResponse); + put.mockResolvedValue(putEcho); + + await seedTeamLabelsIfEmpty(); + + expect(put).toHaveBeenCalledTimes(1); + expect(put).toHaveBeenCalledWith("/api/v1/classification/labels", { + labels: DEFAULT_CLASSIFICATION_LABELS, + }); + }); + + it("is a no-op when the team already has a set (never clobbers)", async () => { + get.mockResolvedValue(storedResponse); + + await seedTeamLabelsIfEmpty(); + + expect(put).not.toHaveBeenCalled(); + }); + + it("rides out a transient fetch failure, then seeds", async () => { + vi.useFakeTimers(); + get + .mockRejectedValueOnce(new Error("network blip")) + .mockResolvedValueOnce(emptyResponse); + put.mockResolvedValue(putEcho); + + const pending = seedTeamLabelsIfEmpty(); + await vi.runAllTimersAsync(); + await pending; + + expect(get).toHaveBeenCalledTimes(2); + expect(put).toHaveBeenCalledTimes(1); + }); + + it("rides out a transient write failure, then seeds", async () => { + vi.useFakeTimers(); + get.mockResolvedValue(emptyResponse); + put + .mockRejectedValueOnce(new Error("network blip")) + .mockResolvedValueOnce(putEcho); + + const pending = seedTeamLabelsIfEmpty(); + await vi.runAllTimersAsync(); + await pending; + + expect(put).toHaveBeenCalledTimes(2); + }); + + it("throws after a persistent fetch failure without writing (clobber-safe)", async () => { + vi.useFakeTimers(); + get.mockRejectedValue(new Error("backend down")); + + const pending = seedTeamLabelsIfEmpty(); + const assertion = expect(pending).rejects.toThrow("backend down"); + await vi.runAllTimersAsync(); + await assertion; + + // Never writes when the fetch can't confirm the team has no set. + expect(put).not.toHaveBeenCalled(); + expect(get).toHaveBeenCalledTimes(3); + }); + + it("throws after a persistent write failure", async () => { + vi.useFakeTimers(); + get.mockResolvedValue(emptyResponse); + put.mockRejectedValue(new Error("write failed")); + + const pending = seedTeamLabelsIfEmpty(); + const assertion = expect(pending).rejects.toThrow("write failed"); + await vi.runAllTimersAsync(); + await assertion; + + expect(put).toHaveBeenCalledTimes(3); + }); +}); diff --git a/frontend/editor/src/proprietary/services/labelsBackend.ts b/frontend/editor/src/proprietary/services/labelsBackend.ts new file mode 100644 index 0000000000..8259f124de --- /dev/null +++ b/frontend/editor/src/proprietary/services/labelsBackend.ts @@ -0,0 +1,99 @@ +/** + * Backend layer for the team's classification labels + * (`/api/v1/classification/labels`) — one shared, server-truth list, editable + * only by a team leader (SaaS) / admin (self-hosted). A team with none reads as + * 204 → `null`, and callers fall back to the built-in + * {@link DEFAULT_CLASSIFICATION_LABELS}. + */ + +import apiClient from "@app/services/apiClient"; +import { + DEFAULT_CLASSIFICATION_LABELS, + type ClassificationLabel, +} from "@app/data/classificationLabels"; + +const TEAM_ENDPOINT = "/api/v1/classification/labels"; + +/** Wire shape shared with the backend: the label list wrapped in an object. */ +interface LabelsPayload { + labels: ClassificationLabel[]; +} + +async function fetchLabels( + endpoint: string, +): Promise { + const res = await apiClient.get(endpoint, { + suppressErrorToast: true, + }); + // 204 No Content (nothing stored) comes back as an empty body. Only an + // explicit 204 / empty string means "none"; anything else is a real payload. + if (res.status === 204 || res.data === "") return null; + return (res.data as LabelsPayload).labels ?? []; +} + +async function saveLabels( + endpoint: string, + labels: ClassificationLabel[], +): Promise { + const res = await apiClient.put(endpoint, { labels }); + return res.data.labels ?? []; +} + +/** The team's stored labels, or `null` when it has none (use the default). */ +export function fetchTeamLabels(): Promise { + return fetchLabels(TEAM_ENDPOINT); +} + +/** Persist the team's labels; returns the stored value. */ +export function saveTeamLabels( + labels: ClassificationLabel[], +): Promise { + return saveLabels(TEAM_ENDPOINT, labels); +} + +/** Seed-write retry budget: the seed is a hard prerequisite (see below), so ride + * out a transient blip rather than fail setup on the first hiccup. */ +const SEED_MAX_ATTEMPTS = 3; +const SEED_RETRY_MS = 400; + +async function withRetry(op: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < SEED_MAX_ATTEMPTS; attempt++) { + try { + return await op(); + } catch (error) { + lastError = error; + if (attempt < SEED_MAX_ATTEMPTS - 1) { + await new Promise((resolve) => + setTimeout(resolve, SEED_RETRY_MS * (attempt + 1)), + ); + } + } + } + throw lastError; +} + +/** + * Seed the team's label set with the built-in defaults when it has none yet. + * + * The engine holds no default vocabulary, so the backend can only send what the + * team has stored — a team with an empty set classifies nothing (documents come + * back unlabelled and don't group). This writes the frontend's single default + * copy into the team set the first time a classification policy is set up, so + * classification works out of the box; the backend gates the write to admins. + * + * The seed is a hard prerequisite of enabling a classification policy, so it must + * land or fail loudly — never silently leave an empty set behind a created + * policy. Both the fetch and the write retry a transient failure; if either + * ultimately fails this THROWS, and the caller aborts the setup (the admin sees + * the error and retries) instead of shipping a policy that classifies nothing — + * the same way a failed policy save already aborts setup. Still clobber-safe: it + * writes only when the fetch DEFINITIVELY reports no set (204 → null), so it + * never overwrites a team's real (possibly customised) labels, and it's a no-op + * once any set exists (later admin edits are the source of truth). + */ +export async function seedTeamLabelsIfEmpty(): Promise { + const existing = await withRetry(fetchTeamLabels); + if (existing != null) return; + await withRetry(() => saveTeamLabels(DEFAULT_CLASSIFICATION_LABELS)); +} diff --git a/frontend/editor/src/proprietary/services/labelsFile.ts b/frontend/editor/src/proprietary/services/labelsFile.ts new file mode 100644 index 0000000000..613b144c1f --- /dev/null +++ b/frontend/editor/src/proprietary/services/labelsFile.ts @@ -0,0 +1,107 @@ +/** + * Client-side import/export + validation for a classification-labels JSON file + * (`{"labels":[{"id":"invoice","name":"Invoice","icon":"receipt-long"}, …]}`). + * Sharing a label set between teams is done by exporting the JSON here and + * importing it on another team. Validation mirrors the backend + * (`LabelsValidator`) so a malformed file is caught before it's uploaded — the + * backend re-validates as the authority. A file may omit `id` (e.g. an older + * export); it is then derived from the name. + */ + +import { downloadJsonAsFile } from "@app/utils/downloadUtils"; +import { LABEL_ICON_KEYS } from "@app/data/labelIcons"; +import { + labelId, + type ClassificationLabel, +} from "@app/data/classificationLabels"; + +// Kept in sync with the backend LabelsValidator (the authority); enforced here +// too so an oversized import is rejected before upload. +const MAX_LABELS = 500; +const MAX_TEXT_LENGTH = 128; + +interface LabelsFileShape { + labels: ClassificationLabel[]; +} + +/** A label's effective id: the provided one, else derived from the name. */ +function effectiveId(label: Partial): string { + const provided = typeof label.id === "string" ? label.id.trim() : ""; + return provided || labelId(label.name?.trim() ?? ""); +} + +/** Human-readable problems with a candidate labels file; empty means valid. */ +export function validateLabels(value: unknown): string[] { + const errors: string[] = []; + if (typeof value !== "object" || value === null) { + return ["File is not a labels object."]; + } + const { labels } = value as Partial; + if (!Array.isArray(labels)) { + return ['File must have a "labels" list.']; + } + if (labels.length > MAX_LABELS) { + errors.push(`Too many labels (max ${MAX_LABELS}).`); + } + const seenIds = new Set(); + for (const label of labels) { + if (!isText(label?.name)) { + errors.push("Every label needs a non-empty name."); + continue; + } + const name = label.name.trim(); + if (name.length > MAX_TEXT_LENGTH) { + errors.push(`Label "${name}" is over ${MAX_TEXT_LENGTH} characters.`); + } + const id = effectiveId(label); + if (!id) { + errors.push(`Label "${name}" has no usable id.`); + continue; + } + if (seenIds.has(id)) errors.push(`Duplicate label: ${name}`); + seenIds.add(id); + } + return errors; +} + +/** Coerce a validated value into a normalized label list (trims, drops extras). */ +export function normalizeLabels( + labels: ClassificationLabel[], +): ClassificationLabel[] { + return labels.map((label): ClassificationLabel => { + const name = label.name.trim(); + const id = effectiveId(label); + // Keep the icon only if it's a known palette key, so a hand-crafted import + // can't set an unbundled key that renders blank. + return label.icon && LABEL_ICON_KEYS.has(label.icon) + ? { id, name, icon: label.icon } + : { id, name }; + }); +} + +/** Parse + validate a picked file, resolving to a normalized label list. */ +export async function parseLabelsFile( + file: File, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await file.text()); + } catch { + throw new Error("That file isn't valid JSON."); + } + const errors = validateLabels(parsed); + if (errors.length > 0) throw new Error(errors[0]); + return normalizeLabels((parsed as LabelsFileShape).labels); +} + +/** Trigger a download of the labels as a pretty-printed JSON file. */ +export function downloadLabels( + labels: ClassificationLabel[], + fileName = "classification-labels.json", +): void { + downloadJsonAsFile({ labels }, fileName); +} + +function isText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} diff --git a/frontend/editor/src/proprietary/services/policyApi.ts b/frontend/editor/src/proprietary/services/policyApi.ts index b3f1fd999f..a10c7a8382 100644 --- a/frontend/editor/src/proprietary/services/policyApi.ts +++ b/frontend/editor/src/proprietary/services/policyApi.ts @@ -51,6 +51,15 @@ export async function deletePolicy(id: string): Promise { await apiClient.delete(`/api/v1/policies/${encodeURIComponent(id)}`); } +/** + * Persist the team's run order (server-side, shared by the whole team). Sends the + * ordered backend policy ids; the backend maps position → order and ignores any + * id outside the caller's team. Team-leader/admin only (403 otherwise). + */ +export async function reorderPolicies(orderedIds: string[]): Promise { + await apiClient.put("/api/v1/policies/order", orderedIds); +} + /** Run a stored policy by id on the supplied files; returns the run id. */ export async function runStoredPolicy( id: string, diff --git a/frontend/editor/src/proprietary/services/policyBackend.ts b/frontend/editor/src/proprietary/services/policyBackend.ts index e1ee2400f4..fdec83494d 100644 --- a/frontend/editor/src/proprietary/services/policyBackend.ts +++ b/frontend/editor/src/proprietary/services/policyBackend.ts @@ -26,12 +26,15 @@ import type { PolicyState } from "@app/types/policies"; export async function fetchPoliciesByCategory(): Promise< Map > { + // The backend returns policies in the team's run order; the list index IS the + // order (server-side, shared team-wide), which we carry onto the decoded state. const stored = await policyApi.listPolicies(); const byCategory = new Map(); - for (const policy of stored) { + stored.forEach((policy, index) => { const decoded = fromBackendPolicy(policy); - if (decoded.categoryId) byCategory.set(decoded.categoryId, decoded); - } + if (decoded.categoryId) + byCategory.set(decoded.categoryId, { ...decoded, order: index }); + }); return byCategory; } @@ -57,6 +60,8 @@ export function decodedToState( runOn: decoded.folder.runOn, folderId: localFolderId, backendId: decoded.id, + // Server-side run-order position (team-wide); drives the settings reorder list. + order: decoded.order, // Catalog-category policies are built-in defaults (not deletable). isDefault: true, }; diff --git a/frontend/editor/src/proprietary/services/policyLiveData.test.ts b/frontend/editor/src/proprietary/services/policyLiveData.test.ts index c3016f071b..1bb4687164 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.test.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.test.ts @@ -4,6 +4,8 @@ import { runsToActivity, runsToStats, policyActiveFor, + progressByCategory, + retryableFailedRuns, } from "@app/services/policyLiveData"; import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; @@ -120,6 +122,117 @@ describe("runsToStats", () => { }); }); +describe("progressByCategory", () => { + it("tallies running / completed / total per category", () => { + const map = progressByCategory([ + run({ runId: "a", categoryId: "classification", status: "RUNNING" }), + run({ + runId: "b", + categoryId: "classification", + status: "COMPLETED", + imported: true, + }), + run({ runId: "c", categoryId: "security", status: "RUNNING" }), + ]); + expect(map.get("classification")).toEqual({ + running: 1, + completed: 1, + total: 2, + }); + expect(map.get("security")).toEqual({ running: 1, completed: 0, total: 1 }); + }); + + it("a COMPLETED-but-not-imported run still counts as running (not done)", () => { + const map = progressByCategory([ + run({ categoryId: "security", status: "COMPLETED", imported: false }), + ]); + expect(map.get("security")).toEqual({ running: 1, completed: 0, total: 1 }); + }); + + it("scopes to the current wave — history before sinceStartedAt is excluded", () => { + // The store persists runs across every upload; the panel must only count the + // current wave, so an old batch (startedAt < sinceStartedAt) is ignored. + const map = progressByCategory( + [ + run({ runId: "new1", status: "RUNNING", startedAt: 1000 }), + run({ + runId: "new2", + status: "COMPLETED", + imported: true, + startedAt: 1000, + }), + run({ + runId: "old", + status: "COMPLETED", + imported: true, + startedAt: 10, + }), + ], + 1000, + ); + // Only the two wave runs counted; the pre-wave "old" run is excluded. + expect(map.get("security")).toEqual({ running: 1, completed: 1, total: 2 }); + }); +}); + +describe("retryableFailedRuns — bulk-retry eligibility", () => { + it("includes a plain failed run", () => { + const eligible = retryableFailedRuns([ + run({ runId: "a", status: "FAILED" }), + ]); + expect(eligible.map((r) => r.runId)).toEqual(["a"]); + }); + + it("NEVER retries a (policy, file) that has since succeeded", () => { + // The caveat: a stale failure row must not re-enforce a file that already + // went through — e.g. the user retried it individually and it completed. + const eligible = retryableFailedRuns([ + run({ runId: "ok", status: "COMPLETED", imported: true }), // newest + run({ runId: "old-fail", status: "FAILED" }), + ]); + expect(eligible).toEqual([]); + }); + + it("skips a (policy, file) whose retry is already running", () => { + const eligible = retryableFailedRuns([ + run({ runId: "live", status: "RUNNING" }), // newest + run({ runId: "old-fail", status: "FAILED" }), + ]); + expect(eligible).toEqual([]); + }); + + it("keeps only the LATEST failed attempt per (policy, file)", () => { + const eligible = retryableFailedRuns([ + run({ runId: "fail-new", status: "FAILED", startedAt: 2 }), + run({ runId: "fail-old", status: "FAILED", startedAt: 1 }), + ]); + expect(eligible.map((r) => r.runId)).toEqual(["fail-new"]); + }); + + it("treats policies independently — the same file can retry under another policy", () => { + const eligible = retryableFailedRuns([ + run({ runId: "sec-ok", categoryId: "security", status: "COMPLETED" }), + run({ runId: "wm-fail", categoryId: "watermark", status: "FAILED" }), + ]); + expect(eligible.map((r) => r.runId)).toEqual(["wm-fail"]); + }); + + it("excludes runs mid auto-retry backoff and orphans with no local file", () => { + const eligible = retryableFailedRuns([ + run({ runId: "backoff", status: "FAILED", retrying: true }), + run({ runId: "orphan", fileId: "", status: "FAILED" }), + ]); + expect(eligible).toEqual([]); + }); + + it("includes cancelled runs (they never produced output)", () => { + const eligible = retryableFailedRuns([ + run({ runId: "c", status: "CANCELLED" }), + ]); + expect(eligible.map((r) => r.runId)).toEqual(["c"]); + }); +}); + describe("policyActiveFor", () => { it("returns 'Today' for a just-activated policy", () => { expect(policyActiveFor(new Date().toISOString())).toBe("Today"); diff --git a/frontend/editor/src/proprietary/services/policyLiveData.ts b/frontend/editor/src/proprietary/services/policyLiveData.ts index 56401f4d19..2f177bb987 100644 --- a/frontend/editor/src/proprietary/services/policyLiveData.ts +++ b/frontend/editor/src/proprietary/services/policyLiveData.ts @@ -7,7 +7,92 @@ import i18n from "@app/i18n"; import type { PolicyActivityItem, PolicyStats } from "@app/types/policies"; -import type { PolicyRunRecord } from "@app/components/policies/policyRunStore"; +import { + dispatchKey, + isRunInFlight, + type PolicyRunRecord, +} from "@app/components/policies/policyRunStore"; + +/** + * The failed runs that are actually worth re-running, one per (policy, file): + * - terminal-failed (FAILED/CANCELLED) and not already in an auto-retry backoff; + * - with a local input file to re-run on (reconciled server orphans have none); + * - EXCLUDING any (policy, file) that has since succeeded or is currently + * running — a stale failure row must never re-enforce a file that already + * went through, or race a run that's still going. + * Runs are newest-first in the store, so the first failed run seen per key is + * the latest attempt. This is the groundwork for bulk "retry all failed" and, + * later, "run a newly-enabled policy across not-yet-processed files". + */ +export function retryableFailedRuns( + runs: PolicyRunRecord[], +): PolicyRunRecord[] { + // Keys settled by a success or still being worked — off-limits for retry. + const settledOrActive = new Set(); + for (const run of runs) { + if (run.status === "COMPLETED" || isRunInFlight(run)) { + settledOrActive.add(dispatchKey(run.categoryId, run.fileId)); + } + } + const seen = new Set(); + const eligible: PolicyRunRecord[] = []; + for (const run of runs) { + if (run.status !== "FAILED" && run.status !== "CANCELLED") continue; + if (run.retrying) continue; + if (!run.fileId) continue; + const key = dispatchKey(run.categoryId, run.fileId); + if (settledOrActive.has(key) || seen.has(key)) continue; + seen.add(key); + eligible.push(run); + } + return eligible; +} + +/** Live per-policy run tally — drives the panel's processing ring + counts. */ +export interface PolicyRunProgress { + /** Runs still working: dispatched/running, retrying, or done-but-not-imported. */ + running: number; + /** Runs that finished successfully AND landed in the workspace. */ + completed: number; + /** Every run in the current wave for the policy (running + completed + failed). */ + total: number; +} + +/** Empty tally, so callers can render a zero state without null checks. */ +export const EMPTY_RUN_PROGRESS: PolicyRunProgress = { + running: 0, + completed: 0, + total: 0, +}; + +/** + * Tally runs per policy category for the panel's live indicators: how many are + * still processing, how many completed, and the total in the CURRENT wave. + * Keyed by categoryId. + * + * `sinceStartedAt` scopes to the current upload wave (the store resets it when a + * run starts with nothing in flight) so the counts reflect "this upload", not the + * whole run history persisted in localStorage across every past upload. + */ +export function progressByCategory( + runs: PolicyRunRecord[], + sinceStartedAt = 0, +): Map { + const byCat = new Map(); + for (const run of runs) { + if (run.startedAt < sinceStartedAt) continue; + const p = byCat.get(run.categoryId) ?? { + running: 0, + completed: 0, + total: 0, + }; + p.total += 1; + if (isRunInFlight(run)) p.running += 1; + else if (run.status === "COMPLETED") p.completed += 1; + byCat.set(run.categoryId, p); + } + return byCat; +} /** Relative "Nm/Nh ago" for an activity timestamp (epoch ms). */ function relativeTime(ts: number): string { diff --git a/frontend/editor/src/proprietary/services/policyPipeline.ts b/frontend/editor/src/proprietary/services/policyPipeline.ts index b27bd09e4d..c04adb78b6 100644 --- a/frontend/editor/src/proprietary/services/policyPipeline.ts +++ b/frontend/editor/src/proprietary/services/policyPipeline.ts @@ -101,6 +101,16 @@ export interface PolicyRunView { createdAt: number; } +/** + * Operations that run as policy pipeline steps but are NOT user-facing tools, so + * they have no tool-registry entry and never appear in the tool picker. Maps the + * operation id straight to its backend endpoint. + */ +const POLICY_OPERATION_ENDPOINTS: Record = { + // Document classification — dispatched only by the Classification policy. + classify: "/api/v1/ai/tools/classify-and-label", +}; + /** Resolve a frontend operation id to its backend tool endpoint path. */ function resolveEndpoint( operation: string, @@ -109,10 +119,13 @@ function resolveEndpoint( ): string | null { const config = toolRegistry[operation as keyof ToolRegistry]?.operationConfig; const endpoint = config?.endpoint; - if (!endpoint) return null; - const resolved = - typeof endpoint === "function" ? endpoint(parameters) : endpoint; - return resolved ?? null; + if (endpoint) { + const resolved = + typeof endpoint === "function" ? endpoint(parameters) : endpoint; + if (resolved) return resolved; + } + // Policy-only operations have no registry entry; resolve them directly. + return POLICY_OPERATION_ENDPOINTS[operation] ?? null; } /** @@ -222,6 +235,9 @@ export interface DecodedPolicy { reviewerEmail: string; fieldValues: Record; folder: PolicyFolderSettings; + /** Position in the team's server-side run order (set from the fetch list index, + * not decoded from the policy itself). */ + order?: number; } const DEFAULT_FOLDER: PolicyFolderSettings = { diff --git a/frontend/editor/src/proprietary/services/policyStorage.ts b/frontend/editor/src/proprietary/services/policyStorage.ts index deed307945..82e90b0d40 100644 --- a/frontend/editor/src/proprietary/services/policyStorage.ts +++ b/frontend/editor/src/proprietary/services/policyStorage.ts @@ -53,14 +53,17 @@ export function loadPolicies(): PoliciesByCategory { // Always reconcile against the current category list so a newly-added // category gets a default rather than being undefined. const out: PoliciesByCategory = {}; - for (const cat of loadPolicyCatalog().categories) { + loadPolicyCatalog().categories.forEach((cat, index) => { const merged = { ...defaultState(), ...(parsed[cat.id] ?? {}) }; // Migration: clear the obsolete persisted reviewer email so it re-defaults // to the real signed-in user. if (merged.reviewerEmail === STALE_REVIEWER_EMAIL) merged.reviewerEmail = ""; + // Default execution order to the catalog position until an admin reorders, + // so ordered dispatch is deterministic before any explicit order is set. + if (merged.order == null) merged.order = index; out[cat.id] = merged; - } + }); return out; } @@ -97,6 +100,24 @@ export function updatePolicy( return next; } +/** + * Persist a new execution order. Assigns `order` 0..n-1 to the given categories in + * the sequence provided, so after any reorder every listed policy has an explicit, + * contiguous order (no reliance on the catalog-index default). Categories omitted + * from the list keep their current order. + */ +export function reorderPolicies( + orderedCategoryIds: string[], +): PoliciesByCategory { + const current = loadPolicies(); + const next: PoliciesByCategory = { ...current }; + orderedCategoryIds.forEach((id, index) => { + if (next[id]) next[id] = { ...next[id], order: index }; + }); + persist(next); + return next; +} + /** Reset a category to its unconfigured default (the "Delete policy" action). */ export function resetPolicy(categoryId: string): PoliciesByCategory { return updatePolicy(categoryId, { diff --git a/frontend/editor/src/proprietary/types/policies.ts b/frontend/editor/src/proprietary/types/policies.ts index b0285a4d22..a1f6ea70f6 100644 --- a/frontend/editor/src/proprietary/types/policies.ts +++ b/frontend/editor/src/proprietary/types/policies.ts @@ -51,6 +51,11 @@ export interface PolicyCategory { * or configured. Only Security is live today. */ comingSoon?: boolean; + /** + * Requires the AI engine to be enabled. Hidden from the catalog when the + * engine is off, so the policy only appears where it can actually run. + */ + requiresAiEngine?: boolean; } /** @@ -136,6 +141,13 @@ export interface PolicyState { outputNamePosition?: "prefix" | "suffix" | "auto-number"; /** When the policy runs: on "upload" or before "export". Defaults to "upload". */ runOn?: "upload" | "export"; + /** + * Execution order among policies that share a trigger. When several policies run + * on the same event they fire in ascending `order`, each on the previous one's + * output (a cumulative chain). Defaults to the policy's position in the catalog + * until an admin reorders them, which persists an explicit value for every policy. + */ + order?: number; /** * The backing folder-trigger record (a Watched Folders `WatchedFolder`) that * holds this policy's editable steps (its automation), output config and run diff --git a/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css new file mode 100644 index 0000000000..de669c7f29 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.css @@ -0,0 +1,136 @@ +/* Category-manager modal: editable parent categories with member-label chips. + Matches the labels editor's width so the two read as a set. */ +.fsg-modal { + width: min(1100px, 94vw); + max-width: min(1100px, 94vw); +} + +.fsg-body { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 60vh; + overflow-y: auto; + padding-right: 4px; +} + +.fsg-cat { + display: flex; + flex-direction: column; + gap: 6px; +} + +.fsg-cat-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--text-primary, inherit); +} + +.fsg-cat-toggle { + display: inline-flex; + border: none; + background: transparent; + padding: 0; + cursor: pointer; +} + +.fsg-chevron { + font-size: 1.1rem !important; + color: var(--text-muted, rgba(128, 128, 128, 0.9)); + margin-left: -4px; +} + +.fsg-cat-name { + flex: 1; + text-align: left; + border: none; + background: transparent; + padding: 2px 4px; + border-radius: 4px; + font: inherit; + color: inherit; + cursor: text; +} + +.fsg-cat-name:hover { + background: var(--hover-bg, rgba(128, 128, 128, 0.12)); +} + +.fsg-rename { + flex: 1; +} + +.fsg-cat-action { + display: inline-flex; + border: none; + background: transparent; + padding: 2px; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted, rgba(128, 128, 128, 0.9)); +} + +.fsg-cat-action:hover { + background: var(--hover-bg, rgba(128, 128, 128, 0.12)); + color: var(--text-primary, inherit); +} + +.fsg-cat-body { + display: flex; + flex-direction: column; + gap: 6px; + /* Indent past the chevron so the hierarchy reads at a glance. */ + padding-left: 24px; +} + +/* Member chips are the shared @app/ui/LabelChip. */ +.fsg-chips { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.fsg-count { + font-size: 11px; + font-weight: 500; + color: var(--text-muted, rgba(128, 128, 128, 0.9)); +} + +.fsg-add, +.fsg-new { + display: flex; + align-items: center; + gap: 6px; +} + +.fsg-new { + padding-top: 4px; + border-top: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.2)); +} + +.fsg-add-input { + flex: 1; + min-width: 0; + padding: 4px 8px; + font-size: 12px; + border: 1px solid var(--border-subtle, rgba(128, 128, 128, 0.35)); + border-radius: 6px; + background: transparent; + color: var(--text-primary, inherit); +} + +.fsg-add-input:focus { + outline: none; + border-color: var(--color-primary, #4f46e5); +} + +.fsg-footer { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + gap: 8px; +} diff --git a/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.tsx b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.tsx new file mode 100644 index 0000000000..12e1430e3c --- /dev/null +++ b/frontend/editor/src/saas/components/shared/FileSidebarGroupControls.tsx @@ -0,0 +1,411 @@ +// The Files-sidebar category manager: a "tune" button opening a modal where you shape the parent categories your files group under. Each category (collapsible, busiest first) has an editable name + icon, a hide toggle, delete, and its member-label chips; add existing team labels to a category, create new categories, reset to the built-in defaults. All device-local (grouping only — it never changes the team's label vocabulary); files in no visible category fall back to "Other". + +import { useMemo, useState, useSyncExternalStore } from "react"; +import { useTranslation } from "react-i18next"; +import TuneIcon from "@mui/icons-material/Tune"; +import RestartAltIcon from "@mui/icons-material/RestartAlt"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined"; +import VisibilityIcon from "@mui/icons-material/Visibility"; +import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; +import AddIcon from "@mui/icons-material/Add"; +import { TextInput } from "@mantine/core"; +import { Modal } from "@app/ui/Modal"; +import { Button } from "@app/ui/Button"; +import { ActionIcon } from "@app/ui/ActionIcon"; +import { LabelChip } from "@app/ui/LabelChip"; +import { LabelIconPicker } from "@app/components/policies/LabelIconPicker"; +import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; +import { useClassificationLabels } from "@app/hooks/useClassificationLabels"; +import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons"; +import { bucketStubsByLabel } from "@app/components/shared/fileSidebarGroupingLogic"; +import { + addCategory, + addLabelToCategory, + deleteCategory, + getSidebarCategories, + removeLabelFromCategory, + renameCategory, + resetSidebarCategories, + setCategoryHidden, + setCategoryIcon, + subscribeSidebarCategories, +} from "@app/services/fileSidebarCategories"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import "@app/components/shared/FileSidebarGroupControls.css"; + +interface FileSidebarGroupControlsProps { + /** The files currently listed, for live per-label counts. */ + stubs: StirlingFileStub[]; +} + +/** New categories start with a neutral folder icon the user can change. */ +const NEW_CATEGORY_ICON = "folder"; + +export function FileSidebarGroupControls({ + stubs, +}: FileSidebarGroupControlsProps) { + const { t } = useTranslation(); + const enabled = useClassificationEnabled(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const categories = useSyncExternalStore( + subscribeSidebarCategories, + getSidebarCategories, + ); + // Only fetch the team label set while the picker is open. + const { teamLabels: labelSet } = useClassificationLabels(open); + + // Bucketed once and reused for both the per-label and per-category counts below. + const byLabel = useMemo(() => bucketStubsByLabel(stubs), [stubs]); + + // Per-label file counts from the same bucketing the sidebar groups use. + const labelCounts = useMemo(() => { + const counts = new Map(); + for (const [key, bucket] of byLabel) counts.set(key, bucket.stubs.length); + return counts; + }, [byLabel]); + + // Files per category (deduped across its labels). + const categoryCounts = useMemo(() => { + const counts = new Map(); + for (const category of categories) { + const ids = new Set(); + for (const key of category.labelKeys) { + for (const stub of byLabel.get(key)?.stubs ?? []) { + ids.add(stub.id as string); + } + } + counts.set(category.id, ids.size); + } + return counts; + }, [byLabel, categories]); + + // Translated display name + icon per label id, plus a name→id lookup for the + // add-label input (which matches on the visible/canonical text). + const vocab = useMemo(() => { + const byId = new Map(); + const idByName = new Map(); + for (const label of labelSet) { + const display = t(`classification.labels.${label.id}`, label.name); + byId.set(label.id, { display, icon: label.icon }); + idByName.set(display.toLowerCase(), label.id); + idByName.set(label.name.toLowerCase(), label.id); + } + return { byId, idByName }; + }, [labelSet, t]); + + const labelDisplay = (id: string) => vocab.byId.get(id)?.display ?? id; + const labelIcon = (id: string) => + vocab.byId.get(id)?.icon ?? DEFAULT_LABEL_ICON; + + const q = query.trim().toLowerCase(); + const matches = (text: string) => q === "" || text.toLowerCase().includes(q); + + // Busiest categories first (ties keep declaration order). + const sortedCategories = useMemo( + () => + [...categories].sort( + (a, b) => + (categoryCounts.get(b.id) ?? 0) - (categoryCounts.get(a.id) ?? 0), + ), + [categories, categoryCounts], + ); + + // Per-category collapse; on open only the busiest starts expanded. + const [expanded, setExpanded] = useState>(new Set()); + const toggleExpanded = (id: string) => + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + // Inline rename + add-label drafts, keyed by category id. + const [renaming, setRenaming] = useState(null); + const [renameDraft, setRenameDraft] = useState(""); + const [addDraft, setAddDraft] = useState>({}); + const [newCategory, setNewCategory] = useState(""); + + const openPicker = () => { + setQuery(""); + setRenaming(null); + setNewCategory(""); + setExpanded( + new Set(sortedCategories.length > 0 ? [sortedCategories[0].id] : []), + ); + setOpen(true); + }; + + const commitRename = (id: string) => { + const name = renameDraft.trim(); + if (name) renameCategory(id, name); + setRenaming(null); + }; + + // Add an existing team label to a category (device-local grouping only — categories don't change + // the team vocabulary). A name that isn't a known team label is ignored; the input's datalist + // steers callers to real ones. + const addLabel = (categoryId: string) => { + const name = (addDraft[categoryId] ?? "").trim(); + if (!name) return; + const labelId = vocab.idByName.get(name.toLowerCase()); + if (!labelId) return; + addLabelToCategory(categoryId, labelId); + setAddDraft((prev) => ({ ...prev, [categoryId]: "" })); + }; + + const createCategory = () => { + const name = newCategory.trim(); + if (!name) return; + const id = addCategory(name, NEW_CATEGORY_ICON); + setExpanded((prev) => new Set(prev).add(id)); + setNewCategory(""); + }; + + // Classification off (non-AI SaaS tenant) → no "customize groups" affordance, + // matching the flat, ungrouped list. (All hooks above run unconditionally.) + if (!enabled) return null; + + return ( + <> + {/* -external: revealed on section-header hover, like the Browse button. */} + + + + + setOpen(false)} + width="xl" + className="fsg-modal" + title={t("fileSidebar.groupsModal.title", "Sidebar categories")} + subtitle={t( + "fileSidebar.groupsModal.subtitle", + "Group your files into parent categories. Add existing or new labels to a category, rename it, or create your own. Files in none of your categories appear under “Other”.", + )} + footer={ +
+ + +
+ } + > +
+ setQuery(e.currentTarget.value)} + placeholder={t("fileSidebar.groupsModal.search", "Search labels…")} + size="sm" + data-autofocus + /> + + {sortedCategories.map((category) => { + const memberKeys = category.labelKeys.filter((key) => + matches(labelDisplay(key)), + ); + // Show the category if its name matches, or any member label matches. + if (!matches(category.name) && memberKeys.length === 0) return null; + const isExpanded = q !== "" || expanded.has(category.id); + const suggestions = [...vocab.byId.keys()] + .filter((id) => !category.labelKeys.includes(id)) + .map((id) => labelDisplay(id)); + return ( +
+
+ toggleExpanded(category.id)} + > + {isExpanded ? ( + + ) : ( + + )} + + setCategoryIcon(category.id, icon)} + ariaLabel={t( + "fileSidebar.groupsModal.categoryIconAria", + "Choose an icon for {{name}}", + { name: category.name }, + )} + /> + {renaming === category.id ? ( + setRenameDraft(e.currentTarget.value)} + onBlur={() => commitRename(category.id)} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(category.id); + if (e.key === "Escape") setRenaming(null); + }} + /> + ) : ( + + )} + + {categoryCounts.get(category.id) ?? 0} + + + setCategoryHidden(category.id, !category.hidden) + } + > + {category.hidden ? ( + + ) : ( + + )} + + deleteCategory(category.id)} + > + + +
+ + {isExpanded && ( +
+
+ {memberKeys.map((key) => ( + + removeLabelFromCategory(category.id, key) + } + removeAriaLabel={t( + "fileSidebar.groupsModal.removeLabel", + "Remove {{name}}", + { name: labelDisplay(key) }, + )} + /> + ))} +
+
+ + setAddDraft((prev) => ({ + ...prev, + [category.id]: e.target.value, + })) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + addLabel(category.id); + } + }} + /> + + {suggestions.map((name) => ( + + +
+
+ )} +
+ ); + })} + +
+ setNewCategory(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + createCategory(); + } + }} + /> + +
+
+
+ + ); +} diff --git a/frontend/editor/src/saas/components/shared/fileSidebarGrouping.test.ts b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.test.ts new file mode 100644 index 0000000000..ee55eabb5e --- /dev/null +++ b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { buildLabelGroups } from "@app/components/shared/fileSidebarGroupingLogic"; +import type { SidebarCategory } from "@app/services/fileSidebarCategories"; +import type { StirlingFileStub } from "@app/types/fileContext"; + +// The grouping only reads id/lastModified/classificationLabels. classificationLabels +// hold label IDS (a file's stored classification), and categories key on ids too. +function stub( + id: string, + labels?: string[], + lastModified = 0, +): StirlingFileStub { + return { id, lastModified, classificationLabels: labels } as StirlingFileStub; +} + +const t = (_key: string, fallback: string) => fallback; + +function cat( + id: string, + name: string, + labelKeys: string[], + hidden = false, +): SidebarCategory { + return { id, name, icon: "folder", labelKeys, hidden }; +} + +const LEGAL = cat("legal", "Legal", ["nda", "contract"]); +const FINANCE = cat("finance", "Financial", ["invoice"]); + +describe("buildLabelGroups", () => { + it("rolls a category's labels into ONE group (deduped)", () => { + const groups = buildLabelGroups( + [ + stub("a", ["nda"]), + stub("b", ["contract"]), + stub("c", ["nda", "contract"]), + ], + [], + t, + [LEGAL], + )!; + const legal = groups.find((g) => g.id === "category:legal")!; + expect(legal.label).toBe("Legal"); + expect(legal.stubs.map((s) => s.id)).toEqual(["a", "b", "c"]); + }); + + it("gives a label not in any category its own group", () => { + const groups = buildLabelGroups( + [stub("a", ["weird-one"])], + [{ id: "weird-one", name: "Weird one" }], + t, + [LEGAL], + )!; + const group = groups.find((g) => g.id === "label:weird-one")!; + expect(group.label).toBe("Weird one"); + expect(group.stubs.map((s) => s.id)).toEqual(["a"]); + }); + + it("moves a hidden category's files into Other", () => { + const groups = buildLabelGroups([stub("a", ["invoice"])], [], t, [ + cat("finance", "Financial", ["invoice"], true), + ])!; + expect(groups.some((g) => g.id === "category:finance")).toBe(false); + expect(groups.at(-1)!.id).toBe("other"); + expect(groups.at(-1)!.stubs.map((s) => s.id)).toEqual(["a"]); + }); + + it("a file with labels in two categories appears in both", () => { + const groups = buildLabelGroups([stub("a", ["nda", "invoice"])], [], t, [ + LEGAL, + FINANCE, + ])!; + expect( + groups.find((g) => g.id === "category:legal")!.stubs.map((s) => s.id), + ).toEqual(["a"]); + expect( + groups.find((g) => g.id === "category:finance")!.stubs.map((s) => s.id), + ).toEqual(["a"]); + expect(groups.some((g) => g.id === "other")).toBe(false); + }); + + it("puts unlabelled files in Other at the bottom", () => { + const groups = buildLabelGroups( + [stub("a", ["invoice"]), stub("b"), stub("c", [])], + [], + t, + [FINANCE], + )!; + const other = groups.at(-1)!; + expect(other.id).toBe("other"); + expect(other.stubs.map((s) => s.id)).toEqual(["b", "c"]); + }); + + it("orders groups: Recent, groups alphabetically, Other last", () => { + const groups = buildLabelGroups( + [ + stub("a", ["invoice"]), + stub("b", ["nda"]), + stub("c", ["zzz"]), + stub("d"), + ], + [], + t, + [LEGAL, FINANCE], + )!; + expect(groups.map((g) => g.id)).toEqual([ + "recent", + "category:finance", + "category:legal", + "label:zzz", + "other", + ]); + }); + + it("returns null for an empty library", () => { + expect(buildLabelGroups([], [], t, [LEGAL])).toBeNull(); + }); +}); diff --git a/frontend/editor/src/saas/components/shared/fileSidebarGrouping.tsx b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.tsx new file mode 100644 index 0000000000..911b44de26 --- /dev/null +++ b/frontend/editor/src/saas/components/shared/fileSidebarGrouping.tsx @@ -0,0 +1,119 @@ +// Classification override of the Files-sidebar grouping seam: Recent, one group per VISIBLE category (device-local, editable — default from the built-in label families), a standalone group for any label not yet in a category, then Other for files in none of those. Labels are cached on the stub via a lazy metadata backfill so grouping stays cheap. + +import { + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { useTranslation } from "react-i18next"; +import { useIndexedDB } from "@app/contexts/IndexedDBContext"; +import { useClassificationEnabled } from "@app/hooks/useClassificationEnabled"; +import { useClassificationLabels } from "@app/hooks/useClassificationLabels"; +import { fileStorage } from "@app/services/fileStorage"; +import { readStubClassificationLabels } from "@app/services/fileClassification"; +import { hasInFlightPolicyRuns } from "@app/components/policies/policyRunStore"; +import { + getSidebarCategories, + subscribeSidebarCategories, +} from "@app/services/fileSidebarCategories"; +import { buildLabelGroups } from "@app/components/shared/fileSidebarGroupingLogic"; +import type { FileId } from "@app/types/file"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileSidebarGroup } from "@core/components/shared/fileSidebarGrouping"; + +export type { FileSidebarGroup }; +// Pure grouping logic lives in a component-free module so tests don't drag in the picker's UI deps. +export { + buildLabelGroups, + bucketStubsByLabel, +} from "@app/components/shared/fileSidebarGroupingLogic"; +// The sidebar's group-picker button + modal (core renders a null stub). +export { FileSidebarGroupControls } from "@app/components/shared/FileSidebarGroupControls"; + +/** Files read per effect pass, so a big library backfills over several ticks. */ +const BACKFILL_BATCH = 3; +/** Recheck delay when the backfill yields to an active policy wave. */ +const BACKFILL_BUSY_RETRY_MS = 4000; + +/** Schedule work for the browser's idle time (or soon after, as a fallback). */ +function scheduleIdle(task: () => void): () => void { + if (typeof requestIdleCallback === "function") { + const handle = requestIdleCallback(task, { timeout: 2000 }); + return () => cancelIdleCallback(handle); + } + const timer = window.setTimeout(task, 200); + return () => window.clearTimeout(timer); +} + +export function useFileSidebarGroups( + stubs: StirlingFileStub[], +): FileSidebarGroup[] | null { + const { t } = useTranslation(); + // Classification off (AI disabled) → no grouping at all: return the flat list + // like core, and don't fetch team labels or backfill from metadata. Gates the + // whole feature so an AI-off SaaS tenant sees no Recent/Other/category chrome. + const enabled = useClassificationEnabled(); + const { teamLabels: labelSet } = useClassificationLabels(enabled); + const { bumpRevision } = useIndexedDB(); + // Attempted reads keyed by id+lastModified: a re-classified file (new version bumps lastModified) is re-read and leaves "Other" on its own, while a truly-unlabelled file keeps a stable key and is read once. + const attempted = useRef>(new Set()); + const attemptKey = (s: StirlingFileStub) => + `${s.id as string}:${s.lastModified ?? 0}`; + // Bumped to re-attempt a backfill pass that yielded to an active policy wave. + const [retryTick, setRetryTick] = useState(0); + + // Fallback for files that arrive with labels already in metadata but no policy delivery (imports/shares): read+cache a few per idle pass, yielding while a policy wave is in flight since those stubs get stamped on delivery anyway. + useEffect(() => { + if (!enabled) return; + const pending = stubs + .filter( + (s) => !s.classificationLabels && !attempted.current.has(attemptKey(s)), + ) + .slice(0, BACKFILL_BATCH); + if (pending.length === 0) return; + let cancelled = false; + let retryTimer: number | undefined; + const cancelIdle = scheduleIdle(() => { + if (cancelled) return; + // Deliveries stamp labels during a wave, so reading now is wasted parsing; recheck after it (a timer self-heals when a wave ends without a stubs change). + if (hasInFlightPolicyRuns()) { + retryTimer = window.setTimeout(() => { + if (!cancelled) setRetryTick((n) => n + 1); + }, BACKFILL_BUSY_RETRY_MS); + return; + } + void (async () => { + let wrote = false; + for (const stub of pending) { + attempted.current.add(attemptKey(stub)); + const labels = await readStubClassificationLabels(stub); + if (cancelled) return; + if (labels) { + const ok = await fileStorage.updateFileMetadata(stub.id as FileId, { + classificationLabels: labels, + }); + if (ok) wrote = true; + } + } + // One revision bump per batch → the sidebar re-reads and re-groups. + if (!cancelled && wrote) bumpRevision(); + })(); + }); + return () => { + cancelled = true; + cancelIdle(); + if (retryTimer !== undefined) window.clearTimeout(retryTimer); + }; + }, [enabled, stubs, bumpRevision, retryTick]); + + const categories = useSyncExternalStore( + subscribeSidebarCategories, + getSidebarCategories, + ); + return useMemo( + () => (enabled ? buildLabelGroups(stubs, labelSet, t, categories) : null), + [enabled, stubs, labelSet, t, categories], + ); +} diff --git a/frontend/editor/src/saas/components/shared/fileSidebarGroupingLogic.ts b/frontend/editor/src/saas/components/shared/fileSidebarGroupingLogic.ts new file mode 100644 index 0000000000..5b61d4a18b --- /dev/null +++ b/frontend/editor/src/saas/components/shared/fileSidebarGroupingLogic.ts @@ -0,0 +1,134 @@ +// Pure grouping logic for the Files sidebar, split from the React seam module so it can be imported (and unit-tested) without pulling in the category-picker component and its heavy UI deps. + +import { DEFAULT_LABEL_ICON } from "@app/data/labelIcons"; +import { accentColor, accentCycleColor } from "@app/utils/accentColors"; +import { + categorizedLabelKeys, + type SidebarCategory, +} from "@app/services/fileSidebarCategories"; +import type { StirlingFileStub } from "@app/types/fileContext"; +import type { FileSidebarGroup } from "@core/components/shared/fileSidebarGrouping"; + +export type { FileSidebarGroup }; + +/** Files shown in the always-expanded "Recent" group. */ +const RECENT_COUNT = 8; + +/** Per label id (a file's stored classification ids) → the stubs carrying it. */ +export function bucketStubsByLabel( + stubs: StirlingFileStub[], +): Map { + const byLabel = new Map(); + for (const stub of stubs) { + for (const labelId of stub.classificationLabels ?? []) { + const bucket = byLabel.get(labelId); + if (bucket) bucket.stubs.push(stub); + else byLabel.set(labelId, { stubs: [stub] }); + } + } + return byLabel; +} + +/** + * Pure grouping: Recent (top {@link RECENT_COUNT} by lastModified), then visible groups sorted + * alphabetically, then Other (files in no visible group) last. Visible groups are the non-hidden + * {@link SidebarCategory} entries with ≥1 file (a file lands in every category it has a label in), + * plus a standalone group for any label on a file that isn't in a category yet. + */ +export function buildLabelGroups( + stubs: StirlingFileStub[], + labelSet: readonly { id: string; name: string; icon?: string }[], + t: (key: string, fallback: string) => string, + categories: SidebarCategory[], +): FileSidebarGroup[] | null { + if (stubs.length === 0) return null; + + // Display name + icon per label id from the effective set; ids no longer in the + // set still group, resolving to the id text and the default icon. + const nameById = new Map(); + const iconById = new Map(); + for (const label of labelSet) { + nameById.set(label.id, label.name); + iconById.set(label.id, label.icon); + } + const labelName = (id: string) => + t(`classification.labels.${id}`, nameById.get(id) ?? id); + + const recent = [...stubs] + .sort((a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0)) + .slice(0, RECENT_COUNT); + + const byLabel = bucketStubsByLabel(stubs); + const inACategory = categorizedLabelKeys(categories); + + const visible: Omit[] = []; + + // One group per visible category: every file carrying any of its labels, in the input's order + // (deduped — a file with two of the category's labels appears once). + for (const category of categories) { + if (category.hidden) continue; + const ids = new Set(category.labelKeys); + const members = stubs.filter((stub) => + (stub.classificationLabels ?? []).some((labelId) => ids.has(labelId)), + ); + if (members.length === 0) continue; + visible.push({ + id: `category:${category.id}`, + label: category.name, + icon: category.icon, + stubs: members, + defaultExpanded: false, + }); + } + + // A label on a file but not in any category still gets its own group, so nothing is stranded + // until the user files it into a category. + for (const [labelId, bucket] of byLabel) { + if (inACategory.has(labelId)) continue; + visible.push({ + id: `label:${labelId}`, + label: labelName(labelId), + icon: iconById.get(labelId) ?? DEFAULT_LABEL_ICON, + stubs: bucket.stubs, + defaultExpanded: false, + }); + } + + visible.sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: "base" }), + ); + + // Other = files in no visible group: unlabelled, or labelled only under hidden categories. + const covered = new Set(); + for (const group of visible) { + for (const stub of group.stubs) covered.add(stub.id as string); + } + const other = stubs.filter((stub) => !covered.has(stub.id as string)); + + const groups: FileSidebarGroup[] = [ + { + id: "recent", + label: t("fileSidebar.recent", "Recent"), + icon: "history", + stubs: recent, + defaultExpanded: true, + }, + ...visible.map((group, index) => ({ + ...group, + // Theme-adaptive accent cycled in display order, so each icon reads at a glance. + color: accentCycleColor(index), + })), + ]; + if (other.length > 0) { + groups.push({ + id: "other", + label: t("fileSidebar.other", "Other"), + icon: DEFAULT_LABEL_ICON, + // Neutral grey for the "Other" (unlabelled) group. + color: accentColor("gray"), + stubs: other, + defaultExpanded: false, + }); + } + return groups; +} diff --git a/frontend/editor/src/saas/hooks/useClassificationEnabled.ts b/frontend/editor/src/saas/hooks/useClassificationEnabled.ts new file mode 100644 index 0000000000..a776f77986 --- /dev/null +++ b/frontend/editor/src/saas/hooks/useClassificationEnabled.ts @@ -0,0 +1,11 @@ +// SaaS override of the classification-enabled seam: classification is available +// exactly when the AI engine is on for this tenant. Off → the sidebar grouping, +// group-picker, per-file label chips and the file-details Classification section +// all stay hidden, so an AI-disabled SaaS tenant sees the plain flat file list +// with no hint the feature exists. + +import { useAiEngineEnabled } from "@app/hooks/useAiEngineEnabled"; + +export function useClassificationEnabled(): boolean { + return useAiEngineEnabled(); +} From e5a258a6485677e2147126e7366307d30f53e9f7 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:57:58 +0100 Subject: [PATCH 05/13] =?UTF-8?q?Dev:=20redirect=20bare=20subpath=20/app?= =?UTF-8?q?=20=E2=86=92=20/app/=20when=20RUN=5FSUBPATH=20is=20set=20(#6934?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem With `RUN_SUBPATH=app`, the app is served under base `/app/`. Vite serves `index.html` at `/app/` and redirects `/` → `/app/`, but a bare **`/app`** (no trailing slash) returns **404** — so you had to type `localhost:5173/app/` to load the app. `/app` should work too. ## Fix A small dev + preview middleware that **301-redirects `/app` → `/app/`** (query string preserved), so either form loads the app. Only active when `RUN_SUBPATH` is set; no-op otherwise. Also routed the vite `base` through the same slash-stripped `runSubpath` value the middleware uses, so a stray `RUN_SUBPATH=/app/` can't produce a doubled `//app//` base. ## Verified (dev server + prod build, `RUN_SUBPATH=app`) | Request | Before | After | |---|---|---| | `GET /app` | 404 | **301 → `/app/`** | | `GET /app?foo=1` | 404 | **301 → `/app/?foo=1`** (query kept) | | `GET /app/` | 200 | 200 (unchanged) | | `GET /` | 302 → `/app/` | 302 → `/app/` (unchanged) | Production build under the subpath still emits `` and `/app/assets/...`. Lint + format green. --- frontend/editor/vite.config.ts | 42 +++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/frontend/editor/vite.config.ts b/frontend/editor/vite.config.ts index 17c0c09282..059f98332e 100644 --- a/frontend/editor/vite.config.ts +++ b/frontend/editor/vite.config.ts @@ -6,7 +6,7 @@ import { constants, brotliCompress, gzip } from "node:zlib"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { defineConfig, loadEnv } from "vite"; -import type { PluginOption } from "vite"; +import type { Connect, PluginOption } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; import { viteStaticCopy } from "vite-plugin-static-copy"; @@ -129,6 +129,38 @@ function prerenderOgPlugin(): PluginOption { }; } +/** + * When the app is served under a subpath (RUN_SUBPATH → base like "/app/"), Vite + * serves index.html at "/app/" and redirects "/" → the base, but a bare "/app" + * (no trailing slash) 404s. This middleware redirects "/app" → "/app/" so either + * form loads the app in dev and `vite preview`. Query strings are preserved. + */ +function subpathBareRedirectPlugin(subpath: string): PluginOption { + const bare = `/${subpath}`; + const withSlash = `${bare}/`; + const redirect: Connect.NextHandleFunction = (req, res, next) => { + const url = req.url ?? ""; + const q = url.indexOf("?"); + const pathname = q === -1 ? url : url.slice(0, q); + if (pathname === bare) { + res.statusCode = 301; + res.setHeader("Location", withSlash + (q === -1 ? "" : url.slice(q))); + res.end(); + return; + } + next(); + }; + return { + name: "subpath-bare-redirect", + configureServer(server) { + server.middlewares.use(redirect); + }, + configurePreviewServer(server) { + server.middlewares.use(redirect); + }, + }; +} + // NOTE: cloud/ is a SHARED layer, not a runnable build flavor — it's compiled // into the saas and desktop builds. It has no entry here and no vite tsconfig; // it is only typechecked standalone via editor/src/cloud/tsconfig.json @@ -178,6 +210,9 @@ export default defineConfig(async ({ mode }) => { const tsconfigProject = TSCONFIG_MAP[effectiveMode]; + // Subpath the app is served under (base becomes "//"). Empty = root. + const runSubpath = (env.RUN_SUBPATH || "").replace(/^\/+|\/+$/g, ""); + // Backend proxy target: default localhost:8080. Override via BACKEND_URL env var // so the top-level dev launcher can wire a dynamically-assigned backend port. const backendUrl = process.env.BACKEND_URL || "http://localhost:8080"; @@ -217,6 +252,7 @@ export default defineConfig(async ({ mode }) => { return { plugins: [ react(), + ...(runSubpath ? [subpathBareRedirectPlugin(runSubpath)] : []), tsconfigPaths({ projects: [tsconfigProject], }), @@ -330,8 +366,8 @@ export default defineConfig(async ({ mode }) => { // an absolute base so deep-route asset paths resolve to /assets/... // Trailing slash required: it becomes ``, and browsers resolve // relative URLs (manifest.json, favicon) against the base's *directory*. - base: env.RUN_SUBPATH - ? `/${env.RUN_SUBPATH}/` + base: runSubpath + ? `/${runSubpath}/` : process.env.VITE_BUILD_FOR_PREVIEW === "1" ? "/" : "./", From d3638d786de4bc21fc0eb3d609d6df74cc904571 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:58:32 +0100 Subject: [PATCH 06/13] Portal home: cut the mock blocks, wire the rest to real data (#6931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this changes Cleaning up the portal home page. Most of what was under the plan card was mock — it hit endpoints that don't exist on any backend, so on SaaS it just showed a wall of "—" and "Nothing here yet". I removed the fake stuff and wired the bits worth keeping to real data. **Scrapped (all mock, no backend anywhere — self-hosted included):** - The "No usage yet" usage chart - The KPI strip (Docs/30d, Pipelines, Agents active, Eval pass rate) - The "Build a pipeline in seconds" fork wizard (fake build animation, deploy was a TODO) - The Sources/Pipelines/Agents product cards - The "Popular use cases" marketing cards - Enterprise region health - The "Try a PDF operation" runner Deleted their component/api/mock/MSW/story files too, plus the now-dead i18n keys and CSS. **Wired to real data (finished, not removed):** - The plan strip and the sidebar footer now show the **real** 30-day processed-PDF count from `/api/v1/usage/fleet-stats` (was a mock KPI). Shows "—" honestly when the backend can't compute it, never a fake number. - **Recent activity** now reads the **real audit log** — the same endpoint the Infrastructure → Audit tab uses. **Result:** one simple layout for all tiers — plan strip → recent activity + quick actions → "What runs on your PDFs" (the real policy summary). Every block is backed by data that actually exists. Net: **−3,221 / +89 lines**, 17 files removed. ## Testing typecheck (all variants), full test suite (1211), saas + proprietary builds, lint, format, storybook build, toml-sort — all green. The `unusedTranslations` test guarantees no orphaned i18n keys were left behind. --- .../public/locales/en-US/translation.toml | 151 +------ frontend/editor/src/portal/api/home.ts | 44 -- frontend/editor/src/portal/api/ops.ts | 37 -- .../portal/components/PipelineForkWizard.css | 225 ----------- .../components/PipelineForkWizard.stories.tsx | 20 - .../portal/components/PipelineForkWizard.tsx | 162 -------- .../src/portal/components/PopularUseCases.css | 87 ---- .../src/portal/components/PopularUseCases.tsx | 89 ---- .../components/ProcessingStatusStrip.css | 37 +- .../components/ProcessingStatusStrip.tsx | 87 +--- .../src/portal/components/RecentActivity.tsx | 75 ++-- .../editor/src/portal/components/Sidebar.css | 18 - .../editor/src/portal/components/Sidebar.tsx | 51 +-- .../src/portal/components/SingleOpRunner.css | 345 ---------------- .../components/SingleOpRunner.stories.tsx | 53 --- .../src/portal/components/SingleOpRunner.tsx | 369 ----------------- .../src/portal/components/UsageAreaChart.css | 115 ------ .../components/UsageAreaChart.stories.tsx | 53 --- .../src/portal/components/UsageAreaChart.tsx | 288 ------------- .../editor/src/portal/mocks/handlers/home.ts | 43 -- .../editor/src/portal/mocks/handlers/index.ts | 6 - .../editor/src/portal/mocks/handlers/ops.ts | 30 -- frontend/editor/src/portal/mocks/home.ts | 285 ------------- frontend/editor/src/portal/mocks/ops.ts | 166 -------- frontend/editor/src/portal/views/Home.css | 79 ---- .../editor/src/portal/views/Home.stories.tsx | 16 +- frontend/editor/src/portal/views/Home.tsx | 379 +----------------- 27 files changed, 89 insertions(+), 3221 deletions(-) delete mode 100644 frontend/editor/src/portal/api/home.ts delete mode 100644 frontend/editor/src/portal/api/ops.ts delete mode 100644 frontend/editor/src/portal/components/PipelineForkWizard.css delete mode 100644 frontend/editor/src/portal/components/PipelineForkWizard.stories.tsx delete mode 100644 frontend/editor/src/portal/components/PipelineForkWizard.tsx delete mode 100644 frontend/editor/src/portal/components/PopularUseCases.css delete mode 100644 frontend/editor/src/portal/components/PopularUseCases.tsx delete mode 100644 frontend/editor/src/portal/components/SingleOpRunner.css delete mode 100644 frontend/editor/src/portal/components/SingleOpRunner.stories.tsx delete mode 100644 frontend/editor/src/portal/components/SingleOpRunner.tsx delete mode 100644 frontend/editor/src/portal/components/UsageAreaChart.css delete mode 100644 frontend/editor/src/portal/components/UsageAreaChart.stories.tsx delete mode 100644 frontend/editor/src/portal/components/UsageAreaChart.tsx delete mode 100644 frontend/editor/src/portal/mocks/handlers/home.ts delete mode 100644 frontend/editor/src/portal/mocks/handlers/ops.ts delete mode 100644 frontend/editor/src/portal/mocks/home.ts delete mode 100644 frontend/editor/src/portal/mocks/ops.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index acaaf00d80..5f07f5d462 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6791,23 +6791,6 @@ description = "This view hit an unexpected error. Try again, or pick another sec retry = "Try again" title = "Something went wrong on this page" -[portal.forkWizard] -subtitle = "Clone a proven workflow and tune it — every template ships the same four-stage backbone." -title = "Fork a starter pipeline" - -[portal.forkWizard.action] -cancel = "Cancel" -deploy = "Deploy pipeline" -pickAnother = "Pick another" - -[portal.forkWizard.status] -building = "Building…" -ready = "Ready to deploy" - -[portal.home.chart.empty] -description = "Once documents are processed, your 30-day usage appears here." -title = "No usage yet" - [portal.home.editor] activeUsers = "{{n}} active" invite = "Invite teammates" @@ -6825,25 +6808,6 @@ afternoon = "Good afternoon" evening = "Good evening" morning = "Good morning" -[portal.home.kpis.enterprise] -docs30d = "Docs / 30d" -evalPassRate = "Eval pass rate" -p95Latency = "P95 latency" -slaUptime = "SLA uptime (30d)" - -[portal.home.kpis.free] -agents = "Agents" -docsProcessed = "Docs processed" -docsProcessedDescription = "Free plan cap" -operations = "Operations" -pipelines = "Pipelines" - -[portal.home.kpis.pro] -agentsActive = "Agents active" -docs30d = "Docs / 30d" -evalPassRate = "Eval pass rate" -pipelines = "Pipelines" - [portal.home.onboarding] dismiss = "Dismiss setup" notStarted = "Not started" @@ -6871,25 +6835,6 @@ blurb = "{{connected}} connected · every PDF governed where it lands" chip = "{{connected}} connected" title = "Connect your sources" -[portal.home.productGrid] -ariaLabel = "Process PDFs at scale" - -[portal.home.productGrid.agents] -blurb = "Wire your agent via MCP, REST, or tool definitions. Deterministic operations, scenarios, evals." -cta = "Connect an agent" -title = "Agents" - -[portal.home.productGrid.pipelines] -badge = "Hero" -blurb = "Compose document workflows from typed operations. Upload a sample to get suggestions or start blank." -cta = "Build a pipeline" -title = "Pipelines" - -[portal.home.productGrid.sources] -blurb = "Attach pipelines where PDFs already live — S3, agents, SharePoint, webhooks, batch, email." -cta = "Connect a source" -title = "Sources" - [portal.home.quickActions] subtitle = "Top tasks for today" title = "Quick actions" @@ -6906,18 +6851,6 @@ title = "Connect a source" blurb = "Scoped key with rate limits and IP allowlist" title = "Issue an API key" -[portal.home.quickActions.tryOp] -blurb = "Drop a sample, pick an op, see the JSON" -title = "Try a PDF operation" - -[portal.home.regions] -subtitle = "Real-time status for every deployed Stirling region." -title = "Region health" - -[portal.home.regions.empty] -description = "Once a region is deployed, its health appears here." -title = "No regions yet" - [portal.infrastructure] manageEditorDeployment = "Manage Editor deployment" sectionsAriaLabel = "Infrastructure sections" @@ -7270,51 +7203,6 @@ new_other = "{{count}} new" description = "No new notifications." title = "You're all caught up" -[portal.opRunner] -durationMs = "{{ms}} ms" -featuredOps = "Featured ops" -subtitle = "Drop a sample, pick an op, see what Stirling returns." -title = "Try a PDF operation" - -[portal.opRunner.action] -close = "Close" -openBuilder = "Open the pipeline builder" -run = "Run operation" -runAgain = "Run again" -running = "Running…" - -[portal.opRunner.drop] -hint = "or use a sample document." -pickAnother = "Pick another sample" -replaceHint = "Drop again or pick another sample to replace." -title = "Drop a PDF here" -useSample = "Use a sample" - -[portal.opRunner.empty] -description = "Once operations are published, they'll show up here." -title = "No featured ops yet" - -[portal.opRunner.error] -title = "The operation didn't complete" -unknown = "Unknown error" - -[portal.opRunner.hint] -aSample = "a sample" -press = "Press" -ready = "Ready" -toInvoke = "to invoke" - -[portal.opRunner.hint.runOn] -before = "Run" -middle = "on" - -[portal.opRunner.running] -title = "Running {{label}}…" - -[portal.opRunner.status] -completed = "Completed" -failed = "Failed" - [portal.pipelines] subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources." title = "Pipelines" @@ -7573,9 +7461,6 @@ soon = "Soon" [portal.processingStatus] managePlan = "Manage plan" -pdfsThisMonth = "PDFs this month" -progressLabel = "{{used}} of {{cap}} PDFs used this month" -upgrade = "Upgrade" volumeSuffix = "PDFs processed · last 30 days" [portal.procurement] @@ -7944,8 +7829,8 @@ appEditor = "Editor" appProcessor = "Processor" brandSuffix = "Stirling Processor" docsCount = "{{docs}} docs" -docsProcessed = "Docs processed" linkAccount = "Link Stirling account" +planEditor = "Editor plan" planEnterprise = "Enterprise plan" planProcessor = "Processor plan" primaryNav = "Primary navigation" @@ -8053,40 +7938,6 @@ action = "Sign in again" body = "Your Stirling account session has expired. Sign in again to view billing — your instance stays linked." title = "Session expired" -[portal.usageChart] -defaultLabel = "Docs processed · last 30 days" -delta = "{{pct}}% vs prior 30d" -docsValue = "{{value}} docs" -srAnnounce = "{{date}}: {{value}} docs" - -[portal.useCases] -title = "Popular use cases" -viewAll = "View all pipelines" - -[portal.useCases.items.authenticity] -blurb = "Cryptographic checks at the document boundary — signature validation, tamper detection, signing flows for outbound documents. Trust decisions in the pipeline, not your app code." -cta = "Try authenticity check" -eyebrow = "AUTHENTICITY" -title = "Verify signatures and detect tampering" - -[portal.useCases.items.autoRouting] -blurb = "One classifier reads what arrived — KYC form, invoice, contract, COI — and routes to the right downstream pipeline. No manual triage, no docs in the wrong workflow." -cta = "Build a classifier pipeline" -eyebrow = "AUTO-ROUTING" -title = "Auto-classify and route incoming documents" - -[portal.useCases.items.piiRedaction] -blurb = "Strip sensitive fields before storage, indexing, or LLM processing. Schema-aware, per-field audit, BYOK or HYOK keys. Compliance at the document boundary, not per pipeline." -cta = "See redaction pipelines" -eyebrow = "PII REDACTION" -title = "Redact PII before it leaves your stack" - -[portal.useCases.items.trainingData] -blurb = "Batch-import an archive, redact PII, classify, chunk, and emit ready-to-load JSON for fine-tuning, eval sets, or RAG. Self-completing and replayable." -cta = "Build a training-data pipeline" -eyebrow = "TRAINING DATA" -title = "Turn PDFs into training data" - [portal.users] title = "Users" diff --git a/frontend/editor/src/portal/api/home.ts b/frontend/editor/src/portal/api/home.ts deleted file mode 100644 index 467fd6a66a..0000000000 --- a/frontend/editor/src/portal/api/home.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { apiClient } from "@portal/api/http"; -import type { - ActivityEvent, - KpiEntry, - RegionHealth, - UsageSeriesResponse, -} from "@portal/mocks/home"; -import type { Tier } from "@portal/contexts/TierContext"; - -export type { - ActivityEvent, - ActivityKind, - KpiEntry, - PipelineStage, - PipelineTemplate, - RegionHealth, - UsagePoint, - UsageSeriesResponse, -} from "@portal/mocks/home"; -export { PIPELINE_STAGES, PIPELINE_TEMPLATES } from "@portal/mocks/home"; - -/** GET /v1/analytics/usage?window=30d */ -export async function fetchUsageSeries(): Promise { - return apiClient.local.json( - "/v1/analytics/usage?window=30d", - ); -} - -/** GET /v1/activity?limit=8 */ -export async function fetchRecentActivity(): Promise { - return apiClient.local.json("/v1/activity?limit=8"); -} - -/** GET /v1/home/kpis?tier=… */ -export async function fetchHomeKpis(tier: Tier): Promise { - return apiClient.local.json( - `/v1/home/kpis?tier=${encodeURIComponent(tier)}`, - ); -} - -/** GET /v1/regions/health (Enterprise) */ -export async function fetchRegionHealth(): Promise { - return apiClient.local.json("/v1/regions/health"); -} diff --git a/frontend/editor/src/portal/api/ops.ts b/frontend/editor/src/portal/api/ops.ts deleted file mode 100644 index c8a564f307..0000000000 --- a/frontend/editor/src/portal/api/ops.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { apiClient, HttpError } from "@portal/api/http"; -import type { FeaturedOp, OpResultMap } from "@portal/mocks/ops"; - -export type { FeaturedOp, OpResultMap }; - -/** GET /v1/ops/featured */ -export async function fetchFeaturedOps(): Promise { - return apiClient.local.json("/v1/ops/featured"); -} - -export class UnknownOpError extends Error { - constructor(public readonly opId: string) { - super(`Unknown op: ${opId}`); - this.name = "UnknownOpError"; - } -} - -/** POST /v1/ops/{opId}/run */ -export async function runSingleOp( - opId: string, - sample: string, -): Promise<{ result: OpResultMap; durationMs: number }> { - try { - return await apiClient.local.json<{ - result: OpResultMap; - durationMs: number; - }>(`/v1/ops/${encodeURIComponent(opId)}/run`, { - method: "POST", - body: { sample }, - }); - } catch (err) { - if (err instanceof HttpError && err.status === 404) { - throw new UnknownOpError(opId); - } - throw err; - } -} diff --git a/frontend/editor/src/portal/components/PipelineForkWizard.css b/frontend/editor/src/portal/components/PipelineForkWizard.css deleted file mode 100644 index a126f0dc4f..0000000000 --- a/frontend/editor/src/portal/components/PipelineForkWizard.css +++ /dev/null @@ -1,225 +0,0 @@ -.portal-fork__head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 0.875rem; -} - -.portal-fork__title { - margin: 0; - font-size: 0.9375rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-fork__sub { - margin: 0.125rem 0 0; - font-size: 0.75rem; - color: var(--color-text-4); - max-width: 32rem; -} - -/* Template picker */ -.portal-fork__templates { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.625rem; -} - -@media (max-width: 48rem) { - .portal-fork__templates { - grid-template-columns: 1fr; - } -} - -.portal-fork__template { - display: flex; - flex-direction: column; - gap: 0.375rem; - padding: 0.75rem 0.875rem; - text-align: left; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-left: 3px solid var(--accent, var(--color-blue)); - border-radius: var(--radius-md); - transition: - background var(--motion-fast), - border-color var(--motion-fast), - transform var(--motion-fast); -} - -.portal-fork__template[data-accent="blue"] { - --accent: var(--color-blue); -} -.portal-fork__template[data-accent="purple"] { - --accent: var(--color-purple); -} -.portal-fork__template[data-accent="green"] { - --accent: var(--color-green); -} -.portal-fork__template[data-accent="amber"] { - --accent: var(--color-amber); -} - -.portal-fork__template:hover { - background: var(--color-bg-hover); - border-color: var(--color-border); - border-left-color: var(--accent); - transform: translateY(-1px); -} - -.portal-fork__template-name { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-fork__template-blurb { - font-size: 0.75rem; - color: var(--color-text-4); - line-height: 1.45; -} - -.portal-fork__template-types { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - margin-top: 0.125rem; -} - -/* Build / ready state */ -.portal-fork__build-head { - display: flex; - flex-direction: column; - margin-bottom: 0.875rem; -} - -.portal-fork__build-head strong { - font-size: 0.875rem; - color: var(--color-text-1); -} - -.portal-fork__build-head span { - font-size: 0.75rem; - color: var(--color-text-4); -} - -.portal-fork__stages { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; -} - -@media (max-width: 48rem) { - .portal-fork__stages { - grid-template-columns: 1fr 1fr; - } -} - -.portal-fork__stage { - position: relative; - display: flex; - flex-direction: column; - gap: 0.375rem; - padding: 0.625rem 0.75rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - opacity: 0.55; - transition: - opacity var(--motion-base), - border-color var(--motion-base), - background var(--motion-base); -} - -.portal-fork__stage.is-active, -.portal-fork__stage.is-done { - opacity: 1; -} - -.portal-fork__stage.is-active { - border-color: var(--color-blue); - background: var(--color-blue-light); -} - -.portal-fork__stage.is-done { - border-color: color-mix(in srgb, var(--color-green) 35%, transparent); - background: var(--color-green-light); -} - -.portal-fork__stage-mark { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.5rem; - height: 1.5rem; - border-radius: 50%; - font-size: 0.75rem; - font-weight: 600; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border); - color: var(--color-text-4); -} - -.portal-fork__stage.is-done .portal-fork__stage-mark { - background: var(--color-green); - border-color: var(--color-green); - color: var(--color-text-on-accent); -} - -.portal-fork__stage.is-active .portal-fork__stage-mark { - border-color: var(--color-blue); - color: var(--color-blue); -} - -.portal-fork__stage-text { - display: flex; - flex-direction: column; -} - -.portal-fork__stage-text strong { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-fork__stage-text span { - font-size: 0.6875rem; - color: var(--color-text-4); - line-height: 1.4; -} - -.portal-fork__stage-spin { - position: absolute; - top: 0.625rem; - right: 0.625rem; - width: 0.875rem; - height: 0.875rem; - border: 2px solid color-mix(in srgb, var(--color-blue) 30%, transparent); - border-top-color: var(--color-blue); - border-radius: 50%; - animation: portal-fork-spin 0.7s linear infinite; -} - -@keyframes portal-fork-spin { - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: reduce) { - .portal-fork__stage-spin { - animation: none; - } -} - -.portal-fork__build-actions { - display: flex; - justify-content: flex-end; - gap: 0.5rem; - margin-top: 1rem; -} diff --git a/frontend/editor/src/portal/components/PipelineForkWizard.stories.tsx b/frontend/editor/src/portal/components/PipelineForkWizard.stories.tsx deleted file mode 100644 index 8701e8e9c5..0000000000 --- a/frontend/editor/src/portal/components/PipelineForkWizard.stories.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { PipelineForkWizard } from "@portal/components/PipelineForkWizard"; - -const meta: Meta = { - title: "Portal/Home/PipelineForkWizard", - component: PipelineForkWizard, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -/** Pick a template to watch the deterministic four-stage build animation. */ -export const Default: Story = {}; diff --git a/frontend/editor/src/portal/components/PipelineForkWizard.tsx b/frontend/editor/src/portal/components/PipelineForkWizard.tsx deleted file mode 100644 index 3a27b5984a..0000000000 --- a/frontend/editor/src/portal/components/PipelineForkWizard.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, Card, Chip, StatusBadge } from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; -import { - PIPELINE_STAGES, - PIPELINE_TEMPLATES, - type PipelineTemplate, -} from "@portal/api/home"; -import "@portal/components/PipelineForkWizard.css"; - -/** - * Wizard phases: - * - `pick` — choose a starter template - * - `building` — deterministic stage-by-stage build animation - * - `ready` — all four stages lit; offer deploy - */ -type Phase = "pick" | "building" | "ready"; - -/** Time each build stage stays "in progress" before the next lights up. */ -const STAGE_STEP_MS = 550; - -export function PipelineForkWizard() { - const { t } = useTranslation(); - const { setActiveView } = useView(); - const [phase, setPhase] = useState("pick"); - const [template, setTemplate] = useState(null); - // How many stages have completed. Drives both the animation and the - // pick→building→ready transitions; advanced purely by a fixed-interval timer - // so the sequence is identical on every run (no Math.random / Date.now). - const [builtStages, setBuiltStages] = useState(0); - const timerRef = useRef(null); - - // Advance one stage per tick while building; settle into `ready` once all - // stages are done. The effect re-arms itself on each builtStages change - // rather than holding a single long-lived interval, so cleanup is trivial. - useEffect(() => { - if (phase !== "building") return; - if (builtStages >= PIPELINE_STAGES.length) { - setPhase("ready"); - return; - } - timerRef.current = window.setTimeout(() => { - setBuiltStages((n) => n + 1); - }, STAGE_STEP_MS); - return () => { - if (timerRef.current !== null) window.clearTimeout(timerRef.current); - }; - }, [phase, builtStages]); - - function fork(t: PipelineTemplate) { - setTemplate(t); - setBuiltStages(0); - setPhase("building"); - } - - function reset() { - setPhase("pick"); - setTemplate(null); - setBuiltStages(0); - } - - function deploy() { - // TODO(backend): POST /v1/pipelines { templateId, name } to create the - // forked pipeline. Without a backend, route to the pipelines list. - setActiveView("pipelines"); - } - - return ( - -
-
-

{t("portal.forkWizard.title")}

-

{t("portal.forkWizard.subtitle")}

-
- {phase !== "pick" && template && ( - - {phase === "ready" - ? t("portal.forkWizard.status.ready") - : t("portal.forkWizard.status.building")} - - )} -
- - {phase === "pick" && ( -
- {PIPELINE_TEMPLATES.map((t) => ( - - ))} -
- )} - - {phase !== "pick" && template && ( -
-
- {template.name} - {template.blurb} -
- -
    - {PIPELINE_STAGES.map((stage, i) => { - const done = i < builtStages; - const active = phase === "building" && i === builtStages; - const cls = - "portal-fork__stage" + - (done ? " is-done" : "") + - (active ? " is-active" : ""); - return ( -
  1. - - {done ? "✓" : i + 1} - - - {stage.label} - {stage.detail} - - {active && ( - - )} -
  2. - ); - })} -
- -
- - -
-
- )} -
- ); -} diff --git a/frontend/editor/src/portal/components/PopularUseCases.css b/frontend/editor/src/portal/components/PopularUseCases.css deleted file mode 100644 index af79deb2a9..0000000000 --- a/frontend/editor/src/portal/components/PopularUseCases.css +++ /dev/null @@ -1,87 +0,0 @@ -.portal-usecases { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.portal-usecases__head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.5rem; -} - -.portal-usecases__title { - margin: 0; - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-usecases__viewall, -.portal-usecases__cta { - background: none; - border: none; - padding: 0; - cursor: pointer; - font-family: inherit; - font-weight: 500; - display: inline-flex; - align-items: center; - gap: 0.25rem; - transition: opacity var(--motion-fast); -} - -.portal-usecases__viewall { - font-size: 0.8125rem; - color: var(--color-blue); -} - -.portal-usecases__viewall:hover, -.portal-usecases__cta:hover { - opacity: 0.8; -} - -.portal-usecases__grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.875rem; -} - -@media (max-width: 50rem) { - .portal-usecases__grid { - grid-template-columns: 1fr; - } -} - -.portal-usecases__card { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.portal-usecases__eyebrow { - font-size: 0.625rem; - font-weight: 600; - letter-spacing: 0.08em; -} - -.portal-usecases__card-title { - margin: 0.125rem 0 0; - font-size: 1rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-usecases__blurb { - margin: 0; - font-size: 0.8125rem; - line-height: 1.5; - color: var(--color-text-3); -} - -.portal-usecases__cta { - margin-top: 0.5rem; - align-self: flex-start; - font-size: 0.8125rem; -} diff --git a/frontend/editor/src/portal/components/PopularUseCases.tsx b/frontend/editor/src/portal/components/PopularUseCases.tsx deleted file mode 100644 index 85ccc90c72..0000000000 --- a/frontend/editor/src/portal/components/PopularUseCases.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { Button, Card, type CardProps } from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; -import "@portal/components/PopularUseCases.css"; - -type Accent = NonNullable; - -interface UseCase { - /** Stable key into the useCases.items.* translation table. */ - key: string; - accent: Accent; -} - -const ACCENT_COLOR: Partial> = { - default: "var(--color-blue)", - premium: "var(--color-purple)", - success: "var(--color-green)", - warning: "var(--color-amber)", - danger: "var(--color-red)", -}; - -/** - * Curated landing-page use cases — a teaser, not the full catalogue. The - * exhaustive per-vertical endpoint list lives on the Documents view; here we - * surface the four cross-cutting pipelines people reach for first. The display - * copy (eyebrow, title, blurb, cta) is keyed into useCases.items.. - */ -const USE_CASES: UseCase[] = [ - { key: "autoRouting", accent: "default" }, - { key: "piiRedaction", accent: "danger" }, - { key: "trainingData", accent: "premium" }, - { key: "authenticity", accent: "success" }, -]; - -export function PopularUseCases() { - const { t } = useTranslation(); - const { setActiveView } = useView(); - return ( -
-
-

{t("portal.useCases.title")}

- -
-
- {USE_CASES.map((uc) => ( - - - {t(`portal.useCases.items.${uc.key}.eyebrow`)} - -

- {t(`portal.useCases.items.${uc.key}.title`)} -

-

- {t(`portal.useCases.items.${uc.key}.blurb`)} -

- -
- ))} -
-
- ); -} diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.css b/frontend/editor/src/portal/components/ProcessingStatusStrip.css index 911bf99541..63341798f6 100644 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.css +++ b/frontend/editor/src/portal/components/ProcessingStatusStrip.css @@ -1,39 +1,4 @@ -.portal-statusstrip--free { - display: flex; - flex-direction: column; -} - -/* The Banner body already provides padding; lay out the free meter inside it. */ -.portal-statusstrip--free .sui-banner__body { - display: flex; - flex-direction: column; - gap: 0.375rem; -} - -.portal-statusstrip__free-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.5rem; -} - -.portal-statusstrip__free-label { - font-size: 0.8125rem; - color: var(--color-text-2); -} - -.portal-statusstrip__free-label strong { - color: var(--color-text-1); - font-weight: 600; -} - -.portal-statusstrip__free-pct { - font-size: 0.75rem; - font-family: var(--font-mono); - color: var(--color-text-4); -} - -/* Paid strip: single inline row */ +/* Single inline row: plan + real processed-PDF volume + manage-plan shortcut. */ .portal-statusstrip--paid { display: flex; align-items: center; diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx index 1ef41f5e0f..af198d488f 100644 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx +++ b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx @@ -1,90 +1,23 @@ import { useTranslation } from "react-i18next"; -import { Banner, Button, ProgressBar, Skeleton } from "@app/ui"; +import { Button, Skeleton } from "@app/ui"; import { TIER_INFO, useTier } from "@portal/contexts/TierContext"; import { useView } from "@portal/contexts/ViewContext"; import { useAsync } from "@portal/hooks/useAsync"; -import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; +import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats"; import "@portal/components/ProcessingStatusStrip.css"; /** - * Parses the free-tier "used / cap" KPI string (e.g. "247 / 500") into its - * parts. The free meter is the headline KPI value rather than a separate - * endpoint, so the strip reads it from the same `fetchHomeKpis` payload the - * KPI cards use — no duplicate fetch, no second source of truth. + * A thin one-line header above the home content: current plan + the real 30-day + * processed-PDF volume (from the fleet-usage endpoint), with a shortcut to the + * Usage page. Renders "—" while loading or when the backend can't compute the + * figure (e.g. EE auditing disabled) — never a fabricated number. */ -function parseUsage(value: KpiEntry["value"]): { - used: number; - cap: number; -} | null { - const match = String(value).match(/([\d,]+)\s*\/\s*([\d,]+)/); - if (!match) return null; - const used = Number(match[1].replace(/,/g, "")); - const cap = Number(match[2].replace(/,/g, "")); - if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0) return null; - return { used, cap }; -} - export function ProcessingStatusStrip() { const { t } = useTranslation(); const { tier } = useTier(); const { setActiveView } = useView(); - const { data: kpis, loading } = useAsync( - () => fetchHomeKpis(tier), - [tier], - ); + const { data, loading } = useAsync(() => fetchFleetStats(), []); - if (loading) { - return ( -
- - -
- ); - } - - if (tier === "free") { - const usage = parseUsage(kpis?.[0]?.value ?? ""); - const used = usage?.used ?? 0; - const cap = usage?.cap ?? 500; - const ratio = cap > 0 ? used / cap : 0; - const nearCap = ratio >= 0.8; - - return ( - setActiveView("usage")} - > - {t("portal.processingStatus.upgrade")} - - ) : undefined - } - > -
- - {used.toLocaleString()} / {cap.toLocaleString()}{" "} - {t("portal.processingStatus.pdfsThisMonth")} - - - {Math.round(ratio * 100)}% - -
- -
- ); - } - - // Pro / enterprise: plan name + headline volume from the first KPI. - const volume = kpis?.[0]?.value; return (
@@ -99,7 +32,11 @@ export function ProcessingStatusStrip() { · - {volume ?? "—"}{" "} + {loading ? ( + + ) : ( + {data?.pdfsProcessed?.toLocaleString() ?? "—"} + )}{" "} {t("portal.processingStatus.volumeSuffix")} @@ -63,13 +90,13 @@ export function RecentActivity() { /> )} - {events && events.length > 0 && ( + {events.length > 0 && (
    {events.map((event) => (
  1. @@ -77,15 +104,17 @@ export function RecentActivity() { {event.action} - {event.time} + + {event.timestamp} +
    -
    {event.subject}
    +
    {event.target}
    - {event.detail} + {AUDIT_CAT_LABEL[event.category]} · {event.actor} - - {event.status} + + {AUDIT_STATUS_LABEL[event.status]}
diff --git a/frontend/editor/src/portal/components/Sidebar.css b/frontend/editor/src/portal/components/Sidebar.css index fcc8c7e6a8..62d6226151 100644 --- a/frontend/editor/src/portal/components/Sidebar.css +++ b/frontend/editor/src/portal/components/Sidebar.css @@ -91,29 +91,11 @@ gap: 0.5rem; } -.portal-sidebar__usage-label { - color: var(--color-text-4); -} - .portal-sidebar__usage-value { color: var(--color-usage-value); font-weight: 500; } -.portal-sidebar__usage-track { - margin-top: 0.5rem; - height: 0.25rem; - background: var(--color-usage-track); - border-radius: var(--radius-pill); - overflow: hidden; -} - -.portal-sidebar__usage-fill { - height: 100%; - background: var(--grad-blue-btn); - transition: width var(--motion-slow); -} - .portal-sidebar__plan { display: inline-flex; align-items: center; diff --git a/frontend/editor/src/portal/components/Sidebar.tsx b/frontend/editor/src/portal/components/Sidebar.tsx index fdb5a61b03..9bf7c8c373 100644 --- a/frontend/editor/src/portal/components/Sidebar.tsx +++ b/frontend/editor/src/portal/components/Sidebar.tsx @@ -8,7 +8,7 @@ import { useTheme } from "@portal/contexts/ThemeContext"; import { useUI } from "@portal/contexts/UIContext"; import { LinkAccountFooterItem } from "@portal/components/LinkAccountFooterItem"; import { useAsync } from "@portal/hooks/useAsync"; -import { fetchHomeKpis, type KpiEntry } from "@portal/api/home"; +import { fetchFleetStats, type FleetStats } from "@portal/api/fleetStats"; import { EDITOR_URL, EDITOR_IS_SAME_APP } from "@portal/auth/editorUrl"; import markLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg"; import markDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg"; @@ -24,48 +24,17 @@ import "@portal/components/Sidebar.css"; function UsageFooter() { const { tier } = useTier(); const { t } = useTranslation(); - // Read the same endpoint Home's KPI strip uses so the doc count here can't - // drift from the headline figure. The first KPI is always the doc total. - const { data: kpis, loading } = useAsync( - () => fetchHomeKpis(tier), - [tier], - ); - const docs = loading ? undefined : kpis?.[0]?.value; - - if (tier === "free") { - // The free doc KPI is formatted "used / cap"; parse it for the meter. - const [used, cap] = - typeof docs === "string" - ? docs.split("/").map((s) => Number(s.replace(/[^\d]/g, ""))) - : []; - const pct = used && cap ? (used / cap) * 100 : 0; - return ( -
-
- - {t("portal.shell.sidebar.docsProcessed")} - - {docs ?? "—"} -
-
-
-
-
- ); - } + // Real 30-day processed-PDF count from the fleet-usage endpoint (the same + // source as Home's status strip, so the two can't drift). null → "—". + const { data, loading } = useAsync(() => fetchFleetStats(), []); + const docs = loading ? undefined : (data?.pdfsProcessed ?? undefined); const planLabel = - tier === "pro" - ? t("portal.shell.sidebar.planProcessor", "Processor plan") - : t("portal.shell.sidebar.planEnterprise", "Enterprise plan"); + tier === "free" + ? t("portal.shell.sidebar.planEditor", "Editor plan") + : tier === "pro" + ? t("portal.shell.sidebar.planProcessor", "Processor plan") + : t("portal.shell.sidebar.planEnterprise", "Enterprise plan"); return (
diff --git a/frontend/editor/src/portal/components/SingleOpRunner.css b/frontend/editor/src/portal/components/SingleOpRunner.css deleted file mode 100644 index 47b998388a..0000000000 --- a/frontend/editor/src/portal/components/SingleOpRunner.css +++ /dev/null @@ -1,345 +0,0 @@ -.portal-runner__layout { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); - gap: 1rem; - min-height: 26rem; -} - -@media (max-width: 50rem) { - .portal-runner__layout { - grid-template-columns: 1fr; - } -} - -.portal-runner__left, -.portal-runner__right { - display: flex; - flex-direction: column; - gap: 0.875rem; - min-width: 0; -} - -/* Drop zone */ -.portal-runner__drop { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.625rem; - padding: 1.25rem; - border: 1.5px dashed var(--color-border-input); - border-radius: var(--radius-md); - background: var(--color-bg-subtle); - color: var(--color-text-3); - text-align: center; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -.portal-runner__drop.is-armed, -.portal-runner__drop:hover { - border-color: var(--color-blue); - background: var(--color-blue-light); - color: var(--color-blue); -} - -.portal-runner__drop.has-file { - border-color: var(--color-green); - background: var(--color-green-light); - color: var(--color-text-2); -} - -.portal-runner__drop-icon { - color: var(--color-text-4); -} - -.portal-runner__drop.is-armed .portal-runner__drop-icon, -.portal-runner__drop.has-file .portal-runner__drop-icon { - color: inherit; -} - -.portal-runner__drop-text { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.125rem; - font-size: 0.8125rem; -} - -.portal-runner__drop-text strong { - font-size: 0.875rem; - color: var(--color-text-1); - font-family: var(--font-mono); -} - -.portal-runner__drop-text span { - font-size: 0.75rem; - color: var(--color-text-4); -} - -.portal-runner__sample-btn { - font-size: 0.75rem; - font-weight: 500; - color: var(--color-blue); - padding: 0.25rem 0.5rem; - border-radius: var(--radius-sm); - transition: background var(--motion-fast); -} - -.portal-runner__sample-btn:hover { - background: var(--color-bg-hover); -} - -/* Op picker */ -.portal-runner__section-title { - font-size: 0.6875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-section-label); - margin-bottom: 0.5rem; -} - -.portal-runner__ops { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.375rem; -} - -.portal-runner__op { - position: relative; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.125rem; - padding: 0.5rem 0.625rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - text-align: left; - transition: - border-color var(--motion-fast), - background var(--motion-fast); -} - -.portal-runner__op:hover { - border-color: var(--color-border-hover); - background: var(--color-bg-hover); -} - -.portal-runner__op.is-selected { - border-color: var(--color-blue); - background: var(--color-blue-light); -} - -.portal-runner__op[data-accent="purple"].is-selected { - border-color: var(--color-purple); - background: var(--color-purple-light); -} -.portal-runner__op[data-accent="green"].is-selected { - border-color: var(--color-green); - background: var(--color-green-light); -} -.portal-runner__op[data-accent="amber"].is-selected { - border-color: var(--color-amber); - background: var(--color-amber-light); -} -.portal-runner__op[data-accent="red"].is-selected { - border-color: var(--color-red); - background: var(--color-red-light); -} - -.portal-runner__op-label { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-runner__op-endpoint { - font-family: var(--font-mono); - font-size: 0.6875rem; - color: var(--color-text-4); -} - -.portal-runner__op-blurb { - font-size: 0.6875rem; - color: var(--color-text-3); - line-height: 1.35; -} - -.portal-runner__op--skeleton { - display: flex; - flex-direction: column; - gap: 0.375rem; - cursor: default; -} -.portal-runner__op--skeleton:hover { - border-color: var(--color-border-light); - background: transparent; -} - -/* Right column states */ -.portal-runner__right { - border: 1px solid var(--color-border-light); - border-radius: var(--radius-md); - background: var(--color-bg-subtle); - padding: 1.125rem; - overflow: hidden; - min-height: 22rem; -} - -.portal-runner__hint { - color: var(--color-text-3); -} - -.portal-runner__hint-eyebrow { - font-size: 0.6875rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-blue); - margin-bottom: 0.5rem; -} - -.portal-runner__hint h3 { - margin: 0 0 0.5rem; - font-size: 1rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-runner__hint h3 code { - font-family: var(--font-mono); - font-size: 0.9375rem; - color: var(--color-blue); - background: var(--color-blue-light); - padding: 0 0.25rem; - border-radius: var(--radius-xs); -} - -.portal-runner__hint p { - margin: 0; - font-size: 0.8125rem; - line-height: 1.5; -} - -.portal-runner__hint p code { - font-family: var(--font-mono); - font-size: 0.75rem; - color: var(--color-text-2); - background: var(--color-bg-muted); - padding: 0 0.25rem; - border-radius: var(--radius-xs); -} - -.portal-runner__hint kbd { - font-family: var(--font-mono); - font-size: 0.6875rem; - background: var(--color-bg-muted); - padding: 0.0625rem 0.3125rem; - border-radius: var(--radius-xs); - border: 1px solid var(--color-border); -} - -/* Running state */ -.portal-runner__running { - display: flex; - align-items: center; - gap: 1rem; - padding: 1rem; - background: var(--color-blue-light); - border: 1px solid var(--color-blue-border); - border-radius: var(--radius-md); - color: var(--color-blue); -} - -.portal-runner__running-title { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-1); - margin-bottom: 0.25rem; -} - -.portal-runner__running code { - font-family: var(--font-mono); - font-size: 0.75rem; -} - -.portal-runner__spinner, -.portal-runner__spinner-lg { - border-radius: 50%; - border: 2px solid currentColor; - border-right-color: transparent; - animation: spin 0.7s linear infinite; - flex-shrink: 0; -} - -.portal-runner__spinner { - width: 0.875rem; - height: 0.875rem; -} - -.portal-runner__spinner-lg { - width: 1.5rem; - height: 1.5rem; -} - -/* Done state */ -.portal-runner__result { - display: flex; - flex-direction: column; - height: 100%; -} - -.portal-runner__result-head { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.5rem 0.75rem; - background: var(--code-bg-header); - border-bottom: 1px solid var(--code-border); - border-radius: var(--radius-md) var(--radius-md) 0 0; - color: var(--code-muted); - font-family: var(--font-mono); - font-size: 0.75rem; -} - -.portal-runner__result-head code { - flex: 1; - font-family: inherit; - color: var(--code-keyword); -} - -.portal-runner__result-meta { - font-size: 0.6875rem; - color: var(--code-dim); -} - -.portal-runner__result-code { - margin: 0; - padding: 0.875rem 1rem; - background: var(--code-bg); - color: var(--code-text); - font-family: var(--font-mono); - font-size: 0.75rem; - line-height: 1.6; - border-radius: 0 0 var(--radius-md) var(--radius-md); - overflow: auto; - white-space: pre; - max-height: 22rem; -} - -/* Footer */ -.portal-runner__footer-status { - margin-right: auto; - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.75rem; - color: var(--color-text-4); - font-family: var(--font-mono); -} - -.portal-runner__footer-status code { - font-family: inherit; -} diff --git a/frontend/editor/src/portal/components/SingleOpRunner.stories.tsx b/frontend/editor/src/portal/components/SingleOpRunner.stories.tsx deleted file mode 100644 index 7f696d1948..0000000000 --- a/frontend/editor/src/portal/components/SingleOpRunner.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; -import { SingleOpRunner } from "@portal/components/SingleOpRunner"; - -const meta: Meta = { - title: "Portal/Home/SingleOpRunner", - component: SingleOpRunner, - parameters: { layout: "fullscreen" }, - args: { open: true, onClose: () => console.log("close") }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -export const Idle: Story = {}; - -export const PreSelectedOp: Story = { - args: { initialOpId: "redact" }, -}; - -export const RunFailsWithUnknownOp: Story = { - parameters: { - msw: { - handlers: [ - http.post("/v1/ops/:opId/run", () => - HttpResponse.json({ error: "Unknown op" }, { status: 404 }), - ), - ], - }, - }, -}; - -export const SlowOp: Story = { - parameters: { - msw: { - handlers: [ - http.post("/v1/ops/:opId/run", async () => { - await delay(4000); - return HttpResponse.json({ - result: { schema: "demo", note: "took its time" }, - durationMs: 4000, - }); - }), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/components/SingleOpRunner.tsx b/frontend/editor/src/portal/components/SingleOpRunner.tsx deleted file mode 100644 index 3abb9f9850..0000000000 --- a/frontend/editor/src/portal/components/SingleOpRunner.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, EmptyState, Modal, Skeleton, StatusBadge } from "@app/ui"; -import { useView } from "@portal/contexts/ViewContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchFeaturedOps, - runSingleOp, - type FeaturedOp, - type OpResultMap, -} from "@portal/api/ops"; -import "@portal/components/SingleOpRunner.css"; - -type Phase = "idle" | "running" | "done" | "error"; - -interface SingleOpRunnerProps { - open: boolean; - onClose: () => void; - /** Optional pre-selected op id (e.g. carousel deep-link). */ - initialOpId?: string; -} - -const SAMPLE_DOCS = [ - "certificate-of-insurance.pdf", - "loss-run-2025.pdf", - "invoice-acme-corp-q1.pdf", - "prior-auth-cigna-12471.pdf", - "contract-acme-msa-v3.pdf", -]; - -interface RunResult { - result: OpResultMap; - durationMs: number; - opId: string; -} - -export function SingleOpRunner({ - open, - onClose, - initialOpId, -}: SingleOpRunnerProps) { - const { t } = useTranslation(); - const { setActiveView } = useView(); - const opsState = useAsync(() => fetchFeaturedOps(), []); - const { data: ops } = opsState; - const { isLoading: opsIsLoading, isEmpty: opsIsEmpty } = - useSectionFlags(opsState); - - const [selectedOpId, setSelectedOpId] = useState( - initialOpId ?? null, - ); - const [phase, setPhase] = useState("idle"); - const [sample, setSample] = useState(null); - const [dragOver, setDragOver] = useState(false); - const [runResult, setRunResult] = useState(null); - const [errorMsg, setErrorMsg] = useState(null); - - // Default selection once the catalogue loads. - useEffect(() => { - if (!selectedOpId && ops && ops.length > 0) { - setSelectedOpId(ops[0].id); - } - }, [ops, selectedOpId]); - - const selectedOp = useMemo( - () => ops?.find((o) => o.id === selectedOpId) ?? null, - [ops, selectedOpId], - ); - - useEffect(() => { - if (open) { - setSelectedOpId(initialOpId ?? ops?.[0]?.id ?? null); - setPhase("idle"); - setSample(null); - setRunResult(null); - setErrorMsg(null); - } - // ops is intentionally not in deps — we don't want to reset state when - // the catalogue arrives while the modal is open. - }, [open, initialOpId]); - - const pickSample = useCallback(() => { - if (!ops) return; - const idx = ops.findIndex((o) => o.id === selectedOpId); - const safeIdx = idx < 0 ? 0 : idx; - setSample(SAMPLE_DOCS[safeIdx % SAMPLE_DOCS.length]); - }, [ops, selectedOpId]); - - async function run() { - if (!selectedOp) return; - let activeSample = sample; - if (!activeSample) { - pickSample(); - // pickSample updates state; use the synchronously-derived value for the - // call so we don't race. - const idx = ops?.findIndex((o) => o.id === selectedOpId) ?? 0; - activeSample = SAMPLE_DOCS[idx % SAMPLE_DOCS.length]; - } - setPhase("running"); - setErrorMsg(null); - try { - const res = await runSingleOp(selectedOp.id, activeSample); - setRunResult({ ...res, opId: selectedOp.id }); - setPhase("done"); - } catch (err) { - setPhase("error"); - setErrorMsg(err instanceof Error ? err.message : String(err)); - } - } - - function reset() { - setPhase("idle"); - setRunResult(null); - setErrorMsg(null); - } - - function buildPipelineWithOp() { - onClose(); - setActiveView("pipelines"); - } - - function onDragOver(e: React.DragEvent) { - e.preventDefault(); - setDragOver(true); - } - function onDragLeave() { - setDragOver(false); - } - function onDrop(e: React.DragEvent) { - e.preventDefault(); - setDragOver(false); - const file = e.dataTransfer.files[0]; - if (file) setSample(file.name); - else pickSample(); - } - - return ( - -
- {phase === "running" && selectedOp && ( - <> - - POST {selectedOp.endpoint} - - )} - {phase === "done" && ( - - 200 OK - - )} - {phase === "error" && ( - - {errorMsg ?? t("portal.opRunner.status.failed")} - - )} -
- - {phase === "done" ? ( - <> - - - - ) : ( - - )} - - } - > -
- {/* Left column: file + op picker */} -
-
-
- - - - -
-
- {sample ? ( - <> - {sample} - {t("portal.opRunner.drop.replaceHint")} - - ) : ( - <> - {t("portal.opRunner.drop.title")} - {t("portal.opRunner.drop.hint")} - - )} -
- -
- -
-
- {t("portal.opRunner.featuredOps")} -
-
- {opsIsLoading && - Array.from({ length: 4 }).map((_, i) => ( -
- - - -
- ))} - {opsIsEmpty && ( - - )} - {ops?.map((op) => ( - - ))} -
-
-
- - {/* Right column: state-driven result panel */} -
- {phase === "idle" && selectedOp && ( -
-
- {t("portal.opRunner.hint.ready")} -
-

- {t("portal.opRunner.hint.runOn.before")}{" "} - {selectedOp.label}{" "} - {t("portal.opRunner.hint.runOn.middle")}{" "} - {sample ?? t("portal.opRunner.hint.aSample")} -

-

- {selectedOp.blurb}. {t("portal.opRunner.hint.press")}{" "} - {t("portal.opRunner.action.run")}{" "} - {t("portal.opRunner.hint.toInvoke")}{" "} - POST {selectedOp.endpoint}. -

-
- )} - {phase === "running" && selectedOp && ( -
-
-
-
- {t("portal.opRunner.running.title", { - label: selectedOp.label, - })} -
- POST {selectedOp.endpoint} -
-
- )} - {phase === "done" && selectedOp && runResult && ( -
-
- - {t("portal.opRunner.status.completed")} - - POST {selectedOp.endpoint} - - {t("portal.opRunner.durationMs", { - ms: runResult.durationMs, - })} - -
-
-                {JSON.stringify(runResult.result, null, 2)}
-              
-
- )} - {phase === "error" && ( -
-
- {t("portal.opRunner.status.failed")} -
-

{t("portal.opRunner.error.title")}

-

{errorMsg ?? t("portal.opRunner.error.unknown")}

-
- )} -
-
-
- ); -} diff --git a/frontend/editor/src/portal/components/UsageAreaChart.css b/frontend/editor/src/portal/components/UsageAreaChart.css deleted file mode 100644 index c6d326c501..0000000000 --- a/frontend/editor/src/portal/components/UsageAreaChart.css +++ /dev/null @@ -1,115 +0,0 @@ -.portal-chart { - position: relative; - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1.125rem 1.25rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); -} - -.portal-chart__head { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 0.75rem; -} - -.portal-chart__label { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-section-label); -} - -.portal-chart__value { - margin-top: 0.125rem; - font-size: 1.5rem; - font-weight: 700; - color: var(--color-text-1); - font-variant-numeric: tabular-nums; -} - -.portal-chart__delta { - font-size: 0.75rem; - font-weight: 500; - padding: 0.1875rem 0.5rem; - border-radius: var(--radius-pill); -} - -.portal-chart__delta.is-up { - color: var(--color-green-dark); - background: var(--color-green-light); -} - -.portal-chart__delta.is-down { - color: var(--color-red); - background: var(--color-red-light); -} - -.portal-chart__svg { - width: 100%; - height: 15rem; - display: block; - overflow: visible; -} - -.portal-chart__gridline { - stroke: var(--color-border-light); - stroke-width: 1; - stroke-dasharray: 3 3; -} - -.portal-chart__axis-label { - font-family: var(--font-sans); - font-size: 10.5px; - fill: var(--color-text-5); -} - -.portal-chart__line { - fill: none; - stroke: var(--color-blue); - stroke-width: 1.75; - stroke-linecap: round; - stroke-linejoin: round; -} - -.portal-chart__scrub { - stroke: var(--color-text-5); - stroke-width: 1; - stroke-dasharray: 2 3; - opacity: 0.6; -} - -.portal-chart__dot { - fill: var(--color-blue); - stroke: var(--color-surface); - stroke-width: 2; -} - -.portal-chart__tooltip { - position: absolute; - transform: translateX(-50%); - top: 4rem; - padding: 0.4375rem 0.625rem; - background: var(--color-tooltip-bg); - color: var(--color-tooltip-text); - border-radius: var(--radius-sm); - font-size: 0.75rem; - white-space: nowrap; - pointer-events: none; - box-shadow: var(--shadow-md); - z-index: 2; -} - -.portal-chart__tooltip-date { - font-size: 0.6875rem; - opacity: 0.7; -} - -.portal-chart__tooltip-value { - font-weight: 600; - font-variant-numeric: tabular-nums; -} diff --git a/frontend/editor/src/portal/components/UsageAreaChart.stories.tsx b/frontend/editor/src/portal/components/UsageAreaChart.stories.tsx deleted file mode 100644 index 5c1f3e3dd9..0000000000 --- a/frontend/editor/src/portal/components/UsageAreaChart.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { UsageAreaChart } from "@portal/components/UsageAreaChart"; -import { buildUsageSeries } from "@portal/mocks/home"; - -const meta: Meta = { - title: "Portal/Home/UsageAreaChart", - component: UsageAreaChart, - parameters: { layout: "padded" }, - decorators: [ - (S) => ( -
- -
- ), - ], -}; -export default meta; -type Story = StoryObj; - -const series = buildUsageSeries(); -const total = series.reduce((sum, p) => sum + p.value, 0); - -export const Default: Story = { - args: { data: series, totalValue: total.toLocaleString(), deltaPct: 0.12 }, -}; - -export const NegativeDelta: Story = { - args: { data: series, totalValue: total.toLocaleString(), deltaPct: -0.08 }, -}; - -export const NoDelta: Story = { - args: { data: series }, -}; - -export const Flat: Story = { - args: { - data: series.map((p) => ({ ...p, value: 1200 })), - totalValue: "36,000", - }, -}; - -export const HighVolatility: Story = { - args: { - data: series.map((p, i) => ({ - ...p, - value: Math.round(p.value * (1 + Math.sin(i) * 0.6)), - })), - }, -}; - -export const Empty: Story = { - args: { data: [], totalValue: "—" }, -}; diff --git a/frontend/editor/src/portal/components/UsageAreaChart.tsx b/frontend/editor/src/portal/components/UsageAreaChart.tsx deleted file mode 100644 index ff9750d57c..0000000000 --- a/frontend/editor/src/portal/components/UsageAreaChart.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { useMemo, useRef, useState, type KeyboardEvent } from "react"; -import { useTranslation } from "react-i18next"; -import type { UsagePoint } from "@portal/api/home"; -import "@portal/components/UsageAreaChart.css"; - -interface UsageAreaChartProps { - data: UsagePoint[]; - /** Headline metric shown above the chart (e.g. total 30d, current value). */ - totalLabel?: string; - totalValue?: string; - deltaPct?: number; -} - -const PADDING = { top: 16, right: 20, bottom: 28, left: 32 } as const; -const VIEW = { width: 800, height: 240 } as const; - -function formatTick(date: string): string { - return new Date(date).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }); -} - -function formatNumber(value: number): string { - if (value >= 1000) { - return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`; - } - return value.toLocaleString(); -} - -export function UsageAreaChart({ - data, - totalLabel: totalLabelProp, - totalValue, - deltaPct, -}: UsageAreaChartProps) { - const { t } = useTranslation(); - const totalLabel = totalLabelProp ?? t("portal.usageChart.defaultLabel"); - const [hoverIndex, setHoverIndex] = useState(null); - const svgRef = useRef(null); - - const { points, areaPath, linePath, yMax, yTicks, xTickIndices } = - useMemo(() => { - if (data.length === 0) { - return { - points: [] as Array<{ x: number; y: number; raw: UsagePoint }>, - areaPath: "", - linePath: "", - yMax: 0, - yTicks: [] as number[], - xTickIndices: [] as number[], - }; - } - const max = Math.max(...data.map((d) => d.value)); - // Round yMax up to a "nice" number for ticks. Floor at 500 so an - // all-zero series doesn't divide by zero (which would NaN every point). - const niceMax = Math.max(Math.ceil(max / 500) * 500, 500); - const yTicksCalc = [0, niceMax * 0.5, niceMax]; - - const innerW = VIEW.width - PADDING.left - PADDING.right; - const innerH = VIEW.height - PADDING.top - PADDING.bottom; - const xStep = innerW / Math.max(data.length - 1, 1); - - const pts = data.map((raw, i) => ({ - x: PADDING.left + i * xStep, - y: PADDING.top + innerH - (raw.value / niceMax) * innerH, - raw, - })); - - const linePathStr = pts - .map( - (p, i) => - `${i === 0 ? "M" : "L"} ${p.x.toFixed(2)} ${p.y.toFixed(2)}`, - ) - .join(" "); - const baseY = PADDING.top + innerH; - const areaPathStr = `${linePathStr} L ${pts[pts.length - 1].x.toFixed(2)} ${baseY} L ${pts[0].x.toFixed(2)} ${baseY} Z`; - - // X ticks: 5 evenly spread. - const xIdx: number[] = []; - const tickCount = 5; - for (let t = 0; t < tickCount; t++) { - xIdx.push(Math.round((t * (data.length - 1)) / (tickCount - 1))); - } - - return { - points: pts, - areaPath: areaPathStr, - linePath: linePathStr, - yMax: niceMax, - yTicks: yTicksCalc, - xTickIndices: xIdx, - }; - }, [data]); - - function onMouseMove(e: React.MouseEvent) { - if (!svgRef.current || points.length === 0) return; - const rect = svgRef.current.getBoundingClientRect(); - const xRatio = (e.clientX - rect.left) / rect.width; - const svgX = xRatio * VIEW.width; - // Find nearest point - let nearestIdx = 0; - let nearestDist = Infinity; - for (let i = 0; i < points.length; i++) { - const d = Math.abs(points[i].x - svgX); - if (d < nearestDist) { - nearestDist = d; - nearestIdx = i; - } - } - setHoverIndex(nearestIdx); - } - - const hovered = hoverIndex !== null ? points[hoverIndex] : null; - - function onKeyDown(e: KeyboardEvent) { - if (points.length === 0) return; - if (e.key === "ArrowRight") { - e.preventDefault(); - setHoverIndex((idx) => - idx === null ? 0 : Math.min(points.length - 1, idx + 1), - ); - } else if (e.key === "ArrowLeft") { - e.preventDefault(); - setHoverIndex((idx) => - idx === null ? points.length - 1 : Math.max(0, idx - 1), - ); - } else if (e.key === "Home") { - e.preventDefault(); - setHoverIndex(0); - } else if (e.key === "End") { - e.preventDefault(); - setHoverIndex(points.length - 1); - } else if (e.key === "Escape") { - setHoverIndex(null); - } - } - - const displayTotal = - totalValue ?? data.reduce((sum, p) => sum + p.value, 0).toLocaleString(); - - return ( -
-
-
-
{totalLabel}
-
{displayTotal}
-
- {deltaPct !== undefined && ( -
= 0 ? "is-up" : "is-down") - } - > - {deltaPct >= 0 ? "↑" : "↓"} - {t("portal.usageChart.delta", { - pct: Math.abs(Math.round(deltaPct * 100)), - })} -
- )} -
- - 0 ? 0 : -1} - onMouseMove={onMouseMove} - onMouseLeave={() => setHoverIndex(null)} - onKeyDown={onKeyDown} - onBlur={() => setHoverIndex(null)} - > - - - - - - - - {/* Y gridlines + labels */} - {yTicks.map((tick) => { - const innerH = VIEW.height - PADDING.top - PADDING.bottom; - const y = PADDING.top + innerH - (tick / yMax) * innerH; - return ( - - - - {formatNumber(tick)} - - - ); - })} - - {/* Area + line */} - - - - {/* X ticks */} - {xTickIndices.map((idx) => { - const p = points[idx]; - if (!p) return null; - return ( - - {formatTick(p.raw.date)} - - ); - })} - - {/* Hover scrub + dot + tooltip */} - {hovered && ( - <> - - - - )} - - - {hovered && ( -
-
- {new Date(hovered.raw.date).toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - })} -
-
- {t("portal.usageChart.docsValue", { - value: hovered.raw.value.toLocaleString(), - })} -
-
- )} -
- {hovered - ? t("portal.usageChart.srAnnounce", { - date: new Date(hovered.raw.date).toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - }), - value: hovered.raw.value.toLocaleString(), - }) - : ""} -
-
- ); -} diff --git a/frontend/editor/src/portal/mocks/handlers/home.ts b/frontend/editor/src/portal/mocks/handlers/home.ts deleted file mode 100644 index 5f295a7267..0000000000 --- a/frontend/editor/src/portal/mocks/handlers/home.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { - buildUsageSeries, - buildUsageSeriesResponse, - enterpriseKpisFor, - FREE_KPIS, - proKpisFor, - RECENT_ACTIVITY, - REGION_HEALTH, - type KpiEntry, -} from "@portal/mocks/home"; - -function kpisFor(tier: Tier): KpiEntry[] { - if (tier === "free") return FREE_KPIS; - const docs30d = buildUsageSeries().reduce((sum, p) => sum + p.value, 0); - if (tier === "enterprise") return enterpriseKpisFor(docs30d); - return proKpisFor(docs30d); -} - -export const homeHandlers = [ - http.get("/v1/analytics/usage", async () => { - await delay(120); - return HttpResponse.json(buildUsageSeriesResponse()); - }), - - http.get("/v1/activity", async () => { - await delay(120); - return HttpResponse.json(RECENT_ACTIVITY); - }), - - http.get("/v1/home/kpis", async ({ request }) => { - await delay(120); - const url = new URL(request.url); - const tier = (url.searchParams.get("tier") ?? "pro") as Tier; - return HttpResponse.json(kpisFor(tier)); - }), - - http.get("/v1/regions/health", async () => { - await delay(120); - return HttpResponse.json(REGION_HEALTH); - }), -]; diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 9341ce6304..0ce3059ff6 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -1,8 +1,6 @@ import { assistantHandlers } from "@portal/mocks/handlers/assistant"; import { authHandlers } from "@portal/mocks/handlers/auth"; -import { homeHandlers } from "@portal/mocks/handlers/home"; import { notificationsHandlers } from "@portal/mocks/handlers/notifications"; -import { opsHandlers } from "@portal/mocks/handlers/ops"; import { searchHandlers } from "@portal/mocks/handlers/search"; import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines"; import { sourcesHandlers } from "@portal/mocks/handlers/sources"; @@ -21,8 +19,6 @@ import { linkHandlers } from "@portal/mocks/handlers/link"; export const handlers = [ ...authHandlers, - ...homeHandlers, - ...opsHandlers, ...notificationsHandlers, ...assistantHandlers, ...searchHandlers, @@ -53,8 +49,6 @@ export const handlers = [ * Everything kept is portal-only. `handlers` above is still the full set. */ export const embeddedDataHandlers = [ - ...homeHandlers, - ...opsHandlers, ...notificationsHandlers, ...assistantHandlers, ...searchHandlers, diff --git a/frontend/editor/src/portal/mocks/handlers/ops.ts b/frontend/editor/src/portal/mocks/handlers/ops.ts deleted file mode 100644 index e963cfcef8..0000000000 --- a/frontend/editor/src/portal/mocks/handlers/ops.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { http, HttpResponse, delay } from "msw"; -import { FEATURED_OPS, OP_RESULTS } from "@portal/mocks/ops"; - -export const opsHandlers = [ - http.get("/v1/ops/featured", async () => { - await delay(120); - return HttpResponse.json(FEATURED_OPS); - }), - - http.post("/v1/ops/:opId/run", async ({ params }) => { - const start = performance.now(); - await delay(800 + Math.random() * 200); - const opId = String(params.opId); - const result = OP_RESULTS[opId]; - if (!result) { - return HttpResponse.json( - { error: `Unknown op: ${opId}` }, - { status: 404 }, - ); - } - const enriched = - opId === "sign-output" - ? { ...result, signed_at: new Date().toISOString() } - : result; - return HttpResponse.json({ - result: enriched, - durationMs: Math.round(performance.now() - start), - }); - }), -]; diff --git a/frontend/editor/src/portal/mocks/home.ts b/frontend/editor/src/portal/mocks/home.ts deleted file mode 100644 index 373ae4adc1..0000000000 --- a/frontend/editor/src/portal/mocks/home.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Home dashboard fixtures and the types api/home.ts shares with them. - * api/home.ts imports the types; the MSW handlers in mocks/handlers/ serve the - * fixture data over the intercepted apiClient.local.json() calls. Components never reach - * into this module directly. - * - * Once a real backend exists, the MSW handlers stop being registered and these - * fixtures can be deleted (or kept as test seeds). - */ - -export interface UsagePoint { - /** ISO date (YYYY-MM-DD). */ - date: string; - /** Docs processed on that day. */ - value: number; -} - -/** - * Server response for the usage-series endpoint. Returning the prior window's - * total alongside the points lets the client derive the headline delta - * deterministically from real data rather than carrying a hardcoded figure. - */ -export interface UsageSeriesResponse { - points: UsagePoint[]; - /** Equivalent docs total from the immediately prior 30-day window. */ - priorTotal: number; -} - -/** Builds 30 daily points ending today. Deterministic per day. */ -export function buildUsageSeries(): UsagePoint[] { - const points: UsagePoint[] = []; - const now = new Date(); - for (let i = 29; i >= 0; i--) { - const d = new Date(now); - d.setDate(now.getDate() - i); - const day = d.getDay(); - // Weekend dip + slow uptrend + bounded noise. - const weekend = day === 0 || day === 6 ? 0.55 : 1; - const trend = 1 + (30 - i) * 0.012; - const wobble = 1 + Math.sin(i * 1.3) * 0.18 + Math.cos(i * 0.6) * 0.09; - const base = 1450 * weekend * trend * wobble; - points.push({ - date: d.toISOString().slice(0, 10), - value: Math.round(base), - }); - } - return points; -} - -/** Builds the full usage payload with a plausible prior-window total. */ -export function buildUsageSeriesResponse(): UsageSeriesResponse { - const points = buildUsageSeries(); - const currentTotal = points.reduce((sum, p) => sum + p.value, 0); - // The current window's series simulates ~12% growth over the prior one. - const priorTotal = Math.round(currentTotal / 1.12); - return { points, priorTotal }; -} - -export type ActivityKind = - | "pipeline-run" - | "deploy" - | "drift" - | "eval" - | "agent" - | "billing"; - -export interface ActivityEvent { - id: string; - kind: ActivityKind; - /** Short action verb shown at the top of the row. */ - action: string; - /** Subject of the action (pipeline / endpoint / agent name). */ - subject: string; - /** One-line detail line under the action. */ - detail: string; - /** Relative-time string. */ - time: string; - status: "success" | "warning" | "danger" | "info"; -} - -export const RECENT_ACTIVITY: ActivityEvent[] = [ - { - id: "act-1", - kind: "pipeline-run", - action: "Pipeline run completed", - subject: "COI Compliance", - detail: "1,287 docs · 0.4% errors · P95 412 ms", - time: "2m ago", - status: "success", - }, - { - id: "act-2", - kind: "deploy", - action: "Deployed", - subject: "Prior Auth v3.1.0", - detail: "Promoted to us-east-1, eu-west-1 · golden set 36/36", - time: "14m ago", - status: "success", - }, - { - id: "act-3", - kind: "drift", - action: "Schema drift detected", - subject: "Invoice v3", - detail: "12 docs in 1h didn't match — confidence ↓ 0.07", - time: "1h ago", - status: "warning", - }, - { - id: "act-4", - kind: "eval", - action: "Eval set passed", - subject: "KYC Processor", - detail: "94% (26/28) — 2 cases sent to review", - time: "3h ago", - status: "success", - }, - { - id: "act-5", - kind: "agent", - action: "Agent escalated", - subject: "Contract Router", - detail: "Low-confidence DPA routed to L2 reviewer pool", - time: "5h ago", - status: "info", - }, - { - id: "act-6", - kind: "pipeline-run", - action: "Pipeline run failed", - subject: "Contract Review", - detail: "8% error rate · 14 docs sent to review queue", - time: "8h ago", - status: "danger", - }, - { - id: "act-7", - kind: "billing", - action: "Approaching cap", - subject: "Monthly usage", - detail: "389k of 500k docs · auto-upgrade disabled", - time: "yesterday", - status: "warning", - }, - { - id: "act-8", - kind: "deploy", - action: "Rolled back", - subject: "COI Compliance v2.3.7", - detail: "Confidence regressions on Carrier supplement", - time: "2d ago", - status: "warning", - }, -]; - -/** - * KPI labels are owned by the client (see `KPI_LABELS_BY_TIER` in Home.tsx) - * because they're product copy that should stay stable across loading / empty - * / ready states. The API only ships values + deltas. - */ -export interface KpiEntry { - value: string | number; - delta?: number; - description?: string; - deltaDirection?: "up" | "down" | "flat"; -} - -export const FREE_KPIS: KpiEntry[] = [ - { value: "247 / 500" }, - { value: 189 }, - { value: 3 }, - { value: 1 }, -]; - -export function proKpisFor(docs30d: number): KpiEntry[] { - return [ - { value: docs30d.toLocaleString(), delta: 0.12 }, - { value: 12, delta: 0.16 }, - { value: 7, delta: 0.4 }, - { value: "94.6%", delta: 0.02 }, - ]; -} - -export function enterpriseKpisFor(docs30d: number): KpiEntry[] { - return [ - { value: docs30d.toLocaleString(), delta: 0.18 }, - { value: "412 ms", delta: -0.05 }, - { value: "96.2%", delta: 0.01 }, - { value: "99.987%" }, - ]; -} - -export interface RegionHealth { - name: string; - status: "healthy" | "degraded" | "down"; - meta: string; -} - -export const REGION_HEALTH: RegionHealth[] = [ - { - name: "us-east-1", - status: "healthy", - meta: "2.1k/min · P95 287ms · 99.99% uptime", - }, - { - name: "eu-west-1", - status: "healthy", - meta: "1.4k/min · P95 312ms · 99.98% uptime", - }, - { - name: "ap-southeast-1", - status: "degraded", - meta: "412/min · P95 521ms · 99.92% uptime · degraded", - }, -]; - -/** - * Starter pipelines offered by the Home fork wizard. Forking clones one of - * these templates as the seed for a new developer pipeline. Every template - * runs the same four canonical stages (Ingest → Validate → Secure → Store); - * the fixtures differ only in framing copy and which document types they target. - */ -export interface PipelineTemplate { - id: string; - /** Display name shown on the template chip and the ready-state header. */ - name: string; - /** One-line description of what the forked pipeline does. */ - blurb: string; - /** Document types this template is tuned for. */ - docTypes: string[]; - accent: "blue" | "purple" | "green" | "amber"; -} - -export const PIPELINE_TEMPLATES: PipelineTemplate[] = [ - { - id: "coi-compliance", - name: "COI Compliance", - blurb: "Validate certificates of insurance against carrier requirements.", - docTypes: ["Certificates of insurance", "Loss runs"], - accent: "blue", - }, - { - id: "accounts-payable", - name: "Accounts Payable", - blurb: "Extract line items, match POs, and flag duplicate invoices.", - docTypes: ["Invoices", "Purchase orders"], - accent: "green", - }, - { - id: "contract-review", - name: "Contract Review", - blurb: "Classify clauses, redact PII, and route to the right reviewer.", - docTypes: ["MSAs", "DPAs", "NDAs"], - accent: "purple", - }, - { - id: "prior-authorization", - name: "Prior Authorization", - blurb: "Read auth requests, check coverage, and assemble payer packets.", - docTypes: ["Auth requests", "Clinical notes"], - accent: "amber", - }, -]; - -/** - * The four canonical stages every forked pipeline runs. Fixed and ordered — - * the wizard's build animation lights them up left-to-right. - */ -export interface PipelineStage { - key: string; - label: string; - /** What the stage does, shown under the label in the ready-state grid. */ - detail: string; -} - -export const PIPELINE_STAGES: PipelineStage[] = [ - { key: "ingest", label: "Ingest", detail: "Accept, normalize, deduplicate" }, - { key: "validate", label: "Validate", detail: "Classify and check schema" }, - { key: "secure", label: "Secure", detail: "Redact PII, encrypt at rest" }, - { key: "store", label: "Store", detail: "Emit JSON, persist, notify" }, -]; - -// The free/subscribed "Finish setting up" checklist is no longer fixture-driven: -// SetupChecklist builds its steps client-side and derives completion from the -// live policies + sources APIs. See components/SetupChecklist.tsx. diff --git a/frontend/editor/src/portal/mocks/ops.ts b/frontend/editor/src/portal/mocks/ops.ts deleted file mode 100644 index fc1bd203d1..0000000000 --- a/frontend/editor/src/portal/mocks/ops.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Mock op catalogue + canned operation results for the single-op runner. - * Only api/ops.ts imports from this file. - */ - -export type OpResultMap = Record; - -export interface FeaturedOp { - id: string; - label: string; - endpoint: string; - accent: "blue" | "purple" | "green" | "amber" | "red"; - /** Shown in the picker — short single line. */ - blurb: string; -} - -export const FEATURED_OPS: FeaturedOp[] = [ - { - id: "extract", - label: "Extract", - endpoint: "/v1/extract", - accent: "blue", - blurb: "Pull structured fields into a typed schema", - }, - { - id: "redact", - label: "Redact PII", - endpoint: "/v1/redact", - accent: "red", - blurb: "Mask SSN, DOB, addresses, accounts before storage", - }, - { - id: "classify", - label: "Classify", - endpoint: "/v1/classify", - accent: "purple", - blurb: "Identify document type with a confidence score", - }, - { - id: "ocr", - label: "OCR", - endpoint: "/v1/ocr", - accent: "green", - blurb: "Text-recognize scanned or image pages", - }, - { - id: "validate", - label: "Schema validate", - endpoint: "/v1/validate", - accent: "blue", - blurb: "Check fields, rules, and coverage against the schema", - }, - { - id: "sign-output", - label: "Sign output", - endpoint: "/v1/sign", - accent: "green", - blurb: "Tamper-evident signature over artifact + run metadata", - }, - { - id: "authenticity", - label: "Authenticity", - endpoint: "/v1/authenticity", - accent: "blue", - blurb: "Verify issuer signature, watermark, and metadata", - }, - { - id: "tamper-check", - label: "Tamper check", - endpoint: "/v1/tamper-check", - accent: "amber", - blurb: "Detect modifications since signing or last-known-good state", - }, - { - id: "encrypt-rest", - label: "Encrypt at rest", - endpoint: "/v1/encrypt", - accent: "purple", - blurb: "AES-256 with Stirling-managed, BYOK, or HYOK keys", - }, - { - id: "smart-redact", - label: "Smart redact", - endpoint: "/v1/smart-redact", - accent: "red", - blurb: "Schema-aware redaction with confidence gating", - }, -]; - -/** Canned JSON for each featured op's runner "done" state. */ -export const OP_RESULTS: Record = { - extract: { - schema: "coi.v2", - fields: { - carrier: "Travelers Casualty", - policy_number: "PHB-1108-2025", - gl_limit: 1_000_000, - umbrella_limit: 5_000_000, - effective: "2026-01-15", - expiry: "2027-01-15", - }, - confidence_avg: 0.96, - }, - redact: { - redacted_pages: 4, - pii_types: ["SSN", "DOB", "ADDRESS", "EMAIL"], - occurrences: 19, - redaction_style: "blackout", - audit_id: "rdct_01HVQ7K3ZA9YJ8C", - }, - classify: { - schema: "invoice.v3", - confidence: 0.94, - alternatives: [ - { schema: "credit_memo.v1", confidence: 0.04 }, - { schema: "purchase_order.v2", confidence: 0.02 }, - ], - processing_ms: 287, - }, - ocr: { - pages: 12, - characters_recognized: 28471, - confidence_avg: 0.987, - languages_detected: ["en"], - processing_ms: 1840, - }, - validate: { - schema: "coi.v2", - passed: true, - checks_run: 14, - warnings: [ - { field: "additional_insured", message: "Optional field empty" }, - ], - }, - "sign-output": { - algorithm: "Ed25519", - key_id: "kx-prod-2026", - manifest_hash: - "0xb3f0c1a9d54fa1c0b8fd4eebd7fa11b1b16c9a3e2d2cc6f1f5a2f0a87e1b7a04", - }, - authenticity: { - verified: true, - issuer: "State of California DMV", - signed_at: "2025-11-04T17:22:00Z", - watermark_match: true, - }, - "tamper-check": { - tampered: false, - hash_match: true, - modifications_detected: 0, - last_known_good: "2026-04-22T09:14:00Z", - }, - "encrypt-rest": { - algorithm: "AES-256-GCM", - key_mode: "BYOK", - key_id: "arn:aws:kms:us-east-1:123:key/abc-…", - object_id: "obj_01HVQ7M9B2", - }, - "smart-redact": { - schema: "coi.v2", - redacted_fields: ["named_insured", "dob"], - occurrences: 7, - confidence_gate: 0.85, - gated_by_confidence: 1, - }, -}; diff --git a/frontend/editor/src/portal/views/Home.css b/frontend/editor/src/portal/views/Home.css index 7a4a1f5a37..70752c714b 100644 --- a/frontend/editor/src/portal/views/Home.css +++ b/frontend/editor/src/portal/views/Home.css @@ -24,43 +24,6 @@ color: var(--color-text-4); } -/* Product card grid */ -.portal-home__product-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.875rem; -} - -@media (max-width: 60rem) { - .portal-home__product-grid { - grid-template-columns: 1fr; - } -} - -.portal-home__product-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.portal-home__product-title { - margin: 0; - font-size: 1.0625rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-home__product-blurb { - margin: 0 0 1rem; - font-size: 0.8125rem; - line-height: 1.5; - color: var(--color-text-3); -} - -/* Metric strip now uses the shared . */ - /* Two-column dashboard row (activity + quick actions) */ .portal-home__row { display: grid; @@ -154,45 +117,3 @@ .portal-home__quick-row:hover .portal-home__quick-arrow { color: var(--color-blue); } - -/* Enterprise region strip */ -.portal-home__regions { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; -} - -@media (max-width: 50rem) { - .portal-home__regions { - grid-template-columns: 1fr; - } -} - -.portal-home__region { - padding: 0.625rem 0.75rem; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); -} - -.portal-home__region-name { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-1); -} - -.portal-home__region-dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; -} - -.portal-home__region-meta { - margin-top: 0.25rem; - font-size: 0.6875rem; - color: var(--color-text-4); - font-family: var(--font-mono); -} diff --git a/frontend/editor/src/portal/views/Home.stories.tsx b/frontend/editor/src/portal/views/Home.stories.tsx index feed3f25da..92d3e2ae2f 100644 --- a/frontend/editor/src/portal/views/Home.stories.tsx +++ b/frontend/editor/src/portal/views/Home.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { http, HttpResponse, delay } from "msw"; +import { http, HttpResponse } from "msw"; import { Home } from "@portal/views/Home"; const meta: Meta = { @@ -39,17 +39,3 @@ export const SubscribedInProcurement: Story = { }, }, }; - -export const SlowUsage: Story = { - globals: { tier: "pro" }, - parameters: { - msw: { - handlers: [ - http.get("/v1/analytics/usage", async () => { - await delay(3000); - return HttpResponse.json({ points: [], priorTotal: 0 }); - }), - ], - }, - }, -}; diff --git a/frontend/editor/src/portal/views/Home.tsx b/frontend/editor/src/portal/views/Home.tsx index ff247271c6..b9a42fbab8 100644 --- a/frontend/editor/src/portal/views/Home.tsx +++ b/frontend/editor/src/portal/views/Home.tsx @@ -1,123 +1,19 @@ -import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - Button, - Card, - EmptyState, - MetricCard, - MetricStrip, - Skeleton, - StatusBadge, -} from "@app/ui"; -import { useTier, type Tier } from "@portal/contexts/TierContext"; -import { useView, type ViewId } from "@portal/contexts/ViewContext"; -import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; -import { - fetchHomeKpis, - fetchRegionHealth, - fetchUsageSeries, - type KpiEntry, - type RegionHealth, - type UsageSeriesResponse, -} from "@portal/api/home"; +import { Button, Card } from "@app/ui"; +import { useTier } from "@portal/contexts/TierContext"; +import { useView } from "@portal/contexts/ViewContext"; import { HomeHero } from "@portal/components/HomeHero"; import { HomeGreeting } from "@portal/components/HomeGreeting"; -import { PopularUseCases } from "@portal/components/PopularUseCases"; -import { UsageAreaChart } from "@portal/components/UsageAreaChart"; import { RecentActivity } from "@portal/components/RecentActivity"; -import { SingleOpRunner } from "@portal/components/SingleOpRunner"; import { ProcessingStatusStrip } from "@portal/components/ProcessingStatusStrip"; import { PolicySummary } from "@portal/components/PolicySummary"; -import { PipelineForkWizard } from "@portal/components/PipelineForkWizard"; import "@portal/views/Home.css"; -/* ──────────────────────────────────────────────────────────────────────── */ -/* Product cards (Sources / Pipelines / Agents) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -interface ProductCardProps { - accent: "default" | "premium"; - badge?: string; - title: string; - blurb: string; - cta: string; - target: ViewId; -} - -function ProductCard({ - accent, - badge, - title, - blurb, - cta, - target, -}: ProductCardProps) { - const { setActiveView } = useView(); - return ( - -
-

{title}

- {badge && ( - - {badge} - - )} -
-

{blurb}

- -
- ); -} - -function ProductGrid() { - const { t } = useTranslation(); - return ( -
- - - -
- ); -} - /* ──────────────────────────────────────────────────────────────────────── */ /* Quick actions card */ /* ──────────────────────────────────────────────────────────────────────── */ -function QuickActions({ onTryOp }: { onTryOp: () => void }) { +function QuickActions() { const { t } = useTranslation(); const { setActiveView } = useView(); return ( @@ -131,30 +27,6 @@ function QuickActions({ onTryOp }: { onTryOp: () => void }) {
-
} /> + } /> + } /> + pipelines list
} + /> , ); @@ -131,7 +134,7 @@ describe("PipelineBuilder", () => { }); it("builds a new pipeline: name it, add a tool, and save", async () => { - renderBuilder("/portal/pipelines/new"); + renderBuilder("/processor/pipelines/new"); // The name field is the only textbox before the picker opens. fireEvent.change(await screen.findByRole("textbox"), { @@ -157,7 +160,7 @@ describe("PipelineBuilder", () => { }); it("runs an existing pipeline and reports success", async () => { - renderBuilder("/portal/pipelines/plc-1"); + renderBuilder("/processor/pipelines/plc-1"); fireEvent.click(await screen.findByText("portal.pipelines.detail.run")); @@ -168,7 +171,7 @@ describe("PipelineBuilder", () => { }); it("blocks saving a step that needs an uploaded file", async () => { - renderBuilder("/portal/pipelines/new"); + renderBuilder("/processor/pipelines/new"); fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Watermarked" }, @@ -187,7 +190,7 @@ describe("PipelineBuilder", () => { }); it("deletes an existing pipeline after confirmation", async () => { - renderBuilder("/portal/pipelines/plc-1"); + renderBuilder("/processor/pipelines/plc-1"); fireEvent.click(await screen.findByText("portal.pipelines.detail.delete")); fireEvent.click(await screen.findByText("portal.pipelines.delete.confirm")); @@ -197,7 +200,7 @@ describe("PipelineBuilder", () => { }); it("prompts to save or discard when leaving with unsaved edits", async () => { - renderBuilder("/portal/pipelines/new"); + renderBuilder("/processor/pipelines/new"); fireEvent.change(await screen.findByRole("textbox"), { target: { value: "Draft" }, @@ -212,7 +215,7 @@ describe("PipelineBuilder", () => { }); it("leaves immediately when there are no unsaved edits", async () => { - renderBuilder("/portal/pipelines/new"); + renderBuilder("/processor/pipelines/new"); await screen.findByRole("textbox"); fireEvent.click(screen.getByText("portal.pipelines.composer.cancel")); diff --git a/frontend/editor/src/portal/views/Pipelines.test.tsx b/frontend/editor/src/portal/views/Pipelines.test.tsx index 5bc20616bb..6e19469cdb 100644 --- a/frontend/editor/src/portal/views/Pipelines.test.tsx +++ b/frontend/editor/src/portal/views/Pipelines.test.tsx @@ -48,14 +48,17 @@ const RESPONSE: PipelinesOverviewResponse = { ], }; -function renderView(initial = "/portal/pipelines") { +function renderView(initial = "/processor/pipelines") { return render( - } /> - builder new
} /> + } /> builder new
} + /> + pipeline page
} /> diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index 89676d2d29..afb79c0a7f 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -1,6 +1,7 @@ import { lazy } from "react"; import type { ReactElement } from "react"; import { Route } from "react-router-dom"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; // The portal ships as a lazy chunk of the editor. It's included in dev (so it's // always available to work on) and in production builds made with @@ -24,12 +25,18 @@ const PortalApp = includePortal : null; /** - * The portal mounts as an admin-only route-set at /portal/*. Access is gated - * inside PortalApp (its own AuthProvider + AuthGate, plus server enforcement), - * so this just wires the lazy route into the editor's router when the portal is - * included in this build. + * The portal mounts as an admin-only route-set at PORTAL_BASENAME (/processor/*). + * Access is gated inside PortalApp (its own AuthProvider + AuthGate, plus server + * enforcement), so this just wires the lazy route into the editor's router when + * the portal is included in this build. */ export function getAdminRouteExtensions(): ReactElement[] { if (!PortalApp) return []; - return [} />]; + return [ + } + />, + ]; } From 01751bf2f0aaeff3a7973ede383516464a064a01 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 9 Jul 2026 13:07:26 +0100 Subject: [PATCH 08/13] Improve logic for tracking which files have already been processed in policies (#6903) # Description of Changes Replaces the `.stirling/done` folder and its friends with a ledger in the DB which tracks which documents have been processed. This should scale dramatically better since it's just a few bytes being written for each PDF processed, rather than each PDF being duplicated and held in the folder forever. It's designed to work with the current folder source, but also with S3 buckets and other sources in mind - each source will define its own strategy for ensuring it knows whether the documents have had policies run on them or not, and they all get written to the same ledger. --- AGENTS.md | 1 + .../policy/controller/PolicyController.java | 22 ++ .../policy/engine/PolicyEngine.java | 8 +- .../policy/engine/PolicyRunner.java | 49 ++- .../policy/engine/PolicySweep.java | 89 +++++ .../proprietary/policy/engine/SweepKind.java | 10 + .../policy/input/FolderInputSource.java | 274 +++++++++---- .../proprietary/policy/input/InputSource.java | 16 +- .../policy/input/ResolveContext.java | 36 ++ .../proprietary/policy/ledger/ClaimState.java | 9 + .../policy/ledger/FolderIdentities.java | 41 ++ .../policy/ledger/IdentityHasher.java | 19 + .../ledger/InProcessProcessedLedger.java | 229 +++++++++++ .../policy/ledger/JpaProcessedLedger.java | 212 +++++++++++ .../policy/ledger/ProcessedFileEntity.java | 97 +++++ .../policy/ledger/ProcessedFileId.java | 37 ++ .../ledger/ProcessedFileRepository.java | 195 ++++++++++ .../policy/ledger/ProcessedFileStatus.java | 17 + .../policy/ledger/ProcessedLedger.java | 100 +++++ .../policy/output/FolderOutputSink.java | 128 ++++++- .../policy/output/InlineOutputSink.java | 4 +- .../policy/output/OutputDelivery.java | 8 + .../policy/output/PolicyOutputSink.java | 2 +- .../policy/trigger/FolderWatchTrigger.java | 4 +- .../configuration/DatabaseConfig.java | 2 + .../controller/PolicyControllerTest.java | 52 +++ .../policy/engine/PolicyRunnerTest.java | 124 +++++- .../policy/input/FolderInputSourceTest.java | 326 ++++++++++++++-- .../policy/ledger/FolderIdentitiesTest.java | 84 ++++ .../ledger/InProcessProcessedLedgerTest.java | 12 + .../ledger/JpaProcessedLedgerDbTest.java | 36 ++ .../ledger/ProcessedLedgerContractTest.java | 360 ++++++++++++++++++ .../policy/output/FolderOutputSinkTest.java | 111 +++++- .../trigger/FolderWatchTriggerTest.java | 10 +- .../saas/V32__policy_processed_files.sql | 30 ++ .../public/locales/en-US/translation.toml | 16 + frontend/editor/src/portal/api/policies.ts | 13 + .../components/policies/PolicyDetailPanel.tsx | 15 + .../components/sources/ConnectWizard.test.tsx | 15 +- .../portal/components/sources/sourceTypes.ts | 37 ++ frontend/editor/src/portal/views/Policies.tsx | 7 + 41 files changed, 2700 insertions(+), 157 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepKind.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolveContext.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ClaimState.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/FolderIdentities.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/IdentityHasher.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedger.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileId.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileStatus.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedLedger.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputDelivery.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/FolderIdentitiesTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedgerTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedgerDbTest.java create mode 100644 app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/ProcessedLedgerContractTest.java create mode 100644 app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql diff --git a/AGENTS.md b/AGENTS.md index 9afdad5937..7e529e4be9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -453,6 +453,7 @@ The frontend is organized with a clear separation of concerns: - **CRITICAL**: Always update translations in `en-US` only - all other languages (including `en-GB`) are handled separately - Translation files are located in `frontend/editor/public/locales/` +- After changing any translation file, run `task pre-commit:fix` ## Important Notes diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index 3d076bb527..9494317751 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -48,6 +48,7 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.PolicyValidator; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; @@ -87,6 +88,7 @@ public class PolicyController { private final PolicyManagementAuthority policyManagementAuthority; private final PolicyTriggerManager policyTriggerManager; private final PolicyOverviewService policyOverviewService; + private final ProcessedLedger processedLedger; private final List policyTriggers; private final ApplicationProperties applicationProperties; private final TempFileManager tempFileManager; @@ -352,6 +354,7 @@ public class PolicyController { boolean accessible = policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); if (accessible && policyStore.delete(policyId)) { + processedLedger.clearPolicy(policyId); // Cancel any now-orphaned folder watch promptly rather than leaving the WatchKey open // until the next reconcile sweep. policyTriggerManager.notifyPoliciesChanged(); @@ -360,6 +363,25 @@ public class PolicyController { return ResponseEntity.notFound().build(); } + @DeleteMapping("/{policyId}/processed-history") + @Operation( + summary = "Clear a policy's processed-file history", + description = + "Forgets which source files this policy has already processed, so its next" + + " sweep reprocesses everything currently in its sources. Does not" + + " touch the files themselves.") + public ResponseEntity clearProcessedHistory(@PathVariable String policyId) { + requirePolicyEditingAllowed(); + // Scope to the caller's team: a policy in another team reads as not-found. + boolean accessible = + policyStore.get(policyId).filter(policyAccessGuard::canAccess).isPresent(); + if (!accessible) { + return ResponseEntity.notFound().build(); + } + processedLedger.clearPolicy(policyId); + return ResponseEntity.noContent().build(); + } + @PostMapping(value = "/{policyId}/run", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation( summary = "Run a stored policy", diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java index ff5d73efb3..98a6f90096 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyEngine.java @@ -35,6 +35,7 @@ import stirling.software.proprietary.policy.model.Policy; import stirling.software.proprietary.policy.model.PolicyInputs; import stirling.software.proprietary.policy.model.PolicyRun; import stirling.software.proprietary.policy.model.WaitState; +import stirling.software.proprietary.policy.output.OutputDelivery; import stirling.software.proprietary.policy.output.PolicyOutputSink; import stirling.software.proprietary.policy.progress.PolicyProgressListener; import stirling.software.proprietary.service.DownstreamEntitlementError; @@ -203,7 +204,12 @@ public class PolicyEngine { PolicyExecutionResult result = stepExecutor.execute(run.getDefinition(), inputs, listener); OutputSpec output = run.getDefinition().output(); - List outputs = sinkFor(output).deliver(runId, result.files(), output); + List outputs = + sinkFor(output) + .deliver( + new OutputDelivery(runId, run.getPolicyId()), + result.files(), + output); taskManager.setMultipleFileResults(runId, outputs); taskManager.setComplete(runId); run.complete(outputs); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java index fa2a190e8f..5a35b879c0 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyRunner.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.Policy; @@ -27,7 +28,8 @@ import stirling.software.proprietary.policy.source.SourceStore; /** * Turns a policy's referenced sources into runs: each {@code sourceId} is resolved live to its * persisted {@link Source}, then to an {@link InputSpec}. Triggers decide when and call - * {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. + * {@link #run(Policy)}; the controller uses the supplied-input and ad-hoc entry points. A {@link + * SweepKind#FULL} sweep also reconciles the processed-file ledger against what is present. */ @Slf4j @Service @@ -39,6 +41,12 @@ public class PolicyRunner { private final List inputSources; private final SourceStore sourceStore; private final SourceDocCounter docCounter; + private final ProcessedLedger processedLedger; + + /** Full-listing sweep: resolve every source, then reconcile the ledger. */ + public List run(Policy policy) { + return run(policy, SweepKind.FULL); + } /** * Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so @@ -47,15 +55,21 @@ public class PolicyRunner { * rest. Returns the ids of the runs it started (empty when sources yielded no work), so a * manual trigger can report back which runs to follow. */ - public List run(Policy policy) { + public List run(Policy policy, SweepKind sweep) { + long sweepStart = System.currentTimeMillis(); + PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger); + List runIds = new ArrayList<>(); List sourceIds = policy.sourceIds(); if (sourceIds.isEmpty()) { - return List.of(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); + // Generator pipeline: one run with no input. Still fall through to the cleanup + // below so rows recorded for its folder outputs are pruned like anything else, + // instead of accumulating until the policy is deleted. + runIds.add(startRun(policy, PolicyInputs.of(List.of()), unused -> {})); } - List runIds = new ArrayList<>(); for (String sourceId : sourceIds) { Source source = sourceStore.get(sourceId).orElse(null); if (source == null) { + // No veto: a deleted source's rows should age out via the cleanup below. log.warn("Policy {} references missing source {}; skipping", policy.id(), sourceId); continue; } @@ -65,9 +79,21 @@ public class PolicyRunner { sourceId, source.name(), policy.id()); + // Veto: a paused source's files cannot be stamped, so they must not be pruned. + context.vetoCleanup(); continue; } - runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec())); + runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec(), context)); + } + if (context.cleanupAllowed()) { + processedLedger.markSeen(policy.id(), context.presentIdentities()); + int removed = processedLedger.deleteUnseen(policy.id(), sweepStart); + if (removed > 0) { + log.debug( + "Pruned {} ledger row(s) for files no longer present (policy {})", + removed, + policy.id()); + } } return runIds; } @@ -86,26 +112,33 @@ public class PolicyRunner { /** * Resolves the source and starts a run per unit; records how many documents the source fed and - * returns the ids of the runs started. + * returns the ids of the runs started. Any source that could not be listed completely vetoes + * this sweep's ledger cleanup. */ - private List pullAndRun(Policy policy, String sourceId, InputSpec spec) { + private List pullAndRun( + Policy policy, String sourceId, InputSpec spec, PolicySweep context) { InputSource source = sourceFor(spec); if (source == null) { log.warn( "No input source for type '{}' (policy {}); skipping", spec.type(), policy.id()); + context.vetoCleanup(); return List.of(); } + if (!source.listsExhaustively()) { + context.vetoCleanup(); + } List work; try { - work = source.resolve(spec); + work = source.resolve(spec, context); } catch (IOException | RuntimeException e) { log.warn( "Failed to resolve source '{}' for policy {}: {}", spec.type(), policy.id(), e.getMessage()); + context.vetoCleanup(); return List.of(); } List runIds = new ArrayList<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java new file mode 100644 index 0000000000..5302bacdb3 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicySweep.java @@ -0,0 +1,89 @@ +package stirling.software.proprietary.policy.engine; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import stirling.software.proprietary.policy.input.ResolveContext; +import stirling.software.proprietary.policy.ledger.ClaimState; +import stirling.software.proprietary.policy.ledger.ProcessedFileStatus; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; + +/** + * The {@link ResolveContext} for one policy sweep: scopes ledger calls to the policy, gathers the + * present-identity union across sources, prefetches claim state in bulk so per-file claims skip + * their row lookup, and vetoes presence cleanup when any source could not be listed completely + * (pruning would wrongly forget its files). + */ +final class PolicySweep implements ResolveContext { + + private final String policyId; + private final SweepKind kind; + private final ProcessedLedger ledger; + private final Set present = new HashSet<>(); + // Claim states loaded in bulk at reportPresent; a claim outside the prefetch falls back to a + // single lookup. A stale entry cannot double-claim (the ledger re-checks every transition), + // it can only defer a file to the next sweep. + private final Map prefetched = new HashMap<>(); + private final Set prefetchedIdentities = new HashSet<>(); + private boolean cleanupVetoed; + + PolicySweep(String policyId, SweepKind kind, ProcessedLedger ledger) { + this.policyId = policyId; + this.kind = kind; + this.ledger = ledger; + } + + @Override + public synchronized boolean claim(String identity, String gate, Supplier contentHash) { + ClaimState observed = + prefetchedIdentities.contains(identity) + ? prefetched.get(identity) + : ledger.statesFor(policyId, List.of(identity)).get(identity); + boolean claimed = ledger.claim(policyId, identity, gate, contentHash, observed); + if (claimed) { + // A nested source surfacing the same file later in this sweep sees it in flight + // without another lookup. + prefetchedIdentities.add(identity); + prefetched.put(identity, new ClaimState(ProcessedFileStatus.PROCESSING, gate, null)); + } + return claimed; + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(policyId, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + // Deliberately not policy-scoped: consume deletion needs every claimant's consensus. + return ledger.allSettledDone(identity); + } + + @Override + public synchronized void reportPresent(Collection identities) { + if (kind == SweepKind.FULL) { + present.addAll(identities); + } + prefetched.putAll(ledger.statesFor(policyId, identities)); + prefetchedIdentities.addAll(identities); + } + + synchronized void vetoCleanup() { + cleanupVetoed = true; + } + + synchronized boolean cleanupAllowed() { + return kind == SweepKind.FULL && !cleanupVetoed; + } + + synchronized Set presentIdentities() { + return Set.copyOf(present); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepKind.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepKind.java new file mode 100644 index 0000000000..c2455749ae --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/SweepKind.java @@ -0,0 +1,10 @@ +package stirling.software.proprietary.policy.engine; + +/** + * How thorough a policy sweep is: {@link #FULL} (complete listing; also stamps presence and prunes + * the ledger) or {@link #LIGHT} (event-driven; claims only, cost proportional to what changed). + */ +public enum SweepKind { + FULL, + LIGHT +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java index c00f5b4a33..9270cc0c1b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/FolderInputSource.java @@ -1,12 +1,17 @@ package stirling.software.proprietary.policy.input; import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import java.util.stream.Stream; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; @@ -19,17 +24,20 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.FolderIdentities; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.PolicyInputs; /** - * Reads input files from a directory; each ready file is its own unit of work so one failure does - * not affect the others. - * - *

Mode option: "consume" (default) claims each file by moving it into {@code - * .stirling/processing} then routes it to {@code .stirling/done} or {@code .stirling/error}, so - * each file runs once; "snapshot" reads without moving, so every run sees the full set. Readiness - * is checked first so files mid-write are skipped. + * Reads input files from a directory; each ready file is its own unit of work, claimed through the + * {@link ResolveContext} ledger rather than moved aside, so nothing accumulates in a work + * directory. Options: "mode" is "consume" (default: a processed file is removed once every policy + * that claimed it has settled successfully and it is still the version that ran; failures stay in + * place and are not retried until they change) or "snapshot" (stateless, every run sees the full + * set); "recursive" descends into subdirectories; "identity" is "stat" (default, any size/mtime + * change is a new version) or "hash" (content-verified, so a touch does not reprocess). Hidden + * files and directories, including the legacy {@code .stirling} work dir, are never picked up, and + * files mid-write are skipped by the readiness check. */ @Slf4j @Service @@ -38,11 +46,6 @@ import stirling.software.proprietary.policy.model.PolicyInputs; public class FolderInputSource implements InputSource { private static final String TYPE = FolderAccessGuard.FOLDER_TYPE; - // Bookkeeping lives under one hidden dir so the watched folder stays tidy. - private static final String WORK_SUBDIR = ".stirling"; - private static final String PROCESSING_SUBDIR = "processing"; - private static final String DONE_SUBDIR = "done"; - private static final String ERROR_SUBDIR = "error"; private final FileReadinessChecker readinessChecker; private final FolderAccessGuard accessGuard; @@ -68,70 +71,195 @@ public class FolderInputSource implements InputSource { } @Override - public List resolve(InputSpec spec) throws IOException { + public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { FolderConfig config = FolderConfig.from(spec.options()); Path inputDir = accessGuard.requirePermitted(config.directory()); if (!Files.isDirectory(inputDir)) { - log.debug("Folder input dir does not exist: {}", inputDir); - return List.of(); + // Fail rather than return empty: an unmounted drive must read as "could not list", + // which vetoes the sweep's presence cleanup, not as "verifiably no files", which + // would wipe the policy's history and reprocess everything on remount. + throw new NoSuchFileException( + inputDir.toString(), null, "input directory does not exist"); + } + Path canonicalDir = FolderIdentities.canonicalDir(inputDir); + List present = listFiles(inputDir, config.recursive()); + + if (config.snapshot()) { + List work = new ArrayList<>(); + for (Path file : present) { + if (readinessChecker.isReady(file)) { + work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file))))); + } + } + return work; } - List ready = new ArrayList<>(); - try (Stream entries = Files.list(inputDir)) { - entries.filter(Files::isRegularFile) - .filter(readinessChecker::isReady) - .forEach(ready::add); - } + ctx.reportPresent( + present.stream() + .map(file -> FolderIdentities.identity(canonicalDir, inputDir, file)) + .toList()); List work = new ArrayList<>(); - for (Path file : ready) { - if (config.snapshot()) { - work.add(ResolvedInput.of(PolicyInputs.of(List.of(fileResource(file))))); - } else { - Path claimed = claim(inputDir, file); - if (claimed == null) { - continue; // another sweep/process grabbed it - } - work.add( - new ResolvedInput( - PolicyInputs.of(List.of(fileResource(claimed))), - success -> route(inputDir, claimed, success))); + for (Path file : present) { + if (!readinessChecker.isReady(file)) { + continue; } + String identity = FolderIdentities.identity(canonicalDir, inputDir, file); + MemoizedContentHash contentHash = + config.hashIdentity() ? new MemoizedContentHash(file) : null; + String gate; + boolean claimed; + try { + gate = FolderIdentities.statGate(file); + claimed = ctx.claim(identity, gate, contentHash); + } catch (IOException | UncheckedIOException e) { + log.debug("Could not read {} for its version: {}", file, e.getMessage()); + continue; // vanished or unreadable mid-sweep; the next sweep sees the truth + } + if (!claimed) { + continue; + } + work.add( + new ResolvedInput( + PolicyInputs.of(List.of(fileResource(file))), + success -> + completeConsumed( + ctx, identity, file, gate, contentHash, success))); } return work; } - // Atomic move into processing/: only one sweep can win the claim, the rest see the file gone. - private Path claim(Path inputDir, Path file) { + /** + * Settle at the version this run claimed - never a re-read, so a file replaced mid-run reads as + * a new unclaimed version next sweep instead of being marked processed. Then remove the input + * only when it is still the processed version (a mid-run replacement must survive) and every + * policy that claimed it has settled DONE, so co-watching policies all read the original and + * one failure parks the file for everyone. A failed run settles ERROR and never deletes; the + * DONE row of a file that could not be deleted still stops reprocessing. + */ + private static void completeConsumed( + ResolveContext ctx, + String identity, + Path file, + String claimGate, + MemoizedContentHash contentHash, + boolean success) { + ctx.settle(identity, claimGate, claimedHash(file, claimGate, contentHash), success); + if (!success) { + return; + } try { - Path processingDir = workDir(inputDir, PROCESSING_SUBDIR); - Files.createDirectories(processingDir); - Path claimed = uniqueTarget(processingDir, file.getFileName().toString()); - Files.move(file, claimed, StandardCopyOption.ATOMIC_MOVE); - return claimed; + if (FolderIdentities.statGate(file).equals(claimGate) && ctx.allSettledDone(identity)) { + Files.deleteIfExists(file); + } + } catch (NoSuchFileException alreadyGone) { + // Removed by the user or a co-watching policy's own consensus delete: nothing to do. } catch (IOException e) { - log.debug("Could not claim {}: {}", file, e.getMessage()); + log.warn("Could not remove consumed input {}: {}", file, e.getMessage()); + } + } + + /** + * The claimed version's content hash: the value computed during the claim when the ledger + * consulted the verifier, else computed now while the file is still at the claimed gate (so the + * hash describes what actually ran), else null. Always null in stat mode. + */ + private static String claimedHash(Path file, String claimGate, MemoizedContentHash hash) { + if (hash == null) { return null; } + String computed = hash.valueIfComputed(); + if (computed != null) { + return computed; + } + try { + if (FolderIdentities.statGate(file).equals(claimGate)) { + return hash.get(); + } + } catch (IOException | UncheckedIOException e) { + log.debug("Could not hash {} at settle: {}", file, e.getMessage()); + } + return null; } - private void route(Path inputDir, Path claimed, boolean success) { - String subdir = success ? DONE_SUBDIR : ERROR_SUBDIR; - try { - Path destDir = workDir(inputDir, subdir); - Files.createDirectories(destDir); - Files.move( - claimed, - uniqueTarget(destDir, claimed.getFileName().toString()), - StandardCopyOption.ATOMIC_MOVE); - } catch (IOException e) { - log.warn( - "Could not move processed input {} to {}: {}", claimed, subdir, e.getMessage()); + /** Lazy verification tier: invoked at most once by the ledger, retained for the settle. */ + private static final class MemoizedContentHash implements Supplier { + + private final Path file; + private volatile String value; + + private MemoizedContentHash(Path file) { + this.file = file; + } + + @Override + public String get() { + if (value == null) { + try { + value = FolderIdentities.contentHash(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + return value; + } + + String valueIfComputed() { + return value; } } - private static Path workDir(Path inputDir, String subdir) { - return inputDir.resolve(WORK_SUBDIR).resolve(subdir); + /** Every non-hidden regular file in the source, readable or not. */ + private static List listFiles(Path inputDir, boolean recursive) throws IOException { + List files = new ArrayList<>(); + if (!recursive) { + try (Stream entries = Files.list(inputDir)) { + entries.filter(Files::isRegularFile) + .filter(file -> !hidden(file)) + .forEach(files::add); + } + return files; + } + // Hidden subtrees are pruned wholesale; symlinked directories are not followed. + Files.walkFileTree( + inputDir, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory( + Path dir, BasicFileAttributes attributes) { + if (!dir.equals(inputDir) && hidden(dir)) { + return FileVisitResult.SKIP_SUBTREE; + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attributes) { + if (attributes.isRegularFile() && !hidden(file)) { + files.add(file); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException e) { + log.debug("Skipping unreadable entry {}: {}", file, e.getMessage()); + return FileVisitResult.CONTINUE; + } + }); + return files; + } + + private static boolean hidden(Path path) { + Path name = path.getFileName(); + if (name != null && name.toString().startsWith(".")) { + return true; + } + try { + return Files.isHidden(path); + } catch (IOException e) { + return false; + } } private static Resource fileResource(Path path) { @@ -144,27 +272,15 @@ public class FolderInputSource implements InputSource { }; } - private static Path uniqueTarget(Path dir, String filename) { - Path candidate = dir.resolve(filename); - if (!Files.exists(candidate)) { - return candidate; - } - int dot = filename.lastIndexOf('.'); - String base = dot < 0 ? filename : filename.substring(0, dot); - String ext = dot < 0 ? "" : filename.substring(dot); - for (int n = 1; ; n++) { - Path next = dir.resolve(base + " (" + n + ")" + ext); - if (!Files.exists(next)) { - return next; - } - } - } - - record FolderConfig(Path directory, boolean snapshot) { + record FolderConfig(Path directory, boolean snapshot, boolean recursive, boolean hashIdentity) { private static final String DIRECTORY_OPTION = "directory"; private static final String MODE_OPTION = "mode"; private static final String MODE_SNAPSHOT = "snapshot"; + private static final String RECURSIVE_OPTION = "recursive"; + private static final String IDENTITY_OPTION = "identity"; + private static final String IDENTITY_STAT = "stat"; + private static final String IDENTITY_HASH = "hash"; static FolderConfig from(Map options) { Object directory = options.get(DIRECTORY_OPTION); @@ -173,7 +289,17 @@ public class FolderInputSource implements InputSource { } Object mode = options.get(MODE_OPTION); boolean snapshot = mode != null && MODE_SNAPSHOT.equals(mode.toString()); - return new FolderConfig(Path.of(directory.toString()), snapshot); + Object recursive = options.get(RECURSIVE_OPTION); + boolean recurse = recursive != null && Boolean.parseBoolean(recursive.toString()); + Object identity = options.get(IDENTITY_OPTION); + boolean hash = identity != null && IDENTITY_HASH.equals(identity.toString()); + if (identity != null + && !IDENTITY_STAT.equals(identity.toString()) + && !IDENTITY_HASH.equals(identity.toString())) { + throw new IllegalArgumentException( + "folder input 'identity' must be 'stat' or 'hash'"); + } + return new FolderConfig(Path.of(directory.toString()), snapshot, recurse, hash); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java index 436f6c5263..d32c2fc546 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/InputSource.java @@ -24,9 +24,21 @@ public interface InputSource { /** * Resolve the spec into zero or more units of work, each carrying one run's files and a - * completion hook. Empty list means nothing to run right now. + * completion hook. Empty list means nothing to run right now. Discovery is read-only - files + * stay where the user put them; "already processed" is tracked through {@code ctx} (claim on + * pickup, settle on completion, report what is present so stale ledger rows can be pruned). */ - List resolve(InputSpec spec) throws IOException; + List resolve(InputSpec spec, ResolveContext ctx) throws IOException; + + /** + * Whether {@link #resolve} observes everything in the source (a complete listing) rather than + * e.g. only what events surfaced. Presence cleanup of the ledger is skipped for the whole + * policy unless every enabled source says true - wrongly pruning history would reprocess a + * whole folder, while keeping a few stale rows costs nothing. + */ + default boolean listsExhaustively() { + return true; + } /** * Filesystem dirs this source draws from, for the folder-watch trigger. Advisory: resolving is diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolveContext.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolveContext.java new file mode 100644 index 0000000000..9282b44d5a --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/ResolveContext.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.policy.input; + +import java.util.Collection; +import java.util.function.Supplier; + +/** + * A source's policy-scoped window onto the processed-file ledger for one sweep. Thread-safe and + * valid for the lifetime of the work units the source issued ({@link #settle} fires from async run + * completions). + */ +public interface ResolveContext { + + /** + * Atomically claim a file at its current version; true means this sweep runs it. A null {@code + * contentHash} makes any gate change a new version; a non-null supplier is invoked at most + * once, only on a gate mismatch, and a matching hash refreshes the stored gate instead of + * reprocessing. Supplier exceptions propagate. + */ + boolean claim(String identity, String gate, Supplier contentHash); + + /** Record a claimed file's outcome at its final version ({@code finalContentHash} nullable). */ + void settle(String identity, String finalGate, String finalContentHash, boolean success); + + /** + * Whether every policy holding a ledger row for this identity has settled it DONE. Cross-policy + * by design: consume-mode deletion is a consensus of all claimants, so a shared input is + * removed only once nobody still needs it (in-flight, failed, and interrupted rows all veto). + */ + boolean allSettledDone(String identity); + + /** + * Report every identity present right now, readable or not; feeds presence cleanup of rows + * whose file is gone. + */ + void reportPresent(Collection identities); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ClaimState.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ClaimState.java new file mode 100644 index 0000000000..4f3dc0312f --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ClaimState.java @@ -0,0 +1,9 @@ +package stirling.software.proprietary.policy.ledger; + +/** + * A row's claim-relevant state as read by {@link ProcessedLedger#statesFor}: what a sweep observed + * before deciding a claim. May be stale by the time the claim runs; every ledger transition + * re-checks the observed state in its WHERE clause, so staleness defers a claim to a later sweep + * rather than double-running one. + */ +public record ClaimState(ProcessedFileStatus status, String gate, String contentHash) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/FolderIdentities.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/FolderIdentities.java new file mode 100644 index 0000000000..8ece937147 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/FolderIdentities.java @@ -0,0 +1,41 @@ +package stirling.software.proprietary.policy.ledger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; + +import stirling.software.proprietary.billing.ContentHasher; + +/** + * The folder backend's identity and version scheme, shared by {@code FolderInputSource} and {@code + * FolderOutputSink} so outputs are recorded under exactly the identity the next scan derives. + * Directories are canonicalised with {@code toRealPath()} so symlinked aliases agree. + */ +public final class FolderIdentities { + + private FolderIdentities() {} + + /** Canonical form of a configured directory; resolves symlinks, so the dir must exist. */ + public static Path canonicalDir(Path dir) throws IOException { + return dir.toRealPath(); + } + + /** Identity of {@code file} under {@code dir}: its path re-rooted onto the canonical dir. */ + public static String identity(Path canonicalDir, Path dir, Path file) { + return canonicalDir.resolve(dir.relativize(file)).normalize().toString(); + } + + /** The cheap version gate: a change to content length or mtime means "look closer". */ + public static String statGate(Path file) throws IOException { + BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class); + return attributes.size() + ":" + attributes.lastModifiedTime().toMillis(); + } + + /** + * The strong version token: distinguishes a real change from a touch, at the cost of a read. + */ + public static String contentHash(Path file) throws IOException { + return ContentHasher.sha256(file); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/IdentityHasher.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/IdentityHasher.java new file mode 100644 index 0000000000..32c30c0254 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/IdentityHasher.java @@ -0,0 +1,19 @@ +package stirling.software.proprietary.policy.ledger; + +import java.nio.charset.StandardCharsets; + +import stirling.software.proprietary.billing.ContentHasher; + +/** + * Fixed-width key form of a source-owned identity, so any identity length fits the ledger's primary + * key. Backend-agnostic: every source type's identities are keyed through here, which is why this + * does not live with the folder backend's {@link FolderIdentities}. + */ +public final class IdentityHasher { + + private IdentityHasher() {} + + public static String identityHash(String identity) { + return ContentHasher.sha256(identity.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedger.java new file mode 100644 index 0000000000..436e62f2c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedger.java @@ -0,0 +1,229 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * In-memory {@link ProcessedLedger} for tests and DB-less wiring; kept semantically identical to + * {@code JpaProcessedLedger} by the shared contract test. + */ +public class InProcessProcessedLedger implements ProcessedLedger { + + private final Map> rowsByPolicy = new HashMap<>(); + private final Supplier nowMillis; + + public InProcessProcessedLedger() { + this(System::currentTimeMillis); + } + + public InProcessProcessedLedger(Supplier nowMillis) { + this.nowMillis = nowMillis; + } + + @Override + public synchronized Map statesFor( + String policyId, Collection identities) { + Map rows = rowsByPolicy.getOrDefault(policyId, Map.of()); + Map states = new HashMap<>(); + for (String identity : identities) { + Row row = rows.get(identity); + if (row != null) { + states.put(identity, new ClaimState(row.status, row.gate, row.contentHash)); + } + } + return states; + } + + // Single-lock store: the live row is never staler than any observed snapshot, so decide + // against it directly; the conditional updates of the JPA ledger yield the same outcomes. + @Override + public synchronized boolean claim( + String policyId, + String identity, + String gate, + Supplier contentHash, + ClaimState observed) { + Map rows = rowsByPolicy.computeIfAbsent(policyId, key -> new HashMap<>()); + long now = nowMillis.get(); + Row row = rows.get(identity); + if (row == null) { + String hash = contentHash == null ? null : contentHash.get(); + rows.put(identity, new Row(gate, hash, ProcessedFileStatus.PROCESSING, 1, now)); + return true; + } + if (row.status == ProcessedFileStatus.PROCESSING) { + return false; + } + if (gate.equals(row.gate)) { + if (row.status == ProcessedFileStatus.INTERRUPTED && row.attempts < MAX_ATTEMPTS) { + row.status = ProcessedFileStatus.PROCESSING; + row.attempts++; + row.lastSeen = now; + return true; + } + return false; + } + if (contentHash == null) { + row.gate = gate; + row.contentHash = null; + row.status = ProcessedFileStatus.PROCESSING; + row.attempts = 1; + row.lastSeen = now; + return true; + } + String hash = contentHash.get(); + if (Objects.equals(hash, row.contentHash)) { + if (row.status == ProcessedFileStatus.INTERRUPTED && row.attempts < MAX_ATTEMPTS) { + row.gate = gate; + row.status = ProcessedFileStatus.PROCESSING; + row.attempts++; + row.lastSeen = now; + return true; + } + if (row.status != ProcessedFileStatus.INTERRUPTED) { + row.gate = gate; + row.lastSeen = now; + } + return false; + } + row.gate = gate; + row.contentHash = hash; + row.status = ProcessedFileStatus.PROCESSING; + row.attempts = 1; + row.lastSeen = now; + return true; + } + + @Override + public synchronized void settle( + String policyId, + String identity, + String finalGate, + String finalContentHash, + boolean success) { + upsertSettled( + policyId, + identity, + finalGate, + finalContentHash, + success ? ProcessedFileStatus.DONE : ProcessedFileStatus.ERROR); + } + + @Override + public synchronized void recordOutput( + String policyId, String identity, String gate, String contentHash) { + upsertSettled(policyId, identity, gate, contentHash, ProcessedFileStatus.DONE); + } + + @Override + public synchronized void forgetOutput(String policyId, String identity, String gate) { + Map rows = rowsByPolicy.get(policyId); + if (rows == null) { + return; + } + Row row = rows.get(identity); + if (row != null && row.status == ProcessedFileStatus.DONE && gate.equals(row.gate)) { + rows.remove(identity); + } + } + + private void upsertSettled( + String policyId, + String identity, + String gate, + String contentHash, + ProcessedFileStatus status) { + Map rows = rowsByPolicy.computeIfAbsent(policyId, key -> new HashMap<>()); + long now = nowMillis.get(); + Row row = rows.get(identity); + if (row == null) { + rows.put(identity, new Row(gate, contentHash, status, 1, now)); + return; + } + row.gate = gate; + row.contentHash = contentHash; + row.status = status; + row.lastSeen = now; + } + + @Override + public synchronized boolean allSettledDone(String identity) { + for (Map rows : rowsByPolicy.values()) { + Row row = rows.get(identity); + if (row != null && row.status != ProcessedFileStatus.DONE) { + return false; + } + } + return true; + } + + @Override + public synchronized void markSeen(String policyId, Collection identities) { + Map rows = rowsByPolicy.get(policyId); + if (rows == null) { + return; + } + long now = nowMillis.get(); + for (String identity : identities) { + Row row = rows.get(identity); + if (row != null) { + row.lastSeen = now; + } + } + } + + @Override + public synchronized int deleteUnseen(String policyId, long seenSinceMillis) { + Map rows = rowsByPolicy.get(policyId); + if (rows == null) { + return 0; + } + int before = rows.size(); + rows.values() + .removeIf( + row -> + row.lastSeen < seenSinceMillis + && row.status != ProcessedFileStatus.PROCESSING); + return before - rows.size(); + } + + @Override + public synchronized void clearPolicy(String policyId) { + rowsByPolicy.remove(policyId); + } + + @Override + public synchronized void recoverInterrupted() { + for (Map rows : rowsByPolicy.values()) { + for (Row row : rows.values()) { + if (row.status == ProcessedFileStatus.PROCESSING) { + row.status = ProcessedFileStatus.INTERRUPTED; + } + } + } + } + + private static final class Row { + private String gate; + private String contentHash; + private ProcessedFileStatus status; + private int attempts; + private long lastSeen; + + private Row( + String gate, + String contentHash, + ProcessedFileStatus status, + int attempts, + long lastSeen) { + this.gate = gate; + this.contentHash = contentHash; + this.status = status; + this.attempts = attempts; + this.lastSeen = lastSeen; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java new file mode 100644 index 0000000000..43f0e6795d --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedger.java @@ -0,0 +1,212 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import lombok.extern.slf4j.Slf4j; + +/** + * Durable {@link ProcessedLedger}; the runtime bean. A fresh claim is a flushed insert so a + * concurrent winner surfaces as a constraint violation; every other transition is a conditional + * update that re-checks the observed state, so a lost race reports 0 rows and the caller skips. + * Boot recovery assumes the single node the folder-watch trigger assumes: runs live in memory, so + * after a restart every PROCESSING row is stale. + */ +@Slf4j +@Service +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class JpaProcessedLedger implements ProcessedLedger { + + private static final int STAMP_CHUNK = 500; + + private final ProcessedFileRepository repository; + private final Supplier nowMillis; + + @Autowired + public JpaProcessedLedger(ProcessedFileRepository repository) { + this(repository, System::currentTimeMillis); + } + + // Clock seam so tests can pin "now"; the runtime bean uses the wall clock above. + JpaProcessedLedger(ProcessedFileRepository repository, Supplier nowMillis) { + this.repository = repository; + this.nowMillis = nowMillis; + } + + @Override + public Map statesFor(String policyId, Collection identities) { + if (identities.isEmpty()) { + return Map.of(); + } + Map identityByHash = new HashMap<>(); + for (String identity : identities) { + identityByHash.put(IdentityHasher.identityHash(identity), identity); + } + Map states = new HashMap<>(); + List hashes = List.copyOf(identityByHash.keySet()); + for (int from = 0; from < hashes.size(); from += STAMP_CHUNK) { + List rows = + repository.findByPolicyIdAndIdentityHashIn( + policyId, + hashes.subList(from, Math.min(from + STAMP_CHUNK, hashes.size()))); + for (ProcessedFileEntity row : rows) { + states.put( + identityByHash.get(row.getIdentityHash()), + new ClaimState(row.getStatus(), row.getSignature(), row.getContentHash())); + } + } + return states; + } + + @Override + public boolean claim( + String policyId, + String identity, + String gate, + Supplier contentHash, + ClaimState observed) { + String identityHash = IdentityHasher.identityHash(identity); + long now = nowMillis.get(); + if (observed == null) { + try { + repository.saveAndFlush( + new ProcessedFileEntity( + policyId, + identityHash, + identity, + gate, + contentHash == null ? null : contentHash.get(), + ProcessedFileStatus.PROCESSING, + now)); + return true; + } catch (DataIntegrityViolationException concurrentClaim) { + return false; + } + } + if (observed.status() == ProcessedFileStatus.PROCESSING) { + return false; + } + if (gate.equals(observed.gate())) { + if (observed.status() == ProcessedFileStatus.INTERRUPTED) { + return repository.retryInterruptedAtGate( + policyId, identityHash, gate, MAX_ATTEMPTS, now) + > 0; + } + return false; + } + if (contentHash == null) { + return repository.reclaimAtNewGate(policyId, identityHash, gate, now) > 0; + } + String hash = contentHash.get(); + if (hash.equals(observed.contentHash())) { + if (observed.status() == ProcessedFileStatus.INTERRUPTED) { + return repository.retryInterruptedSameContent( + policyId, identityHash, gate, hash, MAX_ATTEMPTS, now) + > 0; + } + repository.refreshGate(policyId, identityHash, gate, hash, now); + return false; + } + return repository.reclaimAtNewContent(policyId, identityHash, gate, hash, now) > 0; + } + + @Override + public void settle( + String policyId, + String identity, + String finalGate, + String finalContentHash, + boolean success) { + upsertSettled( + policyId, + identity, + finalGate, + finalContentHash, + success ? ProcessedFileStatus.DONE : ProcessedFileStatus.ERROR); + } + + @Override + public void recordOutput(String policyId, String identity, String gate, String contentHash) { + upsertSettled(policyId, identity, gate, contentHash, ProcessedFileStatus.DONE); + } + + @Override + public void forgetOutput(String policyId, String identity, String gate) { + repository.deleteDoneAt(policyId, IdentityHasher.identityHash(identity), gate); + } + + /** + * Settle-or-insert: the row may have been presence-cleaned mid-run, and an output row may be + * brand new. + */ + private void upsertSettled( + String policyId, + String identity, + String gate, + String contentHash, + ProcessedFileStatus status) { + String identityHash = IdentityHasher.identityHash(identity); + long now = nowMillis.get(); + if (repository.settle(policyId, identityHash, gate, contentHash, status, now) > 0) { + return; + } + try { + ProcessedFileEntity row = + new ProcessedFileEntity( + policyId, identityHash, identity, gate, contentHash, status, now); + repository.saveAndFlush(row); + } catch (DataIntegrityViolationException concurrentInsert) { + repository.settle(policyId, identityHash, gate, contentHash, status, now); + } + } + + @Override + public boolean allSettledDone(String identity) { + return !repository.existsByIdentityHashAndStatusNot( + IdentityHasher.identityHash(identity), ProcessedFileStatus.DONE); + } + + @Override + public void markSeen(String policyId, Collection identities) { + if (identities.isEmpty()) { + return; + } + List hashes = identities.stream().map(IdentityHasher::identityHash).toList(); + long now = nowMillis.get(); + for (int from = 0; from < hashes.size(); from += STAMP_CHUNK) { + repository.stampSeen( + policyId, + hashes.subList(from, Math.min(from + STAMP_CHUNK, hashes.size())), + now); + } + } + + @Override + public int deleteUnseen(String policyId, long seenSinceMillis) { + return repository.deleteUnseen(policyId, seenSinceMillis); + } + + @Override + public void clearPolicy(String policyId) { + repository.deleteByPolicy(policyId); + } + + @Override + @EventListener(ApplicationReadyEvent.class) + public void recoverInterrupted() { + int recovered = repository.markAllProcessingInterrupted(nowMillis.get()); + if (recovered > 0) { + log.info("Recovered {} policy input file(s) interrupted by shutdown", recovered); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java new file mode 100644 index 0000000000..b05c8b1eec --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java @@ -0,0 +1,97 @@ +package stirling.software.proprietary.policy.ledger; + +import java.io.Serializable; + +import org.springframework.data.domain.Persistable; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * One processed-file ledger row: the version a policy last settled a file at, and where it is in + * the claim lifecycle. Keyed by SHA-256 of the source-owned identity so any identity length fits a + * fixed-width index. {@code isNew} is always true: the entity is only saved for fresh inserts + * (everything else is a conditional update), so a lost insert race surfaces as a constraint + * violation rather than a silent merge. + */ +@Entity +@Table(name = "policy_processed_files") +@IdClass(ProcessedFileId.class) +@NoArgsConstructor +@Getter +@Setter +public class ProcessedFileEntity implements Serializable, Persistable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "policy_id") + private String policyId; + + @Id + @Column(name = "identity_hash", length = 64) + private String identityHash; + + @Column(name = "identity", length = 4096) + private String identity; + + @Column(name = "signature") + private String signature; + + @Column(name = "content_hash", length = 64) + private String contentHash; + + @Enumerated(EnumType.STRING) + @Column(name = "status", length = 16) + private ProcessedFileStatus status; + + @Column(name = "attempts") + private int attempts; + + @Column(name = "last_seen") + private long lastSeen; + + @Column(name = "updated_at") + private long updatedAt; + + public ProcessedFileEntity( + String policyId, + String identityHash, + String identity, + String signature, + String contentHash, + ProcessedFileStatus status, + long nowMillis) { + this.policyId = policyId; + this.identityHash = identityHash; + this.identity = identity; + this.signature = signature; + this.contentHash = contentHash; + this.status = status; + this.attempts = 1; + this.lastSeen = nowMillis; + this.updatedAt = nowMillis; + } + + @Override + @Transient + public ProcessedFileId getId() { + return new ProcessedFileId(policyId, identityHash); + } + + @Override + @Transient + public boolean isNew() { + return true; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileId.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileId.java new file mode 100644 index 0000000000..fd3d554022 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileId.java @@ -0,0 +1,37 @@ +package stirling.software.proprietary.policy.ledger; + +import java.io.Serializable; +import java.util.Objects; + +/** Composite key for {@link ProcessedFileEntity}: one row per policy per file identity. */ +public class ProcessedFileId implements Serializable { + + private static final long serialVersionUID = 1L; + + private String policyId; + private String identityHash; + + public ProcessedFileId() {} + + public ProcessedFileId(String policyId, String identityHash) { + this.policyId = policyId; + this.identityHash = identityHash; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ProcessedFileId other)) { + return false; + } + return Objects.equals(policyId, other.policyId) + && Objects.equals(identityHash, other.identityHash); + } + + @Override + public int hashCode() { + return Objects.hash(policyId, identityHash); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileRepository.java new file mode 100644 index 0000000000..def89821ae --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileRepository.java @@ -0,0 +1,195 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * Conditional updates for the processed-file ledger: each claim variant re-checks in its WHERE + * clause the state it was decided against, so a racing claim loses cleanly with 0 rows updated. + * Transactional per call so the ledger can run them without an enclosing transaction. + */ +@Repository +public interface ProcessedFileRepository + extends JpaRepository { + + /** + * Re-claim a settled row at a new gate without content verification; clears the stored hash, + * which described content this claim never checked. + */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.contentHash = null, e.attempts = 1," + + " e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and e.signature <> :gate") + int reclaimAtNewGate( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("now") long now); + + /** Re-claim a settled row whose content verifiably changed (or was never hashed). */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.contentHash = :contentHash, e.attempts = 1," + + " e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and (e.contentHash is null or e.contentHash <> :contentHash)") + int reclaimAtNewContent( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("now") long now); + + /** The gate moved but the content did not: track the new gate without changing status. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.signature = :gate, e.lastSeen = :now," + + " e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING" + + " and e.contentHash = :contentHash and e.signature <> :gate") + int refreshGate( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("now") long now); + + /** Bounded retry of an INTERRUPTED row at the same gate. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.attempts = e.attempts + 1, e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" + + " and e.signature = :gate and e.attempts < :maxAttempts") + int retryInterruptedAtGate( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("maxAttempts") int maxAttempts, + @Param("now") long now); + + /** Bounded retry of an INTERRUPTED row whose gate moved but whose content is unchanged. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING," + + " e.signature = :gate, e.attempts = e.attempts + 1, e.lastSeen = :now," + + " e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED" + + " and e.contentHash = :contentHash and e.attempts < :maxAttempts") + int retryInterruptedSameContent( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("maxAttempts") int maxAttempts, + @Param("now") long now); + + /** + * Unconditional settle (only the claiming run settles a row); returns 0 when the row was + * removed mid-run so the caller re-inserts. + */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status = :status, e.signature = :gate," + + " e.contentHash = :contentHash, e.lastSeen = :now, e.updatedAt = :now" + + " where e.policyId = :policyId and e.identityHash = :identityHash") + int settle( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate, + @Param("contentHash") String contentHash, + @Param("status") ProcessedFileStatus status, + @Param("now") long now); + + /** Whether any policy's row at this identity is in a state other than {@code status}. */ + boolean existsByIdentityHashAndStatusNot(String identityHash, ProcessedFileStatus status); + + /** One policy's rows across a chunk of identity hashes, for a sweep's claim snapshot. */ + List findByPolicyIdAndIdentityHashIn( + String policyId, Collection identityHashes); + + /** + * Remove an output record whose rename never landed, only while still settled exactly as + * recorded; a row a claim has since taken over is left alone. + */ + @Modifying + @Transactional + @Query( + "delete from ProcessedFileEntity e where e.policyId = :policyId" + + " and e.identityHash = :identityHash and e.signature = :gate" + + " and e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.DONE") + int deleteDoneAt( + @Param("policyId") String policyId, + @Param("identityHash") String identityHash, + @Param("gate") String gate); + + /** Stamp presence for the given identities; chunked by the caller for very large folders. */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.lastSeen = :now" + + " where e.policyId = :policyId and e.identityHash in :identityHashes") + int stampSeen( + @Param("policyId") String policyId, + @Param("identityHashes") Collection identityHashes, + @Param("now") long now); + + /** + * Presence cleanup: remove rows not stamped since the sweep began, keeping in-flight claims. + */ + @Modifying + @Transactional + @Query( + "delete from ProcessedFileEntity e where e.policyId = :policyId" + + " and e.lastSeen < :cutoff and e.status <>" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING") + int deleteUnseen(@Param("policyId") String policyId, @Param("cutoff") long cutoff); + + @Modifying + @Transactional + @Query("delete from ProcessedFileEntity e where e.policyId = :policyId") + int deleteByPolicy(@Param("policyId") String policyId); + + /** Boot recovery: after a restart every PROCESSING row is stale (single node). */ + @Modifying + @Transactional + @Query( + "update ProcessedFileEntity e set e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.INTERRUPTED," + + " e.updatedAt = :now" + + " where e.status =" + + " stirling.software.proprietary.policy.ledger.ProcessedFileStatus.PROCESSING") + int markAllProcessingInterrupted(@Param("now") long now); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileStatus.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileStatus.java new file mode 100644 index 0000000000..68b8b5d00c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileStatus.java @@ -0,0 +1,17 @@ +package stirling.software.proprietary.policy.ledger; + +/** Lifecycle of one {@code (policy, file)} ledger row. */ +public enum ProcessedFileStatus { + + /** Claimed; a run is in flight. */ + PROCESSING, + + /** Run completed at this version. */ + DONE, + + /** Run failed; skipped until the file changes (clear-history is the manual retry). */ + ERROR, + + /** Was PROCESSING when the JVM died; retried a bounded number of times. */ + INTERRUPTED +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedLedger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedLedger.java new file mode 100644 index 0000000000..848a764f60 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedLedger.java @@ -0,0 +1,100 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +/** + * Remembers which files a policy has processed, one row per {@code (policy, identity)}, so sources + * track files in place. Identities are opaque source-owned strings; versions are two-tier: a cheap + * gate compared every sweep plus an optional content hash consulted only when the gate moves. + * Presence reconciliation ({@link #markSeen} + {@link #deleteUnseen}) keeps the table bounded. + */ +public interface ProcessedLedger { + + /** + * Claims of one version before an {@link ProcessedFileStatus#INTERRUPTED} row stops retrying. + */ + int MAX_ATTEMPTS = 3; + + /** + * One-query snapshot of the rows for these identities, keyed by identity; identities with no + * row are absent. Feeds the {@code observed} parameter of {@link #claim(String, String, String, + * Supplier, ClaimState)} so a sweep decides its claims without a per-file lookup. + */ + Map statesFor(String policyId, Collection identities); + + /** + * Atomically claim a file at its current version, deciding against {@code observed} (this row's + * entry from {@link #statesFor}; null means no row was seen); true means this caller runs it. A + * stale {@code observed} cannot double-claim - every transition re-checks the observed state, + * so a lost race skips until a later sweep. A null {@code contentHash} makes any gate change a + * new version; a non-null supplier is invoked at most once, only on a gate mismatch, and a + * matching hash refreshes the stored gate instead of reprocessing. Supplier exceptions + * propagate. + */ + boolean claim( + String policyId, + String identity, + String gate, + Supplier contentHash, + ClaimState observed); + + /** Snapshot-then-claim convenience for a single file; sweeps batch via {@link #statesFor}. */ + default boolean claim( + String policyId, String identity, String gate, Supplier contentHash) { + return claim( + policyId, + identity, + gate, + contentHash, + statesFor(policyId, List.of(identity)).get(identity)); + } + + /** Record a claimed file's outcome at its final version ({@code finalContentHash} nullable). */ + void settle( + String policyId, + String identity, + String finalGate, + String finalContentHash, + boolean success); + + /** + * Record a produced file as {@link ProcessedFileStatus#DONE} so the policy skips its own + * outputs. Must be called before the file is visible at this identity; other policies have no + * row and still process it. + */ + void recordOutput(String policyId, String identity, String gate, String contentHash); + + /** + * Remove an output record whose file never became visible (its rename lost the name race to a + * concurrent writer), so whatever file actually owns that identity is claimable at any version. + * A no-op unless the row is still settled exactly as recorded, so a claim that took the row + * over in the meantime is left alone. + */ + void forgetOutput(String policyId, String identity, String gate); + + /** + * Whether every row at this identity - across all policies, by design - is {@link + * ProcessedFileStatus#DONE}. Consume-mode deletion gates on this so a shared input is removed + * only once every claimant has processed it; in-flight, failed, and interrupted rows all veto, + * parking the file. Vacuously true when no rows exist. + */ + boolean allSettledDone(String identity); + + /** Stamp presence for every identity a full-listing sweep observed. */ + void markSeen(String policyId, Collection identities); + + /** + * Remove rows not seen since {@code seenSinceMillis}, keeping in-flight claims. Only call after + * every enabled source listed completely; returns the number of rows removed. + */ + int deleteUnseen(String policyId, long seenSinceMillis); + + /** Forget everything for a policy. */ + void clearPolicy(String policyId); + + /** Boot recovery: flip stale in-flight claims to {@link ProcessedFileStatus#INTERRUPTED}. */ + void recoverInterrupted(); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java index 8821a2940b..ed3cf707ad 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/FolderOutputSink.java @@ -2,11 +2,18 @@ package stirling.software.proprietary.policy.output; import java.io.IOException; import java.io.InputStream; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestOutputStream; +import java.security.MessageDigest; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.stream.Stream; import org.apache.commons.io.FilenameUtils; import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; @@ -19,14 +26,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.ResultFile; +import stirling.software.proprietary.billing.ContentHasher; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.FolderIdentities; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; /** - * Writes a run's outputs to the {@code directory} given in the {@link OutputSpec}. Files are - * streamed (not buffered) and uniquely named to avoid clobbering. Returned {@link ResultFile}s - * carry a synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry, - * so folder outputs are not downloadable via {@code /files/{id}}. + * Writes a run's outputs to the {@code directory} given in the {@link OutputSpec}. Each output is + * staged under a hidden {@code .stirling/tmp} dir, recorded in the processed-file ledger, then + * atomically renamed into place, so the producing policy's row exists before the file is + * discoverable and half-written outputs are never visible. Returned {@link ResultFile}s carry a + * synthetic id since the deliverable is the file on disk, not a {@code FileStorage} entry. */ @Slf4j @Service @@ -37,7 +48,11 @@ public class FolderOutputSink implements PolicyOutputSink { static final String TYPE = FolderAccessGuard.FOLDER_TYPE; static final String DIRECTORY_OPTION = "directory"; + // Staging entries are renamed away within one delivery; anything older is a crash leftover. + private static final Duration STALE_TMP_AGE = Duration.ofDays(1); + private final FolderAccessGuard accessGuard; + private final ProcessedLedger processedLedger; @Override public String type() { @@ -55,20 +70,25 @@ public class FolderOutputSink implements PolicyOutputSink { } @Override - public List deliver(String runId, List outputs, OutputSpec spec) - throws IOException { + public List deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { Path targetDir = accessGuard.requirePermitted(directoryOf(spec)); Files.createDirectories(targetDir); + Path canonicalDir = FolderIdentities.canonicalDir(targetDir); + Path tmpDir = canonicalDir.resolve(".stirling").resolve("tmp"); + Files.createDirectories(tmpDir); + sweepStaleTmp(tmpDir); List results = new ArrayList<>(); for (int i = 0; i < outputs.size(); i++) { Resource resource = outputs.get(i); String name = safeName(resource.getFilename(), i); - Path target = uniqueTarget(targetDir, name); - try (InputStream is = resource.getInputStream()) { - Files.copy(is, target); - } - long size = Files.size(target); + Path staged = tmpDir.resolve(UUID.randomUUID().toString()); + String contentHash = stage(resource, staged, delivery.policyId() != null); + long size = Files.size(staged); + // Size and mtime survive the rename. + String gate = FolderIdentities.statGate(staged); + Path target = moveIntoPlace(delivery, canonicalDir, name, staged, gate, contentHash); String contentType = MediaTypeFactory.getMediaType(name) .orElse(MediaType.APPLICATION_OCTET_STREAM) @@ -80,11 +100,95 @@ public class FolderOutputSink implements PolicyOutputSink { .contentType(contentType) .fileSize(size) .build()); - log.debug("Wrote policy run {} output to {}", runId, target); + log.debug("Wrote policy run {} output to {}", delivery.runId(), target); } return results; } + /** + * Stream the output to its staging path. For a recorded delivery (stored policy) the content + * hash is digested in the same pass, so the ledger gets both version tiers without re-reading a + * possibly huge output; ad-hoc runs record nothing and skip the digest entirely. + */ + private static String stage(Resource resource, Path staged, boolean hashed) throws IOException { + if (!hashed) { + try (InputStream is = resource.getInputStream()) { + Files.copy(is, staged); + } + return null; + } + MessageDigest digest = ContentHasher.newSha256(); + try (InputStream is = resource.getInputStream(); + DigestOutputStream out = + new DigestOutputStream(Files.newOutputStream(staged), digest)) { + is.transferTo(out); + } + return ContentHasher.toHex(digest.digest()); + } + + /** + * The ledger row must exist before the file is visible at its final path, or a sweep could + * claim the producing policy's own output in the gap. Losing the chosen name to a concurrent + * writer forgets the just-recorded row - whatever file actually owns that name must stay + * claimable at any version - then re-picks. + */ + private Path moveIntoPlace( + OutputDelivery delivery, + Path dir, + String name, + Path staged, + String gate, + String contentHash) + throws IOException { + while (true) { + Path target = uniqueTarget(dir, name); + if (delivery.policyId() != null) { + processedLedger.recordOutput( + delivery.policyId(), target.toString(), gate, contentHash); + } + try { + Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE); + return target; + } catch (FileAlreadyExistsException raced) { + if (delivery.policyId() != null) { + processedLedger.forgetOutput(delivery.policyId(), target.toString(), gate); + } + log.debug("Output name {} taken concurrently; re-picking", target); + } + } + } + + /** Best-effort removal of staging leftovers from crashed deliveries. */ + private static void sweepStaleTmp(Path tmpDir) { + Instant cutoff = Instant.now().minus(STALE_TMP_AGE); + try (Stream entries = Files.list(tmpDir)) { + entries.filter(Files::isRegularFile) + .filter( + entry -> { + try { + return Files.getLastModifiedTime(entry) + .toInstant() + .isBefore(cutoff); + } catch (IOException e) { + return false; + } + }) + .forEach( + entry -> { + try { + Files.deleteIfExists(entry); + } catch (IOException e) { + log.debug( + "Could not remove stale staging file {}: {}", + entry, + e.getMessage()); + } + }); + } catch (IOException e) { + log.debug("Could not sweep staging dir {}: {}", tmpDir, e.getMessage()); + } + } + private static Path directoryOf(OutputSpec spec) { Object directory = spec.options().get(DIRECTORY_OPTION); if (directory == null || directory.toString().isBlank()) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java index 0fcd7323ff..904eb20523 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/InlineOutputSink.java @@ -41,8 +41,8 @@ public class InlineOutputSink implements PolicyOutputSink { } @Override - public List deliver(String runId, List outputs, OutputSpec spec) - throws IOException { + public List deliver( + OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { List results = new ArrayList<>(); for (int i = 0; i < outputs.size(); i++) { Resource resource = outputs.get(i); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputDelivery.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputDelivery.java new file mode 100644 index 0000000000..ef3b59bebe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/OutputDelivery.java @@ -0,0 +1,8 @@ +package stirling.software.proprietary.policy.output; + +/** + * Context for one run's output delivery. {@code policyId} is null for ad-hoc pipelines; when + * present, sinks record outputs in the processed-file ledger so the producing policy does not + * re-ingest them. + */ +public record OutputDelivery(String runId, String policyId) {} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java index 6e6c80ba4d..7a9744f9e7 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/PolicyOutputSink.java @@ -25,6 +25,6 @@ public interface PolicyOutputSink { default void validate(OutputSpec spec) {} /** Persist/deliver the output files and return their descriptors. */ - List deliver(String runId, List outputs, OutputSpec spec) + List deliver(OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java index 79ba3e52e1..dee78e8488 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/trigger/FolderWatchTrigger.java @@ -29,6 +29,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.config.FolderAccessGuard; import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.Policy; @@ -202,7 +203,8 @@ public class FolderWatchTrigger implements PolicyTrigger { } if (dirs.stream().anyMatch(changedDirs::contains)) { log.debug("Folder-watch policy {} ({}) saw activity", policy.id(), policy.name()); - policyRunner.run(policy); + // Light: the periodic reconcile does the full sweep. + policyRunner.run(policy, SweepKind.LIGHT); } } } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index 74f904c455..2e9c4d3e5b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -34,6 +34,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.repository", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.repository", "stirling.software.proprietary.integration.repository", @@ -46,6 +47,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException; "stirling.software.proprietary.workflow.model", "stirling.software.proprietary.policy.store", "stirling.software.proprietary.policy.source", + "stirling.software.proprietary.policy.ledger", "stirling.software.proprietary.accountlink", "stirling.software.proprietary.access.model", "stirling.software.proprietary.integration.model", diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index 8a9a085653..f38263ad3e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -34,6 +34,7 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle; import stirling.software.proprietary.policy.engine.PolicyRunRegistry; import stirling.software.proprietary.policy.engine.PolicyRunner; import stirling.software.proprietary.policy.engine.PolicyValidator; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.PipelineDefinition; import stirling.software.proprietary.policy.model.PipelineStep; import stirling.software.proprietary.policy.model.Policy; @@ -62,6 +63,8 @@ class PolicyControllerTest { private stirling.software.proprietary.policy.overview.PolicyOverviewService policyOverviewService; + @Mock private ProcessedLedger processedLedger; + @Mock private TempFileManager tempFileManager; @Mock private JobOwnershipService jobOwnershipService; @@ -89,6 +92,7 @@ class PolicyControllerTest { policyManagementAuthority, policyTriggerManager, policyOverviewService, + processedLedger, policyTriggers, applicationProperties, tempFileManager, @@ -398,6 +402,7 @@ class PolicyControllerTest { ResponseEntity response = controller.deletePolicy("a"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(processedLedger).clearPolicy("a"); verify(policyTriggerManager).notifyPoliciesChanged(); } @@ -431,6 +436,53 @@ class PolicyControllerTest { } } + @Nested + @DisplayName("clearProcessedHistory") + class ClearProcessedHistory { + + @Test + @DisplayName("clears an accessible policy's history") + void clears() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(true); + + ResponseEntity response = controller.clearProcessedHistory("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + verify(processedLedger).clearPolicy("a"); + } + + @Test + @DisplayName("returns 404 when policy is not accessible") + void notAccessible() { + applicationProperties.getSecurity().setEnableLogin(false); + Policy p = policy("a", 1L); + when(policyStore.get("a")).thenReturn(Optional.of(p)); + when(policyAccessGuard.canAccess(p)).thenReturn(false); + + ResponseEntity response = controller.clearProcessedHistory("a"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(processedLedger, never()).clearPolicy(any()); + } + + @Test + @DisplayName("forbidden when login enabled and caller cannot edit") + void forbidden() { + applicationProperties.getSecurity().setEnableLogin(true); + when(policyManagementAuthority.canEditPolicies()).thenReturn(false); + + assertThatThrownBy(() -> controller.clearProcessedHistory("a")) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN)); + } + } + @Nested @DisplayName("runStoredPolicy") class RunStoredPolicy { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java index b1ab05f8ba..13189b2481 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyRunnerTest.java @@ -4,15 +4,19 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import java.io.IOException; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; @@ -24,7 +28,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.proprietary.policy.input.InputSource; +import stirling.software.proprietary.policy.input.ResolveContext; import stirling.software.proprietary.policy.input.ResolvedInput; +import stirling.software.proprietary.policy.ledger.ProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.model.PipelineStep; @@ -39,15 +45,15 @@ import stirling.software.proprietary.policy.source.Source; import stirling.software.proprietary.policy.source.SourceStore; /** - * Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs. Verifies - * it pulls every source, runs one job per unit of work, feeds each unit's completion hook the run - * outcome, and that a generator (no sources) still runs once. + * Tests for {@link PolicyRunner}: the one place that turns a policy's sources into runs, and the + * orchestrator of ledger hygiene (presence stamping + cleanup on complete FULL sweeps). */ @ExtendWith(MockitoExtension.class) class PolicyRunnerTest { @Mock private PolicyEngine policyEngine; @Mock private InputSource folderSource; + @Mock private ProcessedLedger processedLedger; private final SourceStore sourceStore = new InProcessSourceStore(); private PolicyRunner runner; @@ -59,7 +65,8 @@ class PolicyRunnerTest { policyEngine, List.of(folderSource), sourceStore, - new InProcessSourceDocCounter()); + new InProcessSourceDocCounter(), + processedLedger); } @Test @@ -73,6 +80,9 @@ class PolicyRunnerTest { ArgumentCaptor inputs = ArgumentCaptor.forClass(PolicyInputs.class); verify(policyEngine).runPolicy(eq(policy), inputs.capture(), any()); assertTrue(inputs.getValue().primary().isEmpty()); + // Ledger hygiene still runs: rows recorded for a generator policy's folder outputs + // are pruned by its own sweeps rather than accumulating until the policy is deleted. + verify(processedLedger).deleteUnseen(eq("p1"), anyLong()); } @Test @@ -80,7 +90,7 @@ class PolicyRunnerTest { InputSpec spec = InputSpec.folder("/in"); Policy policy = policy(List.of(spec)); when(folderSource.supports(spec)).thenReturn(true); - when(folderSource.resolve(spec)) + when(folderSource.resolve(eq(spec), any())) .thenReturn( List.of( ResolvedInput.of(PolicyInputs.of(List.of())), @@ -100,7 +110,7 @@ class PolicyRunnerTest { AtomicBoolean outcome = new AtomicBoolean(false); ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set); when(folderSource.supports(spec)).thenReturn(true); - when(folderSource.resolve(spec)).thenReturn(List.of(unit)); + when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit)); CompletableFuture completion = new CompletableFuture<>(); when(policyEngine.runPolicy(any(), any(), any())) .thenReturn(new PolicyRunHandle("r", completion)); @@ -121,7 +131,7 @@ class PolicyRunnerTest { AtomicBoolean outcome = new AtomicBoolean(true); ResolvedInput unit = new ResolvedInput(PolicyInputs.of(List.of()), outcome::set); when(folderSource.supports(spec)).thenReturn(true); - when(folderSource.resolve(spec)).thenReturn(List.of(unit)); + when(folderSource.resolve(eq(spec), any())).thenReturn(List.of(unit)); CompletableFuture completion = new CompletableFuture<>(); when(policyEngine.runPolicy(any(), any(), any())) .thenReturn(new PolicyRunHandle("r", completion)); @@ -143,6 +153,98 @@ class PolicyRunnerTest { verifyNoInteractions(policyEngine); } + @Test + void aFullSweepStampsPresenceAndPrunesUnseenRows() throws Exception { + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.listsExhaustively()).thenReturn(true); + when(folderSource.resolve(eq(spec), any())) + .thenAnswer( + invocation -> { + ResolveContext ctx = invocation.getArgument(1); + ctx.reportPresent(List.of("/in/a.pdf", "/in/b.pdf")); + return List.of(); + }); + + runner.run(policy); + + // Presence reporting also bulk-prefetches claim state: one lookup for the whole listing. + verify(processedLedger).statesFor(eq("p1"), eq(List.of("/in/a.pdf", "/in/b.pdf"))); + verify(processedLedger).markSeen("p1", Set.of("/in/a.pdf", "/in/b.pdf")); + verify(processedLedger).deleteUnseen(eq("p1"), anyLong()); + } + + @Test + void aLightSweepClaimsButSkipsLedgerHygiene() throws Exception { + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.resolve(eq(spec), any())) + .thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of())))); + when(policyEngine.runPolicy(any(), any(), any())) + .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); + + runner.run(policy, SweepKind.LIGHT); + + verify(policyEngine).runPolicy(eq(policy), any(), any()); + verify(processedLedger, never()).markSeen(any(), any()); + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); + } + + @Test + void aSourceThatFailsToResolveVetoesCleanupButOthersStillRun() throws Exception { + InputSpec broken = InputSpec.folder("/broken"); + InputSpec healthy = InputSpec.folder("/healthy"); + Policy policy = policy(List.of(broken, healthy)); + when(folderSource.supports(any())).thenReturn(true); + when(folderSource.listsExhaustively()).thenReturn(true); + when(folderSource.resolve(eq(broken), any())).thenThrow(new IOException("mount gone")); + when(folderSource.resolve(eq(healthy), any())) + .thenReturn(List.of(ResolvedInput.of(PolicyInputs.of(List.of())))); + when(policyEngine.runPolicy(any(), any(), any())) + .thenReturn(new PolicyRunHandle("r", new CompletableFuture<>())); + + runner.run(policy); + + verify(policyEngine).runPolicy(eq(policy), any(), any()); // healthy source still ran + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); // history preserved + } + + @Test + void aDisabledSourceVetoesCleanup() { + InputSpec spec = InputSpec.folder("/in"); + String pausedId = sourceStore.save(disabledSourceFrom(spec)).id(); + Policy policy = policyReferencing(List.of(pausedId)); + + runner.run(policy); + + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); + } + + @Test + void aNonExhaustiveSourceVetoesCleanup() throws Exception { + InputSpec spec = InputSpec.folder("/in"); + Policy policy = policy(List.of(spec)); + when(folderSource.supports(spec)).thenReturn(true); + when(folderSource.listsExhaustively()).thenReturn(false); + when(folderSource.resolve(eq(spec), any())).thenReturn(List.of()); + + runner.run(policy); + + verify(processedLedger, never()).deleteUnseen(any(), anyLong()); + } + + @Test + void aMissingSourceDoesNotVetoCleanup() { + // A deleted source's rows age out precisely because cleanup still runs. + Policy policy = policyReferencing(List.of("ghost-source-id")); + + runner.run(policy); + + verify(processedLedger).deleteUnseen(eq("p1"), anyLong()); + } + @Test void runWithSuppliedInputsBypassesSources() { Policy policy = policy(List.of(InputSpec.folder("/in"))); @@ -159,6 +261,10 @@ class PolicyRunnerTest { private Policy policy(List sources) { List sourceIds = sources.stream().map(spec -> sourceStore.save(sourceFrom(spec)).id()).toList(); + return policyReferencing(sourceIds); + } + + private static Policy policyReferencing(List sourceIds) { return new Policy( "p1", "p", @@ -173,4 +279,8 @@ class PolicyRunnerTest { private static Source sourceFrom(InputSpec spec) { return new Source(null, "src", spec.type(), spec.options(), true, "owner", null); } + + private static Source disabledSourceFrom(InputSpec spec) { + return new Source(null, "src", spec.type(), spec.options(), false, "owner", null); + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java index 62bfa3aabb..64178ce9bb 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/FolderInputSourceTest.java @@ -1,17 +1,23 @@ package stirling.software.proprietary.policy.input; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,18 +30,26 @@ import org.springframework.core.env.StandardEnvironment; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.FileReadinessChecker; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.source.InProcessSourceStore; -/** Tests for {@link FolderInputSource}: consume (claim + route) and snapshot (read-only) modes. */ +/** + * Tests for {@link FolderInputSource}: consume mode tracks files in place through the ledger, + * snapshot stays stateless, and discovery skips hidden entries and honours the recursive option. + */ @ExtendWith(MockitoExtension.class) class FolderInputSourceTest { + private static final String POLICY = "p1"; + @Mock private FileReadinessChecker readinessChecker; @TempDir Path tempDir; private FolderInputSource source; + private InProcessProcessedLedger ledger; + private RecordingContext ctx; @BeforeEach void setUp() { @@ -45,73 +59,279 @@ class FolderInputSourceTest { new FolderAccessGuard( properties, new StandardEnvironment(), new InProcessSourceStore()); source = new FolderInputSource(readinessChecker, guard); + ledger = new InProcessProcessedLedger(); + ctx = new RecordingContext(); // Lenient: the missing-dir / nonexistent-dir cases return before any readiness check. lenient().when(readinessChecker.isReady(any())).thenReturn(true); } @Test - void consumeClaimsFilesAndRoutesToDoneOnSuccess() throws IOException { + void consumeRemovesTheFileOnceProcessed() throws IOException { Path inputDir = Files.createDirectories(tempDir.resolve("in")); - Files.writeString(inputDir.resolve("doc.pdf"), "data"); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); - List work = source.resolve(InputSpec.folder(inputDir.toString())); + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); assertEquals(1, work.size()); assertEquals(1, work.get(0).inputs().primary().size()); - // Claimed out of the input dir. - assertFalse(Files.exists(inputDir.resolve("doc.pdf"))); - assertTrue( - Files.exists( - inputDir.resolve(".stirling").resolve("processing").resolve("doc.pdf"))); + // In flight: still on disk, but a second sweep does not pick it up again. + assertTrue(Files.exists(file)); + assertTrue(Files.notExists(inputDir.resolve(".stirling"))); + assertTrue(source.resolve(InputSpec.folder(inputDir.toString()), ctx).isEmpty()); work.get(0).onComplete().accept(true); - assertTrue(Files.exists(inputDir.resolve(".stirling").resolve("done").resolve("doc.pdf"))); - assertFalse( - Files.exists( - inputDir.resolve(".stirling").resolve("processing").resolve("doc.pdf"))); + assertTrue(Files.notExists(file)); + assertTrue(source.resolve(InputSpec.folder(inputDir.toString()), ctx).isEmpty()); } @Test - void consumeRoutesToErrorOnFailure() throws IOException { + void aFileReplacedMidRunSurvivesTheDeleteAndRunsAgain() throws IOException { Path inputDir = Files.createDirectories(tempDir.resolve("in")); - Files.writeString(inputDir.resolve("doc.pdf"), "data"); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); - List work = source.resolve(InputSpec.folder(inputDir.toString())); - work.get(0).onComplete().accept(false); + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); + // The user saves a new version while the run is executing. + Files.writeString(file, "new data, different size"); + work.get(0).onComplete().accept(true); - assertTrue(Files.exists(inputDir.resolve(".stirling").resolve("error").resolve("doc.pdf"))); + // The delete is version-guarded: the replacement is not the file that ran, so it stays + // and is claimed as fresh work instead of being marked processed. + assertTrue(Files.exists(file)); + assertEquals(1, source.resolve(InputSpec.folder(inputDir.toString()), ctx).size()); } @Test - void snapshotReadsWithoutClaiming() throws IOException { + void aSharedFileIsRemovedOnlyOnceEveryPolicyHasProcessedIt() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + InputSpec spec = InputSpec.folder(inputDir.toString()); + RecordingContext other = new RecordingContext("p2"); + + List mine = source.resolve(spec, ctx); + List theirs = source.resolve(spec, other); + assertEquals(1, mine.size()); + assertEquals(1, theirs.size()); + + mine.get(0).onComplete().accept(true); + // The other policy's claim is still in flight, so the first finisher must not delete. + assertTrue(Files.exists(file)); + + theirs.get(0).onComplete().accept(true); + assertTrue(Files.notExists(file)); + } + + @Test + void aSharedFileStaysParkedWhenAnyPolicyFailsOnIt() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + InputSpec spec = InputSpec.folder(inputDir.toString()); + RecordingContext other = new RecordingContext("p2"); + + List mine = source.resolve(spec, ctx); + List theirs = source.resolve(spec, other); + + theirs.get(0).onComplete().accept(false); + mine.get(0).onComplete().accept(true); + + // The failure parks the file for everyone (retried when it changes), regardless of + // which policy settled last. + assertTrue(Files.exists(file)); + assertTrue(source.resolve(spec, ctx).isEmpty()); + assertTrue(source.resolve(spec, other).isEmpty()); + } + + @Test + void aReDroppedFileIsProcessedAgain() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + + source.resolve(InputSpec.folder(inputDir.toString()), ctx).get(0).onComplete().accept(true); + Files.writeString(file, "data again"); + + assertEquals(1, source.resolve(InputSpec.folder(inputDir.toString()), ctx).size()); + } + + @Test + void aFailedFileStaysInPlaceAndIsNotRetriedUntilItChanges() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + + source.resolve(InputSpec.folder(inputDir.toString()), ctx) + .get(0) + .onComplete() + .accept(false); + + assertTrue(Files.exists(file)); + assertTrue(source.resolve(InputSpec.folder(inputDir.toString()), ctx).isEmpty()); + + Files.setLastModifiedTime(file, FileTime.from(Instant.now().plusSeconds(60))); + assertEquals(1, source.resolve(InputSpec.folder(inputDir.toString()), ctx).size()); + } + + @Test + void statModeRetriesAFailureOnATouchButHashModeDoesNot() throws IOException { + Path statDir = Files.createDirectories(tempDir.resolve("stat")); + Path hashDir = Files.createDirectories(tempDir.resolve("hash")); + Path statFile = statDir.resolve("doc.pdf"); + Path hashFile = hashDir.resolve("doc.pdf"); + Files.writeString(statFile, "data"); + Files.writeString(hashFile, "data"); + InputSpec statSpec = InputSpec.folder(statDir.toString()); + InputSpec hashSpec = + new InputSpec( + "folder", Map.of("directory", hashDir.toString(), "identity", "hash")); + + source.resolve(statSpec, ctx).get(0).onComplete().accept(false); + source.resolve(hashSpec, ctx).get(0).onComplete().accept(false); + + FileTime touched = FileTime.from(Instant.now().plusSeconds(60)); + Files.setLastModifiedTime(statFile, touched); + Files.setLastModifiedTime(hashFile, touched); + + // Same content, new mtime: stat mode calls that a new version and retries; hash mode + // verifies the content is unchanged and keeps the failure parked. + assertEquals(1, source.resolve(statSpec, ctx).size()); + assertTrue(source.resolve(hashSpec, ctx).isEmpty()); + } + + @Test + void hashModeRetriesAFailureOnARealContentChange() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + InputSpec spec = + new InputSpec( + "folder", Map.of("directory", inputDir.toString(), "identity", "hash")); + + source.resolve(spec, ctx).get(0).onComplete().accept(false); + Files.writeString(file, "data v2 - longer"); + + assertEquals(1, source.resolve(spec, ctx).size()); + } + + @Test + void snapshotReadsStatelesslyEverySweep() throws IOException { Path inputDir = Files.createDirectories(tempDir.resolve("in")); Files.writeString(inputDir.resolve("doc.pdf"), "data"); + InputSpec spec = + new InputSpec( + "folder", Map.of("directory", inputDir.toString(), "mode", "snapshot")); - List work = - source.resolve( - new InputSpec( - "folder", - Map.of("directory", inputDir.toString(), "mode", "snapshot"))); + List first = source.resolve(spec, ctx); + first.get(0).onComplete().accept(true); + List second = source.resolve(spec, ctx); + + assertEquals(1, first.size()); + assertEquals(1, second.size()); // no ledger involvement: every run sees the full set + assertTrue(ctx.present.isEmpty()); + assertTrue(Files.exists(inputDir.resolve("doc.pdf"))); + } + + @Test + void hiddenFilesAndTheLegacyWorkDirAreIgnored() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Files.writeString(inputDir.resolve("doc.pdf"), "data"); + Files.writeString(inputDir.resolve(".hidden.pdf"), "secret"); + Path legacy = Files.createDirectories(inputDir.resolve(".stirling").resolve("done")); + Files.writeString(legacy.resolve("old.pdf"), "processed long ago"); + + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); assertEquals(1, work.size()); - // Not moved, and completing the run is a no-op. - assertTrue(Files.exists(inputDir.resolve("doc.pdf"))); - work.get(0).onComplete().accept(true); - assertTrue(Files.exists(inputDir.resolve("doc.pdf"))); + assertEquals(1, ctx.present.size()); + assertTrue(ctx.present.get(0).endsWith("doc.pdf")); + } + + @Test + void recursiveDiscoversSubdirectoriesButNotHiddenOnes() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Files.writeString(inputDir.resolve("top.pdf"), "a"); + Path sub = Files.createDirectories(inputDir.resolve("sub")); + Files.writeString(sub.resolve("nested.pdf"), "b"); + Path hiddenDir = Files.createDirectories(inputDir.resolve(".stirling")); + Files.writeString(hiddenDir.resolve("skipped.pdf"), "c"); + // Sink staging inside a watched subdirectory is pruned at any depth. + Path nestedStaging = Files.createDirectories(sub.resolve(".stirling").resolve("tmp")); + Files.writeString(nestedStaging.resolve("half-delivered"), "d"); + + InputSpec flat = InputSpec.folder(inputDir.toString()); + InputSpec recursive = + new InputSpec( + "folder", Map.of("directory", inputDir.toString(), "recursive", "true")); + + assertEquals(1, source.resolve(flat, ctx).size()); + assertEquals(1, source.resolve(recursive, ctx).size()); // top.pdf already claimed above + assertTrue(ctx.present.stream().anyMatch(identity -> identity.endsWith("nested.pdf"))); + assertTrue(ctx.present.stream().noneMatch(identity -> identity.endsWith("skipped.pdf"))); + assertTrue(ctx.present.stream().noneMatch(identity -> identity.endsWith("half-delivered"))); + } + + @Test + void unreadyFilesAreReportedPresentButNotClaimed() throws IOException { + Path inputDir = Files.createDirectories(tempDir.resolve("in")); + Path file = inputDir.resolve("mid-write.pdf"); + Files.writeString(file, "partial"); + when(readinessChecker.isReady(file)).thenReturn(false); + + List work = source.resolve(InputSpec.folder(inputDir.toString()), ctx); + + assertTrue(work.isEmpty()); + // Reported present so a full sweep does not prune its row while it settles on disk. + assertEquals(1, ctx.present.size()); + assertTrue(ctx.present.get(0).endsWith("mid-write.pdf")); + } + + @Test + void nestedSourcesShareThePolicysLedgerAndDoNotDoubleClaim() throws IOException { + Path parent = Files.createDirectories(tempDir.resolve("in")); + Path child = Files.createDirectories(parent.resolve("sub")); + Files.writeString(child.resolve("doc.pdf"), "data"); + InputSpec parentRecursive = + new InputSpec( + "folder", Map.of("directory", parent.toString(), "recursive", "true")); + InputSpec childFlat = InputSpec.folder(child.toString()); + + // Same sweep, same policy context: whichever source resolves first wins the file. + assertEquals(1, source.resolve(parentRecursive, ctx).size()); + assertTrue(source.resolve(childFlat, ctx).isEmpty()); } @Test void missingDirectoryOptionFails() { assertThrows( IllegalArgumentException.class, - () -> source.resolve(new InputSpec("folder", Map.of()))); + () -> source.resolve(new InputSpec("folder", Map.of()), ctx)); } @Test - void nonexistentDirectoryYieldsNoWork() throws IOException { - List work = - source.resolve(InputSpec.folder(tempDir.resolve("nope").toString())); - assertTrue(work.isEmpty()); + void anUnknownIdentityModeIsRejected() { + assertThrows( + IllegalArgumentException.class, + () -> + source.validate( + new InputSpec( + "folder", + Map.of( + "directory", + tempDir.toString(), + "identity", + "guesswork")))); + } + + @Test + void nonexistentDirectoryFailsResolveSoTheSweepVetoesCleanup() { + // An unreachable directory (e.g. unmounted drive) must surface as a failed listing, not + // an empty one: the runner vetoes presence cleanup on failure, keeping the history that + // an empty listing would wipe. + assertThrows( + NoSuchFileException.class, + () -> source.resolve(InputSpec.folder(tempDir.resolve("nope").toString()), ctx)); } @Test @@ -126,7 +346,7 @@ class FolderInputSourceTest { Path outside = tempDir.resolveSibling("not-allowed"); assertThrows( IllegalArgumentException.class, - () -> source.resolve(InputSpec.folder(outside.toString()))); + () -> source.resolve(InputSpec.folder(outside.toString()), ctx)); assertThrows( IllegalArgumentException.class, () -> source.validate(InputSpec.folder(outside.toString()))); @@ -137,4 +357,40 @@ class FolderInputSourceTest { Path inputDir = tempDir.resolve("in"); assertEquals(List.of(inputDir), source.watchTargets(InputSpec.folder(inputDir.toString()))); } + + /** Policy-scoped context backed by the in-process ledger, recording presence reports. */ + private class RecordingContext implements ResolveContext { + + private final String policyId; + private final List present = new ArrayList<>(); + + private RecordingContext() { + this(POLICY); + } + + private RecordingContext(String policyId) { + this.policyId = policyId; + } + + @Override + public boolean claim(String identity, String gate, Supplier contentHash) { + return ledger.claim(policyId, identity, gate, contentHash); + } + + @Override + public void settle( + String identity, String finalGate, String finalContentHash, boolean success) { + ledger.settle(policyId, identity, finalGate, finalContentHash, success); + } + + @Override + public boolean allSettledDone(String identity) { + return ledger.allSettledDone(identity); + } + + @Override + public void reportPresent(Collection identities) { + present.addAll(identities); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/FolderIdentitiesTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/FolderIdentitiesTest.java new file mode 100644 index 0000000000..9f56411d9b --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/FolderIdentitiesTest.java @@ -0,0 +1,84 @@ +package stirling.software.proprietary.policy.ledger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests for {@link FolderIdentities}: identity derivation must agree for the same file, even + * through a symlinked alias of the directory. + */ +class FolderIdentitiesTest { + + @TempDir Path tempDir; + + @Test + void identityAgreesAcrossASymlinkedAliasOfTheDirectory() throws IOException { + Path real = Files.createDirectories(tempDir.resolve("real")); + Path alias = Files.createSymbolicLink(tempDir.resolve("alias"), real); + Files.writeString(real.resolve("doc.pdf"), "data"); + + String viaReal = + FolderIdentities.identity( + FolderIdentities.canonicalDir(real), real, real.resolve("doc.pdf")); + String viaAlias = + FolderIdentities.identity( + FolderIdentities.canonicalDir(alias), alias, alias.resolve("doc.pdf")); + + assertEquals(viaReal, viaAlias); + } + + @Test + void identityOfANestedFileKeepsItsRelativePath() throws IOException { + Path dir = Files.createDirectories(tempDir.resolve("in")); + Path nested = Files.createDirectories(dir.resolve("sub")).resolve("doc.pdf"); + Files.writeString(nested, "data"); + + String identity = + FolderIdentities.identity(FolderIdentities.canonicalDir(dir), dir, nested); + + assertTrue(identity.endsWith("sub" + java.io.File.separator + "doc.pdf")); + } + + @Test + void theGateTracksSizeAndMtime() throws IOException { + Path file = tempDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + String before = FolderIdentities.statGate(file); + + Files.setLastModifiedTime(file, FileTime.from(Instant.now().plusSeconds(60))); + + assertNotEquals(before, FolderIdentities.statGate(file)); + } + + @Test + void theContentHashIgnoresMtimeButTracksContent() throws IOException { + Path file = tempDir.resolve("doc.pdf"); + Files.writeString(file, "data"); + String before = FolderIdentities.contentHash(file); + + Files.setLastModifiedTime(file, FileTime.from(Instant.now().plusSeconds(60))); + assertEquals(before, FolderIdentities.contentHash(file)); + + Files.writeString(file, "different"); + assertNotEquals(before, FolderIdentities.contentHash(file)); + } + + @Test + void identityHashIsAStableFixedWidthKey() { + String hash = IdentityHasher.identityHash("/in/doc.pdf"); + + assertEquals(64, hash.length()); + assertEquals(hash, IdentityHasher.identityHash("/in/doc.pdf")); + assertNotEquals(hash, IdentityHasher.identityHash("/in/other.pdf")); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedgerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedgerTest.java new file mode 100644 index 0000000000..08ac10061d --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/InProcessProcessedLedgerTest.java @@ -0,0 +1,12 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.function.Supplier; + +/** {@link InProcessProcessedLedger} against the shared {@link ProcessedLedger} contract. */ +class InProcessProcessedLedgerTest extends ProcessedLedgerContractTest { + + @Override + ProcessedLedger newLedger(Supplier nowMillis) { + return new InProcessProcessedLedger(nowMillis); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedgerDbTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedgerDbTest.java new file mode 100644 index 0000000000..8c1bd85c94 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/JpaProcessedLedgerDbTest.java @@ -0,0 +1,36 @@ +package stirling.software.proprietary.policy.ledger; + +import java.util.function.Supplier; + +import org.junit.jupiter.api.AfterEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest; + +/** + * {@link JpaProcessedLedger} against the shared contract on a real (H2) database. The inherited + * tests run outside {@code @DataJpaTest}'s per-test transaction (the transaction attribute resolves + * against the declaring class, the plain contract base), so every ledger call commits in its own + * transaction as at runtime; state is wiped explicitly instead of relying on rollback. + */ +@DataJpaTest +class JpaProcessedLedgerDbTest extends ProcessedLedgerContractTest { + + @Autowired private ProcessedFileRepository repository; + + @AfterEach + void wipeLedger() { + // deleteAll() skips entities whose isNew() is hardcoded true, so use the bulk form. + repository.deleteAllInBatch(); + } + + @Override + ProcessedLedger newLedger(Supplier nowMillis) { + return new JpaProcessedLedger(repository, nowMillis); + } + + @SpringBootConfiguration + @AutoConfigurationPackage + static class TestApp {} +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/ProcessedLedgerContractTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/ProcessedLedgerContractTest.java new file mode 100644 index 0000000000..800e8a6bde --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/ledger/ProcessedLedgerContractTest.java @@ -0,0 +1,360 @@ +package stirling.software.proprietary.policy.ledger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.Supplier; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** The {@link ProcessedLedger} contract, run against every implementation so they cannot drift. */ +abstract class ProcessedLedgerContractTest { + + static final String POLICY = "p1"; + static final String OTHER_POLICY = "p2"; + static final String FILE = "/in/doc.pdf"; + static final String GATE = "100:1111"; + static final String NEW_GATE = "100:2222"; + static final String HASH = "hash-aaa"; + static final String NEW_HASH = "hash-bbb"; + + final AtomicLong clock = new AtomicLong(1_000_000L); + + ProcessedLedger ledger; + + abstract ProcessedLedger newLedger(Supplier nowMillis); + + @BeforeEach + void createLedger() { + ledger = newLedger(clock::get); + } + + @Test + void aFileIsClaimedOnceAndSkippedWhileInFlight() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, null)); // new version waits for settle + } + + @Test + void aSettledFileIsSkippedAtTheSameGate() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void aMovedGateIsReclaimedInGateOnlyMode() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); + } + + @Test + void settlingAtTheOutputsVersionStopsAnInPlaceOverwriteLooping() { + ledger.claim(POLICY, FILE, GATE, null); + // The run overwrote the input; settle re-reads and lands on the produced version. + ledger.settle(POLICY, FILE, NEW_GATE, null, true); + + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, null)); // own output: skip + assertTrue(ledger.claim(POLICY, FILE, "100:3333", null)); // later user edit: reprocess + } + + @Test + void aFailedFileIsNotRetriedUntilItChanges() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, false); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); + } + + @Test + void aTouchedButUnchangedFileRefreshesTheGateInsteadOfReprocessing() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, true); + + // Same content under a new gate (touch / identical re-copy): verified, not reprocessed. + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + + // The gate was refreshed, so the next sweep takes the cheap path: no content read at all. + CountingSupplier counting = new CountingSupplier(HASH); + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, counting)); + assertEquals(0, counting.invocations.get()); + } + + @Test + void theVerificationTierIsNotConsultedWhileTheGateMatches() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, true); + + CountingSupplier counting = new CountingSupplier(HASH); + assertFalse(ledger.claim(POLICY, FILE, GATE, counting)); + assertEquals(0, counting.invocations.get()); + } + + @Test + void aRealContentChangeUnderANewGateIsReprocessed() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, true); + + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, hash(NEW_HASH))); + } + + @Test + void aGateOnlySettledRowCannotBeContentVerifiedSoItReprocesses() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + + // The row stored no hash, so "same content" is unprovable: reprocess on gate change. + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + } + + @Test + void aFailedFileStaysParkedThroughATouch() { + ledger.claim(POLICY, FILE, GATE, hash(HASH)); + ledger.settle(POLICY, FILE, GATE, HASH, false); + + // A touch must not resurrect an ERROR row; only a real content change does. + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + assertTrue(ledger.claim(POLICY, FILE, "100:3333", hash(NEW_HASH))); + } + + @Test + void interruptedRunsAreRetriedABoundedNumberOfTimes() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // attempt 1 dies with the JVM + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // attempt 2 + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // attempt 3, the last + ledger.recoverInterrupted(); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); // parked: no crash-loop + } + + @Test + void aNewGateResetsTheInterruptRetryBudgetInGateOnlyMode() { + for (int attempt = 0; attempt < ProcessedLedger.MAX_ATTEMPTS; attempt++) { + ledger.claim(POLICY, FILE, GATE, null); + ledger.recoverInterrupted(); + } + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); // fresh budget at the new version + } + + @Test + void aTouchDoesNotResetTheInterruptRetryBudgetWhenContentIsVerified() { + assertTrue(ledger.claim(POLICY, FILE, GATE, hash(HASH))); // attempt 1 + ledger.recoverInterrupted(); + // Same content, moved gate: still the interrupted work, still bounded. + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); // attempt 2 + ledger.recoverInterrupted(); + assertTrue(ledger.claim(POLICY, FILE, "100:3333", hash(HASH))); // attempt 3 + ledger.recoverInterrupted(); + + assertFalse(ledger.claim(POLICY, FILE, "100:4444", hash(HASH))); // parked + assertTrue(ledger.claim(POLICY, FILE, "100:5555", hash(NEW_HASH))); // real change: fresh + } + + @Test + void recoveryOnlyTouchesInFlightRows() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + ledger.recoverInterrupted(); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); // still DONE, not retried + } + + @Test + void anOutputIsSkippedByItsProducerButSeenByOtherPolicies() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); // producer skips its own output + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); // chaining still works + } + + @Test + void anOutputIsSkippedByAHashVerifyingProducerEvenIfTheGateMoved() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, hash(HASH))); + } + + @Test + void policiesTrackTheSameFileIndependently() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + } + + @Test + void statesForSnapshotsOnlyExistingRows() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + + Map states = ledger.statesFor(POLICY, List.of(FILE, "/in/other.pdf")); + + assertEquals(1, states.size()); + assertEquals(ProcessedFileStatus.PROCESSING, states.get(FILE).status()); + assertEquals(GATE, states.get(FILE).gate()); + } + + @Test + void aStaleAbsentSnapshotLosesTheClaimRaceInsteadOfDoubleClaiming() { + ClaimState absent = ledger.statesFor(POLICY, List.of(FILE)).get(FILE); // no row yet + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); // another sweep wins meanwhile + + assertFalse(ledger.claim(POLICY, FILE, GATE, null, absent)); + } + + @Test + void aStaleSettledSnapshotCannotReclaimAnInFlightRow() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + ledger.settle(POLICY, FILE, GATE, null, true); + ClaimState settled = ledger.statesFor(POLICY, List.of(FILE)).get(FILE); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); // a fresh sweep reclaims first + + assertFalse(ledger.claim(POLICY, FILE, "100:3333", null, settled)); + } + + @Test + void aForgottenOutputIsClaimableAtAnyVersion() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + ledger.forgetOutput(POLICY, FILE, GATE); + + // Even a byte-identical file at that identity is fresh work: the record is gone. + assertTrue(ledger.claim(POLICY, FILE, GATE, hash(HASH))); + } + + @Test + void forgetOutputLeavesARowReclaimedInTheMeantime() { + ledger.recordOutput(POLICY, FILE, GATE, HASH); + assertTrue(ledger.claim(POLICY, FILE, NEW_GATE, null)); // a real claim took the row over + + ledger.forgetOutput(POLICY, FILE, GATE); + + assertFalse(ledger.claim(POLICY, FILE, NEW_GATE, null)); // still in flight, not deleted + } + + @Test + void deletionConsensusNeedsEveryClaimantSettledDone() { + assertTrue(ledger.allSettledDone(FILE)); // vacuous: no rows yet + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + ledger.settle(POLICY, FILE, GATE, null, true); + assertFalse(ledger.allSettledDone(FILE)); // the other claim is still in flight + ledger.settle(OTHER_POLICY, FILE, GATE, null, true); + assertTrue(ledger.allSettledDone(FILE)); + } + + @Test + void aFailedClaimVetoesDeletionConsensus() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertTrue(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + ledger.settle(POLICY, FILE, GATE, null, true); + ledger.settle(OTHER_POLICY, FILE, GATE, null, false); + assertFalse(ledger.allSettledDone(FILE)); + } + + @Test + void anInterruptedClaimVetoesDeletionConsensus() { + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + ledger.recoverInterrupted(); + assertFalse(ledger.allSettledDone(FILE)); + } + + @Test + void settleRecreatesARowRemovedMidRun() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.clearPolicy(POLICY); // e.g. a clear-history while the run is in flight + ledger.settle(POLICY, FILE, GATE, null, true); + + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void presenceCleanupRemovesOnlyUnseenSettledRows() { + String inFlight = "/in/in-flight.pdf"; + String stillPresent = "/in/still-present.pdf"; + String deleted = "/in/deleted.pdf"; + ledger.claim(POLICY, inFlight, GATE, null); + ledger.recordOutput(POLICY, stillPresent, GATE, HASH); + ledger.recordOutput(POLICY, deleted, GATE, HASH); + + clock.addAndGet(10_000); + long sweepStart = clock.get(); + ledger.markSeen(POLICY, List.of(inFlight, stillPresent)); // deleted.pdf is gone from disk + assertEquals(1, ledger.deleteUnseen(POLICY, sweepStart)); + + assertFalse(ledger.claim(POLICY, inFlight, GATE, null)); // in-flight row survived + assertFalse(ledger.claim(POLICY, stillPresent, GATE, null)); // stamped row survived + assertTrue(ledger.claim(POLICY, deleted, GATE, null)); // forgotten: a re-drop reprocesses + } + + @Test + void presenceCleanupNeverRemovesInFlightRowsEvenUnstamped() { + ledger.claim(POLICY, FILE, GATE, null); + clock.addAndGet(10_000); + + assertEquals(0, ledger.deleteUnseen(POLICY, clock.get())); + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void rowsWrittenDuringTheSweepSurviveItsCleanup() { + long sweepStart = clock.get(); + // Recorded after the sweep's cutoff: unseen by it, but newer, so it must survive. + clock.addAndGet(5); + ledger.recordOutput(POLICY, FILE, GATE, HASH); + + assertEquals(0, ledger.deleteUnseen(POLICY, sweepStart)); + assertFalse(ledger.claim(POLICY, FILE, GATE, null)); + } + + @Test + void markSeenOnUnknownIdentitiesIsANoOp() { + ledger.markSeen(POLICY, List.of("/never/claimed.pdf")); + assertEquals(0, ledger.deleteUnseen(POLICY, clock.get())); + } + + @Test + void clearPolicyForgetsOnlyThatPolicy() { + ledger.claim(POLICY, FILE, GATE, null); + ledger.settle(POLICY, FILE, GATE, null, true); + ledger.claim(OTHER_POLICY, FILE, GATE, null); + ledger.settle(OTHER_POLICY, FILE, GATE, null, true); + + ledger.clearPolicy(POLICY); + + assertTrue(ledger.claim(POLICY, FILE, GATE, null)); + assertFalse(ledger.claim(OTHER_POLICY, FILE, GATE, null)); + } + + static Supplier hash(String value) { + return () -> value; + } + + static final class CountingSupplier implements Supplier { + final AtomicInteger invocations = new AtomicInteger(); + private final String value; + + CountingSupplier(String value) { + this.value = value; + } + + @Override + public String get() { + invocations.incrementAndGet(); + return value; + } + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java index f1b8b599ac..f9e1c92403 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/FolderOutputSinkTest.java @@ -10,6 +10,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,24 +22,35 @@ import org.springframework.core.io.Resource; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.model.job.ResultFile; import stirling.software.proprietary.policy.config.FolderAccessGuard; +import stirling.software.proprietary.policy.ledger.FolderIdentities; +import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger; import stirling.software.proprietary.policy.model.OutputSpec; import stirling.software.proprietary.policy.source.InProcessSourceStore; -/** Tests for {@link FolderOutputSink}: outputs are written to the configured directory on disk. */ +/** + * Tests for {@link FolderOutputSink}: outputs are staged hidden, recorded in the ledger, then + * atomically renamed into the configured directory. + */ class FolderOutputSinkTest { + private static final OutputDelivery AD_HOC = new OutputDelivery("run-1", null); + private static final OutputDelivery POLICY_RUN = new OutputDelivery("run-1", "p1"); + @TempDir Path tempDir; private FolderOutputSink sink; + private InProcessProcessedLedger ledger; @BeforeEach void setUp() { ApplicationProperties properties = new ApplicationProperties(); properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); + ledger = new InProcessProcessedLedger(); sink = new FolderOutputSink( new FolderAccessGuard( - properties, new StandardEnvironment(), new InProcessSourceStore())); + properties, new StandardEnvironment(), new InProcessSourceStore()), + ledger); } @Test @@ -46,13 +58,80 @@ class FolderOutputSinkTest { Path out = tempDir.resolve("out"); List outputs = List.of(named("a.pdf", "aaa"), named("b.pdf", "bb")); - List results = - sink.deliver("run-1", outputs, OutputSpec.folder(out.toString())); + List results = sink.deliver(AD_HOC, outputs, OutputSpec.folder(out.toString())); assertEquals(2, results.size()); assertTrue(Files.exists(out.resolve("a.pdf"))); assertEquals("aaa", Files.readString(out.resolve("a.pdf"))); assertEquals("bb", Files.readString(out.resolve("b.pdf"))); + // Nothing left behind in the staging dir. + try (Stream staged = Files.list(out.resolve(".stirling").resolve("tmp"))) { + assertEquals(0, staged.count()); + } + } + + @Test + void recordsThePolicysOutputsSoOnlyOtherPoliciesReprocessThem() throws IOException { + Path out = tempDir.resolve("out"); + + sink.deliver(POLICY_RUN, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + Path delivered = FolderIdentities.canonicalDir(out).resolve("a.pdf"); + String gate = FolderIdentities.statGate(delivered); + assertFalse(ledger.claim("p1", delivered.toString(), gate, null)); // producer skips it + assertTrue(ledger.claim("p2", delivered.toString(), gate, null)); // chaining still works + } + + @Test + void aHashVerifyingProducerSkipsItsOwnOutputEvenIfTheGateMoved() throws IOException { + Path out = tempDir.resolve("out"); + + sink.deliver(POLICY_RUN, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + // A hash-verifying reader matches on content even when the stat moved. + Path delivered = FolderIdentities.canonicalDir(out).resolve("a.pdf"); + assertFalse( + ledger.claim( + "p1", + delivered.toString(), + "999:12345", + () -> { + try { + return FolderIdentities.contentHash(delivered); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + })); + } + + @Test + void recordsAnOutputBeforeItBecomesVisible() throws IOException { + Path out = tempDir.resolve("out"); + VisibilityAssertingLedger orderedLedger = new VisibilityAssertingLedger(); + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowedFolderRoots(List.of(tempDir.toString())); + FolderOutputSink orderedSink = + new FolderOutputSink( + new FolderAccessGuard( + properties, new StandardEnvironment(), new InProcessSourceStore()), + orderedLedger); + + orderedSink.deliver( + POLICY_RUN, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + assertTrue(orderedLedger.recorded); + assertTrue(Files.exists(out.resolve("a.pdf"))); + } + + @Test + void adHocDeliveriesRecordNothing() throws IOException { + Path out = tempDir.resolve("out"); + + sink.deliver(AD_HOC, List.of(named("a.pdf", "aaa")), OutputSpec.folder(out.toString())); + + Path delivered = FolderIdentities.canonicalDir(out).resolve("a.pdf"); + // No row was recorded, so any policy (including a hypothetical producer) may claim it. + assertTrue(ledger.claim("p1", delivered.toString(), "any-gate", null)); } @Test @@ -60,7 +139,7 @@ class FolderOutputSinkTest { Path out = tempDir.resolve("out"); List outputs = List.of(named("a.pdf", "first"), named("a.pdf", "second")); - sink.deliver("run-1", outputs, OutputSpec.folder(out.toString())); + sink.deliver(AD_HOC, outputs, OutputSpec.folder(out.toString())); assertTrue(Files.exists(out.resolve("a.pdf"))); assertTrue(Files.exists(out.resolve("a (1).pdf"))); @@ -72,7 +151,7 @@ class FolderOutputSinkTest { assertThrows(IllegalArgumentException.class, () -> sink.validate(noDir)); assertThrows( IllegalArgumentException.class, - () -> sink.deliver("run-1", List.of(named("a.pdf", "x")), noDir)); + () -> sink.deliver(AD_HOC, List.of(named("a.pdf", "x")), noDir)); } @Test @@ -81,7 +160,7 @@ class FolderOutputSinkTest { assertThrows(IllegalArgumentException.class, () -> sink.validate(outside)); assertThrows( IllegalArgumentException.class, - () -> sink.deliver("run-1", List.of(named("a.pdf", "x")), outside)); + () -> sink.deliver(AD_HOC, List.of(named("a.pdf", "x")), outside)); } @Test @@ -90,7 +169,7 @@ class FolderOutputSinkTest { List outputs = List.of(named("../escape.pdf", "x"), named("nested/deep.pdf", "y")); - sink.deliver("run-1", outputs, OutputSpec.folder(out.toString())); + sink.deliver(AD_HOC, outputs, OutputSpec.folder(out.toString())); // Each name is reduced to its bare form inside the target dir; nothing escapes. assertTrue(Files.exists(out.resolve("escape.pdf"))); @@ -106,4 +185,20 @@ class FolderOutputSinkTest { } }; } + + /** Fails the delivery if an output is visible at its final path before being recorded. */ + private static class VisibilityAssertingLedger extends InProcessProcessedLedger { + + private boolean recorded; + + @Override + public synchronized void recordOutput( + String policyId, String identity, String gate, String contentHash) { + assertFalse( + Files.exists(Path.of(identity)), + "output must be recorded before it is visible at its final path"); + recorded = true; + super.recordOutput(policyId, identity, gate, contentHash); + } + } } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java index 58c5123327..2c3adf691e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/trigger/FolderWatchTriggerTest.java @@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.trigger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -26,6 +27,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.common.model.ApplicationProperties; import stirling.software.proprietary.policy.engine.PolicyRunner; +import stirling.software.proprietary.policy.engine.SweepKind; import stirling.software.proprietary.policy.input.InputSource; import stirling.software.proprietary.policy.model.InputSpec; import stirling.software.proprietary.policy.model.OutputSpec; @@ -100,8 +102,8 @@ class FolderWatchTriggerTest { trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(a); - verify(policyRunner, never()).run(b); + verify(policyRunner).run(a, SweepKind.LIGHT); + verify(policyRunner, never()).run(eq(b), any()); } @Test @@ -112,8 +114,8 @@ class FolderWatchTriggerTest { trigger.runForChangedDirs(Set.of(normalized("/in/a"))); - verify(policyRunner).run(good); - verify(policyRunner, never()).run(bad); + verify(policyRunner).run(good, SweepKind.LIGHT); + verify(policyRunner, never()).run(eq(bad), any()); } @Test diff --git a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql new file mode 100644 index 0000000000..f3a81de753 --- /dev/null +++ b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql @@ -0,0 +1,30 @@ +-- Per-policy processed-file ledger: +-- +-- policy_processed_files one row per (policy, file identity) recording the version a policy +-- last settled that file at, so folder sources track files in place +-- instead of moving them into a work directory. signature is a cheap +-- version gate (folder: size:mtime); content_hash an optional strong +-- token consulted only when the gate moves. Rows are claimed into +-- PROCESSING, settled to DONE/ERROR, flipped to INTERRUPTED at boot if +-- a run died with the JVM, and pruned once the file is gone from all of +-- the policy's sources, so the table stays near the set of files +-- currently present. +-- +-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create +-- this, but the migration keeps the schema explicit for the Flyway-managed deployments. + +CREATE TABLE IF NOT EXISTS policy_processed_files ( + policy_id VARCHAR(255) NOT NULL, + identity_hash VARCHAR(64) NOT NULL, + identity VARCHAR(4096), + signature VARCHAR(255) NOT NULL, + content_hash VARCHAR(64), + status VARCHAR(16) NOT NULL, + attempts SMALLINT NOT NULL DEFAULT 1, + last_seen BIGINT NOT NULL DEFAULT 0, + updated_at BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (policy_id, identity_hash) +); + +CREATE INDEX IF NOT EXISTS idx_processed_files_policy_seen + ON policy_processed_files (policy_id, last_seen); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 5f07f5d462..2daef44903 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7327,6 +7327,7 @@ showMore = "Show more" sources = "Sources" [portal.policies.detail.actions] +clearHistory = "Clear processed history" delete = "Delete" editSettings = "Edit settings" pause = "Pause" @@ -7896,6 +7897,14 @@ helperText = "Absolute path Stirling watches for files to process." label = "Directory path" placeholder = "/data/incoming" +[portal.sources.types.folder.fields.identity] +helperText = "Content check reads each changed file, so renames and touches that don't alter content are not reprocessed." +label = "Change detection" + +[portal.sources.types.folder.fields.identity.options] +hash = "Size, date and content check" +stat = "Size and date modified" + [portal.sources.types.folder.fields.mode] label = "Read mode" @@ -7903,6 +7912,13 @@ label = "Read mode" consume = "Consume: process each file once" snapshot = "Snapshot: re-read the folder every run" +[portal.sources.types.folder.fields.recursive] +label = "Folder depth" + +[portal.sources.types.folder.fields.recursive.options] +all = "Include subfolders" +top = "Top level only" + [portal.sources.types.unknown] label = "Source" diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index e225449a16..a28b5d2889 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -177,6 +177,19 @@ export async function deletePolicy(id: string): Promise { ); } +/** + * DELETE /api/v1/policies/{id}/processed-history — forget which source files + * the policy has processed, so its next sweep reprocesses everything present. + */ +export async function clearProcessedHistory(id: string): Promise { + await apiClient.local.json( + `/api/v1/policies/${encodeURIComponent(id)}/processed-history`, + { + method: "DELETE", + }, + ); +} + // ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ──────── const DEFAULT_RETRIES = 3; diff --git a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx index d638cc4b7a..3faa37d076 100644 --- a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx @@ -23,6 +23,7 @@ interface PolicyDetailPanelProps { onRun?: () => void; onTogglePause: () => void; onDelete: () => void; + onClearHistory?: () => void; onRetry?: (item: PolicyActivityItem) => void; } @@ -106,6 +107,7 @@ export function PolicyDetailPanel({ onRun, onTogglePause, onDelete, + onClearHistory, onRetry, }: PolicyDetailPanelProps) { const { t } = useTranslation(); @@ -113,6 +115,9 @@ export function PolicyDetailPanel({ const { category, config, state, steps, stats, activity } = policy; const isPaused = state.status === "paused"; const canDelete = state.isDefault !== true; + // Processed history only exists for watched sources; editor uploads are never ledgered. + const canClearHistory = + onClearHistory !== undefined && state.sources.some((s) => s !== "editor"); const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : null; const hasEditorSource = state.sources.includes("editor"); @@ -161,6 +166,16 @@ export function PolicyDetailPanel({ {t("portal.policies.detail.actions.runNow")} )} + {canClearHistory && ( + + )} +

- diff --git a/frontend/editor/src/portal/components/MocksToggle.css b/frontend/editor/src/portal/components/MocksToggle.css deleted file mode 100644 index 68e5262ede..0000000000 --- a/frontend/editor/src/portal/components/MocksToggle.css +++ /dev/null @@ -1,51 +0,0 @@ -.portal-mocks-toggle { - display: inline-flex; - align-items: center; - gap: var(--space-1_5); - padding: var(--space-1) var(--space-2); - font-family: var(--font-mono); - font-size: 0.6875rem; - font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - border-radius: var(--radius-sm); - border: 1px dashed transparent; - transition: - background var(--motion-fast), - border-color var(--motion-fast), - color var(--motion-fast); -} - -.portal-mocks-toggle.is-on { - color: var(--color-amber-dark); - background: var(--color-amber-light); - border-color: var(--color-amber-border); -} - -.portal-mocks-toggle.is-off { - color: var(--color-text-4); - background: var(--color-bg-muted); - border-color: var(--color-border); -} - -.portal-mocks-toggle:hover { - filter: brightness(1.04); -} - -.portal-mocks-toggle.is-pending { - opacity: 0.6; - cursor: progress; -} - -.portal-mocks-toggle__dot { - width: 0.4375rem; - height: 0.4375rem; - border-radius: 50%; - background: currentColor; - box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 28%, transparent); -} - -.portal-mocks-toggle.is-off .portal-mocks-toggle__dot { - background: var(--color-text-5); - box-shadow: none; -} diff --git a/frontend/editor/src/portal/components/MocksToggle.tsx b/frontend/editor/src/portal/components/MocksToggle.tsx deleted file mode 100644 index a0a12d062e..0000000000 --- a/frontend/editor/src/portal/components/MocksToggle.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/// -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - readMocksPreference, - writeMocksPreference, -} from "@portal/mocks/preference"; -import "@portal/components/MocksToggle.css"; -import { Button } from "@app/ui/Button"; - -/** - * Dev-only header chip that flips MSW interception on and off. Persists the - * preference to localStorage so it survives reloads. Hidden entirely in - * production builds — there's no MSW worker to toggle there. - * - * Toggling reloads the page. Without a reload, components that already - * fetched data via useAsync keep showing the cached result, which makes the - * toggle feel like it does nothing. A reload gives a clean view of what the - * app looks like with/without mocks. - */ -export function MocksToggle() { - const { t } = useTranslation(); - const [enabled] = useState(() => readMocksPreference()); - const [pending, setPending] = useState(false); - - if (!import.meta.env.DEV) return null; - - function toggle() { - if (pending) return; - setPending(true); - writeMocksPreference(!enabled); - window.location.reload(); - } - - return ( - - ); -} diff --git a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx index d10d08bf27..eb3ffdef13 100644 --- a/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx +++ b/frontend/editor/src/portal/components/account-link/LinkedInstancesTable.stories.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { listInstances, type LinkedInstanceRow } from "@portal/mocks/link"; +import type { LinkedInstanceRow } from "@portal/api/link"; +import { listInstances } from "@portal/mocks/link"; import { LinkedInstancesTable } from "@portal/components/account-link/LinkedInstancesTable"; import "@portal/views/AccountLink.css"; diff --git a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx index 2e19223ce8..5357ffd52f 100644 --- a/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentCard.tsx @@ -44,7 +44,7 @@ export function ComponentCard({

{component.name}

- {maturity.label} + {t(maturity.label)} {!unlocked && ( - {formatPrice(component.pricing)} + {formatPrice(component.pricing, t)} @stirling/{component.package} diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx index b2d8a3f173..337cfc6119 100644 --- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx @@ -75,7 +75,7 @@ export function ComponentDetailModal({ {component.name} - {maturity.label} + {t(maturity.label)} } @@ -84,7 +84,7 @@ export function ComponentDetailModal({ unlocked ? (
- {formatPrice(component.pricing)} + {formatPrice(component.pricing, t)}
@@ -90,7 +90,7 @@ export function DealJourney({ loading={advancing} onClick={() => onAdvance(currentStage)} > - {currentStep.gatingAction} + {t(currentStep.gatingAction)} )}
diff --git a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx index 8189d006f8..72ab22a341 100644 --- a/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx +++ b/frontend/editor/src/portal/components/procurement/DocumentLedger.tsx @@ -78,7 +78,9 @@ export function DocumentLedger({ {group.label} {blurb && ( - · {blurb} + + · {t(blurb)} + )} {cur && ( diff --git a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx b/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx index e88c810801..eae9db4d99 100644 --- a/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/LockedState.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { LockedState } from "@portal/components/procurement/LockedState"; -import { JOURNEY } from "@portal/mocks/procurement"; +import { JOURNEY } from "@portal/api/procurement"; import "@portal/views/Procurement.css"; const meta: Meta = { diff --git a/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx b/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx index a27b0b90aa..7adb6951fb 100644 --- a/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx +++ b/frontend/editor/src/portal/components/procurement/StageStepper.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { StageStepper } from "@portal/components/procurement/StageStepper"; -import { JOURNEY } from "@portal/mocks/procurement"; +import { JOURNEY } from "@portal/api/procurement"; import "@portal/views/Procurement.css"; const meta: Meta = { diff --git a/frontend/editor/src/portal/components/procurement/StageStepper.tsx b/frontend/editor/src/portal/components/procurement/StageStepper.tsx index 1981078459..3b17f9a0d9 100644 --- a/frontend/editor/src/portal/components/procurement/StageStepper.tsx +++ b/frontend/editor/src/portal/components/procurement/StageStepper.tsx @@ -1,4 +1,5 @@ import { Fragment } from "react"; +import { useTranslation } from "react-i18next"; import type { DealStage, JourneyStep } from "@portal/api/procurement"; /** Status of a step relative to the deal's current stage. */ @@ -19,6 +20,7 @@ export function StageStepper({ currentStage: DealStage; locked?: boolean; }) { + const { t } = useTranslation(); const order = journey.map((s) => s.stage); const curIdx = locked ? -1 : order.indexOf(currentStage); @@ -40,7 +42,7 @@ export function StageStepper({ )}
- {step.label} + {t(step.label)}
); diff --git a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx index 305bcc622d..515c95923e 100644 --- a/frontend/editor/src/portal/components/users/InviteMemberModal.tsx +++ b/frontend/editor/src/portal/components/users/InviteMemberModal.tsx @@ -41,9 +41,10 @@ interface InviteMemberModalProps { type InviteRole = "member" | "admin"; type Mode = "email" | "direct"; -const ROLE_SELECT_OPTIONS: { value: InviteRole; label: string }[] = [ - { value: "member", label: ROLE_LABEL.member }, - { value: "admin", label: ROLE_LABEL.admin }, +// Values hold i18n keys; resolved with t() where the select renders. +const ROLE_SELECT_OPTIONS: { value: InviteRole; labelKey: string }[] = [ + { value: "member", labelKey: ROLE_LABEL.member }, + { value: "admin", labelKey: ROLE_LABEL.admin }, ]; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -117,9 +118,11 @@ export function InviteMemberModal({ }, [email, username, password]); // Drop the "admin" (Org Owner) option where it can't be assigned (SaaS). - const roleOptions = adminRole - ? ROLE_SELECT_OPTIONS - : ROLE_SELECT_OPTIONS.filter((o) => o.value !== "admin"); + const roleOptions = ( + adminRole + ? ROLE_SELECT_OPTIONS + : ROLE_SELECT_OPTIONS.filter((o) => o.value !== "admin") + ).map((o) => ({ value: o.value, label: t(o.labelKey) })); const authTypeOptions: { value: AuthType; label: string }[] = [ { value: "WEB", label: t("users.invite.authWeb", "Password") }, diff --git a/frontend/editor/src/portal/contexts/TierContext.tsx b/frontend/editor/src/portal/contexts/TierContext.tsx index 1b4742447b..cb8a730640 100644 --- a/frontend/editor/src/portal/contexts/TierContext.tsx +++ b/frontend/editor/src/portal/contexts/TierContext.tsx @@ -1,12 +1,10 @@ import { createContext, useContext, - useEffect, useMemo, useState, type ReactNode, } from "react"; -import { readMocksPreference } from "@portal/mocks/preference"; import { usePlanTier } from "@portal/contexts/usePlanTier"; export type Tier = "free" | "pro" | "enterprise"; @@ -26,9 +24,9 @@ export const TIER_INFO: Record = { interface TierContextValue { tier: Tier; - /** No-op when MSW mocks are off (tier is derived from real link state). */ + /** No-op when the tier is derived from the real plan (i.e. in the app). */ setTier: (tier: Tier) => void; - /** True when the tier value is derived from the real wallet/link, not the dropdown. */ + /** True when the tier value is derived from the real plan, not pinned. */ isDerived: boolean; } @@ -36,38 +34,33 @@ const TierContext = createContext(null); export function TierProvider({ children, - initialTier = "pro", + initialTier, }: { children: ReactNode; + /** + * Pins the tier to a fixed, locally settable value. Storybook and demo + * surfaces pass this to stage a specific tier; the app omits it, so the + * tier is always derived from the real plan (see usePlanTier — link state + * self-hosted, wallet on SaaS). + */ initialTier?: Tier; }) { - // Mocks toggling reloads the page (see MocksToggle), so a single read at mount - // is correct — the preference can't change without us remounting. - const mocksOn = useMemo(() => readMocksPreference(), []); - // Real derived tier. Its source is a per-flavor seam: self-hosted derives it - // from the link/subscription state, SaaS from the wallet (see usePlanTier). + const pinned = initialTier !== undefined; + const [pinnedTier, setPinnedTier] = useState(initialTier ?? "free"); const derivedTier = usePlanTier(); - const [mockTier, setMockTier] = useState(initialTier); - - // When mocks are off, mirror the derived tier so any component keyed on `tier` - // (sidebar plan badge, gated panels) stays consistent. When mocks are on, the - // dropdown wins. - useEffect(() => { - if (!mocksOn) { - setMockTier(derivedTier); - } - }, [mocksOn, derivedTier]); - + // Memo on the resolved tier (not its inputs) so plan transitions that map to + // the same tier don't re-render every consumer. + const tier = pinned ? pinnedTier : derivedTier; const value = useMemo( () => ({ - tier: mocksOn ? mockTier : derivedTier, - // Setter is a no-op when mocks are off — UI controls can disable themselves + tier, + // Setter is a no-op when derived — UI controls can disable themselves // via `isDerived`, but even if one slips through, it has no effect. - setTier: mocksOn ? setMockTier : () => {}, - isDerived: !mocksOn, + setTier: pinned ? setPinnedTier : () => {}, + isDerived: !pinned, }), - [mocksOn, mockTier, derivedTier], + [pinned, tier], ); return {children}; diff --git a/frontend/editor/src/portal/mocks/agents.ts b/frontend/editor/src/portal/mocks/agents.ts index e8523d11c7..6ff7b14910 100644 --- a/frontend/editor/src/portal/mocks/agents.ts +++ b/frontend/editor/src/portal/mocks/agents.ts @@ -1,125 +1,14 @@ /** - * Agent Builder fixtures and the types api/agents.ts shares with them. + * Agent Builder fixtures. Types live in api/agents.ts (the backend contract); + * this module only builds fake data for Storybook and tests. * * An "agent" here is an AI agent that classifies, extracts from, and routes * documents. The builder is its lifecycle surface: scenarios (named test * cases), tool-access governance, an eval / golden set, and version history. - * - * api/agents.ts imports the types; the MSW handlers serve this fixture data - * over the intercepted apiClient.local.json() calls. Components never reach into this - * module directly. Once a real backend exists the handlers stop being - * registered and these fixtures can be deleted (or kept as test seeds). */ import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Domain types */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type AgentStatus = "draft" | "published"; - -/** - * Tool-access posture. `broad` lets the agent call any tool it can reach; - * `restricted` is allow-by-default minus an explicit deny list (the governance - * mode enterprise tenants use to fence agents away from sensitive tools). - */ -export type ToolMode = "broad" | "restricted"; - -/** A named test case describing expected agent behaviour for a kind of input. */ -export interface Scenario { - id: string; - name: string; - /** What the agent is expected to do for this input. */ - expectation: string; - /** Whether this scenario is currently exercised by the eval run. */ - enabled: boolean; -} - -/** A single golden-set check with its last-run outcome. */ -export interface EvalCase { - id: string; - name: string; - /** Last observed pass/fail; null when the case has never been run. */ - passing: boolean | null; - /** Mean latency of the last run in milliseconds. */ - latencyMs: number; -} - -export interface AgentVersion { - /** Display label, e.g. "v3" or "v2-draft". */ - version: string; - status: AgentStatus; - /** ISO timestamp the version was created. */ - createdAt: string; - author: string; - /** One-line change summary. */ - note: string; -} - -export interface Agent { - id: string; - name: string; - /** One-line role description shown under the name in the selector. */ - role: string; - status: AgentStatus; - /** Current working version, e.g. "v3" or "v2-draft". */ - version: string; - model: string; - scenarios: Scenario[]; - toolMode: ToolMode; - /** Tools the agent may not call when `toolMode` is "restricted". */ - deniedTools: string[]; - /** Count of golden-set cases currently passing. */ - evalsPassing: number; - /** Total golden-set cases. */ - evalsTotal: number; - evalCases: EvalCase[]; - versions: AgentVersion[]; -} - -export interface AgentsSummary { - /** Agents in the "published" state. */ - activeAgents: number; - /** Total agents regardless of status. */ - totalAgents: number; - /** Mean eval pass-rate across all agents, 0..1. */ - avgPassRate: number; - /** Total scenarios across all agents. */ - totalScenarios: number; - /** Latest published version label across the fleet, e.g. "v3". */ - latestPublished: string; -} - -export interface AgentsResponse { - summary: AgentsSummary; - agents: Agent[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Presentation metadata (chip tone per status). Product copy, client-side. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export const AGENT_STATUS_TONE: Record = { - published: "success", - draft: "neutral", -}; - -/** - * Catalogue of tools an agent can be granted or denied. Surfaced as the chip - * palette in restricted mode so the deny list is picked from a known set - * rather than free-typed. - */ -export const TOOL_CATALOGUE = [ - "extract.fields", - "classify.document", - "route.pipeline", - "lookup.crm", - "send.email", - "write.audit", - "read.pii", - "invoke.webhook", -] as const; +import type { Agent, AgentsResponse, AgentsSummary } from "@portal/api/agents"; /* ──────────────────────────────────────────────────────────────────────── */ /* Fixture builders */ diff --git a/frontend/editor/src/portal/mocks/browser.ts b/frontend/editor/src/portal/mocks/browser.ts deleted file mode 100644 index 077b41b456..0000000000 --- a/frontend/editor/src/portal/mocks/browser.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { setupWorker } from "msw/browser"; -import { embeddedDataHandlers } from "@portal/mocks/handlers"; - -// Data handlers only (see embeddedDataHandlers) and no seeded auth token: the -// portal shares an origin and auth session with the host editor, so mocking auth -// or seeding a token would log the editor out. -export const worker = setupWorker(...embeddedDataHandlers); - -let workerStarted = false; - -/** - * Start the MSW worker. Idempotent — calling repeatedly is safe. - * - * The toggle flips MSW by writing the preference to localStorage and - * reloading the page, so there's no need for a `stopMockWorker` counterpart: - * the next boot just decides whether to call this or not. - */ -export async function startMockWorker(): Promise { - if (workerStarted) return; - await worker.start({ - onUnhandledRequest: "bypass", - serviceWorker: { url: "/mockServiceWorker.js" }, - quiet: true, - }); - workerStarted = true; -} diff --git a/frontend/editor/src/portal/mocks/docs.ts b/frontend/editor/src/portal/mocks/docs.ts index 2b54d0c17c..9c3c8c3473 100644 --- a/frontend/editor/src/portal/mocks/docs.ts +++ b/frontend/editor/src/portal/mocks/docs.ts @@ -1,8 +1,6 @@ /** - * Developer Docs fixtures and the types api/docs.ts shares with them. - * api/docs.ts imports the types; the MSW handlers in mocks/handlers/ serve the - * fixture data over the intercepted apiClient.local.json() calls. Components never reach - * into this module directly. + * Developer Docs fixtures. Types live in api/docs.ts (the backend contract); + * this module only builds fake data for Storybook and tests. * * Two payloads back the surface: * - the left-hand nav tree (`buildDocsNav`), and @@ -12,37 +10,25 @@ * * Rate limits scale with plan: free is throttled hard, pro lifts the ceiling, * enterprise is negotiated ("Custom"). The rest of the content is tier-neutral. - * - * Once a real backend exists the MSW handlers stop being registered and these - * fixtures can be deleted (or kept as test seeds). */ -import type { CardAccent } from "@app/ui"; import type { Tier } from "@portal/contexts/TierContext"; -import type { CodeLang } from "@app/ui"; +import type { + AgentSkill, + ApiErrorRow, + CodeSample, + DocsContent, + DocsNavSection, + EmbedComponent, + Playbook, + RateLimit, + Sdk, +} from "@portal/api/docs"; /* ──────────────────────────────────────────────────────────────────────── */ /* Navigation */ /* ──────────────────────────────────────────────────────────────────────── */ -/** A leaf entry in the docs nav — maps 1:1 to a content section. */ -export interface DocsNavItem { - /** Stable id used as the in-page section anchor. */ - id: string; - label: string; - /** Optional badge shown to the right of the label (e.g. "New", "Beta"). */ - badge?: string; -} - -/** A top-level grouping in the docs nav tree. */ -export interface DocsNavSection { - id: string; - label: string; - /** Single-glyph icon shown beside the section header. */ - icon: string; - items: DocsNavItem[]; -} - export function buildDocsNav(): DocsNavSection[] { return [ { @@ -96,79 +82,6 @@ export function buildDocsNav(): DocsNavSection[] { /* Reference content */ /* ──────────────────────────────────────────────────────────────────────── */ -/** One tab in a multi-language code snippet. */ -export interface CodeSample { - /** Stable key used as the snippet tab id. */ - key: string; - label: string; - lang: CodeLang; - code: string; -} - -/** Per-tier request ceilings rendered by the rate-limits section. */ -export interface RateLimit { - rpm: string; - burst: string; - concurrency: string; -} - -/** A single HTTP status row in the error table. */ -export interface ApiErrorRow { - code: string; - /** Severity colour — amber for recoverable, red for hard failures. */ - tone: "amber" | "red"; - meaning: string; -} - -export type SdkStatus = "ga" | "beta" | "deprecated"; - -/** An official client library in the SDK matrix. */ -export interface Sdk { - name: string; - /** Single-glyph icon shown beside the name. */ - icon: string; - install: string; - lang: CodeLang; - status: SdkStatus; -} - -/** An embeddable UI component in the drop-in viewer library. */ -export interface EmbedComponent { - name: string; - blurb: string; - /** Stack tag, e.g. "React" or "Web". */ - tag: string; -} - -/** A copy-paste, end-to-end pipeline recipe. */ -export interface Playbook { - title: string; - blurb: string; - /** Ordered stages rendered as a chip flow. */ - steps: string[]; - accent: CardAccent; -} - -/** A bundled, named agent capability — a deterministic op chain. */ -export interface AgentSkill { - name: string; - blurb: string; - /** Op chain shown as a mono string, e.g. "extract · validate". */ - ops: string; -} - -/** The complete data-driven docs payload for one tier. */ -export interface DocsContent { - quickstartSamples: CodeSample[]; - quickstartResponse: string; - rateLimit: RateLimit; - errors: ApiErrorRow[]; - sdks: Sdk[]; - components: EmbedComponent[]; - playbooks: Playbook[]; - skills: AgentSkill[]; -} - const QUICKSTART_SAMPLES: CodeSample[] = [ { key: "curl", diff --git a/frontend/editor/src/portal/mocks/documents.ts b/frontend/editor/src/portal/mocks/documents.ts index b4b7fe2325..02615280bf 100644 --- a/frontend/editor/src/portal/mocks/documents.ts +++ b/frontend/editor/src/portal/mocks/documents.ts @@ -11,136 +11,20 @@ */ import type { Tier } from "@portal/contexts/TierContext"; -import type { ChipAccent, StatusTone } from "@app/ui"; +import type { + DocAuditEvent, + DocAuditKind, + DocumentStatus, + DocumentsResponse, + DocumentsSummary, + ProductType, + ReviewDocument, +} from "@portal/api/documents"; /* ──────────────────────────────────────────────────────────────────────── */ /* Domain types */ /* ──────────────────────────────────────────────────────────────────────── */ -export type DocumentStatus = "processed" | "flagged" | "in-review" | "error"; - -/** Which Stirling product ran the operation. */ -export type ProductType = "API" | "Editor"; - -/** A single field pulled out of the document by extraction. */ -export interface Extraction { - field: string; - value: string; - confidence: number; -} - -export type DocAuditKind = - | "ingested" - | "extracted" - | "flagged" - | "reviewed" - | "approved" - | "archived" - | "elevation"; - -/** One event in a document's lifecycle, newest last. */ -export interface DocAuditEvent { - id: string; - kind: DocAuditKind; - time: string; - actor: string; - detail: string; -} - -export interface ReviewDocument { - id: string; - name: string; - /** File-type label, e.g. "PDF". */ - type: string; - /** Auto-classification label (e.g. "Contract"), or null when not classified. */ - classification: string | null; - /** True when the classification was assigned automatically. */ - auto: boolean; - /** Short descriptive sub-line (editor action or flag reason), or null. */ - note: string | null; - /** Where it was processed. */ - product: ProductType; - /** Pipeline/action, e.g. "contract". Null (or Editor product) renders "Editor". */ - action: string | null; - /** The user who ran it. */ - user: string; - status: DocumentStatus; - /** Reviewer name for in-review docs, e.g. "Sarah K.". */ - reviewer: string | null; - /** Originating source name. */ - source: string; - /** Overall confidence 0..1, or null (unsupported - never shown in the table). */ - confidence: number | null; - fieldsExtracted: number; - /** Relative-time string, e.g. "2 min ago". */ - time: string; - sensitive: boolean; - extractions: Extraction[]; - audit: DocAuditEvent[]; -} - -export interface DocumentsSummary { - totalInQueue: number; - processed: number; - errors: number; - processedToday: number; -} - -export interface DocumentsResponse { - summary: DocumentsSummary; - documents: ReviewDocument[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Presentation metadata (label + chip tone) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export const DOCUMENT_STATUS_LABEL: Record = { - processed: "Processed", - flagged: "Needs Review", - "in-review": "In Review", - error: "Error", -}; - -export const DOCUMENT_STATUS_TONE: Record = { - processed: "success", - flagged: "warning", - "in-review": "purple", - error: "danger", -}; - -export const PRODUCT_CHIP_TONE: Record = { - API: "brand", - Editor: "success", -}; - -/** Classification chip accent: danger when unclassified, warning when it needs a look. */ -export function classificationTone(doc: ReviewDocument): ChipAccent { - if (doc.classification === "Unclassified") return "danger"; - if (doc.status === "processed") return "success"; - return "warning"; -} - -export const DOC_AUDIT_LABEL: Record = { - ingested: "Ingested", - extracted: "Processed", - flagged: "Needs Review", - reviewed: "In Review", - approved: "Approved", - archived: "Archived", - elevation: "Elevation", -}; - -export const DOC_AUDIT_TONE: Record = { - ingested: "info", - extracted: "success", - flagged: "warning", - reviewed: "purple", - approved: "success", - archived: "neutral", - elevation: "purple", -}; - /* ──────────────────────────────────────────────────────────────────────── */ /* Fixture builder */ /* ──────────────────────────────────────────────────────────────────────── */ diff --git a/frontend/editor/src/portal/mocks/editorDeploy.ts b/frontend/editor/src/portal/mocks/editorDeploy.ts index 32d98432f2..04ba214f8e 100644 --- a/frontend/editor/src/portal/mocks/editorDeploy.ts +++ b/frontend/editor/src/portal/mocks/editorDeploy.ts @@ -13,143 +13,13 @@ */ import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Deployment targets */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** Where an Editor deployment can run. */ -export type TargetKind = "cloud" | "docker" | "kubernetes"; - -/** - * Whether a target is usable on the current tier and, if so, whether the org - * has actually stood it up. `locked` targets render an upgrade nudge instead of - * a runnable snippet. - */ -export type TargetState = "running" | "available" | "locked"; - -export interface DeploymentTarget { - kind: TargetKind; - label: string; - /** One-line positioning shown under the title. */ - tagline: string; - state: TargetState; - /** Minimum tier that unlocks this target — drives the upgrade nudge copy. */ - requiresTier: Tier; - /** Install / run snippet for the target's CodeBlock. */ - snippet: string; - /** Language hint for the CodeBlock chrome. */ - snippetLang: "bash" | "plain"; - /** Populated only when `state === "running"`. */ - runningVersion?: string; - /** Count of instances currently reporting in for this target. */ - instanceCount?: number; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Pairing */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** How a self-hosted editor connects itself to the org. */ -export type PairingMethod = "token" | "shortcode" | "iac"; - -export interface PairingOption { - method: PairingMethod; - label: string; - description: string; - /** Minimum tier that unlocks this method. */ - requiresTier: Tier; - /** - * The current secret/handle to display. A long-lived pairing token, a - * TV-style short code, or an IaC reference (e.g. a Terraform module input). - * Pre-masked for token display — never carries the real secret. - */ - value: string; - /** Short codes expire fast; tokens rotate on demand. Relative-time string. */ - expires?: string; - /** Whether this option is currently usable on the active tier. */ - locked: boolean; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Running instances (deployment health) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type InstanceStatus = "healthy" | "degraded" | "offline" | "pairing"; - -export interface EditorInstance { - id: string; - /** Human host label, e.g. "edge-fra-01" or "Managed Cloud (us-east-1)". */ - host: string; - target: TargetKind; - version: string; - region: string; - status: InstanceStatus; - /** Relative-time string, e.g. "12s ago". */ - lastSeen: string; - activeUsers: number; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Summary metric strip */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface DeploymentSummaryMetric { - label: string; - value: string | number; - delta?: number; - deltaDirection?: "up" | "down" | "flat"; - description?: string; -} - -export interface DeploymentSummary { - metrics: DeploymentSummaryMetric[]; - /** Masked service token + its rotation age, shown by the rotation card. */ - serviceToken: { masked: string; lastRotated: string }; - /** Air-gapped activation is enterprise-only; gate the card on this flag. */ - offlineActivationAvailable: boolean; - /** Where users launch the Editor — the org workspace URL (Open in browser). */ - workspaceUrl: string; -} - -export interface EditorDeploymentResponse { - summary: DeploymentSummary; - targets: DeploymentTarget[]; - pairings: PairingOption[]; - instances: EditorInstance[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Presentation metadata (lives client-side — product copy, not data) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface TargetMeta { - icon: string; - tone: "neutral" | "blue" | "purple"; -} - -export const TARGET_META: Record = { - cloud: { icon: "☁", tone: "blue" }, - docker: { icon: "▣", tone: "neutral" }, - kubernetes: { icon: "⎈", tone: "purple" }, -}; - -export const INSTANCE_STATUS_TONE: Record< - InstanceStatus, - "success" | "warning" | "danger" | "info" | "neutral" -> = { - healthy: "success", - degraded: "warning", - offline: "danger", - pairing: "info", -}; - -export const INSTANCE_STATUS_LABEL: Record = { - healthy: "Healthy", - degraded: "Degraded", - offline: "Offline", - pairing: "Pairing", -}; +import type { + DeploymentSummary, + DeploymentTarget, + EditorDeploymentResponse, + EditorInstance, + PairingOption, +} from "@portal/api/editorDeploy"; /* ──────────────────────────────────────────────────────────────────────── */ /* Snippet builders */ diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 0ce3059ff6..8d2139a0b8 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -38,32 +38,5 @@ export const handlers = [ ...linkHandlers, ]; -/** - * The handlers safe to run when the portal shares an origin with the editor. - * Three groups are excluded because their routes overlap endpoints the editor - * itself calls, so mocking them breaks the host app: - * - authHandlers: /api/v1/auth/*, /api/v1/proprietary/ui-data/login (logs the - * editor out; the portal uses the editor's real session instead) - * - policiesHandlers + pipelinesHandlers: both /api/v1/policies* (the editor's - * own policies feature) - * Everything kept is portal-only. `handlers` above is still the full set. - */ -export const embeddedDataHandlers = [ - ...notificationsHandlers, - ...assistantHandlers, - ...searchHandlers, - ...sourcesHandlers, - ...infrastructureHandlers, - ...docsHandlers, - ...procurementHandlers, - ...settingsHandlers, - ...usersHandlers, - ...agentsHandlers, - ...documentsHandlers, - ...sdkComponentsHandlers, - ...editorDeployHandlers, - ...linkHandlers, -]; - export { resetNotificationsStore } from "@portal/mocks/handlers/notifications"; export { resetProcurementStore } from "@portal/mocks/handlers/procurement"; diff --git a/frontend/editor/src/portal/mocks/handlers/link.ts b/frontend/editor/src/portal/mocks/handlers/link.ts index 6ac6781f71..1724ce375f 100644 --- a/frontend/editor/src/portal/mocks/handlers/link.ts +++ b/frontend/editor/src/portal/mocks/handlers/link.ts @@ -1,4 +1,5 @@ import { http, HttpResponse, delay } from "msw"; +import type { LinkInstanceRequest } from "@portal/api/link"; import { getLocalStatus, getLocalUsage, @@ -6,7 +7,6 @@ import { listInstances, revokeInstance, unlinkLocal, - type LinkInstanceRequest, } from "@portal/mocks/link"; /** diff --git a/frontend/editor/src/portal/mocks/handlers/notifications.ts b/frontend/editor/src/portal/mocks/handlers/notifications.ts index e9c809ac52..e3977f543a 100644 --- a/frontend/editor/src/portal/mocks/handlers/notifications.ts +++ b/frontend/editor/src/portal/mocks/handlers/notifications.ts @@ -1,5 +1,6 @@ import { http, HttpResponse, delay } from "msw"; -import { NOTIFICATIONS, type Notification } from "@portal/mocks/notifications"; +import type { Notification } from "@portal/api/notifications"; +import { NOTIFICATIONS } from "@portal/mocks/notifications"; let store: Notification[] = [...NOTIFICATIONS]; diff --git a/frontend/editor/src/portal/mocks/handlers/policies.ts b/frontend/editor/src/portal/mocks/handlers/policies.ts index 8d35ae4535..16bc0933e6 100644 --- a/frontend/editor/src/portal/mocks/handlers/policies.ts +++ b/frontend/editor/src/portal/mocks/handlers/policies.ts @@ -1,10 +1,6 @@ import { http, HttpResponse, delay } from "msw"; -import { - seedPolicies, - seedPolicyRuns, - type WirePolicy, -} from "@portal/mocks/policies"; -import type { PolicyRunView } from "@app/policies/types"; +import { seedPolicies, seedPolicyRuns } from "@portal/mocks/policies"; +import type { PolicyRunView, WirePolicy } from "@app/policies/types"; /** * The portal exercises the REAL policy API base — `/api/v1/policies`, NOT the diff --git a/frontend/editor/src/portal/mocks/handlers/procurement.ts b/frontend/editor/src/portal/mocks/handlers/procurement.ts index e0e6f497c2..37dbc2f75a 100644 --- a/frontend/editor/src/portal/mocks/handlers/procurement.ts +++ b/frontend/editor/src/portal/mocks/handlers/procurement.ts @@ -2,10 +2,12 @@ import { http, HttpResponse, delay } from "msw"; import type { Tier } from "@portal/contexts/TierContext"; import { JOURNEY, - buildProcurement, - seedEnterpriseDeal, type DealStage, type ProcurementResponse, +} from "@portal/api/procurement"; +import { + buildProcurement, + seedEnterpriseDeal, } from "@portal/mocks/procurement"; import { advanceDeal, diff --git a/frontend/editor/src/portal/mocks/infrastructure.ts b/frontend/editor/src/portal/mocks/infrastructure.ts index 5b33e324fb..85aac3eeea 100644 --- a/frontend/editor/src/portal/mocks/infrastructure.ts +++ b/frontend/editor/src/portal/mocks/infrastructure.ts @@ -13,45 +13,29 @@ */ import type { Tier } from "@portal/contexts/TierContext"; +import type { + ApiKey, + AuditEvent, + AuditLogResponse, + AuditSummary, + ComplianceAttestation, + ComplianceCert, + DeploymentRegion, + IpAllowEntry, + KeyManagement, + ModelEntry, + ModelsResponse, + RecentDeployment, + RoutingRule, + SecurityConfig, + StorageConfig, + StorageProvider, +} from "@portal/api/infrastructure"; /* ──────────────────────────────────────────────────────────────────────── */ /* Deployments */ /* ──────────────────────────────────────────────────────────────────────── */ -export type RegionStatus = "healthy" | "degraded" | "down"; - -export interface DeploymentRegion { - name: string; - code: string; - /** Median request latency, ms. */ - latencyMs: number; - /** Current load as a fraction of provisioned capacity (0–1). */ - load: number; - status: RegionStatus; - /** Deployed Stirling engine version. */ - version: string; - /** 30-day uptime as a fraction (0–1). */ - uptime: number; - /** Running instance count. */ - instances: number; - /** Sustained throughput, docs/min. */ - throughput: number; - /** P99 latency, ms. */ - p99Ms: number; -} - -export type DeploymentStatus = "live" | "rolling" | "rolled-back" | "queued"; - -export interface RecentDeployment { - id: string; - version: string; - environment: "production" | "staging" | "canary"; - product: string; - status: DeploymentStatus; - deployedBy: string; - timestamp: string; -} - const REGION_US_EAST: DeploymentRegion = { name: "US East (N. Virginia)", code: "us-east-1", @@ -178,25 +162,6 @@ export function recentDeploymentsFor(tier: Tier): RecentDeployment[] { /* API Keys */ /* ──────────────────────────────────────────────────────────────────────── */ -export type ApiKeyStatus = "active" | "revoked" | "rotate-soon"; -export type ApiKeyPermission = "Read" | "Write" | "Admin"; - -export interface ApiKey { - id: string; - name: string; - /** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */ - prefix: string; - created: string; - lastUsed: string; - status: ApiKeyStatus; - /** Requests/min ceiling. */ - rateLimit: number; - permissions: ApiKeyPermission[]; - allowedIps: string[]; - usageToday: number; - usageMonth: number; -} - const API_KEYS_ALL: ApiKey[] = [ { id: "key-1", @@ -262,77 +227,6 @@ export function apiKeysFor(tier: Tier): ApiKey[] { /* Security */ /* ──────────────────────────────────────────────────────────────────────── */ -export type AccessPolicy = "stirling" | "byok" | "hyok"; -export type DataResidency = "us" | "eu" | "apac"; -export type CertStatus = "certified" | "in-progress" | "not-started"; - -export interface ComplianceCert { - id: string; - name: string; - status: CertStatus; - detail: string; -} - -export interface IpAllowEntry { - id: string; - label: string; - cidr: string; - addedBy: string; - added: string; -} - -/** - * Where encryption keys live. Mirrors the {@link AccessPolicy} posture but is - * surfaced separately because the key *custody model* (who can decrypt) is the - * detail security teams scrutinise: - * - `managed` — Stirling-owned KMS keys; zero key ops on the customer side. - * - `byok` — customer key, but Stirling can use it to decrypt while processing. - * - `hyok` — key never leaves the customer KMS; Stirling holds only ciphertext. - */ -export type KeyMode = "managed" | "byok" | "hyok"; - -export interface KeyManagement { - mode: KeyMode; - /** Human-readable provider, e.g. "Stirling KMS" or "AWS KMS (customer)". */ - provider: string; - /** ARN-style identifier for the active key. */ - keyId: string; - /** Encryption algorithm in force. */ - algorithm: string; - /** Relative last-rotation time, e.g. "32 days ago". */ - lastRotated: string; - /** Rotation cadence summary, e.g. "Automatic · every 90 days". */ - rotationPolicy: string; - /** - * Whether the customer may switch key custody (BYOK/HYOK). Stirling-managed - * tiers see the posture but cannot change provider — only enterprise can. - */ - customerManaged: boolean; -} - -export type AttestationStatus = "attested" | "in-scope" | "not-applicable"; - -export interface ComplianceAttestation { - id: string; - name: string; - /** Framework family / short descriptor shown under the name. */ - framework: string; - status: AttestationStatus; - /** Coverage or audit detail, e.g. "Type II · audited Apr 2026". */ - detail: string; - /** Stub link to the downloadable report; null when none is available. */ - reportUrl: string | null; -} - -export interface SecurityConfig { - accessPolicy: AccessPolicy; - dataResidency: DataResidency; - certs: ComplianceCert[]; - ipAllowlist: IpAllowEntry[]; - keyManagement: KeyManagement; - attestations: ComplianceAttestation[]; -} - const CERTS_FULL: ComplianceCert[] = [ { id: "soc2", @@ -559,26 +453,6 @@ export function securityFor(tier: Tier): SecurityConfig { /* Storage */ /* ──────────────────────────────────────────────────────────────────────── */ -export type RetentionWindow = "30" | "60" | "90" | "180" | "never"; - -export interface StorageProvider { - id: string; - name: string; - kind: "stirling" | "s3" | "azure"; - connected: boolean; - detail: string; - usedGb: number; -} - -export interface StorageConfig { - /** Total used storage, GB. */ - usedGb: number; - /** Quota ceiling, GB. */ - quotaGb: number; - retention: RetentionWindow; - providers: StorageProvider[]; -} - const PROVIDERS_FULL: StorageProvider[] = [ { id: "stirling", @@ -635,26 +509,6 @@ export function storageFor(tier: Tier): StorageConfig { /* Audit Logs */ /* ──────────────────────────────────────────────────────────────────────── */ -export type AuditCategory = - | "auth" - | "config" - | "elevation" - | "processing" - | "security"; - -export type AuditStatus = "success" | "warning" | "danger" | "info"; - -export interface AuditEvent { - id: string; - timestamp: string; - category: AuditCategory; - action: string; - actor: string; - target: string; - status: AuditStatus; - latencyMs: number; -} - // Mirrors what the real backend (PortalInfraAuditService) returns: audit_events // mapped from real AuditEventType values to the tab's categories. Only real // types appear - there is no audited "elevation" event, so that category is @@ -842,77 +696,10 @@ const AUDIT_EVENTS_ALL: AuditEvent[] = [ }, ]; -export interface AuditSummary { - totalEvents: number; - processing: number; - elevation: number; - config: number; -} - -export interface AuditLogResponse { - summary: AuditSummary; - events: AuditEvent[]; - /** True for the whole-server (admin) view; gates the admin-only CSV export. */ - fullServer: boolean; -} - /* ──────────────────────────────────────────────────────────────────────── */ /* Models */ /* ──────────────────────────────────────────────────────────────────────── */ -export type ModelProvider = "stirling" | "openai" | "anthropic" | "on-prem"; -export type ModelType = "extraction" | "classification" | "ocr" | "llm"; -export type ModelStatus = "active" | "degraded" | "disabled"; - -/** Whether a model's cost is billed per 1k documents or per individual call. */ -export type ModelCostUnit = "per-1k-docs" | "per-call"; - -export interface ModelEntry { - id: string; - name: string; - provider: ModelProvider; - type: ModelType; - status: ModelStatus; - /** Median inference latency, ms. */ - latencyMs: number; - /** Cost in USD for the model's billing unit (see {@link costUnit}). */ - cost: number; - costUnit: ModelCostUnit; - version: string; - /** Share of capacity this model is currently absorbing (0–1). */ - load: number; - /** True for customer-registered bring-your-own / on-prem models. */ - managed: boolean; -} - -/** A binding from a processing operation (optionally a doc-type) to a model. */ -export interface RoutingRule { - id: string; - /** The operation or pipeline stage this rule governs. */ - operation: string; - /** Doc-type scope, or "All document types" for a catch-all. */ - docType: string; - /** id of the {@link ModelEntry} this operation routes to. */ - modelId: string; - modelName: string; - /** Marks the fallback rule applied when no narrower rule matches. */ - isDefault: boolean; -} - -export interface ModelsSummary { - activeModels: number; - /** Capacity-weighted average latency across active models, ms. */ - avgLatencyMs: number; - /** Projected monthly model spend, USD. */ - monthlySpend: number; -} - -export interface ModelsResponse { - summary: ModelsSummary; - models: ModelEntry[]; - routing: RoutingRule[]; -} - const MODELS_ALL: ModelEntry[] = [ { id: "m-extract-v3", diff --git a/frontend/editor/src/portal/mocks/link.ts b/frontend/editor/src/portal/mocks/link.ts index 0dd11e104c..92b4b90f2c 100644 --- a/frontend/editor/src/portal/mocks/link.ts +++ b/frontend/editor/src/portal/mocks/link.ts @@ -1,5 +1,6 @@ /** - * Account-link fixtures and the types api/link.ts shares with them. + * Account-link fixtures. Types live in api/link.ts (the backend contract); + * this module only builds fake data for Storybook and tests. * * "Mode A" combined billing: a self-hosted instance links the org's SaaS account * so its unattended calls bill against the org wallet. Two surfaces: @@ -11,61 +12,17 @@ * - TEAM-WIDE management: the SaaS backend (`GET /instances`, * `POST /instances/{id}/revoke`), called with the admin's JWT. * - * api/link.ts imports the types; the MSW handlers in mocks/handlers/link.ts serve - * this fixture data over the intercepted apiClient.local.json() calls. Components never reach - * into this module directly. Once the real backend is wired the handlers stop - * being registered and these fixtures can be deleted (or kept as test seeds). + * The MSW handlers in mocks/handlers/link.ts serve this fixture data over the + * intercepted apiClient.local.json() calls. Components never reach into this + * module directly. Once the real backend is wired the handlers stop being + * registered and these fixtures can be deleted (or kept as test seeds). */ -/* ──────────────────────────────────────────────────────────────────────── */ -/* Local backend — link / status / unlink (this instance) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** Body for POST /api/v1/account-link/link — the SaaS JWT + optional name. */ -export interface LinkInstanceRequest { - /** Admin's SaaS session JWT, obtained via the hosted-login popup. */ - supabaseJwt: string; - /** Optional label for this instance. */ - name?: string; -} - -/** Link status for this instance (GET /api/v1/account-link/status). */ -export interface LinkStatus { - linked: boolean; - /** Display name the local backend stored at link time; null when unset. */ - name: string | null; -} - -/** - * Locally-accrued usage not yet reported to SaaS (GET /api/v1/account-link/usage). - * The portal adds this on top of the SaaS-synced spend so "current usage" - * includes work done since the last daily sync. Per-category unsynced units for - * the current period; all zero when metering is off or nothing is pending. - */ -export interface LocalUsage { - /** ISO timestamp of the current period start; null when unknown (not yet synced). */ - periodStart: string | null; - apiUnsyncedUnits: number; - aiUnsyncedUnits: number; - automationUnsyncedUnits: number; - totalUnsyncedUnits: number; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* SaaS backend — team-wide instance management */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** A linked instance row (GET /api/v1/account-link/instances). */ -export interface LinkedInstanceRow { - instanceId: number; - deviceId: string; - name: string | null; - /** ISO timestamp the instance was registered. */ - createdAt: string | null; - /** ISO timestamp the instance last presented its credential; null if never. */ - lastSeenAt: string | null; - revoked: boolean; -} +import type { + LinkStatus, + LinkedInstanceRow, + LocalUsage, +} from "@portal/api/link"; /* ──────────────────────────────────────────────────────────────────────── */ /* Mock store — link/unlink/revoke mutate this so the surface feels live */ diff --git a/frontend/editor/src/portal/mocks/notifications.ts b/frontend/editor/src/portal/mocks/notifications.ts index b267bbe34c..12f32470f9 100644 --- a/frontend/editor/src/portal/mocks/notifications.ts +++ b/frontend/editor/src/portal/mocks/notifications.ts @@ -1,21 +1,10 @@ -/** Mock notifications for the header dropdown. */ +/** + * Notification fixtures for the header dropdown. Types live in + * api/notifications.ts (the backend contract); this module only builds fake + * data for Storybook and tests. + */ -export type NotificationCategory = - | "pipeline" - | "deploy" - | "billing" - | "audit" - | "agent" - | "doc"; - -export interface Notification { - id: string; - category: NotificationCategory; - title: string; - description: string; - /** Relative-time string. */ - time: string; -} +import type { Notification } from "@portal/api/notifications"; export const NOTIFICATIONS: Notification[] = [ { diff --git a/frontend/editor/src/portal/mocks/policies.ts b/frontend/editor/src/portal/mocks/policies.ts index b06effe1e1..27d5dfeadb 100644 --- a/frontend/editor/src/portal/mocks/policies.ts +++ b/frontend/editor/src/portal/mocks/policies.ts @@ -1,362 +1,11 @@ /** - * Policies fixtures and the canonical TS model the portal shares with them. - * - * Wire types (`WirePolicy`, `WirePipelineStep`) come from the shared codec - * layer and match the backend record exactly. Catalogue and UI types - * (`PolicyCategory`, `PolicyConfigDef`, `PolicyState`, …) are portal-only: - * the backend has no "category" concept — `categoryId` rides in - * `output.options`. The catalogue assembles client-side in `api/policies.ts` - * from the decoded wire records + these static definitions. - * - * api/policies.ts re-exports everything; components never reach in here. + * Policies fixtures. The canonical TS model and the static catalogue + * definitions live in api/policies.ts (the backend contract); this module + * only builds seed data for the MSW handlers and tests. */ -import type { WirePipelineStep, WirePolicy } from "@app/policies/types"; -import type { PolicyRunView } from "@app/policies/types"; - -export type { - PolicyActivityItem, - PolicyDecodedState, - PolicyRunStatus, - PolicyRunView, - PolicyStats, - WireOutputOptions, - WireOutputSpec, - WirePipelineStep, - WirePolicy, -} from "@app/policies/types"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Catalogue model — portal-specific (lifted from editor types/policies.ts) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type PolicyStatus = "active" | "paused"; - -export type PolicyRowStatus = "active" | "paused" | "setup"; - -export type PolicyFieldType = "toggle" | "select" | "chips" | "text"; - -export interface PolicyField { - label: string; - key: string; - type: PolicyFieldType; - value: boolean | string | string[]; - options?: string[]; -} - -export interface PolicyCategory { - id: string; - label: string; - icon: string; - tone: "neutral" | "blue" | "purple" | "green" | "amber" | "red"; - desc: string; - providesClassification?: boolean; - comingSoon?: boolean; -} - -export interface PolicyConfigDef { - summary: string; - rules: string[]; - scopeLabel: string; - fields: PolicyField[]; - defaultOperations: WirePipelineStep[]; -} - -export interface PolicyState { - configured: boolean; - status: PolicyStatus; - sources: string[]; - scopeTypes: string[]; - reviewerEmail: string; - fieldValues: Record; - outputMode?: "new_file" | "new_version"; - outputName?: string; - outputNamePosition?: "prefix" | "suffix" | "auto-number"; - runOn?: "upload" | "export"; - maxRetries?: number; - retryDelayMinutes?: number; - backendId?: string; - isDefault?: boolean; -} - -export interface PolicySetupResult { - fieldValues: Record; - sources: string[]; - scopeTypes: string[]; - reviewerEmail: string; - outputMode: "new_file" | "new_version"; - outputName: string; - outputNamePosition: "prefix" | "suffix" | "auto-number"; - runOn: "upload" | "export"; - maxRetries: number; - retryDelayMinutes: number; - steps: WirePipelineStep[]; -} - -export interface DecoratedPolicy { - category: PolicyCategory; - config: PolicyConfigDef; - state: PolicyState; - steps: WirePipelineStep[]; - stats: import("@app/policies/types").PolicyStats; - activity: import("@app/policies/types").PolicyActivityItem[]; -} - -export interface PoliciesSummary { - active: number; - paused: number; - categories: number; - docsEnforced: number; -} - -export interface PoliciesResponse { - summary: PoliciesSummary; - catalogue: CatalogueEntry[]; -} - -export interface CatalogueEntry { - category: PolicyCategory; - config: PolicyConfigDef; - policy: DecoratedPolicy | null; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Tool → endpoint registry */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export const TOOL_ENDPOINTS: Record = { - redact: "/api/v1/security/auto-redact", - sanitize: "/api/v1/security/sanitize-pdf", - watermark: "/api/v1/security/add-watermark", - ocr: "/api/v1/misc/ocr-pdf", - flatten: "/api/v1/misc/flatten", - compress: "/api/v1/misc/compress-pdf", -}; - -export const ENDPOINT_LABELS: Record = { - "/api/v1/security/auto-redact": "Redact PII", - "/api/v1/security/sanitize-pdf": "Remove JavaScript", - "/api/v1/security/add-watermark": "Watermark", - "/api/v1/misc/ocr-pdf": "OCR", - "/api/v1/misc/flatten": "Flatten", - "/api/v1/misc/compress-pdf": "Compress", -}; - -export function humanizeEndpoint(path: string): string { - if (ENDPOINT_LABELS[path]) return ENDPOINT_LABELS[path]; - const last = path.split("/").filter(Boolean).pop() ?? path; - return last - .replace(/-/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()) - .trim(); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Catalogue definitions */ -/* ──────────────────────────────────────────────────────────────────────── */ - -const DEFAULT_PII_PATTERNS: string[] = [ - "\\b(?!000|666|9\\d{2})\\d{3}([- ])(?!00)\\d{2}\\1(?!0000)\\d{4}\\b", - "\\b(?:4\\d{12}(?:\\d{3})?|5[1-5]\\d{14}|3[47]\\d{13}|6(?:011|5\\d{2})\\d{12})\\b", -]; - -export const POLICY_CATEGORIES: PolicyCategory[] = [ - { - id: "ingestion", - label: "Ingestion", - icon: "layers", - tone: "blue", - desc: "Classify documents, extract structured data, enforce naming conventions, and normalize pages.", - providesClassification: true, - comingSoon: true, - }, - { - id: "security", - label: "Security", - icon: "shield", - tone: "purple", - desc: "Detect PII, redact, strip active content, and watermark documents.", - }, - { - id: "compliance", - label: "Compliance", - icon: "check", - tone: "amber", - desc: "Enforce HIPAA, GDPR, SOC 2, or FedRAMP requirements on every document.", - comingSoon: true, - }, - { - id: "routing", - label: "Routing", - icon: "route", - tone: "green", - desc: "Auto-route documents to the right team, folder, or system.", - comingSoon: true, - }, - { - id: "retention", - label: "Retention", - icon: "clock", - tone: "neutral", - desc: "Set how long documents are kept, when to archive, and when to delete.", - comingSoon: true, - }, -]; - -export const POLICY_CONFIG: Record = { - ingestion: { - summary: - "Classifies documents, extracts structured data, enforces naming, and normalizes pages.", - rules: ["Classify", "Extract", "Name", "Normalize"], - scopeLabel: "All documents", - defaultOperations: [ - { operation: TOOL_ENDPOINTS.ocr, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, - ], - fields: [ - { - label: "Min confidence", - key: "minConfidence", - type: "select", - value: "80%", - options: ["60%", "70%", "80%", "90%", "95%"], - }, - { - label: "Below threshold", - key: "belowThreshold", - type: "select", - value: "Flag for review", - options: ["Flag for review", "Route to bucket", "Hold"], - }, - ], - }, - security: { - summary: - "Detects and redacts PII, strips active content (JavaScript), and watermarks documents.", - rules: ["Redact PII", "Remove JavaScript", "Watermark"], - scopeLabel: "All documents", - defaultOperations: [ - { - operation: TOOL_ENDPOINTS.redact, - parameters: { - mode: "automatic", - useRegex: true, - convertPDFToImage: true, - wordsToRedact: DEFAULT_PII_PATTERNS, - }, - }, - { - operation: TOOL_ENDPOINTS.sanitize, - parameters: { - removeJavaScript: true, - removeEmbeddedFiles: false, - removeMetadata: false, - removeLinks: false, - removeFonts: false, - }, - }, - { - operation: TOOL_ENDPOINTS.watermark, - // convertPDFToImage bakes the watermark in so it can't be stripped - parameters: { - convertPDFToImage: true, - }, - }, - ], - fields: [], - }, - compliance: { - summary: - "Validates documents against regulatory frameworks before they leave the system.", - rules: ["Framework scan", "Enforce action", "Audit trail"], - scopeLabel: "All documents", - defaultOperations: [ - { operation: TOOL_ENDPOINTS.sanitize, parameters: {} }, - { operation: TOOL_ENDPOINTS.flatten, parameters: {} }, - ], - fields: [ - { - label: "Frameworks", - key: "frameworks", - type: "chips", - value: ["HIPAA"], - options: ["HIPAA", "GDPR", "SOC 2", "FedRAMP", "PCI DSS", "ISO 27001"], - }, - { - label: "When non-compliant", - key: "onViolation", - type: "select", - value: "Flag for review", - options: [ - "Flag for review", - "Block export", - "Auto-redact PHI", - "Quarantine document", - ], - }, - { label: "Audit trail", key: "auditTrail", type: "toggle", value: true }, - { label: "Access log", key: "accessLog", type: "toggle", value: true }, - ], - }, - routing: { - summary: - "Routes documents to the right destination based on type and classification.", - rules: ["Auto-classify", "Route to folder", "Webhook notify"], - scopeLabel: "All documents", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], - fields: [ - { - label: "Destination", - key: "destination", - type: "select", - value: "Documents", - options: ["Documents", "S3 bucket", "SharePoint", "Webhook"], - }, - { label: "Webhook URL", key: "webhookUrl", type: "text", value: "" }, - { label: "Notify on route", key: "notify", type: "toggle", value: false }, - ], - }, - retention: { - summary: - "Enforces how long documents are kept, when to archive, and when to delete.", - rules: ["Retention hold", "Auto-archive", "Deletion block"], - scopeLabel: "All documents", - defaultOperations: [{ operation: TOOL_ENDPOINTS.compress, parameters: {} }], - fields: [ - { - label: "Keep for", - key: "keepFor", - type: "select", - value: "7 years", - options: ["30 days", "1 year", "3 years", "7 years", "Indefinite"], - }, - { - label: "Archive after", - key: "archiveAfter", - type: "select", - value: "Never", - options: ["30 days", "90 days", "1 year", "Never"], - }, - { - label: "Immutable hold", - key: "immutableHold", - type: "toggle", - value: false, - }, - ], - }, -}; - -export const POLICY_DOC_TYPES: string[] = [ - "Contracts", - "Invoices", - "Tax documents", - "HR records", - "Insurance", - "Medical / PHI", - "Legal filings", - "Financial reports", -]; +import type { PolicyRunView, WirePolicy } from "@app/policies/types"; +import { POLICY_CONFIG } from "@portal/api/policies"; /* ──────────────────────────────────────────────────────────────────────── */ /* Seed data — real backend wire format */ diff --git a/frontend/editor/src/portal/mocks/preference.ts b/frontend/editor/src/portal/mocks/preference.ts deleted file mode 100644 index ebd134c7e9..0000000000 --- a/frontend/editor/src/portal/mocks/preference.ts +++ /dev/null @@ -1,31 +0,0 @@ -/// - -/** - * Lightweight preference helpers — pulled out of mocks/browser.ts so they - * don't drag MSW + every handler + every fixture into any chunk that just - * needs to *read* the user's choice. Loading the actual worker stays a - * dynamic import. - */ - -const STORAGE_KEY = "stirling.portal.mocks-enabled"; - -export function readMocksPreference(): boolean { - if (typeof window === "undefined") return false; - // An explicit user toggle (persisted) always wins. - const stored = window.localStorage.getItem(STORAGE_KEY); - if (stored === "true") return true; - if (stored === "false") return false; - // Build-time default: VITE_PORTAL_MOCKS forces mocks on/off. The single-origin - // proxy sets it false so the portal hits the real backend (otherwise the dev - // mock worker would seed a fake token over the shared real one). Falls back to - // on-in-dev, off-in-production. - const envDefault = import.meta.env.VITE_PORTAL_MOCKS; - if (envDefault === "true") return true; - if (envDefault === "false") return false; - return import.meta.env.DEV; -} - -export function writeMocksPreference(enabled: boolean): void { - if (typeof window === "undefined") return; - window.localStorage.setItem(STORAGE_KEY, String(enabled)); -} diff --git a/frontend/editor/src/portal/mocks/procurement.ts b/frontend/editor/src/portal/mocks/procurement.ts index 4faeefa491..1f3f5a1934 100644 --- a/frontend/editor/src/portal/mocks/procurement.ts +++ b/frontend/editor/src/portal/mocks/procurement.ts @@ -1,194 +1,21 @@ /** - * Procurement fixtures and the types api/procurement.ts shares with them. - * api/procurement.ts imports the types; the MSW handlers in - * mocks/handlers/procurement.ts serve this fixture data over the intercepted - * httpJson() calls. Components never reach into this module directly. + * Procurement fixtures. Types and the journey definition live in + * api/procurement.ts (the backend contract); this module only builds the fake + * deal data the MSW handlers in mocks/handlers/procurement.ts serve over the + * intercepted httpJson() calls, for Storybook and tests. * - * Procurement models the enterprise commercial journey, trial → quote → - * agreement → payment → implementation, plus the paperwork ledger that rides - * alongside it. The journey is enterprise-only; free/pro tiers receive a - * minimal locked payload the view renders as an upgrade prompt. - * - * Once a real commercial backend exists the MSW handlers stop being registered - * and these fixtures can be deleted (or kept as test seeds). + * The handlers serve Storybook and tests, so these fixtures stay in sync with + * the api contract for as long as those need them. */ import type { Tier } from "@portal/contexts/TierContext"; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Journey stages */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** - * The five-stage enterprise journey. The id is the contract value the backend - * advances; the labels below are the buyer-facing stage names (Agreement and - * Payment read more plainly than the internal `security` / `procurement`). - */ -export type DealStage = - | "trial" - | "quote" - | "security" - | "procurement" - | "active"; - -export interface JourneyStep { - stage: DealStage; - /** Buyer-facing stage name. */ - label: string; - /** One-line description of what happens at this stage. */ - blurb: string; - /** - * Label for the single action that advances this stage. The current stage - * surfaces its gating action; `active` is terminal (provisioning). - */ - gatingAction: string; -} - -/** Ordered journey definition, the stepper renders this verbatim. */ -export const JOURNEY: JourneyStep[] = [ - { - stage: "trial", - label: "Trial", - blurb: "Evaluate Stirling against your documents and workflows.", - gatingAction: "Build your quote", - }, - { - stage: "quote", - label: "Quote", - blurb: "Review committed-volume pricing and contract term.", - gatingAction: "Accept your quote", - }, - { - stage: "security", - label: "Agreement", - blurb: "One signature covers MSA, order form, EULA and DPA.", - gatingAction: "Review and sign your agreement", - }, - { - stage: "procurement", - label: "Payment", - blurb: "Pay by card, bank transfer, or against a purchase order.", - gatingAction: "Confirm payment", - }, - { - stage: "active", - label: "Implementation", - blurb: "Provision your workspace and run the go-live playbook.", - gatingAction: "Provisioning your workspace", - }, -]; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Deal header */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface SolutionsEngineer { - name: string; - title: string; - email: string; -} - -export interface TrialInfo { - /** License key seeded for the evaluation. */ - key: string; - /** ISO date the trial began. */ - startedOn: string; - /** ISO date the trial expires. */ - endsOn: string; - /** Whole days remaining (derived in the fixture for a stable demo number). */ - daysLeft: number; - extensionsUsed: number; - maxExtensions: number; -} - -export interface QuoteInfo { - number: string; - /** Annual contract value, in USD. */ - amount: number; - /** Contract term, e.g. "12 months". */ - term: string; - /** ISO date the quote expires. */ - validUntil: string; -} - -export interface Deal { - company: string; - currentStage: DealStage; - engineer: SolutionsEngineer; - trial: TrialInfo; - quote: QuoteInfo; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Document ledger + supporting pool */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** - * Lifecycle of a single document. - * available: ready to grab now (download/sign/pay/upload as the action says) - * action: waiting on the buyer to act (the gating paperwork of a stage) - * pending: issued, awaiting the other side / a system step - * request: not generated yet; the buyer asks for it (some carry a fee) - * complete: done, kept for the record - */ -export type DocStatus = - | "available" - | "action" - | "pending" - | "request" - | "complete"; - -/** What pressing the document's button does. */ -export type DocAction = "download" | "sign" | "pay" | "upload" | "request"; - -export interface LedgerDoc { - id: string; - name: string; - /** Sub-line describing what the document is / what it covers. */ - sub: string; - status: DocStatus; - action: DocAction; - /** Buyer-skippable paperwork (e.g. paid onboarding). */ - optional?: boolean; - /** One-off fee in USD when the document/service is a paid add-on. */ - fee?: number; -} - -/** Document ledger grouped by the journey stage the paperwork belongs to. */ -export interface LedgerGroup { - stage: DealStage; - /** Buyer-facing stage name (matches JourneyStep.label). */ - label: string; - docs: LedgerDoc[]; -} - -/** Categories the stage-agnostic supporting pool is grouped under. */ -export type SupportingCategory = - | "security" - | "legal" - | "corporate" - | "procurement"; - -export interface SupportingGroup { - category: SupportingCategory; - label: string; - docs: LedgerDoc[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Full procurement payload */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface ProcurementResponse { - tier: Tier; - /** True only for enterprise, gates the whole journey + ledger. */ - unlocked: boolean; - /** Present only when unlocked. */ - deal: Deal | null; - journey: JourneyStep[]; - ledger: LedgerGroup[]; - supporting: SupportingGroup[]; -} +import type { + Deal, + LedgerGroup, + ProcurementResponse, + SupportingGroup, +} from "@portal/api/procurement"; +import { JOURNEY } from "@portal/api/procurement"; /* ──────────────────────────────────────────────────────────────────────── */ /* Fixtures */ diff --git a/frontend/editor/src/portal/mocks/procurementMachine.ts b/frontend/editor/src/portal/mocks/procurementMachine.ts index 275b511b51..d889691e2b 100644 --- a/frontend/editor/src/portal/mocks/procurementMachine.ts +++ b/frontend/editor/src/portal/mocks/procurementMachine.ts @@ -17,7 +17,7 @@ import { type LedgerDoc, type LedgerGroup, type SupportingGroup, -} from "@portal/mocks/procurement"; +} from "@portal/api/procurement"; export interface ProcurementStore { deal: Deal; diff --git a/frontend/editor/src/portal/mocks/sdkComponents.ts b/frontend/editor/src/portal/mocks/sdkComponents.ts index aed16d2028..f86797637c 100644 --- a/frontend/editor/src/portal/mocks/sdkComponents.ts +++ b/frontend/editor/src/portal/mocks/sdkComponents.ts @@ -1,126 +1,24 @@ /** - * Components surface fixtures and the types api/sdkComponents.ts shares with them. + * Components surface fixtures. Types and presentation metadata live in + * api/sdkComponents.ts (the backend contract); this module only builds fake + * data for Storybook and tests. * * "Components" are embeddable React/Vue/Vanilla SDK widgets a developer drops * into their own product — a PDF Viewer, an E-Sign flow, an AI Review panel — * each metered per action (per render, per review, per signature). Every * component carries its npm package, maturity, supported frameworks, per-action * price, an install/usage snippet, and its key props. - * - * api/sdkComponents.ts imports the types; the MSW handlers serve the fixture - * data over the intercepted apiClient.local.json() calls. Components never reach into this - * module directly. Once a real backend exists the handlers stop being - * registered and these fixtures can be deleted (or kept as test seeds). */ +import { isUnlocked } from "@portal/api/sdkComponents"; +import type { + ComponentPricing, + ComponentsResponse, + ComponentsSummary, + SdkComponent, +} from "@portal/api/sdkComponents"; import type { Tier } from "@portal/contexts/TierContext"; -/* ──────────────────────────────────────────────────────────────────────── */ -/* Types */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export type ComponentMaturity = "ga" | "beta"; - -export type Framework = "React" | "Vue" | "Vanilla"; - -/** The action a component bills against — surfaces in the price unit label. */ -export type BillingUnit = - | "render" - | "review" - | "approval" - | "signature" - | "check" - | "event" - | "session"; - -export interface ComponentProp { - name: string; - /** TypeScript-ish type expression, shown verbatim in the API table. */ - type: string; - required: boolean; - description: string; -} - -export interface ComponentPricing { - /** Price per billed action in USD. */ - pricePerAction: number; - unit: BillingUnit; - /** Free-tier monthly allowance before metering kicks in; 0 = none. */ - freeQuota: number; -} - -export interface SdkComponent { - id: string; - name: string; - /** Package suffix — full name is `@stirling/`. */ - package: string; - description: string; - maturity: ComponentMaturity; - frameworks: Framework[]; - pricing: ComponentPricing; - /** Install command (npm). */ - install: string; - /** Minimal usage snippet shown under the Code tab. */ - usage: string; - props: ComponentProp[]; - /** - * Embeds attributed to this component over the trailing 30 days — drives the - * per-card usage line. Zero for never-embedded components. - */ - embeds30d: number; - /** - * Tier at which the component becomes available. Components above the active - * tier render locked with an upgrade nudge. `pro` is the default floor. - */ - minTier: Tier; -} - -export interface ComponentsSummary { - /** Count of GA (production-ready) components available to the tier. */ - gaCount: number; - /** Count of Beta components available to the tier. */ - betaCount: number; - /** Total embeds across all components this month. */ - embedsThisMonth: number; - /** Month-to-date spend attributed to component actions, in USD. */ - spendThisMonth: number; -} - -export interface ComponentsResponse { - summary: ComponentsSummary; - components: SdkComponent[]; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Presentation metadata (client-side product copy, not data) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export interface MaturityMeta { - label: string; - tone: "success" | "info"; -} - -export const MATURITY_META: Record = { - ga: { label: "GA", tone: "success" }, - beta: { label: "Beta", tone: "info" }, -}; - -/** Human label for a billing unit, e.g. "render" → "/render". */ -export const BILLING_UNIT_LABEL: Record = { - render: "render", - review: "review", - approval: "approval", - signature: "signature", - check: "check", - event: "event", - session: "session", -}; - -/** Format a price as the per-action string shown on cards, e.g. "$0.04 / review". */ -export function formatPrice(pricing: ComponentPricing): string { - return `$${pricing.pricePerAction.toFixed(2)} / ${BILLING_UNIT_LABEL[pricing.unit]}`; -} - /* ──────────────────────────────────────────────────────────────────────── */ /* Fixtures */ /* ──────────────────────────────────────────────────────────────────────── */ @@ -463,8 +361,6 @@ export function MergeButton({ files }: { files: File[] }) { /* Tier shaping */ /* ──────────────────────────────────────────────────────────────────────── */ -const TIER_RANK: Record = { free: 0, pro: 1, enterprise: 2 }; - /** * Enterprise negotiates volume pricing — renders and reviews come in cheaper. * Applied as a flat per-tier multiplier so the catalogue stays single-sourced. @@ -486,11 +382,6 @@ export function componentsFor(tier: Tier): SdkComponent[] { return CATALOGUE.map((c) => ({ ...c, pricing: priceFor(c, tier) })); } -/** Whether a component is usable at the given tier (vs locked/upgrade). */ -export function isUnlocked(component: SdkComponent, tier: Tier): boolean { - return TIER_RANK[tier] >= TIER_RANK[component.minTier]; -} - export function summaryFor(tier: Tier): ComponentsSummary { const components = componentsFor(tier); const unlocked = components.filter((c) => isUnlocked(c, tier)); diff --git a/frontend/editor/src/portal/mocks/search.ts b/frontend/editor/src/portal/mocks/search.ts index f8c5990c7f..86d332de70 100644 --- a/frontend/editor/src/portal/mocks/search.ts +++ b/frontend/editor/src/portal/mocks/search.ts @@ -1,11 +1,10 @@ -/** Mock quick-action catalogue for the ⌘K search palette. */ +/** + * Mock quick-action catalogue for the ⌘K search palette. The QuickAction type + * lives in api/search.ts (the backend contract); this module only builds fake + * data for Storybook and tests. + */ -export interface QuickAction { - group: "Jump to" | "Create" | "Theme"; - label: string; - /** Keyboard hint shown to the right. */ - hint: string; -} +import type { QuickAction } from "@portal/api/search"; export const QUICK_ACTIONS: QuickAction[] = [ { group: "Jump to", label: "Home", hint: "G H" }, diff --git a/frontend/editor/src/portal/mocks/settings.ts b/frontend/editor/src/portal/mocks/settings.ts index 99bcf313cb..59cceb17d8 100644 --- a/frontend/editor/src/portal/mocks/settings.ts +++ b/frontend/editor/src/portal/mocks/settings.ts @@ -1,88 +1,22 @@ /** - * Account-settings fixtures and the types api/settings.ts shares with them. - * api/settings.ts imports the types; the MSW handlers in mocks/handlers/ serve - * the fixture data over the intercepted apiClient.local.json() call. Components never reach - * into this module directly. + * Account-settings fixtures. Types live in api/settings.ts (the backend + * contract); this module only builds fake data for Storybook and tests. * * The shape is tier-aware: the workspace plan label, available regions, and * data-residency posture differ by tier, so the modal reflects what each plan * can actually configure. */ +import type { + ActiveSession, + BetaFeature, + NotificationDefault, + RegionOption, + SecuritySettings, + SettingsSnapshot, +} from "@portal/api/settings"; import type { Tier } from "@portal/contexts/TierContext"; -export interface RegionOption { - value: string; - label: string; - /** Enterprise-only residency regions are gated below higher tiers. */ - enterpriseOnly?: boolean; -} - -export interface NotificationDefault { - id: string; - enabled: boolean; -} - -/** A device/browser with an active session, shown under Admin → Security. */ -export interface ActiveSession { - id: string; - device: string; - location: string; - lastActive: string; - /** The session viewing this modal — can't be revoked from here. */ - current: boolean; -} - -/** - * Org-wide authentication posture. SSO/SCIM are enterprise capabilities; lower - * tiers see them as locked rows with an upgrade nudge. - */ -export interface SecuritySettings { - mfaEnforced: boolean; - ssoEnabled: boolean; - scimEnabled: boolean; - /** Idle timeout before re-auth, in minutes. */ - sessionTimeoutMins: number; - activeSessions: ActiveSession[]; -} - -/** An opt-in early-access feature flag. */ -export interface BetaFeature { - id: string; - label: string; - description: string; - enabled: boolean; - /** Gated to enterprise — rendered locked below it. */ - enterpriseOnly?: boolean; -} - -/** - * Server snapshot of the account + workspace the modal opens onto. Editable - * fields seed local form state; `planLabel` / `seats` are read-only context. - */ -export interface SettingsSnapshot { - profile: { - name: string; - email: string; - role: string; - /** Avatar image URL, or null to fall back to initials. */ - avatarUrl: string | null; - }; - workspace: { - name: string; - region: string; - planLabel: string; - seats: { used: number; total: number }; - }; - /** Per-category notification toggles, server-default on/off. */ - notifications: NotificationDefault[]; - regions: RegionOption[]; - /** Org-wide authentication + session posture (Admin scope). */ - security: SecuritySettings; - /** Opt-in early-access features (Admin scope). */ - betaFeatures: BetaFeature[]; -} - const REGIONS: RegionOption[] = [ { value: "us-east-1", label: "US East (N. Virginia)" }, { value: "us-west-2", label: "US West (Oregon)" }, diff --git a/frontend/editor/src/portal/mocks/startIfEnabled.ts b/frontend/editor/src/portal/mocks/startIfEnabled.ts deleted file mode 100644 index 74ffe5b386..0000000000 --- a/frontend/editor/src/portal/mocks/startIfEnabled.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { readMocksPreference } from "@portal/mocks/preference"; - -/** - * Start the portal's MSW worker if the mocks preference is on. Await this before - * rendering PortalApp so the worker is registered before the first data fetch. - * The dynamic import keeps MSW and its fixtures out of chunks that don't run it. - */ -export async function startPortalMocksIfEnabled(): Promise { - if (!readMocksPreference()) return; - const { startMockWorker } = await import("@portal/mocks/browser"); - await startMockWorker(); -} diff --git a/frontend/editor/src/portal/mocks/users.ts b/frontend/editor/src/portal/mocks/users.ts index 95b2e2f25c..030a9046a7 100644 --- a/frontend/editor/src/portal/mocks/users.ts +++ b/frontend/editor/src/portal/mocks/users.ts @@ -13,221 +13,18 @@ */ import type { Tier } from "@portal/contexts/TierContext"; +import type { + AccessControls, + Member, + UsersResponse, + UsersSummary, +} from "@portal/api/users"; +import { ROLES } from "@portal/api/users"; /* ──────────────────────────────────────────────────────────────────────── */ /* Roles & members */ /* ──────────────────────────────────────────────────────────────────────── */ -/** The four org roles, most → least privileged, mapped onto the backend's - * authorities + team leadership. Order drives the role select and grid. */ -export type RoleId = "admin" | "team_owner" | "member" | "guest"; - -export type MemberStatus = "active" | "invited" | "suspended"; - -/** - * Effective portal (processor) access for a member: - * admin — implicit, admins always have it - * role — implicit via team-owner leadership (default policy) - * team — inherited from a PORTAL grant on the member's whole team - * granted — explicit per-user PORTAL grant - * none — no access - */ -export type PortalAccessState = "admin" | "role" | "team" | "granted" | "none"; - -export const PORTAL_ACCESS_TONE: Record< - PortalAccessState, - "success" | "info" | "neutral" | "warning" -> = { - admin: "info", - role: "info", - team: "info", - granted: "success", - none: "neutral", -}; - -export interface Member { - id: string; - name: string; - email: string; - role: RoleId; - status: MemberStatus; - /** Effective portal access; set by the view from the grant list. */ - portalAccess?: PortalAccessState; - /** Authoritative server-side portal access (roster DTO); drives whether a chip shows at all. */ - canAccessPortal?: boolean; - /** The explicit PORTAL grant's id, for revoke (present when access = granted). */ - portalGrantId?: number; - /** Relative-time string, e.g. "4m ago". Invited members read "—". */ - lastActive: string; - /** Optional avatar image; falls back to initials when absent. */ - avatarUrl?: string; - /** Backend linkage for row actions (absent on pure fixtures). */ - username?: string; - teamId?: number; - teamName?: string; - /** Holds a LEADER membership on their team (independent of displayed role). */ - teamLead?: boolean; - /** The signed-in admin's own row; self-directed actions are disabled. */ - isSelf?: boolean; - /** Account locked after failed logins (admin can unlock). */ - locked?: boolean; - /** MFA enrolled (admin can reset it). */ - mfaEnabled?: boolean; - /** Auth provider: "web" (password), "oauth2", "saml2", etc. */ - authType?: string; - /** Raw stored authority (e.g. ROLE_USER, ROLE_WEB_ONLY_USER); preserved on team moves. */ - authority?: string; -} - -export interface Role { - id: RoleId; - label: string; - /** One-line summary of what the role can do. */ - summary: string; - /** Concrete permission bullets shown in the reference grid. */ - permissions: string[]; - tone: "purple" | "blue" | "green" | "amber" | "neutral"; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Access controls (tier-scoped) */ -/* ──────────────────────────────────────────────────────────────────────── */ - -/** - * Access posture for the org, shaped by tier. Free exposes only the seat limit - * and an upgrade nudge; pro adds session/MFA self-service; enterprise adds - * SSO/SAML, SCIM provisioning, enforced MFA and a session policy. Fields are - * optional so the panel renders whatever the tier returns. - */ -export interface AccessControls { - tier: Tier; - /** Seats consumed by active + invited members. */ - seatsUsed: number; - /** Total seats on the plan; null = unlimited (enterprise). */ - seatLimit: number | null; - /** Free only: copy for the upgrade nudge. */ - upgradeHint?: string; - /** Pro+: end-user MFA available (self-service, not enforced). */ - mfaAvailable?: boolean; - /** Enterprise: MFA enforced org-wide. */ - mfaEnforced?: boolean; - /** Pro+: idle session timeout, e.g. "30 days" / "12 hours". */ - sessionTimeout?: string; - /** Enterprise: SSO connection summary. */ - sso?: { - provider: string; - status: "connected" | "not_configured"; - /** Email domains that auto-route to SSO. */ - domains: string[]; - }; - /** Enterprise: SCIM directory provisioning. */ - scim?: { - enabled: boolean; - /** Where the directory syncs from, e.g. "Okta". */ - directory: string; - lastSync: string; - }; -} - -export interface UsersSummary { - totalMembers: number; - pendingInvites: number; - seatsUsed: number; - /** null = unlimited. */ - seatLimit: number | null; -} - -export interface UsersResponse { - summary: UsersSummary; - members: Member[]; - roles: Role[]; - access: AccessControls; - /** Whether SMTP is configured (gates emailing passwords/invites). */ - mailEnabled: boolean; - /** Whether email invites will work: SMTP on AND mail.enableInvites=true. Gates the - * "Invite by email" option on self-hosted. */ - emailInvitesEnabled: boolean; -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Presentation metadata — product copy, lives client-side */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export const MEMBER_STATUS_TONE: Record< - MemberStatus, - "success" | "warning" | "danger" | "neutral" | "info" -> = { - active: "success", - invited: "info", - suspended: "danger", -}; - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Role catalogue */ -/* The same five roles exist on every tier — what varies is who can fill */ -/* them and how access is enforced, not the role definitions themselves. */ -/* ──────────────────────────────────────────────────────────────────────── */ - -export const ROLES: Role[] = [ - { - id: "admin", - label: "Admin (Org owner)", - summary: "Full governance over the workspace, settings and members.", - permissions: [ - "Manage users, teams and roles", - "Manage all integrations incl. S3 connections", - "Grant or revoke portal access", - "Everything Team Owner can do", - ], - tone: "purple", - }, - { - id: "team_owner", - label: "Team owner", - summary: "Owns a team — manages its members' resources and shared configs.", - permissions: [ - "Create & manage the team's S3 connections", - "Manage team-owned integration configs", - "Portal access via the default policy", - "Everything Member can do", - ], - tone: "blue", - }, - { - id: "member", - label: "Member", - summary: - "Regular user — works with shared resources and their own configs.", - permissions: [ - "Use the editor and shared integrations", - "Create personal API & MCP configs", - "See team configs shared with them", - "No S3 or workspace management", - ], - tone: "green", - }, - { - id: "guest", - label: "Guest", - summary: "Limited or web-only access; cannot hold personal configs.", - permissions: [ - "Web-only / demo usage", - "No API keys or integrations", - "No portal access", - "Read-only where shared", - ], - tone: "neutral", - }, -]; - -export const ROLE_LABEL: Record = Object.fromEntries( - ROLES.map((r) => [r.id, r.label]), -) as Record; - -export const ROLE_TONE: Record = Object.fromEntries( - ROLES.map((r) => [r.id, r.tone]), -) as Record; - /* ──────────────────────────────────────────────────────────────────────── */ /* Member fixtures */ /* ──────────────────────────────────────────────────────────────────────── */ diff --git a/frontend/editor/src/portal/vite-env.d.ts b/frontend/editor/src/portal/vite-env.d.ts index 91acc858f2..894d70ddb3 100644 --- a/frontend/editor/src/portal/vite-env.d.ts +++ b/frontend/editor/src/portal/vite-env.d.ts @@ -11,8 +11,6 @@ interface ImportMetaEnv { readonly VITE_STRIPE_PUBLISHABLE_KEY: string; /** URL of the editor app (app switcher + non-admin redirect). See editor/.env.proprietary. */ readonly VITE_EDITOR_URL: string; - /** Force MSW mocks on/off ("true"/"false"); empty falls back to dev default. */ - readonly VITE_PORTAL_MOCKS: string; } interface ImportMeta { diff --git a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx index a2b361a86f..cffb02c28c 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicySetupWizard.tsx @@ -696,7 +696,7 @@ export function PolicySetupWizard({ : [...prev, dt], ) } - label={dt} + label={t(`policies.docType.${dt}`, dt)} /> ))}
diff --git a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx index afb79c0a7f..7f60fbdd3b 100644 --- a/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx +++ b/frontend/editor/src/proprietary/routes/adminRouteExtensions.tsx @@ -9,16 +9,12 @@ import { PORTAL_BASENAME } from "@app/routes/portalBasename"; // GHA when the portal or AI layers change). Vite replaces the env with a literal at // build time, so when it's off the dynamic import below is tree-shaken out and the // portal chunk isn't emitted. PortalApp stays module-level so it isn't recreated on -// each render. Mocks start first so the worker is ready before the portal's first -// fetch. +// each render. const includePortal = import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; const PortalApp = includePortal ? lazy(async () => { - const { startPortalMocksIfEnabled } = - await import("@portal/mocks/startIfEnabled"); - await startPortalMocksIfEnabled(); const m = await import("@portal/PortalApp"); return { default: m.PortalApp }; }) From a5ee329c365397df7404ba3f01957f214c17ac16 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Thu, 9 Jul 2026 16:43:38 +0100 Subject: [PATCH 11/13] Further improvements to policies file tracking (#6941) # Description of Changes Fixes requested in review of #6903 --- .../policy/ledger/ProcessedFileEntity.java | 11 +- .../saas/V32__policy_processed_files.sql | 6 + .../public/locales/en-US/translation.toml | 6 + .../components/policies/PolicyDetailPanel.tsx | 380 ++++++++++-------- 4 files changed, 233 insertions(+), 170 deletions(-) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java index b05c8b1eec..2e98025aff 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/ledger/ProcessedFileEntity.java @@ -10,6 +10,7 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import jakarta.persistence.IdClass; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.Transient; @@ -25,7 +26,15 @@ import lombok.Setter; * violation rather than a silent merge. */ @Entity -@Table(name = "policy_processed_files") +@Table( + name = "policy_processed_files", + indexes = { + // presence cleanup: delete this policy's rows unseen since the sweep began + @Index(name = "idx_processed_files_policy_seen", columnList = "policy_id, last_seen"), + // cross-policy deletion consensus: existsByIdentityHashAndStatusNot filters + // identity_hash on its own, so it cannot ride the (policy_id, identity_hash) PK + @Index(name = "idx_processed_files_identity", columnList = "identity_hash") + }) @IdClass(ProcessedFileId.class) @NoArgsConstructor @Getter diff --git a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql index f3a81de753..4be3fa07d8 100644 --- a/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql +++ b/app/saas/src/main/resources/db/migration/saas/V32__policy_processed_files.sql @@ -28,3 +28,9 @@ CREATE TABLE IF NOT EXISTS policy_processed_files ( CREATE INDEX IF NOT EXISTS idx_processed_files_policy_seen ON policy_processed_files (policy_id, last_seen); + +-- The cross-policy deletion-consensus check (existsByIdentityHashAndStatusNot) filters identity_hash +-- alone, so it cannot use the (policy_id, identity_hash) primary key; it runs once per successfully +-- consumed file, so index it to avoid a full scan on the hot path. +CREATE INDEX IF NOT EXISTS idx_processed_files_identity + ON policy_processed_files (identity_hash); diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 6228914b66..1cc2ad8ff1 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7511,6 +7511,12 @@ pause = "Pause" resume = "Resume" runNow = "Run now" +[portal.policies.detail.clearHistory] +body = "This policy will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone." +cancel = "Cancel" +confirm = "Clear history" +title = "Clear processed history?" + [portal.policies.detail.emptyActivity] description = "Documents will appear here once this policy runs." title = "No activity yet" diff --git a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx index f8da1226b0..e198fd92c8 100644 --- a/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx +++ b/frontend/editor/src/portal/components/policies/PolicyDetailPanel.tsx @@ -111,6 +111,7 @@ export function PolicyDetailPanel({ onRetry, }: PolicyDetailPanelProps) { const { t } = useTranslation(); + const [confirmingClear, setConfirmingClear] = useState(false); if (!policy) return null; const { category, config, state, steps, stats, activity } = policy; const isPaused = state.status === "paused"; @@ -136,200 +137,241 @@ export function PolicyDetailPanel({ } return ( - - {canDelete && ( - - )} - {onRun && ( + <> + + {canDelete && ( + + )} + {onRun && ( + + )} + {canClearHistory && ( + + )} - )} - {canClearHistory && ( - - )} - + + } + > + {/* Status + trigger strip */} +
+ {isPaused - ? t("portal.policies.detail.actions.resume") - : t("portal.policies.detail.actions.pause")} - - + ? t("portal.policies.status.paused") + : t("portal.policies.status.active")} + + {hasEditorSource && ( + <> + + · + + {trigger} + + · + + + {outputLabel} + + + )}
- } - > - {/* Status + trigger strip */} -
- - {isPaused - ? t("portal.policies.status.paused") - : t("portal.policies.status.active")} - - {hasEditorSource && ( - <> - - · - - {trigger} - - · - - {outputLabel} - - )} -
- {/* Enforces — plain text, no pills */} -
- - {t("portal.policies.detail.enforces")} - - - {enforceItems - ? enforceItems.map((op, i) => ( - - {i > 0 && ( - - {" "} - →{" "} - - )} - {humanizeEndpoint(op, t)} - - )) - : config.rules.map((r) => t(r)).join(" · ")} - -
- - {/* Sources */} - {state.sources.length > 0 && ( + {/* Enforces — plain text, no pills */}
- {t("portal.policies.detail.sources")} + {t("portal.policies.detail.enforces")} - {state.sources.map(sourceLabel).join(" · ")} + {enforceItems + ? enforceItems.map((op, i) => ( + + {i > 0 && ( + + {" "} + →{" "} + + )} + {humanizeEndpoint(op, t)} + + )) + : config.rules.map((r) => t(r)).join(" · ")}
- )} -

- {t("portal.policies.detail.recentActivity")} -

+ {/* Sources */} + {state.sources.length > 0 && ( +
+ + {t("portal.policies.detail.sources")} + + + {state.sources.map(sourceLabel).join(" · ")} + +
+ )} - {activity.length > 0 ? ( - - {activity.map((item, i) => ( -
- + {t("portal.policies.detail.recentActivity")} + + + {activity.length > 0 ? ( + + {activity.map((item, i) => ( +
- {item.status === "flagged" ? ( - - ) : item.status === "processing" ? ( - - ) : ( - - )} - - - - {item.doc} - - + {item.status === "flagged" ? ( - + + ) : item.status === "processing" ? ( + ) : ( - item.action + )} - - - {item.time} - - {item.status === "flagged" && onRetry && ( - + + + {item.doc} + + + {item.status === "flagged" ? ( + + ) : ( + item.action + )} + + + + {item.time} + + {item.status === "flagged" && onRetry && ( + + )} +
+ ))} +
+ ) : ( + + - ))} - - ) : ( - - + + )} + + + + + - )} - - - - - - - + + setConfirmingClear(false)} + width="sm" + title={t("portal.policies.detail.clearHistory.title")} + footer={ +
+ + +
+ } + > + {t("portal.policies.detail.clearHistory.body")} +
+ ); } From ccfd22b2a926ddb7c62b700b8f9252c46c2aeb71 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:35:30 +0100 Subject: [PATCH 12/13] port editor settings into portal (#6945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portal's `SettingsModal` was a parallel, mock-backed settings implementation. It's replaced by the editor's `AppConfigModal`, mounted via a new `PortalSettingsHost` that supplies the contexts the portal doesn't have (app config, flavor-resolved session, preferences, editor theme). Flavor resolution does the rest: the self-hosted portal gets the admin sections, the SaaS portal gets the saas shell. The self-hosted account-link panel rides in through the existing seam as an extra section. The shell gains three host props (`urlSync`, `initialSection`, `extraSections`); editor behaviour is unchanged. Net −1,300 lines. Manually verified on both flavors against live backends. --- .../public/locales/en-US/translation.toml | 111 --- frontend/editor/public/og-metadata.json | 8 +- .../core/components/shared/AppConfigModal.tsx | 59 +- .../components/shared/AppConfigModalLazy.tsx | 23 +- .../shared/config/configNavSections.tsx | 23 +- .../core/components/shared/config/types.ts | 22 + .../desktop/components/shared/config/types.ts | 19 + .../settings/accountLinkSettings.tsx | 6 +- frontend/editor/src/portal/api/settings.ts | 87 -- .../src/portal/components/PortalChrome.tsx | 16 +- .../portal/components/PortalSettingsHost.tsx | 84 ++ .../src/portal/components/SettingsModal.css | 255 ----- .../src/portal/components/SettingsModal.tsx | 902 ------------------ .../settings/accountLinkSettings.tsx | 20 +- .../editor/src/portal/mocks/handlers/index.ts | 2 - .../src/portal/mocks/handlers/settings.ts | 12 - frontend/editor/src/portal/mocks/settings.ts | 151 --- .../saas/components/shared/AppConfigModal.tsx | 45 +- .../saas/components/shared/config/types.ts | 19 + 19 files changed, 279 insertions(+), 1585 deletions(-) delete mode 100644 frontend/editor/src/portal/api/settings.ts create mode 100644 frontend/editor/src/portal/components/PortalSettingsHost.tsx delete mode 100644 frontend/editor/src/portal/components/SettingsModal.css delete mode 100644 frontend/editor/src/portal/components/SettingsModal.tsx delete mode 100644 frontend/editor/src/portal/mocks/handlers/settings.ts delete mode 100644 frontend/editor/src/portal/mocks/settings.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 1cc2ad8ff1..11193c3ed3 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7913,122 +7913,11 @@ noActionsTitle = "No quick actions" noMatches = "No matches for \"{{query}}\"" noMatchesDescription = "Try a different keyword or browse the catalogue." -[portal.settings] -ariaLabel = "Settings" -cancel = "Cancel" -enterpriseBadge = "Enterprise" -footerNote = "Changes apply to this workspace." -saveChanges = "Save changes" - -[portal.settings.appearance] -themeSub = "Choose how the portal looks on this device." -themeTitle = "Theme" - -[portal.settings.appearance.dark] -hint = "Dim surfaces" -label = "Dark" - -[portal.settings.appearance.light] -hint = "Bright surfaces" -label = "Light" - -[portal.settings.authentication] -sessionTimeout = "Session timeout" -sessionTimeoutHelper = "Members re-authenticate after this idle period." -sub = "Organisation-wide authentication controls." -title = "Sign-in policy" - -[portal.settings.authentication.mfa] -description = "Require every member to complete MFA at sign-in." -label = "Enforce two-factor (MFA)" - -[portal.settings.authentication.scim] -description = "Sync members and roles from your directory." -label = "SCIM provisioning" - -[portal.settings.authentication.sso] -description = "Federate sign-in through your identity provider." -label = "Single sign-on (SAML)" - -[portal.settings.authentication.timeout] -1440 = "24 hours" -240 = "4 hours" -480 = "8 hours" -60 = "1 hour" -720 = "12 hours" - -[portal.settings.earlyAccess] -sub = "Opt into features still in preview." -title = "Preview features" - [portal.settings.groups] -account = "Account" admin = "Admin" -workspace = "Workspace" - -[portal.settings.notifications] -sub = "Pick which events reach your inbox." -title = "Email notifications" - -[portal.settings.notifications.pipeline-failures] -description = "A run errors out or a step times out." -label = "Pipeline failures" - -[portal.settings.notifications.pipeline-success] -description = "Every successful pipeline run finishes." -label = "Pipeline completions" - -[portal.settings.notifications.product-updates] -description = "New operations, sources, and release notes." -label = "Product updates" - -[portal.settings.notifications.security-alerts] -description = "New API keys, sign-ins, or permission changes." -label = "Security alerts" - -[portal.settings.notifications.usage-alerts] -description = "You approach a plan limit or rate cap." -label = "Usage & quota alerts" - -[portal.settings.notifications.weekly-digest] -description = "A Monday summary of volume and health." -label = "Weekly digest" - -[portal.settings.profile] -accountFallback = "Account" -changePhoto = "Change photo" -email = "Email" -emailHelper = "Used for sign-in and notification delivery." -emailPlaceholder = "you@company.com" -fullName = "Full name" -namePlaceholder = "Your name" [portal.settings.sections] account-link = "Account link" -appearance = "Appearance" -authentication = "Authentication" -early-access = "Early access" -general = "General" -notifications = "Notifications" -profile = "Profile" -sessions = "Active sessions" - -[portal.settings.sessions] -revoke = "Revoke" -sub = "Devices currently signed in to this account." -thisDevice = "This device" -title = "Active sessions" - -[portal.settings.workspace] -manageBilling = "Manage billing" -nameLabel = "Workspace name" -namePlaceholder = "Workspace name" -plan = "Plan" -regionEnterpriseSuffix = "{{region}} · Enterprise" -regionHelper = "Where documents are processed and stored at rest." -regionLabel = "Data residency region" -seats = "Seats" -seatsUsed = "{{used}} of {{total}} used" [portal.shell.header] accountFallback = "Account" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index 65f1da789c..1fc552cd80 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -499,6 +499,11 @@ "image": "/og_images/home.png", "title": "Payg Settings - Stirling PDF", "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, + "/settings/account-link": { + "image": "/og_images/home.png", + "title": "Account Link Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" } }, "byPath": { @@ -653,6 +658,7 @@ "/settings/legal": "/settings/legal", "/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses", "/settings/frontendThirdPartyLicenses": "/settings/frontendThirdPartyLicenses", - "/settings/payg": "/settings/payg" + "/settings/payg": "/settings/payg", + "/settings/account-link": "/settings/account-link" } } diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 264db178ac..485403c512 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -11,7 +11,11 @@ import { useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useConfigNavSections } from "@app/components/shared/config/configNavSections"; -import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; +import { + NavKey, + VALID_NAV_KEYS, + type ConfigNavSection, +} from "@app/components/shared/config/types"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent"; import "@app/components/shared/AppConfigModal.css"; @@ -31,6 +35,18 @@ import { stripBasePath, withBasePath } from "@app/constants/app"; interface AppConfigModalProps { opened: boolean; onClose: () => void; + /** + * Mirror the active section to /settings/ URLs (deep links, history + * unwind on close). Hosts mounted away from the editor's /settings route — + * the admin portal — turn this off and the modal keeps its section purely in + * state. + */ + urlSync?: boolean; + /** Section to land on when opening. Only honoured when urlSync is off (URL + * deep links win otherwise). */ + initialSection?: NavKey | null; + /** Host-specific sections appended after the build's registry sections. */ + extraSections?: ConfigNavSection[]; } // Extract section from URL path (e.g., /settings/people -> people) @@ -46,12 +62,18 @@ const getSectionFromPath = (pathname: string): NavKey | null => { const AppConfigModalInner: React.FC = ({ opened, onClose, + urlSync = true, + initialSection, + extraSections, }) => { const { t } = useTranslation(); // Initialize from the URL so a deep link (`/settings/people`) lands on the // right tab without a one-frame "general" flicker. const [active, setActive] = useState( - () => getSectionFromPath(window.location.pathname) ?? "general", + () => + (urlSync ? getSectionFromPath(window.location.pathname) : null) ?? + initialSection ?? + "general", ); const isMobile = useIsMobile(); const navigate = useNavigate(); @@ -66,6 +88,7 @@ const AppConfigModalInner: React.FC = ({ // those update the URL via `history.replaceState` directly and never push // a new React Router location. useEffect(() => { + if (!urlSync) return; const section = getSectionFromPath(location.pathname); if (opened && section) { setActive(section); @@ -77,7 +100,14 @@ const AppConfigModalInner: React.FC = ({ // If at /settings without a section, redirect to general navigate("/settings/general", { replace: true }); } - }, [location.pathname, opened, navigate]); + }, [location.pathname, opened, navigate, urlSync]); + + // Non-URL hosts land the modal on the section they asked for. + useEffect(() => { + if (opened && !urlSync && initialSection) { + setActive(initialSection); + } + }, [opened, urlSync, initialSection]); useEffect(() => { if (opened) { @@ -99,6 +129,7 @@ const AppConfigModalInner: React.FC = ({ const switchSection = useCallback( (key: NavKey) => { setActive(key); + if (!urlSync) return; const alreadyInSettings = stripBasePath( window.location.pathname, ).startsWith("/settings"); @@ -112,7 +143,7 @@ const AppConfigModalInner: React.FC = ({ navigate(`/settings/${key}`); } }, - [navigate], + [navigate, urlSync], ); // Backwards-compat: external `appConfig:navigate` events route through the @@ -156,7 +187,7 @@ const AppConfigModalInner: React.FC = ({ // Only unwind history if settings was opened via the URL; opened via state // there's no /settings entry to pop and navigate(-1) would jump to /files. - if (location.pathname.startsWith("/settings")) { + if (urlSync && location.pathname.startsWith("/settings")) { // "default" key = first entry (deep link/refresh); nothing to pop to. if (location.key === "default") { navigate("/", { replace: true }); @@ -165,7 +196,14 @@ const AppConfigModalInner: React.FC = ({ } } onClose(); - }, [confirmIfDirty, location.key, location.pathname, navigate, onClose]); + }, [ + confirmIfDirty, + location.key, + location.pathname, + navigate, + onClose, + urlSync, + ]); // Synchronous wrapper for contexts (e.g. tour buttons) that need () => void const handleCloseSync = useCallback(() => { @@ -173,12 +211,19 @@ const AppConfigModalInner: React.FC = ({ }, [handleClose]); // Left navigation structure and icons - const configNavSections = useConfigNavSections( + const registrySections = useConfigNavSections( isAdmin, runningEE, loginEnabled, handleCloseSync, ); + const configNavSections = useMemo( + () => + extraSections?.length + ? [...registrySections, ...extraSections] + : registrySections, + [registrySections, extraSections], + ); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx index d242db9a66..1ba57618f0 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx @@ -1,4 +1,8 @@ import { Suspense, lazy, useEffect, useState } from "react"; +import type { + ConfigNavSection, + NavKey, +} from "@app/components/shared/config/types"; // AppConfigModal pulls in the entire settings UI tree (admin sections, // account, supabase auth flows, etc.). We defer loading until the user first @@ -10,11 +14,20 @@ const AppConfigModal = lazy( interface AppConfigModalLazyProps { opened: boolean; onClose: () => void; + /** See AppConfigModal — off for hosts outside the /settings route. */ + urlSync?: boolean; + /** Section to land on when opening (non-URL hosts). */ + initialSection?: NavKey | null; + /** Host-specific sections appended after the build's registry sections. */ + extraSections?: ConfigNavSection[]; } export default function AppConfigModalLazy({ opened, onClose, + urlSync, + initialSection, + extraSections, }: AppConfigModalLazyProps) { const [shouldMount, setShouldMount] = useState(false); @@ -24,7 +37,15 @@ export default function AppConfigModalLazy({ return ( - {shouldMount && } + {shouldMount && ( + + )} ); } diff --git a/frontend/editor/src/core/components/shared/config/configNavSections.tsx b/frontend/editor/src/core/components/shared/config/configNavSections.tsx index db02ad8458..0c9515b68c 100644 --- a/frontend/editor/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/core/components/shared/config/configNavSections.tsx @@ -1,6 +1,5 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { NavKey } from "@app/components/shared/config/types"; import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; import HelpSection from "@app/components/shared/config/configSections/HelpSection"; @@ -9,22 +8,14 @@ import { BackendThirdPartyLicensesSection, FrontendThirdPartyLicensesSection, } from "@app/components/shared/config/configSections/ThirdPartyLicensesSection"; +import type { + ConfigNavItem, + ConfigNavSection, +} from "@app/components/shared/config/types"; -export interface ConfigNavItem { - key: NavKey; - label: string; - icon: string; - component: React.ReactNode; - disabled?: boolean; - disabledTooltip?: string; - badge?: string; - badgeColor?: string; -} - -export interface ConfigNavSection { - title: string; - items: ConfigNavItem[]; -} +// Re-exported for the many existing importers; the definitions live in +// config/types so type-only consumers don't pull the section tree in. +export type { ConfigNavItem, ConfigNavSection }; export interface ConfigColors { navBg: string; diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 9fa87fe915..5cbdd1b169 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -1,3 +1,5 @@ +import type React from "react"; + // Single source of truth for all valid nav keys export const VALID_NAV_KEYS = [ "preferences", @@ -35,9 +37,29 @@ export const VALID_NAV_KEYS = [ "backendThirdPartyLicenses", "frontendThirdPartyLicenses", "payg", + "account-link", ] as const; // Derive the type from the array export type NavKey = (typeof VALID_NAV_KEYS)[number]; // some of these are not used yet, but appear in figma designs + +// Nav structure of the settings modal. Lives here (not configNavSections) so +// consumers that only need the shape don't pull the whole section-component +// tree into their build's typecheck graph. +export interface ConfigNavItem { + key: NavKey; + label: string; + icon: string; + component: React.ReactNode; + disabled?: boolean; + disabledTooltip?: string; + badge?: string; + badgeColor?: string; +} + +export interface ConfigNavSection { + title: string; + items: ConfigNavItem[]; +} diff --git a/frontend/editor/src/desktop/components/shared/config/types.ts b/frontend/editor/src/desktop/components/shared/config/types.ts index a1ab108a1f..daae2b16bc 100644 --- a/frontend/editor/src/desktop/components/shared/config/types.ts +++ b/frontend/editor/src/desktop/components/shared/config/types.ts @@ -1,3 +1,4 @@ +import type React from "react"; import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types"; export const VALID_NAV_KEYS = [ @@ -7,3 +8,21 @@ export const VALID_NAV_KEYS = [ ] as const; export type NavKey = (typeof VALID_NAV_KEYS)[number]; + +// Mirrors the core shape over the widened desktop NavKey union — see the core +// module for why these live in types rather than configNavSections. +export interface ConfigNavItem { + key: NavKey; + label: string; + icon: string; + component: React.ReactNode; + disabled?: boolean; + disabledTooltip?: string; + badge?: string; + badgeColor?: string; +} + +export interface ConfigNavSection { + title: string; + items: ConfigNavItem[]; +} diff --git a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx index 6fc0af735b..556cfe1863 100644 --- a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx +++ b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx @@ -2,8 +2,8 @@ import type { AccountLinkSettingsSeam } from "@portal-proprietary/components/set /** * SaaS has no account-link concept — the signed-in account IS the SaaS account. - * Null drops the "Account link" nav item and its panel from Settings (the shared - * SettingsModal treats the seam as optional), so the link-only AccountLinkPanel - * is never imported into the SaaS bundle. + * Null drops the "Account link" nav item and its panel from the shared settings + * modal (the portal host treats the seam as optional), so the link-only + * AccountLinkPanel is never imported into the SaaS bundle. */ export const accountLinkSettings: AccountLinkSettingsSeam | null = null; diff --git a/frontend/editor/src/portal/api/settings.ts b/frontend/editor/src/portal/api/settings.ts deleted file mode 100644 index 430ea6e14d..0000000000 --- a/frontend/editor/src/portal/api/settings.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { apiClient } from "@portal/api/http"; -import type { Tier } from "@portal/contexts/TierContext"; - -/* - * The account + workspace settings surface. The shape is tier-aware: the - * workspace plan label, available regions, and data-residency posture differ - * by tier, so the modal reflects what each plan can actually configure. - */ - -export interface RegionOption { - value: string; - label: string; - /** Enterprise-only residency regions are gated below higher tiers. */ - enterpriseOnly?: boolean; -} - -export interface NotificationDefault { - id: string; - enabled: boolean; -} - -/** A device/browser with an active session, shown under Admin → Security. */ -export interface ActiveSession { - id: string; - device: string; - location: string; - lastActive: string; - /** The session viewing this modal — can't be revoked from here. */ - current: boolean; -} - -/** - * Org-wide authentication posture. SSO/SCIM are enterprise capabilities; lower - * tiers see them as locked rows with an upgrade nudge. - */ -export interface SecuritySettings { - mfaEnforced: boolean; - ssoEnabled: boolean; - scimEnabled: boolean; - /** Idle timeout before re-auth, in minutes. */ - sessionTimeoutMins: number; - activeSessions: ActiveSession[]; -} - -/** An opt-in early-access feature flag. */ -export interface BetaFeature { - id: string; - label: string; - description: string; - enabled: boolean; - /** Gated to enterprise — rendered locked below it. */ - enterpriseOnly?: boolean; -} - -/** - * Server snapshot of the account + workspace the modal opens onto. Editable - * fields seed local form state; `planLabel` / `seats` are read-only context. - */ -export interface SettingsSnapshot { - profile: { - name: string; - email: string; - role: string; - /** Avatar image URL, or null to fall back to initials. */ - avatarUrl: string | null; - }; - workspace: { - name: string; - region: string; - planLabel: string; - seats: { used: number; total: number }; - }; - /** Per-category notification toggles, server-default on/off. */ - notifications: NotificationDefault[]; - regions: RegionOption[]; - /** Org-wide authentication + session posture (Admin scope). */ - security: SecuritySettings; - /** Opt-in early-access features (Admin scope). */ - betaFeatures: BetaFeature[]; -} - -/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */ -export async function fetchSettings(tier: Tier): Promise { - return apiClient.local.json( - `/v1/settings?tier=${encodeURIComponent(tier)}`, - ); -} diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx index f5b524145d..49172784fc 100644 --- a/frontend/editor/src/portal/components/PortalChrome.tsx +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -6,7 +6,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { AppShell } from "@portal/components/AppShell"; import { AssistantMount } from "@portal/components/AssistantMount"; import { SearchModal } from "@portal/components/SearchModal"; -import { SettingsModal } from "@portal/components/SettingsModal"; +import { PortalSettingsHost } from "@portal/components/PortalSettingsHost"; import { ViewRouter } from "@portal/ViewRouter"; /** @@ -35,18 +35,6 @@ function GlobalShortcuts() { return null; } -/** Bridges the Settings modal's open/close props to UIContext state. */ -function SettingsHost() { - const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); - return ( - - ); -} - /** * The routed view, wrapped in an error boundary so a single view crashing can't * white-screen the portal (the shell + nav stay alive). Keyed by route so @@ -80,7 +68,7 @@ export function PortalChrome() { - + ); } diff --git a/frontend/editor/src/portal/components/PortalSettingsHost.tsx b/frontend/editor/src/portal/components/PortalSettingsHost.tsx new file mode 100644 index 0000000000..d68ad92ba5 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalSettingsHost.tsx @@ -0,0 +1,84 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import AppConfigModalLazy from "@app/components/shared/AppConfigModalLazy"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ThemeProvider } from "@app/components/shared/ThemeProvider"; +import { AuthProvider } from "@app/auth/UseSession"; +import { + VALID_NAV_KEYS, + type ConfigNavSection, + type NavKey, +} from "@app/components/shared/config/types"; +import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; +import { useUI } from "@portal/contexts/UIContext"; + +/** + * Mounts the editor's settings modal (the app-wide settings surface) inside the + * portal. The portal deliberately lives outside the editor's AppProviders, so + * this host supplies the contexts the settings tree needs: app config, user + * preferences, the session provider the account sections read (flavor-resolved: + * Spring on self-hosted, Supabase on SaaS — same underlying session the portal + * is already signed in with), and the editor ThemeProvider (which also carries + * the Mantine theme + toasts the sections expect). URL sync is off — the portal + * owns its own route subtree, so the modal keeps its section purely in state. + * + * Everything (providers included) mounts on first open and stays mounted, so + * the editor theme wiring never runs for portal sessions that never open + * settings. + */ +export function PortalSettingsHost() { + const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); + const { t } = useTranslation(); + const [everOpened, setEverOpened] = useState(false); + + useEffect(() => { + if (settingsOpen) setEverOpened(true); + }, [settingsOpen]); + + // Portal-only sections, appended after the build's registry sections. The + // account-link seam is self-hosted-only (the saas overlay shadows it to null). + const extraSections = useMemo(() => { + if (!accountLinkSettings) return []; + const { navKey, labelKey, icon, Body } = accountLinkSettings; + return [ + { + title: t("portal.settings.groups.admin", "Admin"), + items: [ + { + key: navKey, + label: t(labelKey, "Account link"), + icon, + component: , + }, + ], + }, + ]; + }, [t]); + + const initialSection: NavKey | null = + settingsInitialSection && + (VALID_NAV_KEYS as readonly string[]).includes(settingsInitialSection) + ? (settingsInitialSection as NavKey) + : null; + + if (!everOpened) return null; + + return ( + + + + + + + + + + ); +} diff --git a/frontend/editor/src/portal/components/SettingsModal.css b/frontend/editor/src/portal/components/SettingsModal.css deleted file mode 100644 index 59fa1e0bc4..0000000000 --- a/frontend/editor/src/portal/components/SettingsModal.css +++ /dev/null @@ -1,255 +0,0 @@ -/* The settings overlay hosts a full-bleed two-pane SettingsShell, so the - modal frame contributes no padding of its own and lets the shell scroll. */ -.portal-settings .sui-modal__body { - padding: 0; - overflow: hidden; -} - -.portal-settings__section { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Footer note pushes the action buttons to the right. */ -.portal-settings__footer-note { - margin-right: auto; - align-self: center; - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* ── Profile identity row ─────────────────────────────────────────────── */ -.portal-settings__identity { - display: flex; - align-items: center; - gap: 0.875rem; - padding: 0.875rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-lg); -} - -.portal-settings__identity-meta { - display: flex; - flex-direction: column; - gap: 0.25rem; - min-width: 0; - flex: 1 1 auto; -} - -.portal-settings__identity-name { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.9375rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-settings__identity-email { - font-size: 0.8125rem; - color: var(--color-text-4); -} - -/* ── Preference groups ────────────────────────────────────────────────── */ -.portal-settings__group { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.portal-settings__group + .portal-settings__group { - padding-top: 1rem; - border-top: 1px solid var(--color-border-light); -} - -.portal-settings__group-head { - display: flex; - flex-direction: column; - gap: 0.125rem; -} - -.portal-settings__group-title { - margin: 0; - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-settings__group-sub { - margin: 0; - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* ── Theme picker ─────────────────────────────────────────────────────── */ -.portal-settings__theme { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.625rem; -} - -.portal-settings__theme-card { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem; - text-align: left; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - transition: - border-color var(--motion-fast), - background var(--motion-fast), - box-shadow var(--motion-fast); -} - -.portal-settings__theme-card:hover { - border-color: var(--color-border-strong, var(--color-border)); - background: var(--color-bg-hover); -} - -.portal-settings__theme-card.is-active { - border-color: var(--color-blue); - box-shadow: 0 0 0 1px var(--color-blue); -} - -.portal-settings__theme-swatch { - display: inline-flex; - flex-direction: column; - gap: 2px; - width: 2.25rem; - height: 2.25rem; - padding: 4px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border); - flex: 0 0 auto; -} - -.portal-settings__theme-swatch span { - border-radius: 2px; -} - -.portal-settings__theme-swatch span:first-child { - flex: 0 0 35%; -} - -.portal-settings__theme-swatch span:last-child { - flex: 1 1 auto; -} - -.portal-settings__theme-swatch--light { - background: #ffffff; -} -.portal-settings__theme-swatch--light span:first-child { - background: #cbd5e1; -} -.portal-settings__theme-swatch--light span:last-child { - background: #eef2f7; -} - -.portal-settings__theme-swatch--dark { - background: #0f172a; -} -.portal-settings__theme-swatch--dark span:first-child { - background: #475569; -} -.portal-settings__theme-swatch--dark span:last-child { - background: #1e293b; -} - -.portal-settings__theme-text { - display: flex; - flex-direction: column; - min-width: 0; -} - -.portal-settings__theme-text strong { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-settings__theme-text span { - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* ── Notification rows ────────────────────────────────────────────────── */ -.portal-settings__notifs { - display: flex; - flex-direction: column; -} - -.portal-settings__notif-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.75rem 0; -} - -.portal-settings__notif-row + .portal-settings__notif-row { - border-top: 1px solid var(--color-border-light); -} - -.portal-settings__notif-text { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; -} - -.portal-settings__notif-text strong { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-1); -} - -.portal-settings__notif-text span { - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* Label paired with a gating badge (e.g. "SCIM provisioning" + Enterprise). */ -.portal-settings__row-label { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -/* ── Workspace plan card ──────────────────────────────────────────────── */ -.portal-settings__plan { - display: flex; - flex-direction: column; - gap: 0.625rem; - padding: 0.875rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-lg); -} - -.portal-settings__plan-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -} - -.portal-settings__plan-label { - font-size: 0.8125rem; - color: var(--color-text-3); -} - -.portal-settings__plan-value { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-1); -} - -@media (max-width: 40rem) { - .portal-settings__theme { - grid-template-columns: 1fr; - } -} diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx deleted file mode 100644 index eb2380a9a9..0000000000 --- a/frontend/editor/src/portal/components/SettingsModal.tsx +++ /dev/null @@ -1,902 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Avatar, - Button, - FormField, - Input, - Modal, - Select, - SettingsShell, - Skeleton, - StatusBadge, - ToggleSwitch, - type SelectOption, - type SettingsNavSection, -} from "@app/ui"; -import { useTier, type Tier } from "@portal/contexts/TierContext"; -import { useTheme, type Theme } from "@portal/contexts/ThemeContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - fetchSettings, - type ActiveSession, - type BetaFeature, - type SettingsSnapshot, -} from "@portal/api/settings"; -import { - UsersIcon, - SunIcon, - BellIcon, - SettingsIcon, - PoliciesIcon, - InfrastructureIcon, - SparklesIcon, -} from "@portal/components/icons"; -import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; -import "@portal/components/SettingsModal.css"; - -type SettingsSection = - | "profile" - | "appearance" - | "notifications" - | "general" - | "authentication" - | "sessions" - | "early-access" - | "account-link"; - -function isSettingsSection(value: string | null): value is SettingsSection { - return ( - value === "profile" || - value === "appearance" || - value === "notifications" || - value === "general" || - value === "authentication" || - value === "sessions" || - value === "early-access" || - value === "account-link" - ); -} - -/** Org-wide auth posture the Admin sections edit, mirrored into local state. */ -interface SecurityForm { - mfaEnforced: boolean; - ssoEnabled: boolean; - scimEnabled: boolean; - sessionTimeoutMins: number; -} - -interface SettingsModalProps { - open: boolean; - onClose: () => void; - /** - * Optional section to land on when opening. When `null`/unsupported the modal - * picks the default ("profile"). Set by callers like the sidebar's "Link - * account" affordance → "account-link". - */ - initialSection?: string | null; -} - -/** - * Notification categories with known display copy, in the order the snapshot - * exposes them. Labels and descriptions are resolved via i18n at render time, - * keyed by id; ids absent from this list are skipped. - */ -const NOTIFICATION_IDS = [ - "pipeline-failures", - "pipeline-success", - "usage-alerts", - "weekly-digest", - "security-alerts", - "product-updates", -] as const; - -const THEME_OPTIONS: { value: Theme }[] = [ - { value: "light" }, - { value: "dark" }, -]; - -const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const; - -/** - * Account settings as a portal-wide overlay. A grouped left-nav (Account / - * Workspace / Admin) over a tier-aware snapshot that seeds editable local form - * state. Save is a no-op for the demo — it closes — but the theme control - * writes straight through to ThemeProvider so the change is real and visible. - */ -export function SettingsModal({ - open, - onClose, - initialSection, -}: SettingsModalProps) { - const { t } = useTranslation(); - const { tier } = useTier(); - const { theme, setTheme } = useTheme(); - const [section, setSection] = useState("profile"); - - const navSections = useMemo( - () => [ - { - title: t("portal.settings.groups.account"), - items: [ - { - key: "profile", - label: t("portal.settings.sections.profile"), - icon: , - }, - { - key: "appearance", - label: t("portal.settings.sections.appearance"), - icon: , - }, - { - key: "notifications", - label: t("portal.settings.sections.notifications"), - icon: , - }, - ], - }, - { - title: t("portal.settings.groups.workspace"), - items: [ - { - key: "general", - label: t("portal.settings.sections.general"), - icon: , - }, - ], - }, - { - title: t("portal.settings.groups.admin"), - items: [ - // Account-link is a self-hosted-only section; the SaaS build shadows - // the seam to null, dropping the item entirely. - ...(accountLinkSettings - ? [ - { - key: accountLinkSettings.navKey, - label: t(accountLinkSettings.labelKey), - icon: accountLinkSettings.icon, - }, - ] - : []), - { - key: "authentication", - label: t("portal.settings.sections.authentication"), - icon: , - }, - { - key: "sessions", - label: t("portal.settings.sections.sessions"), - icon: , - }, - { - key: "early-access", - label: t("portal.settings.sections.early-access"), - icon: , - }, - ], - }, - ], - [t], - ); - - const { data: snapshot, loading } = useAsync( - () => fetchSettings(tier), - [tier], - ); - - // Editable copies seeded from the snapshot. Re-seed whenever a fresh snapshot - // arrives (tier switch) or the modal is re-opened, so edits never leak across - // sessions or stack on stale values. - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [workspaceName, setWorkspaceName] = useState(""); - const [region, setRegion] = useState(""); - const [notifications, setNotifications] = useState>( - {}, - ); - const [security, setSecurity] = useState({ - mfaEnforced: false, - ssoEnabled: false, - scimEnabled: false, - sessionTimeoutMins: 480, - }); - const [betaToggles, setBetaToggles] = useState>({}); - - useEffect(() => { - if (!snapshot) return; - setName(snapshot.profile.name); - setEmail(snapshot.profile.email); - setWorkspaceName(snapshot.workspace.name); - setRegion(snapshot.workspace.region); - setNotifications( - Object.fromEntries(snapshot.notifications.map((n) => [n.id, n.enabled])), - ); - setSecurity({ - mfaEnforced: snapshot.security.mfaEnforced, - ssoEnabled: snapshot.security.ssoEnabled, - scimEnabled: snapshot.security.scimEnabled, - sessionTimeoutMins: snapshot.security.sessionTimeoutMins, - }); - setBetaToggles( - Object.fromEntries(snapshot.betaFeatures.map((f) => [f.id, f.enabled])), - ); - }, [snapshot]); - - useEffect(() => { - if (!open) return; - const requested = initialSection ?? null; - setSection(isSettingsSection(requested) ? requested : "profile"); - }, [open, initialSection]); - - const regionOptions = useMemo(() => { - if (!snapshot) return []; - return snapshot.regions.map((r) => ({ - value: r.value, - label: - r.enterpriseOnly && tier !== "enterprise" - ? t("portal.settings.workspace.regionEnterpriseSuffix", { - region: r.label, - }) - : r.label, - disabled: r.enterpriseOnly && tier !== "enterprise", - })); - }, [snapshot, tier, t]); - - const isLoading = loading && !snapshot; - - return ( - - setSection(k as SettingsSection)} - title={t(`portal.settings.sections.${section}`)} - onClose={onClose} - footer={ - <> - - {t("portal.settings.footerNote")} - - - - - } - > - {section === "profile" && ( - - )} - - {section === "appearance" && ( - - )} - - {section === "notifications" && ( - n.id) ?? []} - onToggle={(id, value) => - setNotifications((prev) => ({ ...prev, [id]: value })) - } - /> - )} - - {section === "general" && ( - - )} - - {section === "authentication" && ( - setSecurity((s) => ({ ...s, ...patch }))} - /> - )} - - {section === "sessions" && ( - - )} - - {section === "early-access" && ( - - setBetaToggles((prev) => ({ ...prev, [id]: value })) - } - /> - )} - - {section === "account-link" && accountLinkSettings && ( - - )} - - - ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Profile */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function ProfilePanel({ - loading, - name, - email, - role, - avatarUrl, - onName, - onEmail, -}: { - loading: boolean; - name: string; - email: string; - role?: string; - avatarUrl?: string; - onName: (v: string) => void; - onEmail: (v: string) => void; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
-
- -
- - -
-
- - -
- ); - } - - return ( -
-
- -
-
- {name || t("portal.settings.profile.accountFallback")} - {role && ( - - {role} - - )} -
- {email} -
- -
- - - onName(e.target.value)} - placeholder={t("portal.settings.profile.namePlaceholder")} - /> - - - - onEmail(e.target.value)} - placeholder={t("portal.settings.profile.emailPlaceholder")} - /> - -
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Appearance */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function AppearancePanel({ - theme, - onTheme, -}: { - theme: Theme; - onTheme: (theme: Theme) => void; -}) { - const { t } = useTranslation(); - return ( -
-
-
-

- {t("portal.settings.appearance.themeTitle")} -

-

- {t("portal.settings.appearance.themeSub")} -

-
-
- {THEME_OPTIONS.map((opt) => ( - - ))} -
-
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Notifications */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function NotificationsPanel({ - loading, - notifications, - order, - onToggle, -}: { - loading: boolean; - notifications: Record; - order: string[]; - onToggle: (id: string, value: boolean) => void; -}) { - const { t } = useTranslation(); - return ( -
-
-
-

- {t("portal.settings.notifications.title")} -

-

- {t("portal.settings.notifications.sub")} -

-
- - {loading && ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
- -
- ))} -
- )} - - {!loading && ( -
- {order.map((id) => { - if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) { - return null; - } - return ( -
-
- - {t(`portal.settings.notifications.${id}.label`)} - - - {t(`portal.settings.notifications.${id}.description`)} - -
- onToggle(id, v)} - /> -
- ); - })} -
- )} -
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Workspace */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function WorkspacePanel({ - loading, - workspaceName, - onWorkspaceName, - region, - onRegion, - regionOptions, - planLabel, - seats, -}: { - loading: boolean; - workspaceName: string; - onWorkspaceName: (v: string) => void; - region: string; - onRegion: (v: string) => void; - regionOptions: SelectOption[]; - planLabel?: string; - seats?: { used: number; total: number }; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
- - - -
- ); - } - - return ( -
- - onWorkspaceName(e.target.value)} - placeholder={t("portal.settings.workspace.namePlaceholder")} - /> - - - - - onSecurity({ sessionTimeoutMins: Number(value ?? "0") }) - } - options={SESSION_TIMEOUT_VALUES.map((value) => ({ - value, - label: t(`portal.settings.authentication.timeout.${value}`), - }))} - /> - -
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Admin · Active sessions */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function SessionsPanel({ - loading, - sessions, -}: { - loading: boolean; - sessions: ActiveSession[]; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
- - -
- ); - } - - return ( -
-
-
-

- {t("portal.settings.sessions.title")} -

-

- {t("portal.settings.sessions.sub")} -

-
-
- {sessions.map((s) => ( -
-
- {s.device} - - {s.location} · {s.lastActive} - -
- {s.current ? ( - - {t("portal.settings.sessions.thisDevice")} - - ) : ( - // TODO(backend): DELETE /v1/settings/sessions/{id} - - )} -
- ))} -
-
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Admin · Early access */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function EarlyAccessPanel({ - loading, - tier, - betaFeatures, - betaToggles, - onBeta, -}: { - loading: boolean; - tier: Tier; - betaFeatures: BetaFeature[]; - betaToggles: Record; - onBeta: (id: string, value: boolean) => void; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
- - -
- ); - } - - const isEnterprise = tier === "enterprise"; - - return ( -
-
-
-

- {t("portal.settings.earlyAccess.title")} -

-

- {t("portal.settings.earlyAccess.sub")} -

-
-
- {betaFeatures.map((f) => { - const locked = Boolean(f.enterpriseOnly) && !isEnterprise; - return ( -
-
- - {f.label} - {locked && ( - - {t("portal.settings.enterpriseBadge")} - - )} - - {f.description} -
- onBeta(f.id, v)} - /> -
- ); - })} -
-
-
- ); -} diff --git a/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx index 8e769672d7..22c03ec682 100644 --- a/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx +++ b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx @@ -1,26 +1,26 @@ -import type { ComponentType, ReactNode } from "react"; -import { LinkIcon } from "@portal/components/icons"; +import type { ComponentType } from "react"; import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; export interface AccountLinkSettingsSeam { - /** Section key in the Settings nav + body switch. */ - navKey: string; + /** Nav key in the shared settings modal (registered in config/types.ts). */ + navKey: "account-link"; /** i18n key for the nav label; resolved with `t()` at the call site. */ labelKey: string; - icon: ReactNode; + /** LocalIcon name for the nav item. */ + icon: string; /** The section body — the account-link panel. */ Body: ComponentType; } /** - * The admin "Account link" section of Settings (self-hosted only). The SaaS - * build shadows this file with `null`: the signed-in account IS the SaaS - * account, so there is no instance to link — the nav item and its panel both - * drop out, and nothing imports the link-only AccountLinkPanel. + * The admin "Account link" section of the shared settings modal (self-hosted + * only). The SaaS build shadows this file with `null`: the signed-in account IS + * the SaaS account, so there is no instance to link — the nav item and its + * panel both drop out, and nothing imports the link-only AccountLinkPanel. */ export const accountLinkSettings: AccountLinkSettingsSeam | null = { navKey: "account-link", labelKey: "portal.settings.sections.account-link", - icon: , + icon: "link-rounded", Body: AccountLinkPanel, }; diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 8d2139a0b8..64e885d15f 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -8,7 +8,6 @@ import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; import { procurementHandlers } from "@portal/mocks/handlers/procurement"; import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas"; import { docsHandlers } from "@portal/mocks/handlers/docs"; -import { settingsHandlers } from "@portal/mocks/handlers/settings"; import { usersHandlers } from "@portal/mocks/handlers/users"; import { agentsHandlers } from "@portal/mocks/handlers/agents"; import { policiesHandlers } from "@portal/mocks/handlers/policies"; @@ -28,7 +27,6 @@ export const handlers = [ ...docsHandlers, ...procurementHandlers, ...procurementSaasHandlers, - ...settingsHandlers, ...usersHandlers, ...agentsHandlers, ...policiesHandlers, diff --git a/frontend/editor/src/portal/mocks/handlers/settings.ts b/frontend/editor/src/portal/mocks/handlers/settings.ts deleted file mode 100644 index 8fdd4c02fa..0000000000 --- a/frontend/editor/src/portal/mocks/handlers/settings.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { buildSettingsSnapshot } from "@portal/mocks/settings"; - -export const settingsHandlers = [ - http.get("/v1/settings", async ({ request }) => { - await delay(120); - const url = new URL(request.url); - const tier = (url.searchParams.get("tier") ?? "pro") as Tier; - return HttpResponse.json(buildSettingsSnapshot(tier)); - }), -]; diff --git a/frontend/editor/src/portal/mocks/settings.ts b/frontend/editor/src/portal/mocks/settings.ts deleted file mode 100644 index 59cceb17d8..0000000000 --- a/frontend/editor/src/portal/mocks/settings.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Account-settings fixtures. Types live in api/settings.ts (the backend - * contract); this module only builds fake data for Storybook and tests. - * - * The shape is tier-aware: the workspace plan label, available regions, and - * data-residency posture differ by tier, so the modal reflects what each plan - * can actually configure. - */ - -import type { - ActiveSession, - BetaFeature, - NotificationDefault, - RegionOption, - SecuritySettings, - SettingsSnapshot, -} from "@portal/api/settings"; -import type { Tier } from "@portal/contexts/TierContext"; - -const REGIONS: RegionOption[] = [ - { value: "us-east-1", label: "US East (N. Virginia)" }, - { value: "us-west-2", label: "US West (Oregon)" }, - { value: "eu-west-1", label: "EU West (Ireland)" }, - { value: "eu-central-1", label: "EU Central (Frankfurt)" }, - { - value: "ap-southeast-2", - label: "Asia Pacific (Sydney)", - enterpriseOnly: true, - }, - { value: "ca-central-1", label: "Canada (Central)", enterpriseOnly: true }, -]; - -const PLAN_LABEL: Record = { - free: "Editor plan", - pro: "Processor plan", - enterprise: "Enterprise plan", -}; - -const SEATS: Record = { - free: { used: 1, total: 1 }, - pro: { used: 4, total: 5 }, - enterprise: { used: 38, total: 50 }, -}; - -const WORKSPACE_NAME: Record = { - free: "My Workspace", - pro: "Acme Document Ops", - enterprise: "Acme Corp — Global", -}; - -/** Notification categories shown in Preferences, with sensible per-tier defaults. */ -function notificationsFor(tier: Tier): NotificationDefault[] { - return [ - { id: "pipeline-failures", enabled: true }, - { id: "pipeline-success", enabled: tier !== "free" }, - { id: "usage-alerts", enabled: true }, - { id: "weekly-digest", enabled: tier === "free" }, - { id: "security-alerts", enabled: true }, - { id: "product-updates", enabled: false }, - ]; -} - -/** Session timeout shortens as the plan's security posture tightens. */ -const SESSION_TIMEOUT_MINS: Record = { - free: 1440, - pro: 720, - enterprise: 480, -}; - -function securityFor(tier: Tier): SecuritySettings { - const base: ActiveSession[] = [ - { - id: "sess-current", - device: "Chrome · macOS", - location: "London, UK", - lastActive: "Active now", - current: true, - }, - ]; - if (tier !== "free") { - base.push({ - id: "sess-cli", - device: "Stirling CLI · CI runner", - location: "eu-west-1", - lastActive: "12 min ago", - current: false, - }); - } - if (tier === "enterprise") { - base.push({ - id: "sess-mobile", - device: "Safari · iPhone", - location: "London, UK", - lastActive: "3 h ago", - current: false, - }); - } - return { - // Enterprise tenants enforce MFA + SSO/SCIM org-wide by default. - mfaEnforced: tier === "enterprise", - ssoEnabled: tier === "enterprise", - scimEnabled: tier === "enterprise", - sessionTimeoutMins: SESSION_TIMEOUT_MINS[tier], - activeSessions: base, - }; -} - -function betaFeaturesFor(tier: Tier): BetaFeature[] { - return [ - { - id: "pipeline-canary", - label: "Pipeline canary rollouts", - description: "Shadow-run a new pipeline version before promoting it.", - enabled: false, - }, - { - id: "component-sandboxes", - label: "Live component sandboxes", - description: "Interactive previews for embeddable components.", - enabled: tier !== "free", - }, - { - id: "agent-evals-v2", - label: "Agent evals v2", - description: "Richer golden-set scoring with regression diffs.", - enabled: tier === "enterprise", - enterpriseOnly: true, - }, - ]; -} - -export function buildSettingsSnapshot(tier: Tier): SettingsSnapshot { - return { - profile: { - name: "Reece Browne", - email: "reece@stirlingpdf.com", - role: tier === "enterprise" ? "Org Admin" : "Owner", - avatarUrl: null, - }, - workspace: { - name: WORKSPACE_NAME[tier], - region: tier === "free" ? "us-east-1" : "eu-west-1", - planLabel: PLAN_LABEL[tier], - seats: SEATS[tier], - }, - notifications: notificationsFor(tier), - regions: REGIONS, - security: securityFor(tier), - betaFeatures: betaFeaturesFor(tier), - }; -} diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index b691244e68..7d36b40ee4 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -9,7 +9,10 @@ import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import Overview from "@app/components/shared/config/configSections/Overview"; import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections"; -import { NavKey } from "@app/components/shared/config/types"; +import { + NavKey, + type ConfigNavSection, +} from "@app/components/shared/config/types"; import { stripBasePath, withBasePath } from "@app/constants/app"; import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent"; import "@app/components/shared/AppConfigModal.css"; @@ -21,9 +24,21 @@ import { interface AppConfigModalProps { opened: boolean; onClose: () => void; + /** Accepted for interface parity with the core shell; this shell never + * URL-syncs, so it has no effect. */ + urlSync?: boolean; + /** Section to land on when opening (used by non-URL hosts like the portal). */ + initialSection?: NavKey | null; + /** Host-specific sections appended after the saas registry sections. */ + extraSections?: ConfigNavSection[]; } -const AppConfigModal: React.FC = ({ opened, onClose }) => { +const AppConfigModal: React.FC = ({ + opened, + onClose, + initialSection, + extraSections, +}) => { const isMobile = useMediaQuery("(max-width: 1024px)"); const { signOut, user } = useAuth(); @@ -53,16 +68,21 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { // usage-limit modal CTAs, which need to land on the Plan section), select that section. The // opener (QuickAccessBar) opens the modal whenever the path is /settings/*, but doesn't carry // the section, and `active` defaults to "overview" — so without this a deep link would open on - // Overview rather than the linked section. + // Overview rather than the linked section. Non-URL hosts (the portal) pass the + // section directly instead. useEffect(() => { if (!opened) return; + if (initialSection) { + setActive(initialSection); + return; + } const match = stripBasePath(window.location.pathname).match( /^\/settings\/([^/?#]+)/, ); if (match) { setActive(match[1] as NavKey); } - }, [opened]); + }, [opened, initialSection]); // Listen for notice updates (e.g., "Not enough credits..." next to Plan title) useEffect(() => { @@ -116,15 +136,14 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { // Left navigation structure and icons. The Plan tab now internally branches // free vs subscribed × leader vs member via useWallet(), so the modal no // longer plumbs paygEnabled / isLeader through to the nav builder. - const configNavSections = useMemo( - () => - createSaasConfigNavSections(Overview, openLogoutConfirm, { - isDev, - isAnonymous, - t, - }), - [openLogoutConfirm, isDev, isAnonymous, t], - ); + const configNavSections = useMemo(() => { + const sections = createSaasConfigNavSections(Overview, openLogoutConfirm, { + isDev, + isAnonymous, + t, + }); + return extraSections?.length ? [...sections, ...extraSections] : sections; + }, [openLogoutConfirm, isDev, isAnonymous, t, extraSections]); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/saas/components/shared/config/types.ts b/frontend/editor/src/saas/components/shared/config/types.ts index f80bd1ac19..ebaff637d5 100644 --- a/frontend/editor/src/saas/components/shared/config/types.ts +++ b/frontend/editor/src/saas/components/shared/config/types.ts @@ -1,3 +1,4 @@ +import type React from "react"; import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types"; // SaaS adds an "overview" account section and an "mcp" integrations tab. All @@ -7,3 +8,21 @@ import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/ export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview", "mcp"] as const; export type NavKey = (typeof VALID_NAV_KEYS)[number]; + +// Mirrors the core shape over the widened saas NavKey union — see the core +// module for why these live in types rather than configNavSections. +export interface ConfigNavItem { + key: NavKey; + label: string; + icon: string; + component: React.ReactNode; + disabled?: boolean; + disabledTooltip?: string; + badge?: string; + badgeColor?: string; +} + +export interface ConfigNavSection { + title: string; + items: ConfigNavItem[]; +} From 51d3d27fd34fcf2fd5169a8c93cbf517ba4b6f30 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:13:10 +0100 Subject: [PATCH 13/13] Portal policies: SUI setup forms fixes and improvements (#6927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Reworks the portal's policy setup screens so a policy reads as **its own settings** rather than a list of tools you wire together, and rebuilds the forms on the shared design system so they match the rest of the portal. ## Why Setup showed one card per underlying tool (tool name + a toggle), which exposed the "a policy is a pipeline of tools" plumbing. A policy should read in terms of what it does to a document, not which tools run under the hood. ## Changes - **Setup reads as policy settings.** The per-tool cards are now a plain-language list of what the policy does — "Redact sensitive information", "Strip active content", "Apply a watermark", and so on — each with a short description and a toggle, with its options appearing inline when turned on. - **Consistent design system.** The setup and edit forms use the shared components instead of one-off styling. - **Simpler setup.** Removed two sections that aren't part of what ships here: Document Types (scope-by-type) and Retries. - **Watermarks are text-only.** A policy watermark is a text stamp, so the image option and the type picker are hidden. - **The editor always shows as a source** (it used to disappear when no other sources were connected), and the source tiles now lay out correctly. - **Clearer upsell copy.** Locked policies read **"Upgrade to Enterprise"** instead of "Coming soon". ## Scope UI only — no backend changes. Keeping a policy's settings in sync between the portal and the editor is a known, separate issue and is **not** part of this PR. ## Testing Prettier, ESLint, typecheck (proprietary + saas), and the unused-translation guard all pass. Setup screens verified in Storybook. --- .../public/locales/en-US/translation.toml | 46 +-- .../AddWatermarkSingleStepSettings.tsx | 30 +- frontend/editor/src/portal/PortalApp.tsx | 11 +- .../policies/PolicySetupWizard.stories.tsx | 10 + .../components/policies/PolicySetupWizard.tsx | 290 ++++++++++-------- frontend/editor/src/portal/views/Policies.css | 71 +++-- .../components/policies/Policies.css | 18 +- .../components/policies/PolicyPiiField.tsx | 7 +- .../policies/PolicyWatermarkConfig.tsx | 17 +- .../components/policies/policyStatus.ts | 27 +- 10 files changed, 290 insertions(+), 237 deletions(-) diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 11193c3ed3..af5ed16169 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7404,7 +7404,7 @@ subtitle = "Standing automations that enforce a tool pipeline on every document. title = "Policies" [portal.policies.card] -comingSoon = "Coming soon" +comingSoon = "Upgrade to Enterprise" notSetUp = "Not set up" [portal.policies.categories.compliance] @@ -7566,14 +7566,29 @@ continue = "Continue" enablePolicy = "Enable policy" saveChanges = "Save changes" -[portal.policies.wizard.docTypes] -allDescription = "Set up an Ingestion (classification) policy to narrow this to specific document types." -allTitle = "All document types" -clear = "Clear" -heading = "Document types" -narrow = "Narrow" -selected_one = "{{count}} selected" -selected_other = "{{count}} selected" +[portal.policies.wizard.capability.compress] +desc = "Compresses the document to a smaller file size." +label = "Reduce file size" + +[portal.policies.wizard.capability.flatten] +desc = "Merges form fields and annotations into the page so they can't be edited." +label = "Flatten the document" + +[portal.policies.wizard.capability.ocr] +desc = "Runs OCR so scanned pages become selectable, searchable text." +label = "Make text searchable" + +[portal.policies.wizard.capability.redact] +desc = "Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read." +label = "Redact sensitive information" + +[portal.policies.wizard.capability.sanitize] +desc = "Removes hidden JavaScript so nothing can run automatically when the document is opened." +label = "Strip active content" + +[portal.policies.wizard.capability.watermark] +desc = "Stamps a visible mark (e.g. “Confidential”) across every page." +label = "Apply a watermark" [portal.policies.wizard.errors] noTools = "Enable at least one tool in the workflow first." @@ -7594,11 +7609,6 @@ label = "Output as" newFile = "New file" newVersion = "New version" -[portal.policies.wizard.output.retries] -delayLabel = "Retry delay (min)" -heading = "Retries" -maxLabel = "Max retries" - [portal.policies.wizard.output.runOn] export = "Export" helper = "When the policy fires: on upload, or before export." @@ -7609,22 +7619,20 @@ upload = "Upload" heading = "Settings" [portal.policies.wizard.sources] -emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here." -emptyTitle = "No sources available" heading = "Sources" loading = "Loading sources…" [portal.policies.wizard.tabs] ariaLabel = "Setup steps" settings = "Settings" -workflow = "Workflow" +workflow = "Actions" [portal.policies.wizard.title] edit = "Edit {{category}} policy" setUp = "Set up {{category}} policy" [portal.policies.wizard.workflow] -description = "The sequence of tools this policy runs on each document. Each tool is a Stirling endpoint; toggle the ones this policy should enforce." +description = "Choose what this policy does to every document it processes." [portal.policySummary] activeSummary = "{{active}} / {{total}} active" @@ -7633,7 +7641,7 @@ subtitle = "Standing automations every document passes through, regardless of wh title = "What runs on your PDFs" [portal.policySummary.action] -comingSoon = "Coming soon" +comingSoon = "Upgrade to Enterprise" configure = "Configure" setUp = "Set up" diff --git a/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx index b78ab666ba..1d7859f7e4 100644 --- a/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx +++ b/frontend/editor/src/core/components/tools/addWatermark/AddWatermarkSingleStepSettings.tsx @@ -23,6 +23,8 @@ interface AddWatermarkSingleStepSettingsProps { disabled?: boolean; /** When false, hide the "Flatten PDF pages to images" option (e.g. in policies). */ showFlatten?: boolean; + /** When true, lock to text watermarks: hide the type selector and image option (e.g. in policies). */ + textOnly?: boolean; } const AddWatermarkSingleStepSettings = ({ @@ -30,20 +32,24 @@ const AddWatermarkSingleStepSettings = ({ onParameterChange, disabled = false, showFlatten = true, + textOnly = false, }: AddWatermarkSingleStepSettingsProps) => { + const isText = textOnly || parameters.watermarkType === "text"; + const isImage = !textOnly && parameters.watermarkType === "image"; return ( - {/* Watermark Type Selection */} - - onParameterChange("watermarkType", type) - } - disabled={disabled} - /> + {/* Watermark type selection — hidden when locked to text. */} + {!textOnly && ( + + onParameterChange("watermarkType", type) + } + disabled={disabled} + /> + )} - {/* Conditional settings based on watermark type */} - {parameters.watermarkType === "text" && ( + {isText && ( <> )} - {parameters.watermarkType === "image" && ( + {isImage && ( {/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
- - - + {/* Tool registry is read by portal views (e.g. the policy setup + wizard); mount it above the per-flavor provider split. */} + + + + +
diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx index 275a8c9d15..01aaad2fc6 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider"; import { POLICY_CATEGORIES, POLICY_CONFIG, @@ -12,6 +13,15 @@ const meta: Meta = { title: "Portal/Policies/PolicySetupWizard", component: PolicySetupWizard, parameters: { layout: "fullscreen" }, + // The wizard reads the tool registry (for capability fallback names/icons), + // so stories must supply the provider the app mounts in PortalApp. + decorators: [ + (Story) => ( + + + + ), + ], args: { onClose: () => {}, onSubmit: async () => {}, diff --git a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx index 97be4925bc..e87d25dc9e 100644 --- a/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx +++ b/frontend/editor/src/portal/components/policies/PolicySetupWizard.tsx @@ -4,7 +4,6 @@ import { Banner, Button, Card, - Chip, FormField, Input, Modal, @@ -12,18 +11,23 @@ import { Tabs, ToggleSwitch, } from "@app/ui"; +import { SettingsRow } from "@app/ui/SettingsRow"; import { - POLICY_DOC_TYPES, + TOOL_ENDPOINTS, humanizeEndpoint, type CatalogueEntry, type PipelineStep, type PolicySetupResult, } from "@portal/api/policies"; +import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; import { fetchSources } from "@portal/api/sources"; import { useAsync } from "@portal/hooks/useAsync"; import { PolicyFieldRow } from "@portal/components/policies/PolicyFieldRow"; import { policyIcon } from "@portal/components/policies/policyIcons"; import { sourceTypeMeta } from "@portal/components/sources/sourceTypes"; +import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; +import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig"; +import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig"; import "@portal/views/Policies.css"; interface PolicySetupWizardProps { @@ -65,6 +69,58 @@ function resolveFieldValues( // the portal and can drive this via registry metadata or a defaultEnabled flag. const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]); +/** + * Policy-facing framing for each capability a policy can include. Labels and + * descriptions describe what the policy DOES to a document — deliberately not + * naming the underlying tool — so the setup reads as the policy's own settings + * rather than an assembled chain of tools. Endpoints with no entry fall back to + * the humanised endpoint name with no description. + */ +const CAPABILITY_META: Record< + string, + { labelKey: string; labelEn: string; descKey: string; descEn: string } +> = { + [TOOL_ENDPOINTS.redact]: { + labelKey: "portal.policies.wizard.capability.redact.label", + labelEn: "Redact sensitive information", + descKey: "portal.policies.wizard.capability.redact.desc", + descEn: + "Finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read.", + }, + [TOOL_ENDPOINTS.sanitize]: { + labelKey: "portal.policies.wizard.capability.sanitize.label", + labelEn: "Strip active content", + descKey: "portal.policies.wizard.capability.sanitize.desc", + descEn: + "Removes hidden JavaScript so nothing can run automatically when the document is opened.", + }, + [TOOL_ENDPOINTS.watermark]: { + labelKey: "portal.policies.wizard.capability.watermark.label", + labelEn: "Apply a watermark", + descKey: "portal.policies.wizard.capability.watermark.desc", + descEn: "Stamps a visible mark (e.g. “Confidential”) across every page.", + }, + [TOOL_ENDPOINTS.ocr]: { + labelKey: "portal.policies.wizard.capability.ocr.label", + labelEn: "Make text searchable", + descKey: "portal.policies.wizard.capability.ocr.desc", + descEn: "Runs OCR so scanned pages become selectable, searchable text.", + }, + [TOOL_ENDPOINTS.flatten]: { + labelKey: "portal.policies.wizard.capability.flatten.label", + labelEn: "Flatten the document", + descKey: "portal.policies.wizard.capability.flatten.desc", + descEn: + "Merges form fields and annotations into the page so they can't be edited.", + }, + [TOOL_ENDPOINTS.compress]: { + labelKey: "portal.policies.wizard.capability.compress.label", + labelEn: "Reduce file size", + descKey: "portal.policies.wizard.capability.compress.desc", + descEn: "Compresses the document to a smaller file size.", + }, +}; + function seedTools(entry: CatalogueEntry): ToolState[] { const savedSteps = entry.policy?.steps ?? []; const savedByOp = new Map(savedSteps.map((s) => [s.operation, s])); @@ -117,6 +173,19 @@ function PolicySetupWizardBody({ onSubmit: (entry: CatalogueEntry, result: PolicySetupResult) => Promise; }) { const { t } = useTranslation(); + const { allTools: toolRegistry } = useToolRegistry(); + + // Portal tool operations are endpoint paths (/api/v1/…), not short registry IDs. + // Build a reverse map so we can look up icons and display names by endpoint. + const registryByEndpoint = useMemo(() => { + const map = new Map(); + for (const entry of Object.values(toolRegistry)) { + const ep = (entry as ToolRegistryEntry).operationConfig?.endpoint; + if (typeof ep === "string") map.set(ep, entry as ToolRegistryEntry); + } + return map; + }, [toolRegistry]); + const { category, config, policy } = entry; const isEdit = policy != null; @@ -146,12 +215,9 @@ function PolicySetupWizardBody({ }; return [editorSource, ...backendSources]; }, [sourcesAsync.data, t]); - const [scopeNarrow, setScopeNarrow] = useState( - (policy?.state.scopeTypes.length ?? 0) > 0, - ); - const [scopeTypes, setScopeTypes] = useState( - policy?.state.scopeTypes ?? [], - ); + // Document-type scoping has no UI; preserve any saved scope on edit and + // default new policies to all document types. + const [scopeTypes] = useState(policy?.state.scopeTypes ?? []); // TODO: replace with user-picker backed by GET /api/v1/user/users (UserSummary[]). // Store username (which is the email in Spring Security) as reviewerEmail. // See UserSelector.tsx in the editor for the grouping/display pattern. @@ -166,10 +232,10 @@ function PolicySetupWizardBody({ const [runOn, setRunOn] = useState<"upload" | "export">( policy?.state.runOn ?? "upload", ); - const [maxRetries, setMaxRetries] = useState(policy?.state.maxRetries ?? 3); - const [retryDelayMinutes, setRetryDelayMinutes] = useState( - policy?.state.retryDelayMinutes ?? 5, - ); + // Policies run once; retry config has no UI. Preserve any saved values on + // edit and default new policies to no retries (run once). + const [maxRetries] = useState(policy?.state.maxRetries ?? 0); + const [retryDelayMinutes] = useState(policy?.state.retryDelayMinutes ?? 0); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -188,12 +254,6 @@ function PolicySetupWizardBody({ ); } - function toggleScopeType(dt: string) { - setScopeTypes((prev) => - prev.includes(dt) ? prev.filter((d) => d !== dt) : [...prev, dt], - ); - } - async function submit() { if (submitting) return; if (enabledTools.length === 0) { @@ -211,7 +271,7 @@ function PolicySetupWizardBody({ await onSubmit(entry, { fieldValues, sources, - scopeTypes: scopeNarrow ? scopeTypes : [], + scopeTypes, reviewerEmail, outputMode, outputName: outputName.trim(), @@ -227,8 +287,6 @@ function PolicySetupWizardBody({ } } - const docTypesEnabled = category.providesClassification === true; - return (

- {t("portal.policies.wizard.workflow.description")} + {t( + "portal.policies.wizard.workflow.description", + "Choose what this policy does to every document it processes.", + )}

- {tools.map((tl) => ( - -
- - {humanizeEndpoint(tl.operation, t)} - - - - patchTool(tl.operation, { enabled: checked }) - } - label="" - /> -
-
- ))} + +
+ {tools.map((tl) => { + const meta = CAPABILITY_META[tl.operation]; + const label = meta + ? t(meta.labelKey, meta.labelEn) + : (registryByEndpoint.get(tl.operation)?.name ?? + humanizeEndpoint(tl.operation, t)); + const description = meta + ? t(meta.descKey, meta.descEn) + : undefined; + const hasConfig = + tl.operation === TOOL_ENDPOINTS.redact || + tl.operation === TOOL_ENDPOINTS.watermark; + return ( +
+ + patchTool(tl.operation, { enabled: checked }) + } + label="" + /> + } + /> + {tl.enabled && hasConfig && ( +
+ {tl.operation === TOOL_ENDPOINTS.redact && ( + + patchTool(tl.operation, { parameters }) + } + /> + )} + {tl.operation === TOOL_ENDPOINTS.watermark && ( + + patchTool(tl.operation, { parameters }) + } + /> + )} +
+ )} +
+ ); + })} +
+
)} @@ -352,25 +454,23 @@ function PolicySetupWizardBody({

{t("portal.policies.wizard.sources.heading")}

-
- {sourcesAsync.loading && !sourcesAsync.data ? ( -

- {t("portal.policies.wizard.sources.loading")} -

- ) : availableSources.length === 1 ? ( - - ) : ( - availableSources.map((src) => ( + {sourcesAsync.loading && !sourcesAsync.data ? ( +

+ {t("portal.policies.wizard.sources.loading")} +

+ ) : ( + // The editor is always an available source (unconditionally prepended + // to availableSources), so the list is never empty — no "no sources" + // state exists. +
+ {availableSources.map((src) => ( + // A selectable multi-line tile (icon + name + type + check). + // Uses the shared Button (raw - )) - )} -
- -

- {t("portal.policies.wizard.docTypes.heading")} -

- {!docTypesEnabled ? ( - - ) : ( - -
- - {scopeTypes.length === 0 - ? t("portal.policies.wizard.docTypes.allTitle") - : t("portal.policies.wizard.docTypes.selected", { - count: scopeTypes.length, - })} - - -
- {scopeNarrow && ( -
- {POLICY_DOC_TYPES.map((dt) => ( - toggleScopeType(dt)} - > - {t(`policies.docType.${dt}`, dt)} - - ))} -
- )} -
+ ))} +
)}

@@ -560,33 +613,6 @@ function PolicySetupWizardBody({ )} {/* TODO: reviewer user-picker goes here */} -

- {t("portal.policies.wizard.output.retries.heading")} -

- - - setMaxRetries(Math.max(0, Number(e.target.value) || 0)) - } - /> - - - - setRetryDelayMinutes(Math.max(0, Number(e.target.value) || 0)) - } - /> - )} diff --git a/frontend/editor/src/portal/views/Policies.css b/frontend/editor/src/portal/views/Policies.css index 6bad9365c5..75adbc6a85 100644 --- a/frontend/editor/src/portal/views/Policies.css +++ b/frontend/editor/src/portal/views/Policies.css @@ -225,13 +225,6 @@ font-weight: 600; } -.portal-policies__wizard-subheading { - margin: 0.875rem 0 0; - font-size: 0.75rem; - font-weight: 600; - color: var(--color-text-3); -} - .portal-policies__fields { display: flex; flex-direction: column; @@ -248,17 +241,28 @@ gap: 0.375rem; } -/* Workflow step — tool rows */ -.portal-policies__tool-head { +/* Workflow step — policy capability settings list. + * Rows read as the policy's own settings (label + plain description + toggle), + * not a chain of distinct tools. Config reveals inline beneath an enabled row. */ +.portal-policies__capabilities { display: flex; - align-items: center; - gap: 0.625rem; + flex-direction: column; } -.portal-policies__tool-name { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); +.portal-policies__capability { + padding: 0.75rem 0.875rem; +} + +.portal-policies__capability + .portal-policies__capability { + border-top: 1px solid var(--color-border); +} + +/* An enabled row with config gets a faint tint so the revealed settings read as + * belonging to it. */ +.portal-policies__capability[data-on] .portal-policies__capability-config { + border-top: 1px dashed var(--color-border); + padding-top: 0.75rem; + margin-top: 0.75rem; } /* Sources picker */ @@ -284,11 +288,32 @@ border: 1.5px solid var(--color-border); border-radius: var(--radius-md); cursor: pointer; + /* Shared Button pins a fixed control height and natural width; this tile is a + full-width, two-line card, so fill the grid cell and grow to fit content. */ + width: 100%; + height: auto; + font-weight: inherit; transition: border-color var(--motion-fast), background var(--motion-fast); } +/* The shared Button wraps children in an inner/label node. Let the inner grow + (so the ::after check sits at the far right) and the label carry the + icon + two-line-text row. */ +.portal-policies__source .mantine-Button-inner { + flex: 1; + min-width: 0; +} + +.portal-policies__source .mantine-Button-label { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + white-space: normal; +} + .portal-policies__source::after { content: ""; display: flex; @@ -353,22 +378,6 @@ line-height: 1.4; } -/* Doc-type scope */ -.portal-policies__doctypes-head { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 0.8125rem; - color: var(--color-text-2); -} - -.portal-policies__doctypes { - display: flex; - flex-wrap: wrap; - gap: 0.375rem; - margin-top: 0.625rem; -} - .portal-policies__link { border: none; background: none; diff --git a/frontend/editor/src/proprietary/components/policies/Policies.css b/frontend/editor/src/proprietary/components/policies/Policies.css index 9c4683f0ac..feb698a644 100644 --- a/frontend/editor/src/proprietary/components/policies/Policies.css +++ b/frontend/editor/src/proprietary/components/policies/Policies.css @@ -90,24 +90,8 @@ outline: 2px solid var(--color-blue); outline-offset: -2px; } -/* Policy icons are colourless at rest; hovering or focusing the row reveals the - category colour (blue/purple/green/amber/red — see ROW_ACCENT). The accent - class sets --ib-base; we neutralise --ib-accent here and restore it on hover. */ -.pol-row .sui-iconbadge { - --ib-accent: var(--color-text-3); - transition: - color var(--motion-fast), - background var(--motion-fast); -} -.pol-row:hover .sui-iconbadge, -.pol-row:focus-visible .sui-iconbadge { - --ib-accent: var(--ib-base); -} - /* Processing indicator: a spinning ring around the category icon while the - policy has runs in flight. The ring carries the category accent; the badge - also shows its colour (not the neutral rest tint) so an active policy reads - clearly even before hover. */ + policy has runs in flight. */ .pol-row-icon { position: relative; display: inline-flex; diff --git a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx index d9fef2361d..59fe76c021 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyPiiField.tsx @@ -1,4 +1,4 @@ -import { MultiSelect } from "@mantine/core"; +import { MultiSelect } from "@app/ui/MultiSelect"; import { useTranslation } from "react-i18next"; import { PII_PRESETS } from "@app/data/policyDefinitions"; @@ -49,8 +49,8 @@ export function PolicyPiiField({ return ( ({ value: p.value, @@ -62,7 +62,6 @@ export function PolicyPiiField({ onChange={handleChange} disabled={disabled} clearable - checkIconPosition="right" /> ); } diff --git a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx index 692508a92e..8697c9d030 100644 --- a/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx +++ b/frontend/editor/src/proprietary/components/policies/PolicyWatermarkConfig.tsx @@ -9,10 +9,10 @@ interface PolicyWatermarkConfigProps { } /** - * Watermark configuration for a policy: the full watermark settings minus the - * "Flatten PDF pages to images" toggle (hidden), with flatten forced on so the - * watermark is baked into the page and can't be stripped out. Normalised once - * on mount. + * Watermark configuration for a policy: text watermarks only (the type selector + * and image option are hidden), minus the "Flatten PDF pages to images" toggle + * (hidden), with flatten forced on so the watermark is baked into the page and + * can't be stripped out. Normalised once on mount. */ export function PolicyWatermarkConfig({ parameters, @@ -20,9 +20,11 @@ export function PolicyWatermarkConfig({ disabled, }: PolicyWatermarkConfigProps) { useEffect(() => { - if (parameters.convertPDFToImage !== true) { - onChange({ ...parameters, convertPDFToImage: true }); - } + const patch: Record = {}; + if (parameters.convertPDFToImage !== true) patch.convertPDFToImage = true; + // Policies only support text watermarks. + if (parameters.watermarkType !== "text") patch.watermarkType = "text"; + if (Object.keys(patch).length > 0) onChange({ ...parameters, ...patch }); }, []); return ( @@ -33,6 +35,7 @@ export function PolicyWatermarkConfig({ } disabled={disabled} showFlatten={false} + textOnly /> ); } diff --git a/frontend/editor/src/proprietary/components/policies/policyStatus.ts b/frontend/editor/src/proprietary/components/policies/policyStatus.ts index 5fbd779725..ada31ad11b 100644 --- a/frontend/editor/src/proprietary/components/policies/policyStatus.ts +++ b/frontend/editor/src/proprietary/components/policies/policyStatus.ts @@ -17,10 +17,19 @@ export const STATUS_LABEL: Record = { setup: "Set up", }; -/** - * Per-category accent colour - */ +/** Per-category icon accent — neutral (no tint background) across all categories. */ export const ROW_ACCENT: Record = { + ingestion: "neutral", + security: "neutral", + compliance: "neutral", + routing: "neutral", + retention: "neutral", +}; + +/** Per-category colour for the file badges + enforcement overlay. Separate from + * ROW_ACCENT: the sidebar rows render neutral by design, but the badges keep + * their identity colours so files remain distinguishable at a glance. */ +const BADGE_ACCENT: Record = { ingestion: "blue", classification: "orange", security: "purple", @@ -30,15 +39,9 @@ export const ROW_ACCENT: Record = { }; /** - * CSS colour var for a policy category's accent (blue for unknown categories) — - * the tint used by the file badges and the enforcement overlay. - * - * Derived straight from the accent name (`--color-`), which is exactly - * the token {@link IconBadge} uses for the same accent. Deriving it (rather than - * keeping a second name→var map) means the badge tint can never drift from the - * sidebar's colour — previously `orange` was missing from that map, so the - * Classification badge/overlay rendered untinted while its sidebar row was orange. + * CSS colour var for a policy category's badge accent (blue for unknown + * categories) — the tint used by the file badges and the enforcement overlay. */ export function policyAccentVar(categoryId: string): string { - return `var(--color-${ROW_ACCENT[categoryId] ?? "blue"})`; + return `var(--color-${BADGE_ACCENT[categoryId] ?? "blue"})`; }