merge main
This commit is contained in:
@@ -95,6 +95,7 @@ tasks:
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
@@ -117,6 +118,32 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:all:saas:
|
||||
desc: "Start SaaS backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev:saas
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
POLICIES_ENABLED: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
MOCKS: 'false'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
|
||||
+14
-2
@@ -3,9 +3,12 @@ package stirling.software.SPDF.controller.api;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDPage;
|
||||
import org.apache.pdfbox.pdmodel.PDPageTree;
|
||||
@@ -261,10 +264,19 @@ public class RearrangePagesPDFController {
|
||||
log.info("newPageOrder = {}", newPageOrder);
|
||||
log.info("totalPages = {}", totalPages);
|
||||
|
||||
// Snapshot the desired pages before mutating the source document's page tree.
|
||||
// Snapshot desired pages before mutating the tree; clone repeats (e.g. DUPLICATE)
|
||||
// so each slot is a distinct node, not one PDPage under multiple /Kids.
|
||||
List<PDPage> newPages = new ArrayList<>(newPageOrder.size());
|
||||
Set<Integer> seenIndices = new HashSet<>();
|
||||
for (Integer idx : newPageOrder) {
|
||||
newPages.add(document.getPage(idx));
|
||||
PDPage page = document.getPage(idx);
|
||||
if (!seenIndices.add(idx)) {
|
||||
// Duplicate index: distinct page node sharing content/resources.
|
||||
COSDictionary clonedDict = new COSDictionary();
|
||||
clonedDict.addAll(page.getCOSObject());
|
||||
page = new PDPage(clonedDict);
|
||||
}
|
||||
newPages.add(page);
|
||||
}
|
||||
|
||||
// Rearrange in-place on the source document rather than copying pages into a
|
||||
|
||||
+31
@@ -9,6 +9,7 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
@@ -302,6 +303,11 @@ class RearrangePagesPDFControllerTest {
|
||||
assertNotNull(response);
|
||||
// 2 pages * 3 duplicates = 6 final pages
|
||||
assertEquals(6, realDoc.getNumberOfPages());
|
||||
// Each duplicate must be a distinct page node in the saved output; a shared
|
||||
// node under multiple /Kids is an invalid tree readers reject as cyclic.
|
||||
List<Object> savedPages = reloadAndSnapshot(response);
|
||||
assertEquals(6, savedPages.size());
|
||||
assertEquals(6, new HashSet<>(savedPages).size());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,4 +329,29 @@ class RearrangePagesPDFControllerTest {
|
||||
assertEquals(4, realDoc.getNumberOfPages());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testRearrangePages_SideStitchBooklet_RepeatedPaddingPagesAreDistinctNodes()
|
||||
throws IOException {
|
||||
MockMultipartFile file = createMockPdf();
|
||||
RearrangePagesRequest request = new RearrangePagesRequest();
|
||||
request.setFileInput(file);
|
||||
request.setPageNumbers("");
|
||||
request.setCustomMode("SIDE_STITCH_BOOKLET_SORT");
|
||||
|
||||
// 6 pages is not a multiple of 4, so booklet padding repeats the last page index
|
||||
// several times; each repeat must be a distinct page node, not one shared node.
|
||||
try (PDDocument realDoc = buildRealPdf(6)) {
|
||||
when(pdfDocumentFactory.load(file)).thenReturn(realDoc);
|
||||
|
||||
ResponseEntity<Resource> response = controller.rearrangePages(request);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(200, response.getStatusCode().value());
|
||||
assertEquals(8, realDoc.getNumberOfPages());
|
||||
List<Object> savedPages = reloadAndSnapshot(response);
|
||||
assertEquals(8, savedPages.size());
|
||||
assertEquals(8, new HashSet<>(savedPages).size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,10 @@ dependencies {
|
||||
implementation "software.amazon.awssdk:s3:${awsSdkVersion}"
|
||||
implementation "software.amazon.awssdk:url-connection-client:${awsSdkVersion}"
|
||||
|
||||
// @DataJpaTest slice (Boot 4 ships test slices as separate starters, like webmvc-test at the
|
||||
// root) so policy.source repositories can be exercised against embedded H2.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
|
||||
|
||||
// Testcontainers: real MinIO/LocalStack (S3) and Valkey for integration tests in CI without
|
||||
// manually-started instances. Tests skip cleanly when Docker is unavailable.
|
||||
testImplementation "org.testcontainers:testcontainers:${testcontainersMinioVersion}"
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package stirling.software.proprietary.access.config;
|
||||
|
||||
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.DefaultTeamLeadLookup;
|
||||
import stirling.software.proprietary.access.service.TeamLeadLookup;
|
||||
|
||||
/** Access-layer bean wiring. */
|
||||
@Configuration
|
||||
public class AccessConfig {
|
||||
|
||||
/** No-op {@link TeamLeadLookup} unless another bean is defined. */
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(TeamLeadLookup.class)
|
||||
TeamLeadLookup defaultTeamLeadLookup() {
|
||||
return new DefaultTeamLeadLookup();
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package stirling.software.proprietary.access.controller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.access.model.AccessPermission;
|
||||
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.model.User;
|
||||
|
||||
/** Admin endpoints to grant/revoke access to gated resources (the portal, integration configs). */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/admin/access")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
@Tag(name = "Access Control", description = "Manage resource access grants (portal, integrations)")
|
||||
public class ResourceGrantController {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
|
||||
@GetMapping("/grants")
|
||||
public ResponseEntity<?> list(
|
||||
@RequestParam ResourceType resourceType,
|
||||
@RequestParam(required = false, defaultValue = "") String resourceId) {
|
||||
List<ResourceGrant> grants = accessService.listGrants(resourceType, resourceId);
|
||||
return ResponseEntity.ok(grants.stream().map(this::toDto).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/grants")
|
||||
public ResponseEntity<?> create(
|
||||
@RequestBody GrantRequest request, @AuthenticationPrincipal User admin) {
|
||||
if (request.resourceType() == null
|
||||
|| request.principalType() == null
|
||||
|| request.principalId() == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"resourceType, principalType and principalId are required"));
|
||||
}
|
||||
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();
|
||||
ResourceGrant grant =
|
||||
accessService.grant(
|
||||
request.resourceType(),
|
||||
resourceId,
|
||||
request.principalType(),
|
||||
request.principalId(),
|
||||
permission,
|
||||
admin);
|
||||
return ResponseEntity.ok(toDto(grant));
|
||||
}
|
||||
|
||||
@DeleteMapping("/grants/{id}")
|
||||
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||
accessService.revoke(id);
|
||||
return ResponseEntity.ok(Map.of("message", "Grant revoked"));
|
||||
}
|
||||
|
||||
private Map<String, Object> toDto(ResourceGrant g) {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put("id", g.getId());
|
||||
m.put("resourceType", g.getResourceType());
|
||||
m.put("resourceId", g.getResourceId());
|
||||
m.put("principalType", g.getPrincipalType());
|
||||
m.put("principalId", g.getPrincipalId());
|
||||
m.put("permission", g.getPermission());
|
||||
m.put("createdAt", g.getCreatedAt());
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Request body for creating a grant. */
|
||||
public record GrantRequest(
|
||||
ResourceType resourceType,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId,
|
||||
AccessPermission permission) {}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Permission level a grant confers. MANAGE implies USE. */
|
||||
public enum AccessPermission {
|
||||
USE,
|
||||
MANAGE
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/**
|
||||
* Fallback policy applied when no explicit {@link ResourceGrant} matches. Admins (org owners)
|
||||
* always pass regardless of this policy.
|
||||
*/
|
||||
public enum DefaultAccessPolicy {
|
||||
// Every authenticated user in the deployment (org) may use the resource.
|
||||
ORG_ALL,
|
||||
// Only org admins and team leaders. This is the default for the portal.
|
||||
ADMINS_AND_TEAM_LEADS,
|
||||
// Nobody but the owner, admins, and explicit grantees.
|
||||
EXPLICIT_ONLY
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.MappedSuperclass;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Base for a resource owned by a user, a team, or the server, with grant-based access. */
|
||||
@MappedSuperclass
|
||||
@Getter
|
||||
@Setter
|
||||
public abstract class OwnedResource {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "scope", nullable = false, length = 32)
|
||||
private OwnerScope scope;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_user_id")
|
||||
private User ownerUser;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "owner_team_id")
|
||||
private Team ownerTeam;
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private boolean enabled = true;
|
||||
|
||||
// Server resource that users cannot override with their own of the same kind.
|
||||
@Column(name = "locked", nullable = false)
|
||||
private boolean locked = false;
|
||||
|
||||
// Who, besides owner/admin/grantees, may use this resource.
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "default_access", nullable = false, length = 32)
|
||||
private DefaultAccessPolicy defaultAccess = DefaultAccessPolicy.EXPLICIT_ONLY;
|
||||
|
||||
/** Subclass primary key. */
|
||||
public abstract Long getId();
|
||||
|
||||
public Long getOwnerUserId() {
|
||||
return ownerUser != null ? ownerUser.getId() : null;
|
||||
}
|
||||
|
||||
public Long getOwnerTeamId() {
|
||||
return ownerTeam != null ? ownerTeam.getId() : null;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Ownership scope of an {@link OwnedResource}: a single user, a team, or the whole server. */
|
||||
public enum OwnerScope {
|
||||
USER,
|
||||
TEAM,
|
||||
SERVER
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Who a {@link ResourceGrant} is granted to. Org-wide access is expressed via default policy. */
|
||||
public enum PrincipalType {
|
||||
USER,
|
||||
TEAM
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.UniqueConstraint;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Grants a user or team access to a resource. Owner and admin access are implicit. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "resource_grants",
|
||||
uniqueConstraints =
|
||||
@UniqueConstraint(
|
||||
name = "uk_resource_grant",
|
||||
columnNames = {
|
||||
"resource_type",
|
||||
"resource_id",
|
||||
"principal_type",
|
||||
"principal_id",
|
||||
"permission"
|
||||
}),
|
||||
indexes = {
|
||||
@Index(name = "idx_resource_grants_lookup", columnList = "resource_type,resource_id"),
|
||||
@Index(
|
||||
name = "idx_resource_grants_principal",
|
||||
columnList = "principal_type,principal_id")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class ResourceGrant implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "resource_grant_id")
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "resource_type", nullable = false, length = 64)
|
||||
private ResourceType resourceType;
|
||||
|
||||
// Empty string (never null) for a whole-type grant such as the portal.
|
||||
@Column(name = "resource_id", nullable = false, length = 255)
|
||||
private String resourceId = "";
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "principal_type", nullable = false, length = 32)
|
||||
private PrincipalType principalType;
|
||||
|
||||
@Column(name = "principal_id", nullable = false)
|
||||
private Long principalId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "permission", nullable = false, length = 32)
|
||||
private AccessPermission permission;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "granted_by_user_id")
|
||||
private User grantedBy;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package stirling.software.proprietary.access.model;
|
||||
|
||||
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
|
||||
public enum ResourceType {
|
||||
// The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
|
||||
PORTAL,
|
||||
// A stored S3/MCP/API integration configuration.
|
||||
INTEGRATION_CONFIG
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.access.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
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;
|
||||
|
||||
@Repository
|
||||
public interface ResourceGrantRepository extends JpaRepository<ResourceGrant, Long> {
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndResourceId(
|
||||
ResourceType resourceType, String resourceId);
|
||||
|
||||
List<ResourceGrant> findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType, PrincipalType principalType, Long principalId);
|
||||
|
||||
void deleteByResourceTypeAndResourceId(ResourceType resourceType, String resourceId);
|
||||
|
||||
boolean existsByResourceTypeAndResourceIdAndPrincipalTypeAndPrincipalId(
|
||||
ResourceType resourceType,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId);
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package stirling.software.proprietary.access.security;
|
||||
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
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. */
|
||||
@Component("resourceAccess")
|
||||
@RequiredArgsConstructor
|
||||
public class ResourceAccessSecurity {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
private final UserService userService;
|
||||
|
||||
public boolean canUsePortal() {
|
||||
User user = currentUser();
|
||||
return user != null && accessService.canAccessPortal(user);
|
||||
}
|
||||
|
||||
private User currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
return null;
|
||||
}
|
||||
Object principal = auth.getPrincipal();
|
||||
if (principal instanceof User user) {
|
||||
return user;
|
||||
}
|
||||
if (principal instanceof UserDetails userDetails) {
|
||||
return userService.findByUsername(userDetails.getUsername()).orElse(null);
|
||||
}
|
||||
if (principal instanceof String username && !"anonymousUser".equals(username)) {
|
||||
return userService.findByUsername(username).orElse(null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** No-op {@link TeamLeadLookup}: always false. */
|
||||
public class DefaultTeamLeadLookup implements TeamLeadLookup {
|
||||
|
||||
@Override
|
||||
public boolean isAnyTeamLeader(User user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isLeaderOfTeam(User user, Long teamId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.access.model.OwnedResource;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
/** Ownership and access checks for {@link OwnedResource}, backed by the resource-grant ACL. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class OwnershipService {
|
||||
|
||||
private final ResourceAccessService accessService;
|
||||
private final TeamLeadLookup teamLeadLookup;
|
||||
private final TeamRepository teamRepository;
|
||||
|
||||
/** Whether the user may use the resource. */
|
||||
public boolean canUse(ResourceType type, OwnedResource resource, User user) {
|
||||
if (!resource.isEnabled()) {
|
||||
return isAdmin(user) || isOwner(resource, user);
|
||||
}
|
||||
return accessService.canUseResource(
|
||||
type,
|
||||
String.valueOf(resource.getId()),
|
||||
resource.getOwnerUserId(),
|
||||
resource.getDefaultAccess(),
|
||||
user);
|
||||
}
|
||||
|
||||
/** Whether the user may manage the resource. */
|
||||
public boolean canManage(ResourceType type, OwnedResource resource, User user) {
|
||||
return accessService.canManageResource(
|
||||
type, String.valueOf(resource.getId()), resource.getOwnerUserId(), user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorizes the scope and assigns ownership; {@code lockedOverrideBlocks} guards USER scope.
|
||||
*/
|
||||
public void assignOwnership(
|
||||
OwnedResource resource,
|
||||
OwnerScope scope,
|
||||
Long teamId,
|
||||
User user,
|
||||
BooleanSupplier lockedOverrideBlocks) {
|
||||
resource.setScope(scope);
|
||||
switch (scope) {
|
||||
case USER -> {
|
||||
if (lockedOverrideBlocks.getAsBoolean() && !isAdmin(user)) {
|
||||
throw forbidden(
|
||||
"This is locked to the server configuration by an administrator");
|
||||
}
|
||||
resource.setOwnerUser(user);
|
||||
}
|
||||
case SERVER -> {
|
||||
if (!isAdmin(user)) {
|
||||
throw forbidden("Only administrators can create server-owned resources");
|
||||
}
|
||||
}
|
||||
case TEAM -> {
|
||||
if (teamId == null) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST, "ownerTeamId is required");
|
||||
}
|
||||
Team team =
|
||||
teamRepository
|
||||
.findById(teamId)
|
||||
.orElseThrow(() -> notFound("Team not found"));
|
||||
if (!isAdmin(user) && !teamLeadLookup.isLeaderOfTeam(user, team.getId())) {
|
||||
throw forbidden("Only admins or team leaders can create team-owned resources");
|
||||
}
|
||||
resource.setOwnerTeam(team);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resource ids of the given type the user or their team holds a grant on. */
|
||||
public Set<String> grantedResourceIds(ResourceType type, User user) {
|
||||
return accessService.grantedResourceIds(type, user);
|
||||
}
|
||||
|
||||
public boolean isAdmin(User user) {
|
||||
return user.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
public boolean isOwner(OwnedResource resource, User user) {
|
||||
if (resource.getOwnerUserId() != null && resource.getOwnerUserId().equals(user.getId())) {
|
||||
return true;
|
||||
}
|
||||
// Team-owned: the lead of the owning team owns it.
|
||||
return resource.getOwnerTeamId() != null
|
||||
&& teamLeadLookup.isLeaderOfTeam(user, resource.getOwnerTeamId());
|
||||
}
|
||||
|
||||
private ResponseStatusException forbidden(String message) {
|
||||
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
|
||||
}
|
||||
|
||||
private ResponseStatusException notFound(String message) {
|
||||
return new ResponseStatusException(HttpStatus.NOT_FOUND, message);
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Resolves access to gated resources: owner, then admin, then grant, then default policy. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional(readOnly = true)
|
||||
public class ResourceAccessService {
|
||||
|
||||
private final ResourceGrantRepository grantRepository;
|
||||
private final TeamLeadLookup teamLeadLookup;
|
||||
|
||||
@Value("${security.portal.defaultAccess:ADMINS_AND_TEAM_LEADS}")
|
||||
private DefaultAccessPolicy portalDefaultPolicy;
|
||||
|
||||
// ---- public checks ----
|
||||
|
||||
/** Whether the user may use the portal / processor. */
|
||||
public boolean canAccessPortal(User user) {
|
||||
return canUseResource(ResourceType.PORTAL, "", null, portalDefaultPolicy, user);
|
||||
}
|
||||
|
||||
/** Whether the user may use a resource, falling back to its default policy. */
|
||||
public boolean canUseResource(
|
||||
ResourceType type,
|
||||
String resourceId,
|
||||
Long ownerUserId,
|
||||
DefaultAccessPolicy defaultPolicy,
|
||||
User user) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
if (hasGrant(type, normalize(resourceId), user, AccessPermission.USE)) {
|
||||
return true;
|
||||
}
|
||||
return matchesDefault(defaultPolicy, 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) {
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
if (isOwner(ownerUserId, user) || isAdmin(user)) {
|
||||
return true;
|
||||
}
|
||||
return hasGrant(type, normalize(resourceId), user, AccessPermission.MANAGE);
|
||||
}
|
||||
|
||||
// ---- grant management ----
|
||||
|
||||
@Transactional
|
||||
public ResourceGrant grant(
|
||||
ResourceType type,
|
||||
String resourceId,
|
||||
PrincipalType principalType,
|
||||
Long principalId,
|
||||
AccessPermission permission,
|
||||
User grantedBy) {
|
||||
String rid = normalize(resourceId);
|
||||
ResourceGrant grant =
|
||||
grantRepository.findByResourceTypeAndResourceId(type, rid).stream()
|
||||
.filter(
|
||||
g ->
|
||||
g.getPrincipalType() == principalType
|
||||
&& g.getPrincipalId().equals(principalId))
|
||||
.findFirst()
|
||||
.orElseGet(ResourceGrant::new);
|
||||
grant.setResourceType(type);
|
||||
grant.setResourceId(rid);
|
||||
grant.setPrincipalType(principalType);
|
||||
grant.setPrincipalId(principalId);
|
||||
grant.setPermission(permission);
|
||||
if (grantedBy != null) {
|
||||
grant.setGrantedBy(grantedBy);
|
||||
}
|
||||
return grantRepository.save(grant);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void revoke(Long grantId) {
|
||||
grantRepository.deleteById(grantId);
|
||||
}
|
||||
|
||||
public List<ResourceGrant> listGrants(ResourceType type, String resourceId) {
|
||||
return grantRepository.findByResourceTypeAndResourceId(type, normalize(resourceId));
|
||||
}
|
||||
|
||||
/** Resource ids of the given type that this user (or their team) holds any grant on. */
|
||||
public Set<String> grantedResourceIds(ResourceType type, User user) {
|
||||
if (user == null) {
|
||||
return Set.of();
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, PrincipalType.USER, user.getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
if (user.getTeam() != null) {
|
||||
for (ResourceGrant g :
|
||||
grantRepository.findByResourceTypeAndPrincipalTypeAndPrincipalId(
|
||||
type, PrincipalType.TEAM, user.getTeam().getId())) {
|
||||
ids.add(g.getResourceId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
// ---- internals ----
|
||||
|
||||
private boolean hasGrant(
|
||||
ResourceType type, String resourceId, User user, AccessPermission required) {
|
||||
Long teamId = user.getTeam() != null ? user.getTeam().getId() : null;
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// MANAGE implies USE.
|
||||
private boolean permissionSatisfies(AccessPermission held, AccessPermission required) {
|
||||
if (required == AccessPermission.USE) {
|
||||
return held == AccessPermission.USE || held == AccessPermission.MANAGE;
|
||||
}
|
||||
return held == AccessPermission.MANAGE;
|
||||
}
|
||||
|
||||
private boolean matchesDefault(DefaultAccessPolicy policy, 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);
|
||||
case EXPLICIT_ONLY -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isOwner(Long ownerUserId, User user) {
|
||||
return ownerUserId != null && ownerUserId.equals(user.getId());
|
||||
}
|
||||
|
||||
private boolean isAdmin(User user) {
|
||||
return user.getAuthorities().stream()
|
||||
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority()));
|
||||
}
|
||||
|
||||
private String normalize(String resourceId) {
|
||||
return resourceId == null ? "" : resourceId;
|
||||
}
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Masks, merges and sanitizes secret values in a config map, recursing into nested maps/lists. */
|
||||
@Component
|
||||
public class SecretMasker {
|
||||
|
||||
public static final String MASK = "********";
|
||||
|
||||
// Cap recursion so a pathologically nested payload cannot overflow the stack.
|
||||
private static final int MAX_DEPTH = 32;
|
||||
|
||||
private static final Set<String> SENSITIVE_HINTS =
|
||||
Set.of(
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"credential",
|
||||
"privatekey");
|
||||
|
||||
/** Replace sensitive values with the mask (recursively) for safe display. */
|
||||
public Map<String, Object> mask(Map<String, Object> config) {
|
||||
return mask(config, 0);
|
||||
}
|
||||
|
||||
/** Drop sensitive blank/masked values from an incoming create payload. */
|
||||
public Map<String, Object> sanitize(Map<String, Object> config) {
|
||||
return sanitize(config, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an update over the stored map, keeping stored secrets where the incoming is redacted.
|
||||
*/
|
||||
public Map<String, Object> merge(Map<String, Object> stored, Map<String, Object> incoming) {
|
||||
return merge(stored, incoming, 0);
|
||||
}
|
||||
|
||||
private Map<String, Object> mask(Map<String, Object> config, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : config.entrySet()) {
|
||||
out.put(e.getKey(), maskValue(e.getKey(), e.getValue(), depth));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> sanitize(Map<String, Object> config, int depth) {
|
||||
if (config == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, Object> e : config.entrySet()) {
|
||||
if (isSensitive(e.getKey()) && isRedacted(e.getValue(), depth)) {
|
||||
continue;
|
||||
}
|
||||
out.put(
|
||||
e.getKey(),
|
||||
e.getValue() instanceof Map<?, ?> m && depth < MAX_DEPTH
|
||||
? sanitize(castMap(m), depth + 1)
|
||||
: e.getValue());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> merge(
|
||||
Map<String, Object> stored, Map<String, Object> incoming, int depth) {
|
||||
Map<String, Object> out = new LinkedHashMap<>(stored);
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
String key = e.getKey();
|
||||
Object value = e.getValue();
|
||||
if (isSensitive(key)) {
|
||||
if (!isRedacted(value, depth)) {
|
||||
out.put(key, value); // a real new secret replaces the stored one
|
||||
}
|
||||
continue; // redacted (blank / mask) -> keep stored
|
||||
}
|
||||
if (depth < MAX_DEPTH
|
||||
&& out.get(key) instanceof Map<?, ?> s
|
||||
&& value instanceof Map<?, ?> i) {
|
||||
out.put(key, merge(castMap(s), castMap(i), depth + 1));
|
||||
} else {
|
||||
out.put(key, value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A sensitive key masks its whole value; recurse into non-sensitive containers.
|
||||
private Object maskValue(String key, Object value, int depth) {
|
||||
if (isSensitive(key)) {
|
||||
if (value == null || (value instanceof String s && s.isBlank())) {
|
||||
return value;
|
||||
}
|
||||
return MASK;
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
// Too deep to descend; mask containers rather than risk leaking an unmasked secret.
|
||||
return value instanceof Map<?, ?> || value instanceof List<?> ? MASK : value;
|
||||
}
|
||||
if (value instanceof Map<?, ?> m) {
|
||||
return mask(castMap(m), depth + 1);
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
List<Object> out = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
out.add(item instanceof Map<?, ?> m ? mask(castMap(m), depth + 1) : item);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private boolean isSensitive(String key) {
|
||||
String lower = key.toLowerCase(Locale.ROOT);
|
||||
return SENSITIVE_HINTS.stream().anyMatch(lower::contains);
|
||||
}
|
||||
|
||||
/** Blank, the mask placeholder, or any structure that still contains the mask. */
|
||||
private boolean isRedacted(Object value, int depth) {
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
if (value instanceof String s) {
|
||||
return s.isBlank() || MASK.equals(s);
|
||||
}
|
||||
if (depth >= MAX_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
if (value instanceof Map<?, ?> m) {
|
||||
return m.values().stream().anyMatch(v -> isRedacted(v, depth + 1));
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
return list.stream().anyMatch(v -> isRedacted(v, depth + 1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> castMap(Map<?, ?> map) {
|
||||
return (Map<String, Object>) map;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** Resolves whether a user leads a team. */
|
||||
public interface TeamLeadLookup {
|
||||
|
||||
/** Whether the user leads at least one team. */
|
||||
boolean isAnyTeamLeader(User user);
|
||||
|
||||
/** Whether the user leads the given team. */
|
||||
boolean isLeaderOfTeam(User user, Long teamId);
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package stirling.software.proprietary.integration.controller;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigRequest;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigResponse;
|
||||
import stirling.software.proprietary.integration.service.IntegrationConfigService;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
/** CRUD for S3/MCP/API integration configs. Secrets are never returned. */
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/integrations")
|
||||
@RequiredArgsConstructor
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@Tag(name = "Integrations", description = "Manage S3/MCP/API integration configurations")
|
||||
public class IntegrationConfigController {
|
||||
|
||||
private final IntegrationConfigService service;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<IntegrationConfigResponse>> list(
|
||||
@AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(
|
||||
service.listVisible(user).stream().map(c -> service.toResponse(c, user)).toList());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<IntegrationConfigResponse> create(
|
||||
@RequestBody IntegrationConfigRequest request, @AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(service.toResponse(service.create(request, user), user));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ResponseEntity<IntegrationConfigResponse> get(
|
||||
@PathVariable Long id, @AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(service.toResponse(service.getForUse(id, user), user));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<IntegrationConfigResponse> update(
|
||||
@PathVariable Long id,
|
||||
@RequestBody IntegrationConfigRequest request,
|
||||
@AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
return ResponseEntity.ok(service.toResponse(service.update(id, request, user), user));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> delete(@PathVariable Long id, @AuthenticationPrincipal User user) {
|
||||
requireUser(user);
|
||||
service.delete(id, user);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private void requireUser(User user) {
|
||||
if (user == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Authentication required");
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/**
|
||||
* AES-256-GCM for stored credentials. Key from property, env var, or an auto-generated key file.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class CredentialEncryption {
|
||||
|
||||
private static final String ALGORITHM = "AES";
|
||||
private static final String TRANSFORMATION = "AES/GCM/NoPadding";
|
||||
private static final int GCM_TAG_BITS = 128;
|
||||
private static final int IV_BYTES = 12;
|
||||
private static final String KEY_FILE = "credential-encryption.key";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private static volatile SecretKey key;
|
||||
|
||||
private final String configuredKey;
|
||||
|
||||
public CredentialEncryption(
|
||||
@Value("${stirling.security.credentialEncryptionKey:}") String configuredKey) {
|
||||
this.configuredKey = configuredKey;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
key = resolveKey();
|
||||
log.info("Credential encryption initialised (AES-256-GCM)");
|
||||
}
|
||||
|
||||
private SecretKey resolveKey() {
|
||||
String configured = configuredKey;
|
||||
if (configured == null || configured.isBlank()) {
|
||||
configured = System.getenv("STIRLING_CREDENTIAL_ENCRYPTION_KEY");
|
||||
}
|
||||
if (configured != null && !configured.isBlank()) {
|
||||
return new SecretKeySpec(Base64.getDecoder().decode(configured.trim()), ALGORITHM);
|
||||
}
|
||||
return loadOrCreateKeyFile();
|
||||
}
|
||||
|
||||
private SecretKey loadOrCreateKeyFile() {
|
||||
Path path = Path.of(InstallationPathConfig.getConfigPath(), KEY_FILE);
|
||||
try {
|
||||
if (Files.exists(path)) {
|
||||
String encoded = Files.readString(path).trim();
|
||||
return new SecretKeySpec(Base64.getDecoder().decode(encoded), ALGORITHM);
|
||||
}
|
||||
KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
|
||||
generator.init(256);
|
||||
SecretKey generated = generator.generateKey();
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(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.",
|
||||
path);
|
||||
return generated;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Unable to initialise credential encryption key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String encrypt(String plaintext) {
|
||||
if (plaintext == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] iv = new byte[IV_BYTES];
|
||||
RANDOM.nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
cipher.init(Cipher.ENCRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||
byte[] combined = new byte[iv.length + ciphertext.length];
|
||||
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||
System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length);
|
||||
return Base64.getEncoder().encodeToString(combined);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Failed to encrypt credential", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String decrypt(String stored) {
|
||||
if (stored == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
byte[] combined = Base64.getDecoder().decode(stored);
|
||||
byte[] iv = Arrays.copyOfRange(combined, 0, IV_BYTES);
|
||||
byte[] ciphertext = Arrays.copyOfRange(combined, IV_BYTES, combined.length);
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
cipher.init(Cipher.DECRYPT_MODE, requireKey(), new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("Failed to decrypt credential", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKey requireKey() {
|
||||
SecretKey current = key;
|
||||
if (current == null) {
|
||||
throw new IllegalStateException("Credential encryption not initialised");
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
static void initialiseForTesting(SecretKey testKey) {
|
||||
key = testKey;
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
/** Transparently encrypts/decrypts a string column at rest via {@link CredentialEncryption}. */
|
||||
@Converter
|
||||
public class EncryptedStringConverter implements AttributeConverter<String, String> {
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(String attribute) {
|
||||
return CredentialEncryption.encrypt(attribute);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToEntityAttribute(String dbData) {
|
||||
return CredentialEncryption.decrypt(dbData);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package stirling.software.proprietary.integration.dto;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/** Create/update payload for an integration config. Sensitive config values left blank are kept. */
|
||||
public record IntegrationConfigRequest(
|
||||
IntegrationType integrationType,
|
||||
String name,
|
||||
OwnerScope scope,
|
||||
Long ownerTeamId,
|
||||
Boolean enabled,
|
||||
Boolean locked,
|
||||
DefaultAccessPolicy defaultAccess,
|
||||
Map<String, Object> config) {}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package stirling.software.proprietary.integration.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
|
||||
/** Integration config view. Sensitive config values are masked. */
|
||||
public record IntegrationConfigResponse(
|
||||
Long id,
|
||||
IntegrationType integrationType,
|
||||
String name,
|
||||
OwnerScope scope,
|
||||
Long ownerUserId,
|
||||
Long ownerTeamId,
|
||||
boolean enabled,
|
||||
boolean locked,
|
||||
DefaultAccessPolicy defaultAccess,
|
||||
Map<String, Object> config,
|
||||
boolean canManage,
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt) {}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package stirling.software.proprietary.integration.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Index;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnedResource;
|
||||
import stirling.software.proprietary.integration.crypto.EncryptedStringConverter;
|
||||
|
||||
/** A named S3/MCP/API integration config; scope and ownership live on {@link OwnedResource}. */
|
||||
@Entity
|
||||
@Table(
|
||||
name = "integration_configs",
|
||||
indexes = {
|
||||
@Index(name = "idx_integration_configs_owner", columnList = "owner_user_id"),
|
||||
@Index(name = "idx_integration_configs_type", columnList = "integration_type"),
|
||||
@Index(name = "idx_integration_configs_scope", columnList = "scope")
|
||||
})
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class IntegrationConfig extends OwnedResource implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "integration_config_id")
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "integration_type", nullable = false, length = 32)
|
||||
private IntegrationType integrationType;
|
||||
|
||||
@Column(name = "name", nullable = false)
|
||||
private String name;
|
||||
|
||||
// Type-specific fields as an AES-GCM encrypted JSON blob.
|
||||
@Convert(converter = EncryptedStringConverter.class)
|
||||
@Column(name = "config_encrypted", columnDefinition = "text")
|
||||
private String config;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package stirling.software.proprietary.integration.model;
|
||||
|
||||
/** Kind of external integration a stored config describes. */
|
||||
public enum IntegrationType {
|
||||
S3,
|
||||
MCP,
|
||||
API
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package stirling.software.proprietary.integration.repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@Repository
|
||||
public interface IntegrationConfigRepository extends JpaRepository<IntegrationConfig, Long> {
|
||||
|
||||
List<IntegrationConfig> findByOwnerUser(User ownerUser);
|
||||
|
||||
List<IntegrationConfig> findByOwnerTeam(Team ownerTeam);
|
||||
|
||||
List<IntegrationConfig> findByScope(OwnerScope scope);
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package stirling.software.proprietary.integration.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.service.OwnershipService;
|
||||
import stirling.software.proprietary.access.service.SecretMasker;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigRequest;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigResponse;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/** CRUD for {@link IntegrationConfig}; delegates ownership and masking to shared services. */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
@Transactional(readOnly = true)
|
||||
public class IntegrationConfigService {
|
||||
|
||||
private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final IntegrationConfigRepository repository;
|
||||
private final OwnershipService ownership;
|
||||
private final SecretMasker secretMasker;
|
||||
|
||||
// ---- commands ----
|
||||
|
||||
@Transactional
|
||||
public IntegrationConfig create(IntegrationConfigRequest request, User currentUser) {
|
||||
OwnerScope scope = request.scope() == null ? OwnerScope.USER : request.scope();
|
||||
IntegrationConfig cfg = new IntegrationConfig();
|
||||
cfg.setIntegrationType(require(request.integrationType(), "integrationType"));
|
||||
cfg.setName(require(request.name(), "name"));
|
||||
cfg.setEnabled(request.enabled() == null || request.enabled());
|
||||
cfg.setLocked(request.locked() != null && request.locked());
|
||||
cfg.setDefaultAccess(
|
||||
request.defaultAccess() == null
|
||||
? DefaultAccessPolicy.EXPLICIT_ONLY
|
||||
: request.defaultAccess());
|
||||
|
||||
ownership.assignOwnership(
|
||||
cfg,
|
||||
scope,
|
||||
request.ownerTeamId(),
|
||||
currentUser,
|
||||
() -> lockedServerExists(cfg.getIntegrationType()));
|
||||
cfg.setConfig(writeJson(secretMasker.sanitize(request.config())));
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public IntegrationConfig update(Long id, IntegrationConfigRequest request, User currentUser) {
|
||||
IntegrationConfig cfg = load(id);
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
if (cfg.isLocked() && !ownership.isAdmin(currentUser)) {
|
||||
throw forbidden("This integration is locked by an administrator");
|
||||
}
|
||||
if (request.name() != null) {
|
||||
cfg.setName(request.name());
|
||||
}
|
||||
if (request.enabled() != null) {
|
||||
cfg.setEnabled(request.enabled());
|
||||
}
|
||||
if (request.locked() != null && request.locked() != cfg.isLocked()) {
|
||||
if (!ownership.isAdmin(currentUser)) {
|
||||
throw forbidden("Only administrators can change the locked flag");
|
||||
}
|
||||
cfg.setLocked(request.locked());
|
||||
}
|
||||
if (request.defaultAccess() != null) {
|
||||
cfg.setDefaultAccess(request.defaultAccess());
|
||||
}
|
||||
if (request.config() != null) {
|
||||
cfg.setConfig(
|
||||
writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config())));
|
||||
}
|
||||
return repository.save(cfg);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id, User currentUser) {
|
||||
IntegrationConfig cfg = load(id);
|
||||
if (!ownership.canManage(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot manage this integration");
|
||||
}
|
||||
repository.delete(cfg);
|
||||
}
|
||||
|
||||
// ---- queries ----
|
||||
|
||||
public IntegrationConfig getForUse(Long id, User currentUser) {
|
||||
IntegrationConfig cfg = load(id);
|
||||
if (!ownership.canUse(TYPE, cfg, currentUser)) {
|
||||
throw forbidden("You cannot access this integration");
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
/**
|
||||
* All configs the user owns, plus server/team configs and grant-shared configs they may use.
|
||||
*/
|
||||
public List<IntegrationConfig> listVisible(User currentUser) {
|
||||
Map<Long, IntegrationConfig> byId = new LinkedHashMap<>();
|
||||
for (IntegrationConfig c : repository.findByOwnerUser(currentUser)) {
|
||||
byId.put(c.getId(), c);
|
||||
}
|
||||
for (IntegrationConfig c : repository.findByScope(OwnerScope.SERVER)) {
|
||||
if (ownership.canUse(TYPE, c, currentUser)) {
|
||||
byId.put(c.getId(), c);
|
||||
}
|
||||
}
|
||||
if (currentUser.getTeam() != null) {
|
||||
for (IntegrationConfig c : repository.findByOwnerTeam(currentUser.getTeam())) {
|
||||
if (ownership.canUse(TYPE, c, currentUser)) {
|
||||
byId.put(c.getId(), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (String rid : ownership.grantedResourceIds(TYPE, currentUser)) {
|
||||
if (rid == null || rid.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
Long cid;
|
||||
try {
|
||||
cid = Long.valueOf(rid);
|
||||
} catch (NumberFormatException e) {
|
||||
continue;
|
||||
}
|
||||
if (byId.containsKey(cid)) {
|
||||
continue;
|
||||
}
|
||||
repository
|
||||
.findById(cid)
|
||||
.filter(c -> ownership.canUse(TYPE, c, currentUser))
|
||||
.ifPresent(c -> byId.put(c.getId(), c));
|
||||
}
|
||||
return new ArrayList<>(byId.values());
|
||||
}
|
||||
|
||||
public IntegrationConfigResponse toResponse(IntegrationConfig cfg, User user) {
|
||||
return new IntegrationConfigResponse(
|
||||
cfg.getId(),
|
||||
cfg.getIntegrationType(),
|
||||
cfg.getName(),
|
||||
cfg.getScope(),
|
||||
cfg.getOwnerUserId(),
|
||||
cfg.getOwnerTeamId(),
|
||||
cfg.isEnabled(),
|
||||
cfg.isLocked(),
|
||||
cfg.getDefaultAccess(),
|
||||
secretMasker.mask(readJson(cfg.getConfig())),
|
||||
ownership.canManage(TYPE, cfg, user),
|
||||
cfg.getCreatedAt(),
|
||||
cfg.getUpdatedAt());
|
||||
}
|
||||
|
||||
// ---- integration-specific glue ----
|
||||
|
||||
/** A non-admin can't create a personal config of a type an admin has locked at server scope. */
|
||||
private boolean lockedServerExists(IntegrationType type) {
|
||||
return repository.findByScope(OwnerScope.SERVER).stream()
|
||||
.anyMatch(c -> c.getIntegrationType() == type && c.isLocked());
|
||||
}
|
||||
|
||||
private IntegrationConfig load(Long id) {
|
||||
return repository
|
||||
.findById(id)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new ResponseStatusException(
|
||||
HttpStatus.NOT_FOUND, "Integration not found"));
|
||||
}
|
||||
|
||||
private String writeJson(Map<String, Object> config) {
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsString(config == null ? Map.of() : config);
|
||||
} catch (Exception e) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid config payload");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> readJson(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.readValue(
|
||||
json, new TypeReference<LinkedHashMap<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse integration config JSON", e);
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
|
||||
private <T> T require(T value, String field) {
|
||||
if (value == null || (value instanceof String s && s.isBlank())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, field + " is required");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private ResponseStatusException forbidden(String message) {
|
||||
return new ResponseStatusException(HttpStatus.FORBIDDEN, message);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -21,6 +21,7 @@ import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
|
||||
/**
|
||||
@@ -37,6 +38,7 @@ public class PolicyRunner {
|
||||
private final PolicyEngine policyEngine;
|
||||
private final List<InputSource> inputSources;
|
||||
private final SourceStore sourceStore;
|
||||
private final SourceDocCounter docCounter;
|
||||
|
||||
/**
|
||||
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
|
||||
@@ -65,7 +67,7 @@ public class PolicyRunner {
|
||||
policy.id());
|
||||
continue;
|
||||
}
|
||||
runIds.addAll(pullAndRun(policy, source.toInputSpec()));
|
||||
runIds.addAll(pullAndRun(policy, sourceId, source.toInputSpec()));
|
||||
}
|
||||
return runIds;
|
||||
}
|
||||
@@ -82,7 +84,11 @@ public class PolicyRunner {
|
||||
return policyEngine.submit(definition, inputs, listener);
|
||||
}
|
||||
|
||||
private List<String> pullAndRun(Policy policy, InputSpec spec) {
|
||||
/**
|
||||
* Resolves the source and starts a run per unit; records how many documents the source fed and
|
||||
* returns the ids of the runs started.
|
||||
*/
|
||||
private List<String> pullAndRun(Policy policy, String sourceId, InputSpec spec) {
|
||||
InputSource source = sourceFor(spec);
|
||||
if (source == null) {
|
||||
log.warn(
|
||||
@@ -103,9 +109,12 @@ public class PolicyRunner {
|
||||
return List.of();
|
||||
}
|
||||
List<String> runIds = new ArrayList<>();
|
||||
long docsFed = 0;
|
||||
for (ResolvedInput unit : work) {
|
||||
runIds.add(startRun(policy, unit.inputs(), unit.onComplete()));
|
||||
docsFed += unit.inputs().primary().size();
|
||||
}
|
||||
docCounter.record(sourceId, docsFed);
|
||||
return runIds;
|
||||
}
|
||||
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* Per-source document throughput for the overview row: how many documents a source has fed into
|
||||
* runs in total and over the trailing 24-hour and 30-day windows. Counts documents <em>fed</em>
|
||||
* (picked up by a run), so a snapshot-mode source that re-reads the same files each run counts them
|
||||
* per run.
|
||||
*/
|
||||
public record DocStats(long total, long last24h, long last30d) {
|
||||
|
||||
/** Number of trailing daily buckets in a source's daily series. */
|
||||
public static final int DAYS = 30;
|
||||
|
||||
public static final DocStats ZERO = new DocStats(0, 0, 0);
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* In-memory {@link SourceDocCounter} for tests and any future no-database mode. Holds hourly
|
||||
* buckets per source; the clock is injectable so window boundaries can be exercised
|
||||
* deterministically. {@link JpaSourceDocCounter} is the runtime bean.
|
||||
*/
|
||||
public class InProcessSourceDocCounter implements SourceDocCounter {
|
||||
|
||||
private final Supplier<Instant> clock;
|
||||
private final Map<String, Map<Long, Long>> bucketsBySource = new ConcurrentHashMap<>();
|
||||
|
||||
public InProcessSourceDocCounter() {
|
||||
this(Instant::now);
|
||||
}
|
||||
|
||||
public InProcessSourceDocCounter(Supplier<Instant> clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String sourceId, long docs) {
|
||||
if (docs <= 0) {
|
||||
return;
|
||||
}
|
||||
bucketsBySource
|
||||
.computeIfAbsent(sourceId, key -> new ConcurrentHashMap<>())
|
||||
.merge(currentHour(), docs, Long::sum);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, DocStats> statsFor(Collection<String> sourceIds) {
|
||||
long now = currentHour();
|
||||
Map<String, DocStats> stats = new HashMap<>();
|
||||
for (String id : sourceIds) {
|
||||
Map<Long, Long> buckets = bucketsBySource.getOrDefault(id, Map.of());
|
||||
long total = buckets.values().stream().mapToLong(Long::longValue).sum();
|
||||
long last24h =
|
||||
SourceDocWindows.sumSince(buckets, now - (SourceDocWindows.HOURS_IN_24H - 1));
|
||||
long last30d = SourceDocWindows.sumSince(buckets, SourceDocWindows.firstDayHour(now));
|
||||
stats.put(id, new DocStats(total, last24h, last30d));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> dailySeriesFor(String sourceId) {
|
||||
long now = currentHour();
|
||||
Map<Long, Long> buckets = bucketsBySource.getOrDefault(sourceId, Map.of());
|
||||
return SourceDocWindows.series(SourceDocWindows.byDay(buckets), now / 24);
|
||||
}
|
||||
|
||||
private long currentHour() {
|
||||
return clock.get().getEpochSecond() / 3600;
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.IntSupplier;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Durable {@link SourceDocCounter}; the runtime bean. {@code record} keeps two things in step: an
|
||||
* hourly bucket ({@link SourceDocCountEntity}) that feeds the rolling 24h / 30d / daily-series
|
||||
* windows, and a denormalized lifetime total ({@link SourceDocTotalEntity}) read directly for the
|
||||
* all-time figure. The lifetime counter means the overview never scans a source's whole bucket
|
||||
* history, and lets {@link #pruneOldBuckets()} retire buckets past the 30-day window so the hourly
|
||||
* table stays bounded (~one row per source per active hour, for at most 30 days).
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class JpaSourceDocCounter implements SourceDocCounter {
|
||||
|
||||
private final SourceDocCountRepository countRepository;
|
||||
private final SourceDocTotalRepository totalRepository;
|
||||
private final Supplier<Instant> clock;
|
||||
|
||||
@Autowired
|
||||
public JpaSourceDocCounter(
|
||||
SourceDocCountRepository countRepository, SourceDocTotalRepository totalRepository) {
|
||||
this(countRepository, totalRepository, Instant::now);
|
||||
}
|
||||
|
||||
// Clock seam so tests can pin "now"; the runtime bean uses the wall clock above.
|
||||
JpaSourceDocCounter(
|
||||
SourceDocCountRepository countRepository,
|
||||
SourceDocTotalRepository totalRepository,
|
||||
Supplier<Instant> clock) {
|
||||
this.countRepository = countRepository;
|
||||
this.totalRepository = totalRepository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(String sourceId, long docs) {
|
||||
if (docs <= 0) {
|
||||
return;
|
||||
}
|
||||
long bucketHour = currentHour();
|
||||
upsert(
|
||||
() -> totalRepository.increment(sourceId, docs),
|
||||
() -> totalRepository.saveAndFlush(new SourceDocTotalEntity(sourceId, docs)));
|
||||
upsert(
|
||||
() -> countRepository.increment(sourceId, bucketHour, docs),
|
||||
() ->
|
||||
countRepository.saveAndFlush(
|
||||
new SourceDocCountEntity(sourceId, bucketHour, docs)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add {@code docs} to a per-source running total: increment the existing row, else insert a new
|
||||
* one. The insert is flushed now (in its own transaction, since {@code record} is not
|
||||
* {@code @Transactional}) so a concurrent run's winning insert surfaces as a constraint
|
||||
* violation we retry as an increment, rather than as a silent {@code merge} overwrite or a
|
||||
* later doomed commit.
|
||||
*/
|
||||
private static void upsert(IntSupplier increment, Runnable insert) {
|
||||
if (increment.getAsInt() > 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
insert.run();
|
||||
} catch (DataIntegrityViolationException concurrentInsert) {
|
||||
increment.getAsInt();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, DocStats> statsFor(Collection<String> sourceIds) {
|
||||
if (sourceIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
long now = currentHour();
|
||||
Map<String, Long> totals = sums(totalRepository.totalsFor(sourceIds));
|
||||
Map<String, Long> last24h =
|
||||
sums(
|
||||
countRepository.sumBySourceSince(
|
||||
sourceIds, now - (SourceDocWindows.HOURS_IN_24H - 1)));
|
||||
Map<String, Long> last30d =
|
||||
sums(
|
||||
countRepository.sumBySourceSince(
|
||||
sourceIds, SourceDocWindows.firstDayHour(now)));
|
||||
|
||||
Map<String, DocStats> stats = new HashMap<>();
|
||||
for (String id : sourceIds) {
|
||||
stats.put(
|
||||
id,
|
||||
new DocStats(
|
||||
totals.getOrDefault(id, 0L),
|
||||
last24h.getOrDefault(id, 0L),
|
||||
last30d.getOrDefault(id, 0L)));
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Long> dailySeriesFor(String sourceId) {
|
||||
long now = currentHour();
|
||||
Collection<String> ids = List.of(sourceId);
|
||||
Map<Long, Long> dailyCounts =
|
||||
dailyBySource(
|
||||
countRepository.dailyCountsSince(
|
||||
ids, SourceDocWindows.firstDayHour(now)))
|
||||
.getOrDefault(sourceId, Map.of());
|
||||
return SourceDocWindows.series(dailyCounts, now / 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire hourly buckets older than the 30-day window; the lifetime total is held separately, so
|
||||
* nothing reported is lost. Daily is ample - the read queries already ignore older buckets, so
|
||||
* this is purely storage hygiene.
|
||||
*/
|
||||
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
|
||||
public void pruneOldBuckets() {
|
||||
countRepository.deleteOlderThan(SourceDocWindows.firstDayHour(currentHour()));
|
||||
}
|
||||
|
||||
private long currentHour() {
|
||||
return clock.get().getEpochSecond() / 3600;
|
||||
}
|
||||
|
||||
private static Map<String, Long> sums(List<SourceDocSum> rows) {
|
||||
Map<String, Long> map = new HashMap<>();
|
||||
for (SourceDocSum row : rows) {
|
||||
map.put(row.sourceId(), row.count() == null ? 0L : row.count());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static Map<String, Map<Long, Long>> dailyBySource(List<SourceDayDocSum> rows) {
|
||||
Map<String, Map<Long, Long>> bySource = new HashMap<>();
|
||||
for (SourceDayDocSum row : rows) {
|
||||
bySource.computeIfAbsent(row.sourceId(), key -> new HashMap<>())
|
||||
.merge(row.day(), row.docs() == null ? 0L : row.docs(), Long::sum);
|
||||
}
|
||||
return bySource;
|
||||
}
|
||||
}
|
||||
+14
@@ -74,6 +74,20 @@ public class SourceController {
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@GetMapping("/{sourceId}/document-counts")
|
||||
@Operation(
|
||||
summary = "Daily document counts for a source",
|
||||
description =
|
||||
"The trailing 30-day per-day document series (oldest first) for the source's"
|
||||
+ " sparkline.")
|
||||
public ResponseEntity<List<Long>> documentCounts(@PathVariable String sourceId) {
|
||||
return sourceStore
|
||||
.get(sourceId)
|
||||
.filter(sourceAccessGuard::canAccess)
|
||||
.map(source -> ResponseEntity.ok(overviewService.dailySeries(source.id())))
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
|
||||
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@Operation(
|
||||
summary = "Create or update a source",
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* A {@code (sourceId, epoch-day, summed document count)} row from the daily-aggregate query. The
|
||||
* day is {@code floor(bucketHour / 24)}, i.e. hours-since-epoch collapsed to days-since-epoch.
|
||||
*/
|
||||
public record SourceDayDocSum(String sourceId, Long day, Long docs) {}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.springframework.data.domain.Persistable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
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 hour's document tally for a source: {@code bucketHour} is the hours-since-epoch the documents
|
||||
* were fed, {@code docCount} the running total for that hour. Rolling-window totals are summed from
|
||||
* these buckets. {@code sourceId} is a plain value, not a foreign key, matching the rest of the
|
||||
* subsystem so it stays decoupled from the security entities.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "policy_source_doc_counts")
|
||||
@IdClass(SourceDocCountId.class)
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class SourceDocCountEntity implements Serializable, Persistable<SourceDocCountId> {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "source_id")
|
||||
private String sourceId;
|
||||
|
||||
@Id
|
||||
@Column(name = "bucket_hour")
|
||||
private long bucketHour;
|
||||
|
||||
@Column(name = "doc_count")
|
||||
private long docCount;
|
||||
|
||||
public SourceDocCountEntity(String sourceId, long bucketHour, long docCount) {
|
||||
this.sourceId = sourceId;
|
||||
this.bucketHour = bucketHour;
|
||||
this.docCount = docCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public SourceDocCountId getId() {
|
||||
return new SourceDocCountId(sourceId, bucketHour);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean isNew() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Composite key for {@link SourceDocCountEntity}: one row per source per hour bucket. */
|
||||
public class SourceDocCountId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private String sourceId;
|
||||
private long bucketHour;
|
||||
|
||||
public SourceDocCountId() {}
|
||||
|
||||
public SourceDocCountId(String sourceId, long bucketHour) {
|
||||
this.sourceId = sourceId;
|
||||
this.bucketHour = bucketHour;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof SourceDocCountId other)) {
|
||||
return false;
|
||||
}
|
||||
return bucketHour == other.bucketHour && Objects.equals(sourceId, other.sourceId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(sourceId, bucketHour);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
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;
|
||||
|
||||
@Repository
|
||||
public interface SourceDocCountRepository
|
||||
extends JpaRepository<SourceDocCountEntity, SourceDocCountId> {
|
||||
|
||||
/**
|
||||
* Add to an existing bucket; returns the number of rows updated (0 when the bucket is new).
|
||||
* Transactional per call so {@code JpaSourceDocCounter.record} can run it (and the retry after
|
||||
* a concurrent insert) without an enclosing transaction.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"update SourceDocCountEntity e set e.docCount = e.docCount + :docs"
|
||||
+ " where e.sourceId = :sourceId and e.bucketHour = :bucketHour")
|
||||
int increment(
|
||||
@Param("sourceId") String sourceId,
|
||||
@Param("bucketHour") long bucketHour,
|
||||
@Param("docs") long docs);
|
||||
|
||||
/**
|
||||
* Delete hourly buckets older than {@code floor} (hours-since-epoch). Nothing reads buckets
|
||||
* before the 30-day window ({@code SourceDocWindows.firstDayHour}); the lifetime total lives in
|
||||
* {@code policy_source_doc_totals}, so retiring old buckets keeps the table bounded without
|
||||
* losing any reported figure.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("delete from SourceDocCountEntity e where e.bucketHour < :floor")
|
||||
int deleteOlderThan(@Param("floor") long floor);
|
||||
|
||||
/**
|
||||
* Document total per source restricted to buckets at or after {@code since} (the 24h window).
|
||||
*/
|
||||
@Query(
|
||||
"select new stirling.software.proprietary.policy.source.SourceDocSum("
|
||||
+ "e.sourceId, sum(e.docCount))"
|
||||
+ " from SourceDocCountEntity e"
|
||||
+ " where e.sourceId in :ids and e.bucketHour >= :since"
|
||||
+ " group by e.sourceId")
|
||||
List<SourceDocSum> sumBySourceSince(
|
||||
@Param("ids") Collection<String> ids, @Param("since") long since);
|
||||
|
||||
/**
|
||||
* Per-source, per-day document totals for buckets at or after {@code since}, summed in the
|
||||
* database so the overview reads ~one row per source per active day instead of per active hour.
|
||||
* The day is {@code cast(floor(bucketHour / 24.0) as long)}: {@code 24.0} forces decimal
|
||||
* division and the cast pins the result to a whole day on every dialect.
|
||||
*/
|
||||
@Query(
|
||||
"select new stirling.software.proprietary.policy.source.SourceDayDocSum("
|
||||
+ "e.sourceId, cast(floor(e.bucketHour / 24.0) as long), sum(e.docCount))"
|
||||
+ " from SourceDocCountEntity e"
|
||||
+ " where e.sourceId in :ids and e.bucketHour >= :since"
|
||||
+ " group by cast(floor(e.bucketHour / 24.0) as long), e.sourceId")
|
||||
List<SourceDayDocSum> dailyCountsSince(
|
||||
@Param("ids") Collection<String> ids, @Param("since") long since);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Records and reports how many documents each source feeds into runs. Counting is bucketed by hour
|
||||
* so the overview can report rolling totals ({@link DocStats}) cheaply; {@link JpaSourceDocCounter}
|
||||
* is the runtime bean and {@link InProcessSourceDocCounter} backs tests.
|
||||
*/
|
||||
public interface SourceDocCounter {
|
||||
|
||||
/** Record that {@code docs} documents were fed from {@code sourceId} at the current time. */
|
||||
void record(String sourceId, long docs);
|
||||
|
||||
/**
|
||||
* Document totals for each given source; a source with no recorded docs maps to {@link
|
||||
* DocStats#ZERO}.
|
||||
*/
|
||||
Map<String, DocStats> statsFor(Collection<String> sourceIds);
|
||||
|
||||
/**
|
||||
* The trailing {@link DocStats#DAYS}-day daily document series for one source, oldest first,
|
||||
* for the detail-panel sparkline.
|
||||
*/
|
||||
List<Long> dailySeriesFor(String sourceId);
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
/**
|
||||
* A {@code (sourceId, summed document count)} row, populated by the doc-count aggregate queries.
|
||||
*/
|
||||
public record SourceDocSum(String sourceId, Long count) {}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import org.springframework.data.domain.Persistable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A source's lifetime document total, denormalized so the overview reads the all-time count in one
|
||||
* row instead of scanning the source's whole hourly-bucket history, and so {@link
|
||||
* SourceDocCountEntity} buckets can be pruned to the rolling 30-day window without losing it.
|
||||
*
|
||||
* <p>Like {@link SourceDocCountEntity}, implements {@link Persistable} reporting {@code isNew() ==
|
||||
* true} so a new source's first {@code save} {@code persist}s (a raw INSERT) and a concurrent
|
||||
* insert surfaces as a constraint violation the counter retries as an increment, rather than {@code
|
||||
* merge} silently overwriting it.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "policy_source_doc_totals")
|
||||
@NoArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class SourceDocTotalEntity implements Serializable, Persistable<String> {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "source_id")
|
||||
private String sourceId;
|
||||
|
||||
@Column(name = "doc_total")
|
||||
private long docTotal;
|
||||
|
||||
public SourceDocTotalEntity(String sourceId, long docTotal) {
|
||||
this.sourceId = sourceId;
|
||||
this.docTotal = docTotal;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public String getId() {
|
||||
return sourceId;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transient
|
||||
public boolean isNew() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
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;
|
||||
|
||||
@Repository
|
||||
public interface SourceDocTotalRepository extends JpaRepository<SourceDocTotalEntity, String> {
|
||||
|
||||
/**
|
||||
* Add to a source's lifetime total; returns the number of rows updated (0 when the source has
|
||||
* no total row yet). Transactional per call so {@code JpaSourceDocCounter.record} can run it
|
||||
* (and the retry after a concurrent insert) without an enclosing transaction.
|
||||
*/
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query(
|
||||
"update SourceDocTotalEntity e set e.docTotal = e.docTotal + :docs"
|
||||
+ " where e.sourceId = :sourceId")
|
||||
int increment(@Param("sourceId") String sourceId, @Param("docs") long docs);
|
||||
|
||||
/** Lifetime totals for the given sources, as {@code (sourceId, total)} rows. */
|
||||
@Query(
|
||||
"select new stirling.software.proprietary.policy.source.SourceDocSum("
|
||||
+ "e.sourceId, e.docTotal)"
|
||||
+ " from SourceDocTotalEntity e where e.sourceId in :ids")
|
||||
List<SourceDocSum> totalsFor(@Param("ids") Collection<String> ids);
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Shared rolling-window math over a source's document buckets, so the JPA and in-memory counters
|
||||
* agree on the window boundaries and the daily series. Both define "last 30 days" as the buckets at
|
||||
* or after {@link #firstDayHour}, and build the series from per-day counts with {@link #series}
|
||||
* (the JPA counter aggregates by day in SQL; the in-memory counter groups its hourly buckets with
|
||||
* {@link #byDay}).
|
||||
*/
|
||||
final class SourceDocWindows {
|
||||
|
||||
static final long HOURS_IN_24H = 24;
|
||||
|
||||
private SourceDocWindows() {}
|
||||
|
||||
/**
|
||||
* The hours-since-epoch at the start of the oldest day in the 30-day window, given the current
|
||||
* hour bucket. Both the "last 30 days" total and the daily series are measured from here, so
|
||||
* the KPI and the sparkline always cover the same buckets.
|
||||
*/
|
||||
static long firstDayHour(long nowHour) {
|
||||
return ((nowHour / 24) - (DocStats.DAYS - 1)) * 24;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the {@link DocStats#DAYS}-day daily series (oldest first) from per-day document counts
|
||||
* (keyed by epoch-day, i.e. hours-since-epoch / 24). {@code currentDay} is today's epoch-day;
|
||||
* the series runs back {@code DAYS} days from it.
|
||||
*/
|
||||
static List<Long> series(Map<Long, Long> dailyCounts, long currentDay) {
|
||||
long firstDay = currentDay - (DocStats.DAYS - 1);
|
||||
long[] daily = new long[DocStats.DAYS];
|
||||
for (Map.Entry<Long, Long> day : dailyCounts.entrySet()) {
|
||||
int dayIndex = (int) (day.getKey() - firstDay);
|
||||
if (dayIndex >= 0 && dayIndex < DocStats.DAYS) {
|
||||
daily[dayIndex] += day.getValue();
|
||||
}
|
||||
}
|
||||
return Arrays.stream(daily).boxed().toList();
|
||||
}
|
||||
|
||||
/** Collapse hourly buckets (keyed by hours-since-epoch) into per-day counts (keyed by day). */
|
||||
static Map<Long, Long> byDay(Map<Long, Long> hourlyCounts) {
|
||||
Map<Long, Long> dailyCounts = new HashMap<>();
|
||||
hourlyCounts.forEach((hour, count) -> dailyCounts.merge(hour / 24, count, Long::sum));
|
||||
return dailyCounts;
|
||||
}
|
||||
|
||||
/** Sum hourly buckets at or after {@code since} (hours-since-epoch). */
|
||||
static long sumSince(Map<Long, Long> hourlyCounts, long since) {
|
||||
return hourlyCounts.entrySet().stream()
|
||||
.filter(entry -> entry.getKey() >= since)
|
||||
.mapToLong(Map.Entry::getValue)
|
||||
.sum();
|
||||
}
|
||||
}
|
||||
+18
-3
@@ -30,12 +30,15 @@ public class SourceOverviewService {
|
||||
private final PolicyStore policyStore;
|
||||
private final SourceAccessGuard sourceAccessGuard;
|
||||
private final PolicyAccessGuard policyAccessGuard;
|
||||
private final SourceDocCounter docCounter;
|
||||
|
||||
public SourcesResponse overview() {
|
||||
List<Source> sources = sourceAccessGuard.visibleFrom(sourceStore);
|
||||
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore);
|
||||
|
||||
Map<String, List<Policy>> referencesBySource = referencesBySource(policies);
|
||||
Map<String, DocStats> docStats =
|
||||
docCounter.statsFor(sources.stream().map(Source::id).toList());
|
||||
|
||||
List<SourceView> views =
|
||||
sources.stream()
|
||||
@@ -44,7 +47,8 @@ public class SourceOverviewService {
|
||||
toView(
|
||||
source,
|
||||
referencesBySource.getOrDefault(
|
||||
source.id(), List.of())))
|
||||
source.id(), List.of()),
|
||||
docStats.getOrDefault(source.id(), DocStats.ZERO)))
|
||||
.sorted(
|
||||
Comparator.comparingInt(SourceView::referenceCount)
|
||||
.reversed()
|
||||
@@ -54,6 +58,14 @@ public class SourceOverviewService {
|
||||
return new SourcesResponse(buildKpis(views), views);
|
||||
}
|
||||
|
||||
/**
|
||||
* The 30-day daily document series for one source (oldest first), for the expanded row's
|
||||
* sparkline.
|
||||
*/
|
||||
public List<Long> dailySeries(String sourceId) {
|
||||
return docCounter.dailySeriesFor(sourceId);
|
||||
}
|
||||
|
||||
/** Policies referencing each source id, across the caller's visible policies. */
|
||||
private static Map<String, List<Policy>> referencesBySource(List<Policy> policies) {
|
||||
Map<String, List<Policy>> bySource = new HashMap<>();
|
||||
@@ -65,7 +77,8 @@ public class SourceOverviewService {
|
||||
return bySource;
|
||||
}
|
||||
|
||||
private static SourceView toView(Source source, List<Policy> referencingPolicies) {
|
||||
private static SourceView toView(
|
||||
Source source, List<Policy> referencingPolicies, DocStats docs) {
|
||||
List<SourceView.PolicyRef> refs =
|
||||
referencingPolicies.stream()
|
||||
.map(policy -> new SourceView.PolicyRef(policy.id(), policy.name()))
|
||||
@@ -78,7 +91,9 @@ public class SourceOverviewService {
|
||||
refs.size(),
|
||||
refs,
|
||||
configRows(source),
|
||||
null);
|
||||
docs.total(),
|
||||
docs.last24h(),
|
||||
docs.last30d());
|
||||
}
|
||||
|
||||
/** A disabled (paused) source reads as "disabled"; an unreferenced one reads as "unused". */
|
||||
|
||||
+5
-3
@@ -4,8 +4,8 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* One row in the Sources overview: a persisted input connection shown exactly once, with how many
|
||||
* policies reference it (and which). {@code docsTotal} is {@code null} - per-source document volume
|
||||
* is not tracked yet; the field is reserved so a later doc-accounting pass is additive.
|
||||
* policies reference it (and which) and how many documents it has fed into runs ({@code docsTotal}
|
||||
* lifetime plus the trailing 24-hour and 30-day windows).
|
||||
*/
|
||||
public record SourceView(
|
||||
String id,
|
||||
@@ -15,7 +15,9 @@ public record SourceView(
|
||||
int referenceCount,
|
||||
List<PolicyRef> referencingPolicies,
|
||||
List<DetailRow> config,
|
||||
Long docsTotal) {
|
||||
long docsTotal,
|
||||
long docs24h,
|
||||
long docs30d) {
|
||||
|
||||
/** A policy that references this source. */
|
||||
public record PolicyRef(String id, String name) {}
|
||||
|
||||
+6
-2
@@ -33,8 +33,10 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.storage.repository",
|
||||
"stirling.software.proprietary.workflow.repository",
|
||||
"stirling.software.proprietary.policy.store",
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.policy.source"
|
||||
"stirling.software.proprietary.access.repository",
|
||||
"stirling.software.proprietary.integration.repository"
|
||||
})
|
||||
@EntityScan({
|
||||
"stirling.software.proprietary.security.model",
|
||||
@@ -42,8 +44,10 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
"stirling.software.proprietary.storage.model",
|
||||
"stirling.software.proprietary.workflow.model",
|
||||
"stirling.software.proprietary.policy.store",
|
||||
"stirling.software.proprietary.policy.source",
|
||||
"stirling.software.proprietary.accountlink",
|
||||
"stirling.software.proprietary.policy.source"
|
||||
"stirling.software.proprietary.access.model",
|
||||
"stirling.software.proprietary.integration.model"
|
||||
})
|
||||
public class DatabaseConfig {
|
||||
|
||||
|
||||
+3
@@ -28,6 +28,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.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
@@ -64,6 +65,7 @@ public class AuthController {
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final AiUserDataService aiUserDataService;
|
||||
private final ResourceAccessService resourceAccessService;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@@ -628,6 +630,7 @@ public class AuthController {
|
||||
userMap.put("username", user.getUsername());
|
||||
userMap.put("role", user.getRolesAsString());
|
||||
userMap.put("enabled", user.isEnabled());
|
||||
userMap.put("portalAccess", resourceAccessService.canAccessPortal(user));
|
||||
userMap.put(
|
||||
"authenticationType",
|
||||
user.getAuthenticationType()); // Expose authentication type for SSO detection
|
||||
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
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.when;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
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.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.proprietary.access.model.OwnedResource;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OwnershipServiceTest {
|
||||
|
||||
private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG;
|
||||
|
||||
@Mock private ResourceAccessService accessService;
|
||||
@Mock private TeamLeadLookup teamLeadLookup;
|
||||
@Mock private TeamRepository teamRepository;
|
||||
|
||||
@InjectMocks private OwnershipService ownership;
|
||||
|
||||
/** Minimal concrete OwnedResource for exercising the base behaviour. */
|
||||
static class TestResource extends OwnedResource {
|
||||
private final Long id;
|
||||
|
||||
TestResource(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- scope-based create authorization ----
|
||||
|
||||
@Test
|
||||
void userScopeSetsOwner() {
|
||||
User user = user(7);
|
||||
TestResource r = new TestResource(1L);
|
||||
|
||||
ownership.assignOwnership(r, OwnerScope.USER, null, user, () -> false);
|
||||
|
||||
assertThat(r.getScope()).isEqualTo(OwnerScope.USER);
|
||||
assertThat(r.getOwnerUser()).isSameAs(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void userScopeBlockedByLockedServerOverride() {
|
||||
assertForbidden(
|
||||
() ->
|
||||
ownership.assignOwnership(
|
||||
new TestResource(1L), OwnerScope.USER, null, user(7), () -> true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminMayCreateServerScope() {
|
||||
TestResource r = new TestResource(1L);
|
||||
ownership.assignOwnership(r, OwnerScope.SERVER, null, admin(1), () -> false);
|
||||
assertThat(r.getOwnerUser()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminCannotCreateServerScope() {
|
||||
assertForbidden(
|
||||
() ->
|
||||
ownership.assignOwnership(
|
||||
new TestResource(1L),
|
||||
OwnerScope.SERVER,
|
||||
null,
|
||||
user(7),
|
||||
() -> false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamLeaderMayCreateTeamScope() {
|
||||
Team team = new Team();
|
||||
team.setId(5L);
|
||||
User user = user(7);
|
||||
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
|
||||
when(teamLeadLookup.isLeaderOfTeam(user, 5L)).thenReturn(true);
|
||||
|
||||
TestResource r = new TestResource(1L);
|
||||
ownership.assignOwnership(r, OwnerScope.TEAM, 5L, user, () -> false);
|
||||
|
||||
assertThat(r.getOwnerTeam()).isSameAs(team);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonLeaderCannotCreateTeamScope() {
|
||||
Team team = new Team();
|
||||
team.setId(5L);
|
||||
when(teamRepository.findById(5L)).thenReturn(Optional.of(team));
|
||||
|
||||
assertForbidden(
|
||||
() ->
|
||||
ownership.assignOwnership(
|
||||
new TestResource(1L), OwnerScope.TEAM, 5L, user(7), () -> false));
|
||||
}
|
||||
|
||||
// ---- use / manage ----
|
||||
|
||||
@Test
|
||||
void enabledResourceUseDelegatesToTheAcl() {
|
||||
TestResource r = new TestResource(1L);
|
||||
User user = user(7);
|
||||
when(accessService.canUseResource(any(), any(), any(), any(), any())).thenReturn(true);
|
||||
|
||||
assertThat(ownership.canUse(TYPE, r, user)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledResourceUsableOnlyByOwnerOrAdmin() {
|
||||
TestResource r = new TestResource(1L);
|
||||
r.setEnabled(false);
|
||||
r.setOwnerUser(user(7));
|
||||
|
||||
assertThat(ownership.canUse(TYPE, r, user(7))).isTrue(); // owner
|
||||
assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // someone else
|
||||
assertThat(ownership.canUse(TYPE, r, admin(2))).isTrue(); // admin
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledTeamResourceUsableByLeaderOfOwningTeam() {
|
||||
Team team = new Team();
|
||||
team.setId(5L);
|
||||
TestResource r = new TestResource(1L);
|
||||
r.setEnabled(false);
|
||||
r.setOwnerTeam(team);
|
||||
User leader = user(7);
|
||||
when(teamLeadLookup.isLeaderOfTeam(leader, 5L)).thenReturn(true);
|
||||
|
||||
assertThat(ownership.canUse(TYPE, r, leader)).isTrue(); // lead of the owning team
|
||||
assertThat(ownership.canUse(TYPE, r, user(8))).isFalse(); // not a lead
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void assertForbidden(org.assertj.core.api.ThrowableAssert.ThrowingCallable call) {
|
||||
assertThatThrownBy(call)
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
private User user(long id) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setUsername("user" + id);
|
||||
return u;
|
||||
}
|
||||
|
||||
private User admin(long id) {
|
||||
User u = user(id);
|
||||
new Authority("ROLE_ADMIN", u);
|
||||
return u;
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
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.PrincipalType;
|
||||
import stirling.software.proprietary.access.model.ResourceGrant;
|
||||
import stirling.software.proprietary.access.model.ResourceType;
|
||||
import stirling.software.proprietary.access.repository.ResourceGrantRepository;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ResourceAccessServiceTest {
|
||||
|
||||
private static final ResourceType TYPE = ResourceType.INTEGRATION_CONFIG;
|
||||
private static final String RID = "42";
|
||||
|
||||
@Mock private ResourceGrantRepository grantRepository;
|
||||
@Mock private TeamLeadLookup teamLeadLookup;
|
||||
|
||||
@InjectMocks private ResourceAccessService service;
|
||||
|
||||
@BeforeEach
|
||||
void setPortalDefault() throws Exception {
|
||||
Field f = ResourceAccessService.class.getDeclaredField("portalDefaultPolicy");
|
||||
f.setAccessible(true);
|
||||
f.set(service, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS);
|
||||
}
|
||||
|
||||
// ---- owner / admin short-circuits ----
|
||||
|
||||
@Test
|
||||
void adminMayUseEvenWithExplicitOnlyAndNoGrants() {
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, admin(1)))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerMayUseEvenWithExplicitOnly() {
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE, RID, 5L, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullUserIsAlwaysDenied() {
|
||||
assertThat(service.canUseResource(TYPE, RID, 5L, DefaultAccessPolicy.ORG_ALL, null))
|
||||
.isFalse();
|
||||
assertThat(service.canManageResource(TYPE, RID, 5L, null)).isFalse();
|
||||
}
|
||||
|
||||
// ---- explicit grants ----
|
||||
|
||||
@Test
|
||||
void explicitUserGrantAllowsUse() {
|
||||
stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.USE));
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamGrantAllowsUseForTeamMember() {
|
||||
stubGrants(grant(PrincipalType.TEAM, 7L, AccessPermission.USE));
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE,
|
||||
RID,
|
||||
null,
|
||||
DefaultAccessPolicy.EXPLICIT_ONLY,
|
||||
userInTeam(5, 7)))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamGrantDoesNotLeakToOtherTeams() {
|
||||
stubGrants(grant(PrincipalType.TEAM, 7L, AccessPermission.USE));
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE,
|
||||
RID,
|
||||
null,
|
||||
DefaultAccessPolicy.EXPLICIT_ONLY,
|
||||
userInTeam(5, 99)))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void manageGrantImpliesUse() {
|
||||
stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.MANAGE));
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void useGrantDoesNotImplyManage() {
|
||||
stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.USE));
|
||||
assertThat(service.canManageResource(TYPE, RID, null, user(5))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void manageGrantAllowsManage() {
|
||||
stubGrants(grant(PrincipalType.USER, 5L, AccessPermission.MANAGE));
|
||||
assertThat(service.canManageResource(TYPE, RID, null, user(5))).isTrue();
|
||||
}
|
||||
|
||||
// ---- default policies ----
|
||||
|
||||
@Test
|
||||
void orgAllDefaultAllowsAnyUser() {
|
||||
stubGrants();
|
||||
assertThat(service.canUseResource(TYPE, RID, null, DefaultAccessPolicy.ORG_ALL, user(5)))
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitOnlyDefaultDeniesUngrantedUser() {
|
||||
stubGrants();
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE, RID, null, DefaultAccessPolicy.EXPLICIT_ONLY, user(5)))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void teamLeadDefaultAllowsLeaderButNotRegularUser() {
|
||||
stubGrants();
|
||||
User leader = user(5);
|
||||
when(teamLeadLookup.isAnyTeamLeader(leader)).thenReturn(true);
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE, RID, null, DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS, leader))
|
||||
.isTrue();
|
||||
|
||||
stubGrants();
|
||||
assertThat(
|
||||
service.canUseResource(
|
||||
TYPE,
|
||||
RID,
|
||||
null,
|
||||
DefaultAccessPolicy.ADMINS_AND_TEAM_LEADS,
|
||||
user(6)))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
// ---- portal convenience (default policy ADMINS_AND_TEAM_LEADS) ----
|
||||
|
||||
@Test
|
||||
void portalAccessibleByAdmin() {
|
||||
assertThat(service.canAccessPortal(admin(1))).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void portalDeniedToRegularUser() {
|
||||
when(grantRepository.findByResourceTypeAndResourceId(ResourceType.PORTAL, ""))
|
||||
.thenReturn(List.of());
|
||||
assertThat(service.canAccessPortal(user(5))).isFalse();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void stubGrants(ResourceGrant... grants) {
|
||||
when(grantRepository.findByResourceTypeAndResourceId(TYPE, RID))
|
||||
.thenReturn(List.of(grants));
|
||||
}
|
||||
|
||||
private ResourceGrant grant(PrincipalType type, long principalId, AccessPermission permission) {
|
||||
ResourceGrant g = new ResourceGrant();
|
||||
g.setResourceType(TYPE);
|
||||
g.setResourceId(RID);
|
||||
g.setPrincipalType(type);
|
||||
g.setPrincipalId(principalId);
|
||||
g.setPermission(permission);
|
||||
return g;
|
||||
}
|
||||
|
||||
private User user(long id) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setUsername("user" + id);
|
||||
return u;
|
||||
}
|
||||
|
||||
private User admin(long id) {
|
||||
User u = user(id);
|
||||
new Authority("ROLE_ADMIN", u);
|
||||
return u;
|
||||
}
|
||||
|
||||
private User userInTeam(long id, long teamId) {
|
||||
User u = user(id);
|
||||
Team team = new Team();
|
||||
team.setId(teamId);
|
||||
u.setTeam(team);
|
||||
return u;
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package stirling.software.proprietary.access.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SecretMaskerTest {
|
||||
|
||||
private final SecretMasker masker = new SecretMasker();
|
||||
|
||||
@Test
|
||||
void maskHidesFlatAndNestedSecretsButKeepsPlainFields() {
|
||||
Map<String, Object> nested = new LinkedHashMap<>();
|
||||
nested.put("host", "h.example");
|
||||
nested.put("secretKey", "sk-real");
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("bucket", "acme");
|
||||
config.put("accessKey", "AKIA123");
|
||||
config.put("connection", nested); // non-sensitive parent, sensitive child
|
||||
|
||||
Map<String, Object> masked = masker.mask(config);
|
||||
|
||||
assertThat(masked.get("bucket")).isEqualTo("acme");
|
||||
assertThat(masked.get("accessKey")).isEqualTo(SecretMasker.MASK);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> conn = (Map<String, Object>) masked.get("connection");
|
||||
assertThat(conn.get("host")).isEqualTo("h.example");
|
||||
assertThat(conn.get("secretKey")).isEqualTo(SecretMasker.MASK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeKeepsStoredSecretWhenIncomingIsMasked() {
|
||||
Map<String, Object> stored = Map.of("bucket", "old", "secretKey", "REAL");
|
||||
Map<String, Object> incoming = Map.of("bucket", "new", "secretKey", SecretMasker.MASK);
|
||||
|
||||
Map<String, Object> merged = masker.merge(stored, incoming);
|
||||
|
||||
assertThat(merged.get("bucket")).isEqualTo("new"); // non-secret updated
|
||||
assertThat(merged.get("secretKey")).isEqualTo("REAL"); // secret retained
|
||||
}
|
||||
|
||||
@Test
|
||||
void mergeAcceptsARealNewSecret() {
|
||||
Map<String, Object> merged =
|
||||
masker.merge(Map.of("secretKey", "OLD"), Map.of("secretKey", "NEW"));
|
||||
assertThat(merged.get("secretKey")).isEqualTo("NEW");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sanitizeDropsBlankSecretsOnCreate() {
|
||||
Map<String, Object> incoming = new LinkedHashMap<>();
|
||||
incoming.put("bucket", "b");
|
||||
incoming.put("secretKey", "");
|
||||
|
||||
Map<String, Object> clean = masker.sanitize(incoming);
|
||||
|
||||
assertThat(clean).containsEntry("bucket", "b").doesNotContainKey("secretKey");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deeplyNestedInputIsBoundedNotOverflowing() {
|
||||
// Build a structure far deeper than the recursion cap.
|
||||
Map<String, Object> root = new LinkedHashMap<>();
|
||||
Map<String, Object> cur = root;
|
||||
for (int i = 0; i < 2000; i++) {
|
||||
Map<String, Object> next = new LinkedHashMap<>();
|
||||
cur.put("child", next);
|
||||
cur = next;
|
||||
}
|
||||
cur.put("secretKey", "deep");
|
||||
|
||||
// Must return (bounded recursion), not throw StackOverflowError.
|
||||
assertThat(masker.mask(root)).isNotNull();
|
||||
assertThat(masker.sanitize(root)).isNotNull();
|
||||
assertThat(masker.merge(root, root)).isNotNull();
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class CredentialEncryptionTest {
|
||||
|
||||
@BeforeAll
|
||||
static void initKey() throws Exception {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(256);
|
||||
SecretKey key = generator.generateKey();
|
||||
CredentialEncryption.initialiseForTesting(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripRecoversPlaintext() {
|
||||
String plaintext = "s3-secret-key-ABC123/+=value";
|
||||
String encrypted = CredentialEncryption.encrypt(plaintext);
|
||||
|
||||
assertThat(encrypted).isNotNull().isNotEqualTo(plaintext);
|
||||
assertThat(CredentialEncryption.decrypt(encrypted)).isEqualTo(plaintext);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameInputProducesDifferentCiphertext() {
|
||||
String plaintext = "repeated-secret";
|
||||
|
||||
// Random IV per encryption => ciphertext must differ, but both decrypt back.
|
||||
String first = CredentialEncryption.encrypt(plaintext);
|
||||
String second = CredentialEncryption.encrypt(plaintext);
|
||||
|
||||
assertThat(first).isNotEqualTo(second);
|
||||
assertThat(CredentialEncryption.decrypt(first)).isEqualTo(plaintext);
|
||||
assertThat(CredentialEncryption.decrypt(second)).isEqualTo(plaintext);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullsPassThrough() {
|
||||
assertThat(CredentialEncryption.encrypt(null)).isNull();
|
||||
assertThat(CredentialEncryption.decrypt(null)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tamperedCiphertextIsRejected() {
|
||||
// GCM is authenticated: flipping a byte of the stored blob must fail decryption, not
|
||||
// silently return corrupted plaintext.
|
||||
String encrypted = CredentialEncryption.encrypt("top-secret");
|
||||
byte[] raw = Base64.getDecoder().decode(encrypted);
|
||||
raw[raw.length - 1] ^= 0x01;
|
||||
String tampered = Base64.getEncoder().encodeToString(raw);
|
||||
|
||||
assertThatThrownBy(() -> CredentialEncryption.decrypt(tampered))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package stirling.software.proprietary.integration.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.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
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.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.proprietary.access.model.DefaultAccessPolicy;
|
||||
import stirling.software.proprietary.access.model.OwnerScope;
|
||||
import stirling.software.proprietary.access.service.OwnershipService;
|
||||
import stirling.software.proprietary.access.service.SecretMasker;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigRequest;
|
||||
import stirling.software.proprietary.integration.dto.IntegrationConfigResponse;
|
||||
import stirling.software.proprietary.integration.model.IntegrationConfig;
|
||||
import stirling.software.proprietary.integration.model.IntegrationType;
|
||||
import stirling.software.proprietary.integration.repository.IntegrationConfigRepository;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
import tools.jackson.core.type.TypeReference;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* Integration-glue tests. Ownership authorization and secret masking are covered by {@code
|
||||
* OwnershipServiceTest} / {@code SecretMaskerTest}; here those collaborators are mocked and we
|
||||
* assert this service delegates to them correctly.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IntegrationConfigServiceTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Mock private IntegrationConfigRepository repository;
|
||||
@Mock private OwnershipService ownership;
|
||||
@Mock private SecretMasker secretMasker;
|
||||
|
||||
@InjectMocks private IntegrationConfigService service;
|
||||
|
||||
@Test
|
||||
void createDelegatesOwnershipAndSanitizesConfig() {
|
||||
when(secretMasker.sanitize(any())).thenReturn(Map.of("bucket", "b"));
|
||||
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
User user = user(7);
|
||||
|
||||
IntegrationConfig created =
|
||||
service.create(request(IntegrationType.S3, OwnerScope.USER, null), user);
|
||||
|
||||
assertThat(created.getIntegrationType()).isEqualTo(IntegrationType.S3);
|
||||
assertThat(created.getName()).isEqualTo("name");
|
||||
verify(ownership)
|
||||
.assignOwnership(eq(created), eq(OwnerScope.USER), isNull(), eq(user), any());
|
||||
verify(secretMasker).sanitize(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateMergesConfigViaSecretMasker() throws Exception {
|
||||
IntegrationConfig cfg = config(2L);
|
||||
cfg.setConfig("{\"bucket\":\"old\",\"secretKey\":\"REAL\"}");
|
||||
when(repository.findById(2L)).thenReturn(Optional.of(cfg));
|
||||
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
|
||||
when(secretMasker.merge(any(), any()))
|
||||
.thenReturn(Map.of("bucket", "new", "secretKey", "REAL"));
|
||||
when(repository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
IntegrationConfigRequest req =
|
||||
new IntegrationConfigRequest(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Map.of("bucket", "new", "secretKey", "********"));
|
||||
service.update(2L, req, user(7));
|
||||
|
||||
Map<String, Object> stored =
|
||||
MAPPER.readValue(cfg.getConfig(), new TypeReference<Map<String, Object>>() {});
|
||||
assertThat(stored.get("bucket")).isEqualTo("new");
|
||||
assertThat(stored.get("secretKey")).isEqualTo("REAL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateForbiddenWhenCannotManage() {
|
||||
IntegrationConfig cfg = config(3L);
|
||||
when(repository.findById(3L)).thenReturn(Optional.of(cfg));
|
||||
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
service.update(
|
||||
3L,
|
||||
request(IntegrationType.S3, OwnerScope.USER, null),
|
||||
user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toResponseMasksConfig() {
|
||||
IntegrationConfig cfg = config(4L);
|
||||
cfg.setConfig("{\"secretKey\":\"x\"}");
|
||||
when(secretMasker.mask(any())).thenReturn(Map.of("secretKey", SecretMasker.MASK));
|
||||
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(false);
|
||||
|
||||
IntegrationConfigResponse resp = service.toResponse(cfg, user(7));
|
||||
|
||||
assertThat(resp.config().get("secretKey")).isEqualTo(SecretMasker.MASK);
|
||||
assertThat(resp.canManage()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listIncludesAConfigSharedViaAnExplicitGrant() {
|
||||
User user = user(7);
|
||||
when(repository.findByOwnerUser(user)).thenReturn(List.of());
|
||||
when(repository.findByScope(OwnerScope.SERVER)).thenReturn(List.of());
|
||||
when(ownership.grantedResourceIds(any(), eq(user))).thenReturn(Set.of("99"));
|
||||
IntegrationConfig shared = config(99L);
|
||||
when(repository.findById(99L)).thenReturn(Optional.of(shared));
|
||||
when(ownership.canUse(any(), eq(shared), eq(user))).thenReturn(true);
|
||||
|
||||
List<IntegrationConfig> visible = service.listVisible(user);
|
||||
|
||||
assertThat(visible).extracting(IntegrationConfig::getId).contains(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonAdminCannotChangeLockedFlag() {
|
||||
IntegrationConfig cfg = config(5L); // locked = false
|
||||
when(repository.findById(5L)).thenReturn(Optional.of(cfg));
|
||||
when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true);
|
||||
when(ownership.isAdmin(any())).thenReturn(false);
|
||||
|
||||
IntegrationConfigRequest req =
|
||||
new IntegrationConfigRequest(null, null, null, null, null, true, null, null);
|
||||
|
||||
assertThatThrownBy(() -> service.update(5L, req, user(7)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private IntegrationConfig config(long id) {
|
||||
IntegrationConfig cfg = new IntegrationConfig();
|
||||
cfg.setId(id);
|
||||
cfg.setIntegrationType(IntegrationType.S3);
|
||||
cfg.setName("cfg" + id);
|
||||
cfg.setEnabled(true);
|
||||
cfg.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
private IntegrationConfigRequest request(IntegrationType type, OwnerScope scope, Long teamId) {
|
||||
return new IntegrationConfigRequest(
|
||||
type, "name", scope, teamId, null, null, null, Map.of("bucket", "b"));
|
||||
}
|
||||
|
||||
private User user(long id) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setUsername("user" + id);
|
||||
return u;
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -33,6 +33,7 @@ import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.model.PolicyRun;
|
||||
import stirling.software.proprietary.policy.model.PolicyRunStatus;
|
||||
import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceDocCounter;
|
||||
import stirling.software.proprietary.policy.source.InProcessSourceStore;
|
||||
import stirling.software.proprietary.policy.source.Source;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
@@ -53,7 +54,12 @@ class PolicyRunnerTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
runner = new PolicyRunner(policyEngine, List.of(folderSource), sourceStore);
|
||||
runner =
|
||||
new PolicyRunner(
|
||||
policyEngine,
|
||||
List.of(folderSource),
|
||||
sourceStore,
|
||||
new InProcessSourceDocCounter());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Tests the rolling-window aggregation: documents recorded at different times roll into the total,
|
||||
* the 24-hour / 30-day windows, and the daily series correctly as the clock advances.
|
||||
*/
|
||||
class InProcessSourceDocCounterTest {
|
||||
|
||||
private static InProcessSourceDocCounter seededCounter(AtomicReference<Instant> clock) {
|
||||
InProcessSourceDocCounter counter = new InProcessSourceDocCounter(clock::get);
|
||||
// 40 days ago: outside the 30-day window.
|
||||
clock.set(Instant.parse("2026-05-21T12:00:00Z"));
|
||||
counter.record("s", 100);
|
||||
// 10 days ago: inside 30 days, outside 24 hours.
|
||||
clock.set(Instant.parse("2026-06-20T12:00:00Z"));
|
||||
counter.record("s", 50);
|
||||
// 2 hours ago: inside both windows.
|
||||
clock.set(Instant.parse("2026-06-30T10:00:00Z"));
|
||||
counter.record("s", 7);
|
||||
// Query as of "now".
|
||||
clock.set(Instant.parse("2026-06-30T12:00:00Z"));
|
||||
return counter;
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollsCountsIntoTotalAnd24hAnd30dWindows() {
|
||||
AtomicReference<Instant> clock = new AtomicReference<>();
|
||||
DocStats stats = seededCounter(clock).statsFor(List.of("s")).get("s");
|
||||
|
||||
assertEquals(157, stats.total());
|
||||
assertEquals(7, stats.last24h());
|
||||
assertEquals(57, stats.last30d());
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildsTheDailySeriesOldestFirst() {
|
||||
AtomicReference<Instant> clock = new AtomicReference<>();
|
||||
// Today (index 29) and 10 days ago (index 19) only; 40 days ago is outside the window.
|
||||
List<Long> series = seededCounter(clock).dailySeriesFor("s");
|
||||
|
||||
assertEquals(30, series.size());
|
||||
assertEquals(7L, series.get(29));
|
||||
assertEquals(50L, series.get(19));
|
||||
assertEquals(57L, series.stream().mapToLong(Long::longValue).sum());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSourceWithNoRecordedDocsIsZero() {
|
||||
InProcessSourceDocCounter counter = new InProcessSourceDocCounter();
|
||||
assertEquals(DocStats.ZERO, counter.statsFor(List.of("unknown")).get("unknown"));
|
||||
assertEquals(Collections.nCopies(DocStats.DAYS, 0L), counter.dailySeriesFor("unknown"));
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Exercises {@link JpaSourceDocCounter} against a real (H2) database so the daily-aggregate query
|
||||
* ({@code cast(floor(bucketHour / 24.0) as long)} grouping) and the record upsert are actually run,
|
||||
* not just asserted against a mock. The unit-level window math lives in {@link
|
||||
* InProcessSourceDocCounterTest}.
|
||||
*/
|
||||
@DataJpaTest
|
||||
class JpaSourceDocCounterDbTest {
|
||||
|
||||
// Pinned "now" so seeding and statsFor agree regardless of when the test runs (no hour-tick
|
||||
// flake at the boundary of a wall-clock hour).
|
||||
private static final Instant NOW = Instant.parse("2026-06-30T12:00:00Z");
|
||||
private static final long NOW_HOUR = NOW.getEpochSecond() / 3600;
|
||||
|
||||
@Autowired private SourceDocCountRepository repository;
|
||||
@Autowired private SourceDocTotalRepository totalRepository;
|
||||
|
||||
private JpaSourceDocCounter counter() {
|
||||
return new JpaSourceDocCounter(repository, totalRepository, () -> NOW);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordIncrementsBothTheHourlyBucketAndTheLifetimeTotal() {
|
||||
JpaSourceDocCounter counter = counter();
|
||||
counter.record("s", 5);
|
||||
counter.record("s", 3);
|
||||
|
||||
DocStats stats = counter.statsFor(List.of("s")).get("s");
|
||||
assertEquals(8, stats.total()); // from the denormalized lifetime row
|
||||
assertEquals(8, stats.last24h());
|
||||
assertEquals(8, stats.last30d());
|
||||
assertEquals(8L, counter.dailySeriesFor("s").get(DocStats.DAYS - 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void statsBucketDocsByDayAndWindowFromTheDatabase() {
|
||||
// Seed buckets directly at controlled hours: today, 10 days ago, 40 days ago, plus the
|
||||
// lifetime row (record() would write it, but here we seed history directly).
|
||||
repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR, 7));
|
||||
repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR - 24L * 10, 50));
|
||||
repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR - 24L * 40, 100));
|
||||
totalRepository.saveAndFlush(new SourceDocTotalEntity("s", 157));
|
||||
|
||||
DocStats stats = counter().statsFor(List.of("s")).get("s");
|
||||
assertEquals(157, stats.total()); // lifetime, including the out-of-window 40-days-ago docs
|
||||
assertEquals(7, stats.last24h());
|
||||
assertEquals(57, stats.last30d());
|
||||
|
||||
// The daily series (fetched separately, per source) covers the same 30-day window.
|
||||
List<Long> series = counter().dailySeriesFor("s");
|
||||
assertEquals(DocStats.DAYS, series.size());
|
||||
assertEquals(7L, series.get(DocStats.DAYS - 1)); // today
|
||||
assertEquals(50L, series.get(DocStats.DAYS - 11)); // 10 days ago
|
||||
assertEquals(57L, series.stream().mapToLong(Long::longValue).sum());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pruneRetiresOutOfWindowBucketsButKeepsTheLifetimeTotal() {
|
||||
repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR, 7)); // today
|
||||
repository.saveAndFlush(new SourceDocCountEntity("s", NOW_HOUR - 24L * 40, 100)); // 40d ago
|
||||
totalRepository.saveAndFlush(new SourceDocTotalEntity("s", 107));
|
||||
|
||||
counter().pruneOldBuckets();
|
||||
|
||||
assertEquals(1, repository.count()); // only the in-window bucket remains
|
||||
DocStats stats = counter().statsFor(List.of("s")).get("s");
|
||||
assertEquals(107, stats.total()); // lifetime survives pruning
|
||||
assertEquals(7, stats.last24h());
|
||||
assertEquals(7, stats.last30d());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSourceWithNoRecordedDocsIsZero() {
|
||||
assertEquals(DocStats.ZERO, counter().statsFor(List.of("unknown")).get("unknown"));
|
||||
}
|
||||
|
||||
@SpringBootConfiguration
|
||||
@AutoConfigurationPackage
|
||||
static class TestApp {}
|
||||
}
|
||||
+6
-1
@@ -50,7 +50,12 @@ class SourceControllerTest {
|
||||
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
|
||||
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
|
||||
SourceOverviewService overviewService =
|
||||
new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
|
||||
new SourceOverviewService(
|
||||
sourceStore,
|
||||
policyStore,
|
||||
sourceGuard,
|
||||
policyGuard,
|
||||
new InProcessSourceDocCounter());
|
||||
triggerManager = mock(PolicyTriggerManager.class);
|
||||
// A permissive input source so config validation passes and save can be exercised.
|
||||
InputSource folderInput = mock(InputSource.class);
|
||||
|
||||
+18
-5
@@ -1,7 +1,6 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -31,6 +30,7 @@ class SourceOverviewServiceTest {
|
||||
|
||||
private final SourceStore sourceStore = new InProcessSourceStore();
|
||||
private final PolicyStore policyStore = new InProcessPolicyStore();
|
||||
private final SourceDocCounter docCounter = new InProcessSourceDocCounter();
|
||||
private SourceOverviewService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -41,7 +41,9 @@ class SourceOverviewServiceTest {
|
||||
PolicyManagementAuthority authority = mock(PolicyManagementAuthority.class);
|
||||
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
|
||||
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
|
||||
service = new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
|
||||
service =
|
||||
new SourceOverviewService(
|
||||
sourceStore, policyStore, sourceGuard, policyGuard, docCounter);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -111,7 +113,8 @@ class SourceOverviewServiceTest {
|
||||
SourceAccessGuard sourceGuard = new SourceAccessGuard(userService, properties, authority);
|
||||
PolicyAccessGuard policyGuard = new PolicyAccessGuard(userService, properties, authority);
|
||||
SourceOverviewService scoped =
|
||||
new SourceOverviewService(sourceStore, policyStore, sourceGuard, policyGuard);
|
||||
new SourceOverviewService(
|
||||
sourceStore, policyStore, sourceGuard, policyGuard, docCounter);
|
||||
|
||||
Source ours = teamSource("Ours", "/ours", 1L);
|
||||
teamSource("Theirs", "/theirs", 2L);
|
||||
@@ -128,9 +131,19 @@ class SourceOverviewServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentVolumeIsNotTrackedYet() {
|
||||
void documentCountsReflectRecordedDocs() {
|
||||
Source a = source("A", "/a");
|
||||
assertNull(find(service.overview(), a.id()).docsTotal());
|
||||
Source b = source("B", "/b");
|
||||
docCounter.record(a.id(), 5);
|
||||
docCounter.record(a.id(), 3);
|
||||
|
||||
SourceView av = find(service.overview(), a.id());
|
||||
assertEquals(8, av.docsTotal());
|
||||
assertEquals(8, av.docs24h());
|
||||
assertEquals(8, av.docs30d());
|
||||
|
||||
// A source with no recorded documents reads as zero, not null.
|
||||
assertEquals(0, find(service.overview(), b.id()).docsTotal());
|
||||
}
|
||||
|
||||
private Source source(String name, String directory) {
|
||||
|
||||
+4
-1
@@ -28,6 +28,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.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
@@ -58,6 +59,7 @@ class AuthControllerLoginTest {
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
@Mock private RefreshRateLimitService refreshRateLimitService;
|
||||
@Mock private ResourceAccessService resourceAccessService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
@@ -80,7 +82,8 @@ class AuthControllerLoginTest {
|
||||
refreshRateLimitService,
|
||||
securityProperties,
|
||||
applicationProperties,
|
||||
new stirling.software.proprietary.service.AiUserDataService(null));
|
||||
new stirling.software.proprietary.service.AiUserDataService(null),
|
||||
resourceAccessService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -28,6 +28,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.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
@@ -59,6 +60,7 @@ class AuthControllerMoreTest {
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
@Mock private RefreshRateLimitService refreshRateLimitService;
|
||||
@Mock private ResourceAccessService resourceAccessService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
@@ -81,7 +83,8 @@ class AuthControllerMoreTest {
|
||||
refreshRateLimitService,
|
||||
securityProperties,
|
||||
applicationProperties,
|
||||
new stirling.software.proprietary.service.AiUserDataService(null));
|
||||
new stirling.software.proprietary.service.AiUserDataService(null),
|
||||
resourceAccessService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Per-source document throughput, in two tables:
|
||||
--
|
||||
-- policy_source_doc_counts one row per source per hour bucket (hours-since-epoch), holding how
|
||||
-- many documents that source fed into runs in that hour. Feeds the
|
||||
-- rolling last-24h / last-30d windows and the 30-day daily series, and
|
||||
-- is pruned to that window so it stays bounded.
|
||||
-- policy_source_doc_totals a denormalized lifetime total per source, incremented alongside the
|
||||
-- hourly bucket, so the overview reads the all-time figure in one row
|
||||
-- instead of scanning a source's whole bucket history - and so the
|
||||
-- hourly buckets can be pruned without losing it.
|
||||
--
|
||||
-- Gated by policies.enabled like the rest of the subsystem; Hibernate ddl-auto would also create
|
||||
-- these, but the migration keeps the schema explicit for the Flyway-managed deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_source_doc_counts (
|
||||
source_id VARCHAR(255) NOT NULL,
|
||||
bucket_hour BIGINT NOT NULL,
|
||||
doc_count BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (source_id, bucket_hour)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS policy_source_doc_totals (
|
||||
source_id VARCHAR(255) NOT NULL,
|
||||
doc_total BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (source_id)
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Resource access grants: which user/team may use a gated resource (portal, integration config).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resource_grants (
|
||||
resource_grant_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
resource_type VARCHAR(64) NOT NULL,
|
||||
resource_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
principal_type VARCHAR(32) NOT NULL,
|
||||
principal_id BIGINT NOT NULL,
|
||||
permission VARCHAR(32) NOT NULL,
|
||||
granted_by_user_id BIGINT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_resource_grant
|
||||
ON resource_grants (resource_type, resource_id, principal_type, principal_id, permission);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_lookup
|
||||
ON resource_grants (resource_type, resource_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_resource_grants_principal
|
||||
ON resource_grants (principal_type, principal_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- S3/MCP/API integration configs; config_encrypted holds an AES-GCM encrypted JSON blob.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS integration_configs (
|
||||
integration_config_id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
integration_type VARCHAR(32) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
scope VARCHAR(32) NOT NULL,
|
||||
owner_user_id BIGINT,
|
||||
owner_team_id BIGINT,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
locked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
default_access VARCHAR(32) NOT NULL DEFAULT 'EXPLICIT_ONLY',
|
||||
config_encrypted TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_configs_owner
|
||||
ON integration_configs (owner_user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_configs_type
|
||||
ON integration_configs (integration_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_integration_configs_scope
|
||||
ON integration_configs (scope);
|
||||
@@ -212,8 +212,12 @@ export function usePolicyAutoRun(): void {
|
||||
s.configured &&
|
||||
s.status === "active" &&
|
||||
s.backendId &&
|
||||
// Only auto-run on upload when the policy is set to run on upload
|
||||
// (export-triggered policies enforce at export time instead).
|
||||
// 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) {
|
||||
@@ -275,6 +279,7 @@ export function usePolicyAutoRun(): void {
|
||||
// input file it ran on (needs that input's stub, still in the workspace).
|
||||
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);
|
||||
void importOutputs(run, {
|
||||
addFiles,
|
||||
@@ -282,6 +287,7 @@ export function usePolicyAutoRun(): void {
|
||||
bumpRevision,
|
||||
outputMode,
|
||||
outputName,
|
||||
outputNamePosition,
|
||||
parentStub,
|
||||
}).finally(() => importing.current.delete(run.runId));
|
||||
}
|
||||
@@ -314,9 +320,11 @@ interface ImportContext {
|
||||
bumpRevision: () => void;
|
||||
/** "new_file" adds the output as a separate file; "new_version" versions the input. */
|
||||
outputMode: "new_file" | "new_version";
|
||||
/** Rename rule. Empty → keep the input's filename; set → use the policy's
|
||||
* renamed output (applied server-side per the name-position setting). */
|
||||
/** Rename rule. Empty → keep the input's filename. */
|
||||
outputName: string;
|
||||
/** Where the rename is applied: before ("prefix") or after ("suffix") the
|
||||
* base filename. Defaults to "suffix" when absent. */
|
||||
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||
/** The input file's stub — required to version it; absent if it's been removed. */
|
||||
parentStub: StirlingFileStub | undefined;
|
||||
}
|
||||
@@ -327,6 +335,20 @@ interface ImportContext {
|
||||
* 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.
|
||||
*/
|
||||
function applyOutputName(
|
||||
inputFileName: string,
|
||||
outputName: string,
|
||||
position: "prefix" | "suffix" | "auto-number",
|
||||
): string {
|
||||
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}`;
|
||||
}
|
||||
|
||||
async function reconcileServerRuns(
|
||||
policies: PoliciesByCategory,
|
||||
): Promise<void> {
|
||||
@@ -406,7 +428,11 @@ async function importOutputs(
|
||||
// rule the backend's auto-suffixed name (e.g. "_watermarked_sanitized") would
|
||||
// otherwise rename every output.
|
||||
const targetName = ctx.outputName
|
||||
? undefined // use the run's per-output (renamed) name below
|
||||
? applyOutputName(
|
||||
run.fileName,
|
||||
ctx.outputName,
|
||||
ctx.outputNamePosition ?? "suffix",
|
||||
)
|
||||
: run.fileName;
|
||||
const settled = await Promise.allSettled(
|
||||
pending.map(async (out) => {
|
||||
|
||||
@@ -134,6 +134,7 @@ export function usePolicies() {
|
||||
reviewerEmail: result.reviewerEmail,
|
||||
outputMode: result.folder.outputMode,
|
||||
outputName: result.folder.outputName,
|
||||
outputNamePosition: result.folder.outputNamePosition,
|
||||
runOn: result.folder.runOn,
|
||||
});
|
||||
},
|
||||
@@ -170,6 +171,7 @@ export function usePolicies() {
|
||||
reviewerEmail: result.reviewerEmail,
|
||||
outputMode: result.folder.outputMode,
|
||||
outputName: result.folder.outputName,
|
||||
outputNamePosition: result.folder.outputNamePosition,
|
||||
runOn: result.folder.runOn,
|
||||
});
|
||||
},
|
||||
@@ -234,6 +236,7 @@ export function usePolicies() {
|
||||
reviewerEmail: result.reviewerEmail,
|
||||
outputMode: result.folder.outputMode,
|
||||
outputName: result.folder.outputName,
|
||||
outputNamePosition: result.folder.outputNamePosition,
|
||||
runOn: result.folder.runOn,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -114,6 +114,7 @@ describe("Login", () => {
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
portalAccess: false,
|
||||
role: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -169,6 +170,7 @@ describe("Login", () => {
|
||||
displayName: mockSession.user.username,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
portalAccess: false,
|
||||
role: mockSession.user.role,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -196,6 +198,7 @@ describe("Login", () => {
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
portalAccess: false,
|
||||
role: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
@@ -53,6 +53,7 @@ export function decodedToState(
|
||||
fieldValues: decoded.fieldValues,
|
||||
outputMode: decoded.folder.outputMode,
|
||||
outputName: decoded.folder.outputName,
|
||||
outputNamePosition: decoded.folder.outputNamePosition,
|
||||
runOn: decoded.folder.runOn,
|
||||
folderId: localFolderId,
|
||||
backendId: decoded.id,
|
||||
|
||||
@@ -70,6 +70,7 @@ function activeExportPolicies(): ExportPolicy[] {
|
||||
s.configured &&
|
||||
s.status === "active" &&
|
||||
s.backendId &&
|
||||
(s.sources.length === 0 || s.sources.includes("editor")) &&
|
||||
s.runOn === "export",
|
||||
)
|
||||
.map(([id, s]) => ({
|
||||
|
||||
@@ -131,6 +131,9 @@ export interface PolicyState {
|
||||
* input's filename; when set, it's applied as a prefix/suffix per the policy's
|
||||
* name-position setting. */
|
||||
outputName?: string;
|
||||
/** Whether the rename rule is applied before ("prefix") or after ("suffix")
|
||||
* the base filename, or as an auto-incrementing number. */
|
||||
outputNamePosition?: "prefix" | "suffix" | "auto-number";
|
||||
/** When the policy runs: on "upload" or before "export". Defaults to "upload". */
|
||||
runOn?: "upload" | "export";
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,7 @@ components = "Components"
|
||||
infrastructure = "Infrastructure"
|
||||
usage = "Usage & Billing"
|
||||
docs = "Developer Docs"
|
||||
procurement = "Procurement"
|
||||
settings = "Settings"
|
||||
|
||||
[shell.header]
|
||||
@@ -329,7 +330,11 @@ subtitle = "{{type}} · {{status}}"
|
||||
closeAriaLabel = "Close detail"
|
||||
usedBy = "Used by"
|
||||
notReferenced = "Not referenced by any policy, so it's safe to delete."
|
||||
docsUntracked = "Per-source document volume isn't tracked yet."
|
||||
documents = "Documents"
|
||||
docsTotal = "Total seen"
|
||||
docs24h = "Last 24h"
|
||||
docs30d = "Last 30 days"
|
||||
docsTrend = "Documents over the last 30 days"
|
||||
edit = "Edit"
|
||||
pause = "Pause"
|
||||
resume = "Resume"
|
||||
@@ -374,6 +379,9 @@ label = "Read mode"
|
||||
consume = "Consume: process each file once"
|
||||
snapshot = "Snapshot: re-read the folder every run"
|
||||
|
||||
[sources.types.editor]
|
||||
label = "Editor"
|
||||
|
||||
[sources.types.unknown]
|
||||
label = "Source"
|
||||
|
||||
@@ -691,6 +699,11 @@ description = "{{file}} is signed and ready to transfer. It activates one instan
|
||||
title = "Policies"
|
||||
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
|
||||
|
||||
[policies.offline]
|
||||
title = "Backend unavailable"
|
||||
description = "Your policies are saved and will appear once the connection is restored."
|
||||
retry = "Retry"
|
||||
|
||||
[policies.status]
|
||||
active = "Active"
|
||||
paused = "Paused"
|
||||
@@ -719,15 +732,17 @@ description = "Across active policies"
|
||||
[policies.card]
|
||||
comingSoon = "Coming soon"
|
||||
notSetUp = "Not set up"
|
||||
setUp = "Set up →"
|
||||
|
||||
[policies.detail]
|
||||
title = "{{category}} policy"
|
||||
meta = "Runs on {{event}} · output {{output}}"
|
||||
outputAsNewFile = "as a new file"
|
||||
outputAsNewVersion = "as a new version"
|
||||
enforces = "Enforces"
|
||||
enforceNote = "{{scope}} · originals stay untouched, the enforced version is saved alongside."
|
||||
sources = "Sources"
|
||||
onEveryUpload = "On every upload"
|
||||
onEveryExport = "On every export"
|
||||
showMore = "Show more"
|
||||
showLess = "Show less"
|
||||
retry = "Retry"
|
||||
recentActivity = "Recent activity"
|
||||
|
||||
[policies.detail.actions]
|
||||
@@ -741,10 +756,6 @@ editSettings = "Edit settings"
|
||||
title = "No activity yet"
|
||||
description = "Documents will appear here once this policy runs."
|
||||
|
||||
[policies.detail.scoped]
|
||||
title = "Scoped"
|
||||
description = "Limited to: {{types}}"
|
||||
|
||||
[policies.wizard.title]
|
||||
edit = "Edit {{category}} policy"
|
||||
setUp = "Set up {{category}} policy"
|
||||
@@ -773,6 +784,9 @@ heading = "Settings"
|
||||
|
||||
[policies.wizard.sources]
|
||||
heading = "Sources"
|
||||
loading = "Loading sources…"
|
||||
emptyTitle = "No sources available"
|
||||
emptyDescription = "Connect a source on the Sources page first, then attach it to a policy here."
|
||||
|
||||
[policies.wizard.docTypes]
|
||||
heading = "Document types"
|
||||
@@ -804,9 +818,10 @@ suffix = "Suffix"
|
||||
autoNumber = "Auto-number"
|
||||
placeholder = "Text to add (optional)"
|
||||
|
||||
[policies.wizard.output.reviewerEmail]
|
||||
label = "Reviewer email"
|
||||
helper = "Low-confidence enforcements are routed here for review."
|
||||
[policies.wizard.output.retries]
|
||||
heading = "Retries"
|
||||
maxLabel = "Max retries"
|
||||
delayLabel = "Retry delay (min)"
|
||||
|
||||
[users.summary]
|
||||
members = "Members"
|
||||
@@ -1806,3 +1821,75 @@ body = "Your Stirling account session has expired. Sign in again to view billing
|
||||
loadWallet = "Couldn't load wallet"
|
||||
openStripePortal = "Couldn't open Stripe portal"
|
||||
walletUnavailable = "Wallet unavailable: {{status}} {{statusText}}"
|
||||
|
||||
[procurement]
|
||||
title = "Procurement"
|
||||
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
|
||||
enterpriseBadge = "Enterprise"
|
||||
|
||||
[procurement.journey]
|
||||
eyebrow = "Your rollout"
|
||||
title = "From trial to live, one guided path"
|
||||
subtitle = "Your solutions engineer is on every step. One next action at a time; the full checklist is below."
|
||||
engineerLabel = "Your solutions engineer"
|
||||
trialTitle = "Enterprise trial"
|
||||
daysLeft_one = "{{count}} day left"
|
||||
daysLeft_other = "{{count}} days left"
|
||||
live = "You're live on Stirling Enterprise"
|
||||
nextStep = "Next step: {{action}}"
|
||||
|
||||
[procurement.docs]
|
||||
title = "Documents"
|
||||
subtitle = "Everything you need at each step of the journey, surfaced as the deal moves through it."
|
||||
here = "You're here"
|
||||
done = "Done"
|
||||
count_one = "{{count}} doc"
|
||||
count_other = "{{count}} docs"
|
||||
supportingTitle = "Supporting your evaluation"
|
||||
supportingSubtitle = "SOC 2, security reviews, tax forms and more, ready when your security or procurement team asks. Some carry a one-time fee."
|
||||
show = "Show"
|
||||
hide = "Hide"
|
||||
optional = "Optional"
|
||||
paidAddon = "Paid add-on"
|
||||
upcoming = "Upcoming"
|
||||
|
||||
[procurement.status]
|
||||
available = "Available"
|
||||
action = "Action needed"
|
||||
pending = "Pending"
|
||||
request = "On request"
|
||||
complete = "Complete"
|
||||
|
||||
[procurement.action]
|
||||
download = "Download"
|
||||
sign = "Review & sign"
|
||||
pay = "Pay now"
|
||||
upload = "Upload"
|
||||
request = "Request"
|
||||
|
||||
[procurement.modal]
|
||||
cancel = "Cancel"
|
||||
chooseFile = "Choose file"
|
||||
noFile = "No file selected"
|
||||
signTitle = "Review and sign your agreement"
|
||||
signBody = "Opens the Stirling Enterprise Agreement for e-signature: one signature covers the MSA, order form, EULA and DPA. We countersign automatically and you advance to payment."
|
||||
signCta = "Open for signature"
|
||||
payTitle = "Confirm payment"
|
||||
payBody = "Pay your committed contract by card or bank transfer through Stripe. Your workspace provisions as soon as payment clears."
|
||||
payCta = "Continue to Stripe"
|
||||
uploadTitle = "Upload your purchase order"
|
||||
uploadBody = "Send us your PO and we invoice against it on your terms. Drag in the PDF or pick a file below."
|
||||
uploadCta = "Upload purchase order"
|
||||
requestTitle = "Request this document"
|
||||
requestBodyPaid = "This is a paid add-on. Confirm and your solutions engineer will scope it and send the paperwork."
|
||||
requestBodyFree = "We generate this on demand. Confirm and your solutions engineer will send it across shortly."
|
||||
requestCta = "Request"
|
||||
downloadTitle = "Download"
|
||||
downloadBody = "Your download will begin shortly."
|
||||
downloadCta = "Download"
|
||||
|
||||
[procurement.locked]
|
||||
eyebrow = "Enterprise only"
|
||||
title = "The procurement track opens with Enterprise"
|
||||
description = "Trial keys, committed-volume quotes, the one-signature agreement, payment, and your document ledger all live here once you start an enterprise evaluation."
|
||||
talkToSales = "Talk to sales"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EditorAdmin } from "@portal/views/EditorAdmin";
|
||||
import { Infrastructure } from "@portal/views/Infrastructure";
|
||||
import { Usage } from "@portal/views/Usage";
|
||||
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
|
||||
import { Procurement } from "@portal/views/Procurement";
|
||||
import { VIEW_PATHS } from "@portal/contexts/ViewContext";
|
||||
|
||||
export function ViewRouter() {
|
||||
@@ -27,6 +28,7 @@ export function ViewRouter() {
|
||||
<Route path={VIEW_PATHS.editor} element={<EditorAdmin />} />
|
||||
<Route path={VIEW_PATHS.infrastructure} element={<Infrastructure />} />
|
||||
<Route path={VIEW_PATHS.usage} element={<Usage />} />
|
||||
<Route path={VIEW_PATHS.procurement} element={<Procurement />} />
|
||||
<Route path={VIEW_PATHS.docs} element={<DeveloperDocs />} />
|
||||
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
|
||||
<Route path="/account-link" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
* entitlement calls. It never enters the portal — the browser is the human
|
||||
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
|
||||
*/
|
||||
import { getStoredToken } from "@shared/auth";
|
||||
import { clearStoredToken, getStoredToken } from "@shared/auth";
|
||||
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
|
||||
|
||||
@@ -156,6 +156,12 @@ async function localJson<T>(
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
signal: options.signal,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
// Stale or invalid JWT — clear it so the auth provider re-initialises and
|
||||
// shows the login screen rather than leaving the user stuck with a banner.
|
||||
clearStoredToken();
|
||||
window.dispatchEvent(new CustomEvent("jwt-available"));
|
||||
}
|
||||
return unwrap<T>(res);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,72 +1,173 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { PoliciesResponse, Policy } from "@portal/mocks/policies";
|
||||
|
||||
/**
|
||||
* Policies service layer — the backend contract.
|
||||
* Policies service layer.
|
||||
*
|
||||
* Unlike every other portal surface (which use the mock `/v1/...` base), this
|
||||
* one calls the REAL Stirling policy API base `/api/v1/policies` so it is
|
||||
* genuinely plug-and-play: drop MSW and these exact calls hit the live backend
|
||||
* (PolicyController). The list response is the portal's catalogue shape; the
|
||||
* single-policy / create / delete / run calls match the backend records.
|
||||
* The portal calls the real Stirling policy API (`/api/v1/policies`). MSW
|
||||
* intercepts these calls in dev/Storybook; dropping MSW is enough to hit the
|
||||
* live backend — no call-site changes needed.
|
||||
*
|
||||
* `fetchPolicies()` assembles the decorated catalogue client-side from the
|
||||
* backend's flat `WirePolicy[]` + `PolicyRunView[]`, mirroring the same
|
||||
* approach the editor uses for its own catalogue view.
|
||||
*/
|
||||
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import { fromWirePolicy, toWirePolicy } from "@shared/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@shared/policies/runs";
|
||||
import type { PolicyDecodedState, WirePolicy } from "@shared/policies/types";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
type CatalogueEntry,
|
||||
type DecoratedPolicy,
|
||||
type PoliciesResponse,
|
||||
type PoliciesSummary,
|
||||
type PolicySetupResult,
|
||||
type PolicyState,
|
||||
type PolicyStatus,
|
||||
} from "@portal/mocks/policies";
|
||||
import type { PolicyRunView } from "@shared/policies/types";
|
||||
|
||||
export type {
|
||||
CatalogueEntry,
|
||||
DecoratedPolicy,
|
||||
InputSpec,
|
||||
OutputSpec,
|
||||
PipelineStep,
|
||||
PoliciesResponse,
|
||||
PoliciesSummary,
|
||||
Policy,
|
||||
PolicyActivityItem,
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyDecodedState,
|
||||
PolicyField,
|
||||
PolicyFieldType,
|
||||
PolicyRowStatus,
|
||||
PolicyRunView,
|
||||
PolicySetupResult,
|
||||
PolicySource,
|
||||
PolicyState,
|
||||
PolicyStats,
|
||||
PolicyActivityItem,
|
||||
PolicyStatus,
|
||||
TriggerConfig,
|
||||
WirePolicy,
|
||||
WireOutputOptions,
|
||||
WireOutputSpec,
|
||||
} from "@portal/mocks/policies";
|
||||
export {
|
||||
ENDPOINT_LABELS,
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
POLICY_DOC_TYPES,
|
||||
POLICY_SOURCES,
|
||||
TOOL_ENDPOINTS,
|
||||
humanizeEndpoint,
|
||||
} from "@portal/mocks/policies";
|
||||
|
||||
/** GET /api/v1/policies — the catalogue + every configured policy. */
|
||||
// Re-export the wire step type under the legacy name components depend on.
|
||||
export type { WirePipelineStep as PipelineStep } from "@shared/policies/types";
|
||||
|
||||
// ── Client-side catalogue assembly ───────────────────────────────────────────
|
||||
|
||||
function decoratePolicy(
|
||||
decoded: PolicyDecodedState,
|
||||
runs: PolicyRunView[],
|
||||
isDefault: boolean,
|
||||
): DecoratedPolicy | null {
|
||||
const category = POLICY_CATEGORIES.find((c) => c.id === decoded.categoryId);
|
||||
const config = POLICY_CONFIG[decoded.categoryId];
|
||||
if (!category || !config) return null;
|
||||
|
||||
const policyRuns = runs.filter((r) => r.policyId === decoded.id);
|
||||
const status: PolicyStatus = decoded.enabled ? "active" : "paused";
|
||||
const state: PolicyState = {
|
||||
configured: true,
|
||||
status,
|
||||
sources: decoded.sources,
|
||||
scopeTypes: decoded.scopeTypes,
|
||||
reviewerEmail: decoded.reviewerEmail,
|
||||
fieldValues: decoded.fieldValues,
|
||||
outputMode: decoded.outputMode,
|
||||
outputName: decoded.outputName,
|
||||
outputNamePosition: decoded.outputNamePosition,
|
||||
runOn: decoded.runOn,
|
||||
maxRetries: decoded.maxRetries,
|
||||
retryDelayMinutes: decoded.retryDelayMinutes,
|
||||
backendId: decoded.id,
|
||||
isDefault,
|
||||
};
|
||||
|
||||
return {
|
||||
category,
|
||||
config,
|
||||
state,
|
||||
steps: decoded.steps,
|
||||
stats: runsToStats(policyRuns),
|
||||
activity: runsToActivity(policyRuns),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies + GET /api/v1/policies/runs → assembled catalogue. */
|
||||
export async function fetchPolicies(): Promise<PoliciesResponse> {
|
||||
return apiClient.local.json<PoliciesResponse>("/api/v1/policies");
|
||||
const [wirePolicies, runs] = await Promise.all([
|
||||
apiClient.local.json<WirePolicy[]>("/api/v1/policies"),
|
||||
apiClient.local
|
||||
.json<PolicyRunView[]>("/api/v1/policies/runs")
|
||||
.catch(() => [] as PolicyRunView[]),
|
||||
]);
|
||||
|
||||
const decodedByCategory = new Map<
|
||||
string,
|
||||
{ decoded: PolicyDecodedState; isDefault: boolean }
|
||||
>();
|
||||
for (const wire of wirePolicies) {
|
||||
const decoded = fromWirePolicy(wire);
|
||||
if (decoded.categoryId) {
|
||||
decodedByCategory.set(decoded.categoryId, { decoded, isDefault: false });
|
||||
}
|
||||
}
|
||||
|
||||
const catalogue: CatalogueEntry[] = POLICY_CATEGORIES.map((category) => {
|
||||
const entry = decodedByCategory.get(category.id);
|
||||
const policy = entry
|
||||
? decoratePolicy(entry.decoded, runs, entry.isDefault)
|
||||
: null;
|
||||
return { category, config: POLICY_CONFIG[category.id], policy };
|
||||
});
|
||||
|
||||
const active = wirePolicies.filter((p) => p.enabled).length;
|
||||
const paused = wirePolicies.filter((p) => !p.enabled).length;
|
||||
const enabledPolicyIds = new Set(
|
||||
wirePolicies.filter((p) => p.enabled).map((p) => p.id),
|
||||
);
|
||||
const docsEnforced = runs.filter(
|
||||
(r) =>
|
||||
r.status === "COMPLETED" &&
|
||||
r.policyId != null &&
|
||||
enabledPolicyIds.has(r.policyId),
|
||||
).length;
|
||||
const summary: PoliciesSummary = {
|
||||
active,
|
||||
paused,
|
||||
categories: POLICY_CATEGORIES.length,
|
||||
docsEnforced,
|
||||
};
|
||||
|
||||
return { summary, catalogue };
|
||||
}
|
||||
|
||||
/** GET /api/v1/policies/{id} — one stored policy's raw record. */
|
||||
export async function fetchPolicy(id: string): Promise<Policy> {
|
||||
return apiClient.local.json<Policy>(
|
||||
export async function fetchPolicy(id: string): Promise<WirePolicy> {
|
||||
return apiClient.local.json<WirePolicy>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies — create (blank id) or update (matched id). The backend
|
||||
* assigns owner + team server-side and returns the stored policy with its id.
|
||||
* POST /api/v1/policies — create (blank id) or update (matched id). The
|
||||
* backend stamps owner + teamId server-side and returns the stored record.
|
||||
*/
|
||||
export async function savePolicy(policy: Policy): Promise<Policy> {
|
||||
return apiClient.local.json<Policy>("/api/v1/policies", {
|
||||
export async function savePolicy(wire: WirePolicy): Promise<WirePolicy> {
|
||||
return apiClient.local.json<WirePolicy>("/api/v1/policies", {
|
||||
method: "POST",
|
||||
body: policy,
|
||||
body: wire,
|
||||
});
|
||||
}
|
||||
|
||||
/** DELETE /api/v1/policies/{id} — remove a stored policy. */
|
||||
/** DELETE /api/v1/policies/{id} */
|
||||
export async function deletePolicy(id: string): Promise<void> {
|
||||
await apiClient.local.json<void>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}`,
|
||||
@@ -76,22 +177,80 @@ export async function deletePolicy(id: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/** The async run acknowledgement: a run id to poll for status. */
|
||||
export interface PolicyRunResponse {
|
||||
status: boolean;
|
||||
/** The run id (poll GET /api/v1/policies/run/{id} for status). */
|
||||
fileId: string | null;
|
||||
message: string | null;
|
||||
// ── Wire-build helpers (so Policies.tsx doesn't need codec knowledge) ────────
|
||||
|
||||
const DEFAULT_RETRIES = 3;
|
||||
const DEFAULT_RETRY_DELAY = 5;
|
||||
|
||||
// Catalogue policy bodies carry categoryId at the top level so the pipelines
|
||||
// mock handler can discriminate them from raw pipeline saves on the shared
|
||||
// POST /api/v1/policies endpoint. The real backend ignores unknown fields.
|
||||
type CatalogueWireBody = WirePolicy & { categoryId: string };
|
||||
|
||||
/** Build a wire policy from a setup wizard result. */
|
||||
export function buildWireFromSetup(
|
||||
entry: CatalogueEntry,
|
||||
result: PolicySetupResult,
|
||||
enabled = true,
|
||||
): CatalogueWireBody {
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: entry.policy?.state.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: result.sources,
|
||||
scopeTypes: result.scopeTypes,
|
||||
reviewerEmail: result.reviewerEmail,
|
||||
fieldValues: result.fieldValues,
|
||||
runOn: result.runOn,
|
||||
outputMode: result.outputMode,
|
||||
outputName: result.outputName,
|
||||
outputNamePosition: result.outputNamePosition,
|
||||
maxRetries: result.maxRetries,
|
||||
retryDelayMinutes: result.retryDelayMinutes,
|
||||
steps: result.steps,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a wire policy from an existing decorated policy (e.g. for pause/resume). */
|
||||
export function buildWireFromState(
|
||||
entry: CatalogueEntry,
|
||||
policy: DecoratedPolicy,
|
||||
enabled: boolean,
|
||||
): CatalogueWireBody {
|
||||
const s = policy.state;
|
||||
return {
|
||||
categoryId: entry.category.id,
|
||||
...toWirePolicy({
|
||||
id: s.backendId ?? "",
|
||||
name: `${entry.category.label} Policy`,
|
||||
enabled,
|
||||
categoryId: entry.category.id,
|
||||
sources: s.sources,
|
||||
scopeTypes: s.scopeTypes,
|
||||
reviewerEmail: s.reviewerEmail,
|
||||
fieldValues: s.fieldValues,
|
||||
runOn: s.runOn ?? "upload",
|
||||
outputMode: s.outputMode ?? "new_version",
|
||||
outputName: s.outputName ?? "",
|
||||
outputNamePosition: s.outputNamePosition ?? "suffix",
|
||||
maxRetries: s.maxRetries ?? DEFAULT_RETRIES,
|
||||
retryDelayMinutes: s.retryDelayMinutes ?? DEFAULT_RETRY_DELAY,
|
||||
steps: policy.steps,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies/{id}/run — run a stored policy now. The real endpoint
|
||||
* is multipart (the documents to process); the portal has no files to attach,
|
||||
* so this triggers the policy on whatever the backend has queued and returns a
|
||||
* run id. Runs regardless of the policy's enabled flag.
|
||||
* POST /api/v1/policies/{id}/run — trigger a stored policy immediately. The
|
||||
* real endpoint is multipart; the portal sends no files, relying on whatever
|
||||
* the backend has queued for this policy.
|
||||
*/
|
||||
export async function runPolicy(id: string): Promise<PolicyRunResponse> {
|
||||
return apiClient.local.json<PolicyRunResponse>(
|
||||
export async function runPolicy(id: string): Promise<{ runId: string }> {
|
||||
return apiClient.local.json<{ runId: string }>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}/run`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
DealStage,
|
||||
DocAction,
|
||||
ProcurementResponse,
|
||||
} from "@portal/mocks/procurement";
|
||||
|
||||
export type {
|
||||
Deal,
|
||||
DealStage,
|
||||
DocAction,
|
||||
DocStatus,
|
||||
JourneyStep,
|
||||
LedgerDoc,
|
||||
LedgerGroup,
|
||||
ProcurementResponse,
|
||||
QuoteInfo,
|
||||
SolutionsEngineer,
|
||||
SupportingCategory,
|
||||
SupportingGroup,
|
||||
TrialInfo,
|
||||
} from "@portal/mocks/procurement";
|
||||
export { JOURNEY } from "@portal/mocks/procurement";
|
||||
|
||||
/** GET /v1/procurement?tier=…, the deal, journey, ledger and supporting pool. */
|
||||
export async function fetchProcurement(
|
||||
tier: Tier,
|
||||
): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>(
|
||||
`/v1/procurement?tier=${encodeURIComponent(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Commercial actions. Each mutates the deal server-side and returns the updated
|
||||
* ProcurementResponse, the new canonical state, which the view applies so the
|
||||
* journey progresses. The MSW layer answers these today; a real backend honours
|
||||
* the same contracts unchanged.
|
||||
*/
|
||||
|
||||
/** Advance the deal to the next stage (the journey's primary CTA). */
|
||||
export async function advanceStage(
|
||||
fromStage: DealStage,
|
||||
): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>("/v1/procurement/advance", {
|
||||
method: "POST",
|
||||
body: { fromStage },
|
||||
});
|
||||
}
|
||||
|
||||
/** Sign the Stirling Enterprise Agreement (MSA + order form + EULA + DPA). */
|
||||
export async function signAgreement(
|
||||
docId: string,
|
||||
): Promise<ProcurementResponse> {
|
||||
// A real backend opens an e-signature envelope and completes on callback;
|
||||
// here it completes immediately and advances the deal.
|
||||
return apiClient.local.json<ProcurementResponse>("/v1/procurement/sign", {
|
||||
method: "POST",
|
||||
body: { docId },
|
||||
});
|
||||
}
|
||||
|
||||
/** Pay the contract online (card / bank transfer via Stripe). */
|
||||
export async function payOnline(): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>("/v1/procurement/pay", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Upload a purchase order to invoice against (an alternate payment path). */
|
||||
export async function uploadPurchaseOrder(
|
||||
file: File,
|
||||
): Promise<ProcurementResponse> {
|
||||
// A real backend takes the PO as multipart; the mock only needs the name.
|
||||
return apiClient.local.json<ProcurementResponse>(
|
||||
"/v1/procurement/purchase-order",
|
||||
{
|
||||
method: "POST",
|
||||
body: { fileName: file.name },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Request a document that is generated on demand (some carry a one-off fee). */
|
||||
export async function requestDocument(
|
||||
docId: string,
|
||||
action: DocAction,
|
||||
): Promise<ProcurementResponse> {
|
||||
return apiClient.local.json<ProcurementResponse>(
|
||||
`/v1/procurement/documents/${encodeURIComponent(docId)}/request`,
|
||||
{ method: "POST", body: { action } },
|
||||
);
|
||||
}
|
||||
@@ -28,8 +28,10 @@ export interface SourceView {
|
||||
referenceCount: number;
|
||||
referencingPolicies: SourcePolicyRef[];
|
||||
config: SourceDetailRow[];
|
||||
/** Per-source document volume: not tracked yet (always null for now). */
|
||||
docsTotal: number | null;
|
||||
/** Documents this source has fed into runs: lifetime, plus trailing 24h / 30d windows. */
|
||||
docsTotal: number;
|
||||
docs24h: number;
|
||||
docs30d: number;
|
||||
}
|
||||
|
||||
export interface SourceKpi {
|
||||
@@ -69,6 +71,17 @@ export async function fetchSource(id: string): Promise<Source> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/sources/{id}/document-counts: the trailing 30-day daily document
|
||||
* series (oldest first) for the source's sparkline. Fetched only for the expanded
|
||||
* row, so the overview list stays lightweight.
|
||||
*/
|
||||
export async function fetchSourceDocCounts(id: string): Promise<number[]> {
|
||||
return apiClient.local.json<number[]>(
|
||||
`/api/v1/sources/${encodeURIComponent(id)}/document-counts`,
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /api/v1/sources: create (blank id) or update (matched id) a source. */
|
||||
export async function createSource(source: Source): Promise<Source> {
|
||||
return apiClient.local.json<Source>("/api/v1/sources", {
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RequireAdmin } from "@shared/auth";
|
||||
import { RequirePortalAccess } from "@shared/auth";
|
||||
import { Spinner } from "@shared/components";
|
||||
import { LoginScreen } from "@portal/components/LoginScreen";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
|
||||
/**
|
||||
* Module-level so the reference is stable across renders (RequireAdmin runs it
|
||||
* from an effect). The portal is admin-only today; authenticated non-admins are
|
||||
* bounced to the editor rather than shown an access-denied page.
|
||||
*/
|
||||
// Stable module-level ref; RequirePortalAccess calls it from an effect.
|
||||
function redirectToEditor(): void {
|
||||
window.location.href = EDITOR_URL;
|
||||
}
|
||||
@@ -31,17 +27,11 @@ function FullScreenMessage({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates the whole portal behind an authenticated admin session:
|
||||
* - loading -> spinner
|
||||
* - signed out -> login screen
|
||||
* - signed in, not admin -> redirect to the editor
|
||||
* - signed in admin -> the portal
|
||||
*/
|
||||
/** Gates the portal: login when signed out, redirect to the editor without portal access. */
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<RequireAdmin
|
||||
<RequirePortalAccess
|
||||
fallback={<LoginScreen />}
|
||||
onForbidden={redirectToEditor}
|
||||
loading={
|
||||
@@ -56,6 +46,6 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</RequireAdmin>
|
||||
</RequirePortalAccess>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
UsageIcon,
|
||||
LinkIcon,
|
||||
DocsIcon,
|
||||
ProcurementIcon,
|
||||
SettingsIcon,
|
||||
ChevronDownIcon,
|
||||
} from "@portal/components/icons";
|
||||
@@ -135,8 +136,16 @@ export function Sidebar() {
|
||||
const { activeView, setActiveView } = useView();
|
||||
const { theme } = useTheme();
|
||||
const { openSettings } = useUI();
|
||||
const { tier } = useTier();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Procurement is the enterprise buyer's commercial journey — surfaced only to
|
||||
// enterprise tenants (it has no free/pro equivalent).
|
||||
const platformGroup: NavEntry[] =
|
||||
tier === "enterprise"
|
||||
? [{ id: "procurement", icon: <ProcurementIcon /> }, ...GROUP_PLATFORM]
|
||||
: GROUP_PLATFORM;
|
||||
|
||||
function renderGroup(entries: NavEntry[]) {
|
||||
return entries.map((entry) => (
|
||||
<NavItem
|
||||
@@ -218,7 +227,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
<div className="portal-sidebar__divider" aria-hidden />
|
||||
<div className="portal-sidebar__group">
|
||||
{renderGroup(GROUP_PLATFORM)}
|
||||
{renderGroup(platformGroup)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -244,6 +244,16 @@ export function AgentBuilderIcon(props: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ProcurementIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<path d="m9 15 2 2 4-4" />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkIcon(props: IconProps) {
|
||||
return (
|
||||
<Svg {...props}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, StatusBadge, StatTile } from "@shared/components";
|
||||
import { Card, Chip, StatusBadge } from "@shared/components";
|
||||
import type { CatalogueEntry } from "@portal/api/policies";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import "@portal/views/Policies.css";
|
||||
@@ -9,17 +9,13 @@ interface PolicyCategoryCardProps {
|
||||
onOpen: (entry: CatalogueEntry) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One card per policy category. Configured categories show the live status +
|
||||
* stats and open the detail panel; unconfigured ones show the summary + a
|
||||
* "Set up" affordance; coming-soon categories render locked and inert.
|
||||
*/
|
||||
export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { category, config, policy } = entry;
|
||||
const comingSoon = category.comingSoon === true;
|
||||
const openable = !comingSoon;
|
||||
const status = policy?.state.status;
|
||||
const enforces = config.rules.join(" · ");
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -42,22 +38,39 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<header className="portal-policies__card-head">
|
||||
<span
|
||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
||||
aria-hidden
|
||||
>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
<div className="portal-policies__card-titles">
|
||||
<h2 className="portal-policies__card-title">{category.label}</h2>
|
||||
<span className="portal-policies__card-blurb">{category.desc}</span>
|
||||
</div>
|
||||
{comingSoon ? (
|
||||
<Chip tone="neutral" size="sm">
|
||||
{t("policies.card.comingSoon")}
|
||||
</Chip>
|
||||
) : policy ? (
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
|
||||
<div className="portal-policies__card-identity">
|
||||
<h2 className="portal-policies__card-title">{category.label}</h2>
|
||||
{enforces && (
|
||||
<span className="portal-policies__card-enforces">{enforces}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{comingSoon ? (
|
||||
<Chip tone="neutral" size="sm">
|
||||
{t("policies.card.comingSoon")}
|
||||
</Chip>
|
||||
) : policy ? (
|
||||
<div className="portal-policies__card-meta">
|
||||
<span className="portal-policies__card-statpair">
|
||||
<span className="portal-policies__card-statval">
|
||||
{policy.stats.enforced.toLocaleString()}
|
||||
</span>
|
||||
<span className="portal-policies__card-statlbl">
|
||||
{t("policies.stats.docsEnforced")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-policies__card-statpair">
|
||||
<span className="portal-policies__card-statval">
|
||||
{policy.stats.dataProcessed}
|
||||
</span>
|
||||
<span className="portal-policies__card-statlbl">
|
||||
{t("policies.stats.dataProcessed")}
|
||||
</span>
|
||||
</span>
|
||||
<StatusBadge
|
||||
tone={status === "paused" ? "warning" : "success"}
|
||||
size="sm"
|
||||
@@ -67,45 +80,11 @@ export function PolicyCategoryCard({ entry, onOpen }: PolicyCategoryCardProps) {
|
||||
? t("policies.status.paused")
|
||||
: t("policies.status.active")}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<Chip tone="blue" size="sm">
|
||||
{t("policies.card.notSetUp")}
|
||||
</Chip>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<p className="portal-policies__card-summary">{config.summary}</p>
|
||||
|
||||
{policy ? (
|
||||
<footer className="portal-policies__card-stats">
|
||||
<StatTile
|
||||
label={t("policies.stats.docsEnforced")}
|
||||
value={policy.stats.enforced.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("policies.stats.dataProcessed")}
|
||||
value={policy.stats.dataProcessed}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("policies.stats.activeFor")}
|
||||
value={policy.stats.activeFor}
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
) : (
|
||||
<footer className="portal-policies__card-foot">
|
||||
<div className="portal-policies__card-rules">
|
||||
{config.rules.slice(0, 3).map((rule) => (
|
||||
<Chip key={rule} tone="neutral" size="sm">
|
||||
{rule}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
{!comingSoon && (
|
||||
<span className="portal-policies__card-cta">
|
||||
{t("policies.card.setUp")}
|
||||
</span>
|
||||
)}
|
||||
</footer>
|
||||
<Chip tone="blue" size="sm">
|
||||
{t("policies.card.notSetUp")}
|
||||
</Chip>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ const meta: Meta<typeof PolicyDetailPanel> = {
|
||||
onRun: () => {},
|
||||
onTogglePause: () => {},
|
||||
onDelete: () => {},
|
||||
onRetry: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
@@ -43,3 +44,40 @@ export const CustomNoActivity: Story = {
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Flagged activity items — shows retry button and error expansion. */
|
||||
export const WithFlaggedItems: Story = {
|
||||
args: {
|
||||
policy: {
|
||||
...decorateForStory("security"),
|
||||
state: { ...decorateForStory("security").state, isDefault: false },
|
||||
activity: [
|
||||
{
|
||||
doc: "Q4-Report.pdf",
|
||||
action: "Low-confidence match — routed for review",
|
||||
time: "2h ago",
|
||||
status: "flagged",
|
||||
},
|
||||
{
|
||||
doc: "Contract-2026.pdf",
|
||||
action:
|
||||
"Enforcement failed: timeout after 30s — step 2/3 (redact) did not complete within the allowed window. Check the document for unusual formatting or large embedded images.",
|
||||
time: "4h ago",
|
||||
status: "flagged",
|
||||
},
|
||||
{
|
||||
doc: "Invoice-March.pdf",
|
||||
action: "Enforced successfully",
|
||||
time: "6h ago",
|
||||
status: "enforced",
|
||||
},
|
||||
{
|
||||
doc: "HR-Policy-v3.pdf",
|
||||
action: "Processing…",
|
||||
time: "just now",
|
||||
status: "processing",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,42 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
EmptyState,
|
||||
Modal,
|
||||
StatTile,
|
||||
StatusBadge,
|
||||
} from "@shared/components";
|
||||
import { humanizeEndpoint, type DecoratedPolicy } from "@portal/api/policies";
|
||||
import { policyIcon } from "@portal/components/policies/policyIcons";
|
||||
import {
|
||||
humanizeEndpoint,
|
||||
type DecoratedPolicy,
|
||||
type PolicyActivityItem,
|
||||
} from "@portal/api/policies";
|
||||
import "@portal/views/Policies.css";
|
||||
|
||||
interface PolicyDetailPanelProps {
|
||||
/** The configured policy being viewed, or null when closed. */
|
||||
policy: DecoratedPolicy | null;
|
||||
/** Whether a lifecycle action (run/pause/delete) is in flight. */
|
||||
busy?: boolean;
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onRun: () => void;
|
||||
onRun?: () => void;
|
||||
onTogglePause: () => void;
|
||||
onDelete: () => void;
|
||||
onRetry?: (item: PolicyActivityItem) => void;
|
||||
}
|
||||
|
||||
const ACTIVITY_TONE = {
|
||||
enforced: "success",
|
||||
flagged: "warning",
|
||||
processing: "info",
|
||||
} as const;
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WarnIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SpinIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="portal-policies__activity-spin"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityError({ message }: { message: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const needsToggle = message.length > 80 || message.includes("\n");
|
||||
if (!needsToggle) return <>{message}</>;
|
||||
return (
|
||||
<span className="portal-policies__activity-error">
|
||||
<span
|
||||
className={
|
||||
"portal-policies__activity-error-text" +
|
||||
(expanded ? "" : " portal-policies__activity-error-text--clamped")
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-policies__link portal-policies__activity-error-toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded
|
||||
? t("policies.detail.showLess")
|
||||
: t("policies.detail.showMore")}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrative view for a configured policy: the enforced tool chain, recent
|
||||
* activity, summary stats, and the lifecycle actions (run now, pause/resume,
|
||||
* delete). Built-in (default) policies hide Delete — they're configurable but
|
||||
* not deletable, matching the backend.
|
||||
*/
|
||||
export function PolicyDetailPanel({
|
||||
policy,
|
||||
busy = false,
|
||||
@@ -45,31 +105,36 @@ export function PolicyDetailPanel({
|
||||
onRun,
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
onRetry,
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!policy) return null;
|
||||
const { category, config, state, steps, stats, activity } = policy;
|
||||
const isPaused = state.status === "paused";
|
||||
const canDelete = state.isDefault !== true;
|
||||
const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : [];
|
||||
|
||||
const enforceItems = steps.length > 0 ? steps.map((s) => s.operation) : null;
|
||||
const hasEditorSource = state.sources.includes("editor");
|
||||
const trigger =
|
||||
state.runOn === "export"
|
||||
? t("policies.detail.onEveryExport")
|
||||
: t("policies.detail.onEveryUpload");
|
||||
const outputLabel =
|
||||
state.outputMode === "new_file"
|
||||
? t("policies.detail.outputAsNewFile")
|
||||
: t("policies.detail.outputAsNewVersion");
|
||||
|
||||
function sourceLabel(id: string) {
|
||||
if (id === "editor") return t("sources.types.editor.label");
|
||||
return id;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
width="lg"
|
||||
title={
|
||||
<span className="portal-policies__wizard-title">
|
||||
<span
|
||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
||||
aria-hidden
|
||||
>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{t("policies.detail.title", { category: category.label })}
|
||||
</span>
|
||||
}
|
||||
subtitle={config.summary}
|
||||
title={category.label}
|
||||
footer={
|
||||
<div className="portal-policies__detail-foot">
|
||||
{canDelete && (
|
||||
@@ -84,15 +149,17 @@ export function PolicyDetailPanel({
|
||||
{t("policies.detail.actions.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
style={canDelete ? undefined : { marginRight: "auto" }}
|
||||
>
|
||||
{t("policies.detail.actions.runNow")}
|
||||
</Button>
|
||||
{onRun && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRun}
|
||||
disabled={busy}
|
||||
style={canDelete ? undefined : { marginRight: "auto" }}
|
||||
>
|
||||
{t("policies.detail.actions.runNow")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -109,57 +176,66 @@ export function PolicyDetailPanel({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Status + trigger strip */}
|
||||
<div className="portal-policies__detail-status">
|
||||
<StatusBadge tone={isPaused ? "warning" : "success"} pulse={!isPaused}>
|
||||
{isPaused ? t("policies.status.paused") : t("policies.status.active")}
|
||||
</StatusBadge>
|
||||
<span className="portal-policies__detail-meta">
|
||||
{t("policies.detail.meta", {
|
||||
event: state.runOn ?? "upload",
|
||||
output:
|
||||
state.outputMode === "new_file"
|
||||
? t("policies.detail.outputAsNewFile")
|
||||
: t("policies.detail.outputAsNewVersion"),
|
||||
})}
|
||||
{hasEditorSource && (
|
||||
<>
|
||||
<span className="portal-policies__detail-sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-policies__detail-meta">{trigger}</span>
|
||||
<span className="portal-policies__detail-sep" aria-hidden>
|
||||
·
|
||||
</span>
|
||||
<span className="portal-policies__detail-meta">{outputLabel}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enforces — plain text, no pills */}
|
||||
<div className="portal-policies__detail-inline">
|
||||
<span className="portal-policies__detail-inline-label">
|
||||
{t("policies.detail.enforces")}
|
||||
</span>
|
||||
<span className="portal-policies__detail-inline-value">
|
||||
{enforceItems
|
||||
? enforceItems.map((op, i) => (
|
||||
<span key={op}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="portal-policies__enforce-arrow"
|
||||
aria-hidden
|
||||
>
|
||||
{" "}
|
||||
→{" "}
|
||||
</span>
|
||||
)}
|
||||
{humanizeEndpoint(op)}
|
||||
</span>
|
||||
))
|
||||
: config.rules.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.detail.enforces")}
|
||||
</h3>
|
||||
<Card padding="default">
|
||||
{enforceItems.length > 0 ? (
|
||||
<div className="portal-policies__enforce-flow">
|
||||
{enforceItems.map((op, i) => (
|
||||
<span key={op} className="portal-policies__enforce-item">
|
||||
{i > 0 && (
|
||||
<span className="portal-policies__enforce-arrow" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
<Chip tone="blue" size="sm">
|
||||
{humanizeEndpoint(op)}
|
||||
</Chip>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-policies__enforce-flow">
|
||||
{config.rules.map((rule) => (
|
||||
<Chip key={rule} tone="neutral" size="sm">
|
||||
{rule}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="portal-policies__enforce-note">
|
||||
{t("policies.detail.enforceNote", { scope: config.scopeLabel })}
|
||||
</p>
|
||||
</Card>
|
||||
{/* Sources */}
|
||||
{state.sources.length > 0 && (
|
||||
<div className="portal-policies__detail-inline">
|
||||
<span className="portal-policies__detail-inline-label">
|
||||
{t("policies.detail.sources")}
|
||||
</span>
|
||||
<span className="portal-policies__detail-inline-value">
|
||||
{state.sources.map(sourceLabel).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
{t("policies.detail.recentActivity")}
|
||||
</h3>
|
||||
|
||||
{activity.length > 0 ? (
|
||||
<Card padding="none">
|
||||
{activity.map((item, i) => (
|
||||
@@ -168,20 +244,46 @@ export function PolicyDetailPanel({
|
||||
className="portal-policies__activity-row"
|
||||
>
|
||||
<span
|
||||
className={`portal-policies__activity-dot portal-policies__activity-dot--${ACTIVITY_TONE[item.status]}`}
|
||||
aria-hidden
|
||||
/>
|
||||
className={`portal-policies__activity-icon portal-policies__activity-icon--${
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}`}
|
||||
>
|
||||
{item.status === "flagged" ? (
|
||||
<WarnIcon />
|
||||
) : item.status === "processing" ? (
|
||||
<SpinIcon />
|
||||
) : (
|
||||
<CheckIcon />
|
||||
)}
|
||||
</span>
|
||||
<span className="portal-policies__activity-text">
|
||||
<span className="portal-policies__activity-doc">
|
||||
{item.doc}
|
||||
</span>
|
||||
<span className="portal-policies__activity-action">
|
||||
{item.action}
|
||||
{item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} />
|
||||
) : (
|
||||
item.action
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="portal-policies__activity-time">
|
||||
{item.time}
|
||||
</span>
|
||||
{item.status === "flagged" && onRetry && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-policies__link portal-policies__activity-retry"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("policies.detail.retry")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
@@ -209,16 +311,6 @@ export function PolicyDetailPanel({
|
||||
value={stats.activeFor}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{state.scopeTypes.length > 0 && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("policies.detail.scoped.title")}
|
||||
description={t("policies.detail.scoped.description", {
|
||||
types: state.scopeTypes.join(", "),
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,14 +14,16 @@ import {
|
||||
} from "@shared/components";
|
||||
import {
|
||||
POLICY_DOC_TYPES,
|
||||
POLICY_SOURCES,
|
||||
humanizeEndpoint,
|
||||
type CatalogueEntry,
|
||||
type PipelineStep,
|
||||
type PolicySetupResult,
|
||||
} from "@portal/api/policies";
|
||||
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 "@portal/views/Policies.css";
|
||||
|
||||
interface PolicySetupWizardProps {
|
||||
@@ -59,15 +61,27 @@ function resolveFieldValues(
|
||||
* round-trips); otherwise the category preset's default chain. Each preset step
|
||||
* starts enabled — the user toggles tools off in the workflow.
|
||||
*/
|
||||
// Temporary: tracks which tools start disabled until the tool registry lands in
|
||||
// the portal and can drive this via registry metadata or a defaultEnabled flag.
|
||||
const DISABLED_BY_DEFAULT = new Set(["/api/v1/security/add-watermark"]);
|
||||
|
||||
function seedTools(entry: CatalogueEntry): ToolState[] {
|
||||
const source = entry.policy?.steps?.length
|
||||
? entry.policy.steps
|
||||
: entry.config.defaultOperations;
|
||||
return source.map((s) => ({
|
||||
operation: s.operation,
|
||||
enabled: true,
|
||||
parameters: s.parameters,
|
||||
}));
|
||||
const savedSteps = entry.policy?.steps ?? [];
|
||||
const savedByOp = new Map(savedSteps.map((s) => [s.operation, s]));
|
||||
// Always use defaultOperations as the canonical list so tools added after a
|
||||
// policy was first saved still appear when editing.
|
||||
return entry.config.defaultOperations.map((s) => {
|
||||
const saved = savedByOp.get(s.operation);
|
||||
return {
|
||||
operation: s.operation,
|
||||
enabled: saved
|
||||
? true
|
||||
: savedSteps.length > 0
|
||||
? false
|
||||
: !DISABLED_BY_DEFAULT.has(s.operation),
|
||||
parameters: saved?.parameters ?? s.parameters,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,27 +126,50 @@ function PolicySetupWizardBody({
|
||||
resolveFieldValues(entry),
|
||||
);
|
||||
const [sources, setSources] = useState<string[]>(
|
||||
policy?.state.sources.length ? policy.state.sources : ["editor"],
|
||||
policy?.state.sources ?? ["editor"],
|
||||
);
|
||||
|
||||
const sourcesAsync = useAsync(() => fetchSources(), []);
|
||||
const availableSources = useMemo(() => {
|
||||
const backendSources = (sourcesAsync.data?.sources ?? []).filter(
|
||||
(s) => s.status !== "disabled",
|
||||
);
|
||||
const editorSource = {
|
||||
id: "editor",
|
||||
name: t("sources.types.editor.label"),
|
||||
type: "editor",
|
||||
status: "active" as const,
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [],
|
||||
docsTotal: null,
|
||||
};
|
||||
return [editorSource, ...backendSources];
|
||||
}, [sourcesAsync.data, t]);
|
||||
const [scopeNarrow, setScopeNarrow] = useState(
|
||||
(policy?.state.scopeTypes.length ?? 0) > 0,
|
||||
);
|
||||
const [scopeTypes, setScopeTypes] = useState<string[]>(
|
||||
policy?.state.scopeTypes ?? [],
|
||||
);
|
||||
const [reviewerEmail, setReviewerEmail] = useState(
|
||||
policy?.state.reviewerEmail ?? "you@acme.com",
|
||||
);
|
||||
// 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.
|
||||
const [reviewerEmail] = useState(policy?.state.reviewerEmail ?? "");
|
||||
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
|
||||
policy?.state.outputMode ?? "new_version",
|
||||
);
|
||||
const [outputName, setOutputName] = useState(policy?.state.outputName ?? "");
|
||||
const [outputNamePosition, setOutputNamePosition] = useState<
|
||||
"prefix" | "suffix" | "auto-number"
|
||||
>("suffix");
|
||||
>(policy?.state.outputNamePosition ?? "suffix");
|
||||
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,
|
||||
);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -180,6 +217,8 @@ function PolicySetupWizardBody({
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
runOn,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
steps,
|
||||
});
|
||||
} catch {
|
||||
@@ -197,10 +236,7 @@ function PolicySetupWizardBody({
|
||||
width="lg"
|
||||
title={
|
||||
<span className="portal-policies__wizard-title">
|
||||
<span
|
||||
className={`portal-policies__cat-icon portal-policies__cat-icon--${category.tone}`}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="portal-policies__cat-icon" aria-hidden>
|
||||
{policyIcon(category.icon)}
|
||||
</span>
|
||||
{isEdit
|
||||
@@ -272,9 +308,7 @@ function PolicySetupWizardBody({
|
||||
<span className="portal-policies__tool-name">
|
||||
{humanizeEndpoint(tl.operation)}
|
||||
</span>
|
||||
<code className="portal-policies__tool-endpoint">
|
||||
{tl.operation}
|
||||
</code>
|
||||
<span style={{ flex: 1 }} />
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={tl.enabled}
|
||||
@@ -315,31 +349,43 @@ function PolicySetupWizardBody({
|
||||
{t("policies.wizard.sources.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__sources">
|
||||
{POLICY_SOURCES.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
className={
|
||||
"portal-policies__source" +
|
||||
(sources.includes(src.id)
|
||||
? " portal-policies__source--on"
|
||||
: "")
|
||||
}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
>
|
||||
<span className="portal-policies__source-icon" aria-hidden>
|
||||
{policyIcon(src.icon)}
|
||||
</span>
|
||||
<span className="portal-policies__source-text">
|
||||
<span className="portal-policies__source-label">
|
||||
{src.label}
|
||||
{sourcesAsync.loading && !sourcesAsync.data ? (
|
||||
<p className="portal-policies__sources-loading">
|
||||
{t("policies.wizard.sources.loading")}
|
||||
</p>
|
||||
) : availableSources.length === 1 ? (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
title={t("policies.wizard.sources.emptyTitle")}
|
||||
description={t("policies.wizard.sources.emptyDescription")}
|
||||
/>
|
||||
) : (
|
||||
availableSources.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
type="button"
|
||||
className={
|
||||
"portal-policies__source" +
|
||||
(sources.includes(src.id)
|
||||
? " portal-policies__source--on"
|
||||
: "")
|
||||
}
|
||||
onClick={() => toggleSource(src.id)}
|
||||
>
|
||||
<span className="portal-policies__source-icon" aria-hidden>
|
||||
{sourceTypeMeta(src.type).icon}
|
||||
</span>
|
||||
<span className="portal-policies__source-desc">
|
||||
{src.desc}
|
||||
<span className="portal-policies__source-text">
|
||||
<span className="portal-policies__source-label">
|
||||
{src.name}
|
||||
</span>
|
||||
<span className="portal-policies__source-desc">
|
||||
{src.type}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="portal-policies__wizard-heading">
|
||||
@@ -392,107 +438,130 @@ function PolicySetupWizardBody({
|
||||
{t("policies.wizard.output.heading")}
|
||||
</h3>
|
||||
<div className="portal-policies__fields">
|
||||
<FormField
|
||||
label={t("policies.wizard.output.runOn.label")}
|
||||
helperText={t("policies.wizard.output.runOn.helper")}
|
||||
>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={runOn}
|
||||
onChange={(e) =>
|
||||
setRunOn(e.target.value as "upload" | "export")
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: "upload",
|
||||
label: t("policies.wizard.output.runOn.upload"),
|
||||
},
|
||||
{
|
||||
value: "export",
|
||||
label: t("policies.wizard.output.runOn.export"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("policies.wizard.output.outputAs.label")}>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputMode}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as "new_file" | "new_version";
|
||||
setOutputMode(mode);
|
||||
// Auto-number only applies to separate new files.
|
||||
if (
|
||||
mode === "new_version" &&
|
||||
outputNamePosition === "auto-number"
|
||||
) {
|
||||
setOutputNamePosition("suffix");
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "new_version",
|
||||
label: t("policies.wizard.output.outputAs.newVersion"),
|
||||
},
|
||||
{
|
||||
value: "new_file",
|
||||
label: t("policies.wizard.output.outputAs.newFile"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("policies.wizard.output.filenameRule.label")}>
|
||||
<div className="portal-policies__name-row">
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputNamePosition}
|
||||
onChange={(e) =>
|
||||
setOutputNamePosition(
|
||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: "prefix",
|
||||
label: t("policies.wizard.output.filenameRule.prefix"),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t("policies.wizard.output.filenameRule.suffix"),
|
||||
},
|
||||
...(outputMode === "new_file"
|
||||
? [
|
||||
{
|
||||
value: "auto-number",
|
||||
label: t(
|
||||
"policies.wizard.output.filenameRule.autoNumber",
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{outputNamePosition !== "auto-number" && (
|
||||
<Input
|
||||
{sources.includes("editor") && (
|
||||
<>
|
||||
<FormField
|
||||
label={t("policies.wizard.output.runOn.label")}
|
||||
helperText={t("policies.wizard.output.runOn.helper")}
|
||||
>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputName}
|
||||
placeholder={t(
|
||||
"policies.wizard.output.filenameRule.placeholder",
|
||||
)}
|
||||
onChange={(e) => setOutputName(e.target.value)}
|
||||
value={runOn}
|
||||
onChange={(e) =>
|
||||
setRunOn(e.target.value as "upload" | "export")
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: "upload",
|
||||
label: t("policies.wizard.output.runOn.upload"),
|
||||
},
|
||||
{
|
||||
value: "export",
|
||||
label: t("policies.wizard.output.runOn.export"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("policies.wizard.output.reviewerEmail.label")}
|
||||
helperText={t("policies.wizard.output.reviewerEmail.helper")}
|
||||
>
|
||||
</FormField>
|
||||
<FormField label={t("policies.wizard.output.outputAs.label")}>
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputMode}
|
||||
onChange={(e) => {
|
||||
const mode = e.target.value as "new_file" | "new_version";
|
||||
setOutputMode(mode);
|
||||
// Auto-number only applies to separate new files.
|
||||
if (
|
||||
mode === "new_version" &&
|
||||
outputNamePosition === "auto-number"
|
||||
) {
|
||||
setOutputNamePosition("suffix");
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "new_version",
|
||||
label: t("policies.wizard.output.outputAs.newVersion"),
|
||||
},
|
||||
{
|
||||
value: "new_file",
|
||||
label: t("policies.wizard.output.outputAs.newFile"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("policies.wizard.output.filenameRule.label")}
|
||||
>
|
||||
<div className="portal-policies__name-row">
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputNamePosition}
|
||||
onChange={(e) =>
|
||||
setOutputNamePosition(
|
||||
e.target.value as "prefix" | "suffix" | "auto-number",
|
||||
)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: "prefix",
|
||||
label: t(
|
||||
"policies.wizard.output.filenameRule.prefix",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t(
|
||||
"policies.wizard.output.filenameRule.suffix",
|
||||
),
|
||||
},
|
||||
...(outputMode === "new_file"
|
||||
? [
|
||||
{
|
||||
value: "auto-number",
|
||||
label: t(
|
||||
"policies.wizard.output.filenameRule.autoNumber",
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{outputNamePosition !== "auto-number" && (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={outputName}
|
||||
placeholder={t(
|
||||
"policies.wizard.output.filenameRule.placeholder",
|
||||
)}
|
||||
onChange={(e) => setOutputName(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
{/* TODO: reviewer user-picker goes here */}
|
||||
<h4 className="portal-policies__wizard-subheading">
|
||||
{t("policies.wizard.output.retries.heading")}
|
||||
</h4>
|
||||
<FormField label={t("policies.wizard.output.retries.maxLabel")}>
|
||||
<Input
|
||||
inputSize="sm"
|
||||
type="email"
|
||||
value={reviewerEmail}
|
||||
onChange={(e) => setReviewerEmail(e.target.value)}
|
||||
type="number"
|
||||
value={String(maxRetries)}
|
||||
onChange={(e) =>
|
||||
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("policies.wizard.output.retries.delayLabel")}>
|
||||
<Input
|
||||
inputSize="sm"
|
||||
type="number"
|
||||
value={String(retryDelayMinutes)}
|
||||
onChange={(e) =>
|
||||
setRetryDelayMinutes(Math.max(0, Number(e.target.value) || 0))
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
@@ -3,38 +3,53 @@
|
||||
* policy built straight from the catalogue + seed data, so stories render the
|
||||
* same shapes the MSW handlers serve without standing up the whole API.
|
||||
*/
|
||||
import { fromWirePolicy } from "@shared/policies/codec";
|
||||
import { runsToActivity, runsToStats } from "@shared/policies/runs";
|
||||
import {
|
||||
POLICY_CATEGORIES,
|
||||
POLICY_CONFIG,
|
||||
seedRuntime,
|
||||
seedPolicies,
|
||||
seedPolicyRuns,
|
||||
type DecoratedPolicy,
|
||||
type PolicyState,
|
||||
} from "@portal/mocks/policies";
|
||||
|
||||
export { POLICY_CATEGORIES, POLICY_CONFIG };
|
||||
|
||||
/** A decorated, active policy for a category, mirroring the handler's decorate(). */
|
||||
/** A decorated, active policy for a category, mirroring fetchPolicies() assembly. */
|
||||
export function decorateForStory(categoryId: string): DecoratedPolicy {
|
||||
const category = POLICY_CATEGORIES.find((c) => c.id === categoryId)!;
|
||||
const config = POLICY_CONFIG[categoryId];
|
||||
const rt = seedRuntime().pol_security_default;
|
||||
|
||||
// Use the seeded security policy for any category (story only needs the shape).
|
||||
const wire = seedPolicies()[0];
|
||||
const decoded = fromWirePolicy(wire);
|
||||
const allRuns = seedPolicyRuns();
|
||||
const policyRuns = allRuns.filter((r) => r.policyId === wire.id);
|
||||
|
||||
const state: PolicyState = {
|
||||
configured: true,
|
||||
status: decoded.enabled ? "active" : "paused",
|
||||
sources: decoded.sources,
|
||||
scopeTypes: decoded.scopeTypes,
|
||||
reviewerEmail: decoded.reviewerEmail,
|
||||
fieldValues: decoded.fieldValues,
|
||||
outputMode: decoded.outputMode,
|
||||
outputName: decoded.outputName,
|
||||
outputNamePosition: decoded.outputNamePosition,
|
||||
runOn: decoded.runOn,
|
||||
maxRetries: decoded.maxRetries,
|
||||
retryDelayMinutes: decoded.retryDelayMinutes,
|
||||
backendId: wire.id,
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
return {
|
||||
category,
|
||||
config,
|
||||
state: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: rt.reviewerEmail,
|
||||
fieldValues: {},
|
||||
outputMode: "new_version",
|
||||
outputName: "",
|
||||
runOn: "upload",
|
||||
backendId: "pol_story",
|
||||
isDefault: true,
|
||||
},
|
||||
steps: config.defaultOperations,
|
||||
stats: rt.stats,
|
||||
activity: rt.activity,
|
||||
state,
|
||||
steps: decoded.steps,
|
||||
stats: runsToStats(policyRuns),
|
||||
activity: runsToActivity(policyRuns),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ActionModal } from "@portal/components/procurement/ActionModal";
|
||||
import type { LedgerDoc } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof ActionModal> = {
|
||||
title: "Portal/Procurement/ActionModal",
|
||||
component: ActionModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: { onClose: () => {}, onDone: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ActionModal>;
|
||||
|
||||
const sign: LedgerDoc = {
|
||||
id: "d1",
|
||||
name: "Stirling Enterprise Agreement",
|
||||
sub: "One signature: MSA + order form + EULA + DPA.",
|
||||
status: "action",
|
||||
action: "sign",
|
||||
};
|
||||
|
||||
const pay: LedgerDoc = {
|
||||
id: "d2",
|
||||
name: "Pay online",
|
||||
sub: "Card or bank transfer via Stripe.",
|
||||
status: "pending",
|
||||
action: "pay",
|
||||
};
|
||||
|
||||
const upload: LedgerDoc = {
|
||||
id: "d3",
|
||||
name: "Purchase order",
|
||||
sub: "Upload it and we invoice against it.",
|
||||
status: "request",
|
||||
action: "upload",
|
||||
};
|
||||
|
||||
const requestPaid: LedgerDoc = {
|
||||
id: "d4",
|
||||
name: "Custom security review",
|
||||
sub: "Dedicated session with our security team.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
fee: 5_000,
|
||||
};
|
||||
|
||||
export const Sign: Story = { args: { doc: sign } };
|
||||
export const Pay: Story = { args: { doc: pay } };
|
||||
export const UploadPO: Story = { args: { doc: upload } };
|
||||
export const RequestPaid: Story = { args: { doc: requestPaid } };
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { Button, Modal } from "@shared/components";
|
||||
import type { LedgerDoc, ProcurementResponse } from "@portal/api/procurement";
|
||||
import {
|
||||
payOnline,
|
||||
requestDocument,
|
||||
signAgreement,
|
||||
uploadPurchaseOrder,
|
||||
} from "@portal/api/procurement";
|
||||
import { USD } from "@portal/components/procurement/format";
|
||||
|
||||
interface ActionCopy {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
body: string;
|
||||
cta: string;
|
||||
}
|
||||
|
||||
/** Per-action confirmation copy, with the fee folded into the CTA when present. */
|
||||
function actionCopy(doc: LedgerDoc, t: TFunction): ActionCopy {
|
||||
const fee = doc.fee !== undefined ? ` · ${USD.format(doc.fee)}` : "";
|
||||
switch (doc.action) {
|
||||
case "sign":
|
||||
return {
|
||||
title: t("procurement.modal.signTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("procurement.modal.signBody"),
|
||||
cta: t("procurement.modal.signCta"),
|
||||
};
|
||||
case "pay":
|
||||
return {
|
||||
title: t("procurement.modal.payTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("procurement.modal.payBody"),
|
||||
cta: t("procurement.modal.payCta"),
|
||||
};
|
||||
case "upload":
|
||||
return {
|
||||
title: t("procurement.modal.uploadTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("procurement.modal.uploadBody"),
|
||||
cta: t("procurement.modal.uploadCta"),
|
||||
};
|
||||
case "request":
|
||||
return {
|
||||
title: t("procurement.modal.requestTitle"),
|
||||
subtitle: doc.name,
|
||||
body: doc.fee
|
||||
? t("procurement.modal.requestBodyPaid")
|
||||
: t("procurement.modal.requestBodyFree"),
|
||||
cta: `${t("procurement.modal.requestCta")}${fee}`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: t("procurement.modal.downloadTitle"),
|
||||
subtitle: doc.name,
|
||||
body: t("procurement.modal.downloadBody"),
|
||||
cta: t("procurement.modal.downloadCta"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirmation modal for a document's gating action. Owns its in-flight state;
|
||||
* on success it hands the updated deal back to the caller via `onDone` so the
|
||||
* journey re-renders. Downloads are client-side and just close the modal.
|
||||
*/
|
||||
export function ActionModal({
|
||||
doc,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
doc: LedgerDoc | null;
|
||||
onClose: () => void;
|
||||
onDone: (next: ProcurementResponse) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
if (!doc) return null;
|
||||
const copy = actionCopy(doc, t);
|
||||
const needsFile = doc.action === "upload";
|
||||
|
||||
async function submit() {
|
||||
if (!doc) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
let next: ProcurementResponse | null = null;
|
||||
switch (doc.action) {
|
||||
case "sign":
|
||||
next = await signAgreement(doc.id);
|
||||
break;
|
||||
case "pay":
|
||||
next = await payOnline();
|
||||
break;
|
||||
case "upload":
|
||||
if (file) next = await uploadPurchaseOrder(file);
|
||||
break;
|
||||
case "request":
|
||||
next = await requestDocument(doc.id, doc.action);
|
||||
break;
|
||||
default:
|
||||
// download is client-side; no state change.
|
||||
break;
|
||||
}
|
||||
setFile(null);
|
||||
if (next) onDone(next);
|
||||
else onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
width="md"
|
||||
title={copy.title}
|
||||
subtitle={copy.subtitle}
|
||||
footer={
|
||||
<div className="portal-proc__modal-actions">
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("procurement.modal.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
loading={submitting}
|
||||
disabled={needsFile && !file}
|
||||
onClick={submit}
|
||||
>
|
||||
{copy.cta}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p className="portal-proc__modal-body">{copy.body}</p>
|
||||
{needsFile && (
|
||||
<div className="portal-proc__upload">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx"
|
||||
className="portal-proc__upload-input"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("procurement.modal.chooseFile")}
|
||||
</Button>
|
||||
<span className="portal-proc__upload-name">
|
||||
{file ? file.name : t("procurement.modal.noFile")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DealJourney } from "@portal/components/procurement/DealJourney";
|
||||
import { buildProcurement } from "@portal/mocks/procurement";
|
||||
import type { Deal } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const data = buildProcurement("enterprise");
|
||||
const deal = data.deal as Deal;
|
||||
|
||||
const meta: Meta<typeof DealJourney> = {
|
||||
title: "Portal/Procurement/DealJourney",
|
||||
component: DealJourney,
|
||||
parameters: { layout: "padded" },
|
||||
args: { deal, journey: data.journey, onAdvance: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DealJourney>;
|
||||
|
||||
// Mid-journey at the Agreement stage, the seeded deal state.
|
||||
export const Default: Story = {};
|
||||
|
||||
// Evaluating: the trial strip shows runway + key; next step builds the quote.
|
||||
export const AtTrial: Story = {
|
||||
args: { deal: { ...deal, currentStage: "trial" } },
|
||||
};
|
||||
|
||||
// Terminal stage, provisioning, no further CTA.
|
||||
export const Live: Story = {
|
||||
args: { deal: { ...deal, currentStage: "active" } },
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@shared/components";
|
||||
import type { Deal, DealStage, JourneyStep } from "@portal/api/procurement";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
|
||||
/**
|
||||
* The deal's commercial journey in one card: who's guiding it (the solutions
|
||||
* engineer), where it sits (the stage stepper), trial runway while evaluating,
|
||||
* and the single next action that advances the deal. Mirrors "one next action
|
||||
* at a time"; the full per-stage checklist lives in the Documents card.
|
||||
*/
|
||||
export function DealJourney({
|
||||
deal,
|
||||
journey,
|
||||
onAdvance,
|
||||
advancing = false,
|
||||
}: {
|
||||
deal: Deal;
|
||||
journey: JourneyStep[];
|
||||
onAdvance: (stage: DealStage) => void;
|
||||
advancing?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { engineer, trial, currentStage } = deal;
|
||||
const currentStep = journey.find((s) => s.stage === currentStage);
|
||||
const isTerminal =
|
||||
journey.length > 0 && journey[journey.length - 1].stage === currentStage;
|
||||
|
||||
return (
|
||||
<Card padding="none" className="portal-proc__journey">
|
||||
<div className="portal-proc__journey-head">
|
||||
<div>
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("procurement.journey.eyebrow")}
|
||||
</span>
|
||||
<h2 className="portal-proc__journey-title">
|
||||
{t("procurement.journey.title")}
|
||||
</h2>
|
||||
<p className="portal-proc__journey-sub">
|
||||
{t("procurement.journey.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="portal-proc__se">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("procurement.journey.engineerLabel")}
|
||||
</span>
|
||||
<span className="portal-proc__se-name">{engineer.name}</span>
|
||||
<a
|
||||
className="portal-proc__se-email"
|
||||
href={`mailto:${engineer.email}`}
|
||||
>
|
||||
{engineer.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="portal-proc__journey-stepper">
|
||||
<StageStepper journey={journey} currentStage={currentStage} />
|
||||
</div>
|
||||
|
||||
{currentStage === "trial" && (
|
||||
<div className="portal-proc__trial">
|
||||
<span className="portal-proc__trial-title">
|
||||
{t("procurement.journey.trialTitle")}
|
||||
</span>
|
||||
<span className="portal-proc__trial-dim">
|
||||
{t("procurement.journey.daysLeft", { count: trial.daysLeft })}
|
||||
</span>
|
||||
<span className="portal-proc__trial-key">{trial.key}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="portal-proc__next">
|
||||
<div className="portal-proc__next-label">
|
||||
<span className="portal-proc__next-dot" data-live={isTerminal} />
|
||||
<span>
|
||||
{isTerminal
|
||||
? t("procurement.journey.live")
|
||||
: t("procurement.journey.nextStep", {
|
||||
action: currentStep?.gatingAction ?? "",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{!isTerminal && currentStep && (
|
||||
<Button
|
||||
variant="gradient"
|
||||
accent="purple"
|
||||
loading={advancing}
|
||||
onClick={() => onAdvance(currentStage)}
|
||||
>
|
||||
{currentStep.gatingAction}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DocRow } from "@portal/components/procurement/DocRow";
|
||||
import type { LedgerDoc } from "@portal/api/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof DocRow> = {
|
||||
title: "Portal/Procurement/DocRow",
|
||||
component: DocRow,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onAction: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocRow>;
|
||||
|
||||
const sign: LedgerDoc = {
|
||||
id: "d1",
|
||||
name: "Stirling Enterprise Agreement",
|
||||
sub: "One signature: MSA + order form + EULA + DPA.",
|
||||
status: "action",
|
||||
action: "sign",
|
||||
};
|
||||
|
||||
const download: LedgerDoc = {
|
||||
id: "d2",
|
||||
name: "SOC 2 Type II report",
|
||||
sub: "Independent audit of our security controls.",
|
||||
status: "available",
|
||||
action: "download",
|
||||
};
|
||||
|
||||
const paidAddon: LedgerDoc = {
|
||||
id: "d3",
|
||||
name: "Onboarding & training",
|
||||
sub: "Guided rollout and live training for your team.",
|
||||
status: "request",
|
||||
action: "request",
|
||||
optional: true,
|
||||
fee: 7_500,
|
||||
};
|
||||
|
||||
const done: LedgerDoc = {
|
||||
id: "d4",
|
||||
name: "Formal quote",
|
||||
sub: "Committed-volume pricing, term and line items.",
|
||||
status: "complete",
|
||||
action: "download",
|
||||
};
|
||||
|
||||
// Deal-advancing action, filled purple CTA.
|
||||
export const SignAction: Story = { args: { doc: sign } };
|
||||
|
||||
// Quiet outline action for a ready download.
|
||||
export const Download: Story = { args: { doc: download } };
|
||||
|
||||
// Optional paid add-on, chips flag it and the fee folds into the CTA.
|
||||
export const PaidAddon: Story = { args: { doc: paidAddon } };
|
||||
|
||||
// Completed paperwork keeps a record but offers no further action.
|
||||
export const Complete: Story = { args: { doc: done } };
|
||||
|
||||
// A row in a future, not-yet-reached stage, dimmed, marked "Upcoming", inert.
|
||||
export const Locked: Story = { args: { doc: sign, locked: true } };
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Chip, StatusBadge } from "@shared/components";
|
||||
import type { LedgerDoc } from "@portal/api/procurement";
|
||||
import {
|
||||
ACTION_LABEL_KEY,
|
||||
STATUS_LABEL_KEY,
|
||||
STATUS_TONE,
|
||||
USD,
|
||||
} from "@portal/components/procurement/format";
|
||||
|
||||
/** Maps a document's action to the button accent + variant. */
|
||||
function buttonStyle(doc: LedgerDoc): {
|
||||
variant: "gradient" | "outline";
|
||||
accent: "purple" | "blue";
|
||||
} {
|
||||
// The agreement signature and online payment are the deal-advancing actions;
|
||||
// give them the filled accent CTA. Everything else is a quieter outline.
|
||||
if (doc.action === "sign" || doc.action === "pay") {
|
||||
return { variant: "gradient", accent: "purple" };
|
||||
}
|
||||
return { variant: "outline", accent: "blue" };
|
||||
}
|
||||
|
||||
/**
|
||||
* A single document in the ledger or supporting pool: name + sub-line on the
|
||||
* left, status badge and action button on the right. Optional/fee-bearing docs
|
||||
* carry a chip so the buyer sees a paid add-on before clicking. `locked` is for
|
||||
* rows in a future, not-yet-reached stage: dimmed, marked "Upcoming", inert.
|
||||
*/
|
||||
export function DocRow({
|
||||
doc,
|
||||
onAction,
|
||||
locked = false,
|
||||
}: {
|
||||
doc: LedgerDoc;
|
||||
onAction: (doc: LedgerDoc) => void;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { variant, accent } = buttonStyle(doc);
|
||||
// Locked (future-stage), in-progress (pending) and completed paperwork all
|
||||
// offer no action; only "available", "action" and "request" docs do.
|
||||
const actionable =
|
||||
!locked && doc.status !== "complete" && doc.status !== "pending";
|
||||
const label = t(ACTION_LABEL_KEY[doc.action]);
|
||||
const actionLabel =
|
||||
doc.fee !== undefined ? `${label} · ${USD.format(doc.fee)}` : label;
|
||||
|
||||
return (
|
||||
<div className="portal-proc__doc" data-locked={locked || undefined}>
|
||||
<div className="portal-proc__doc-text">
|
||||
<div className="portal-proc__doc-name-row">
|
||||
<span className="portal-proc__doc-name">{doc.name}</span>
|
||||
{doc.optional && (
|
||||
<Chip tone="neutral" size="sm">
|
||||
{t("procurement.docs.optional")}
|
||||
</Chip>
|
||||
)}
|
||||
{doc.fee !== undefined && (
|
||||
<Chip tone="amber" size="sm">
|
||||
{t("procurement.docs.paidAddon")}
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
<p className="portal-proc__doc-sub">{doc.sub}</p>
|
||||
</div>
|
||||
<div className="portal-proc__doc-actions">
|
||||
<StatusBadge
|
||||
tone={locked ? "neutral" : STATUS_TONE[doc.status]}
|
||||
size="sm"
|
||||
>
|
||||
{locked
|
||||
? t("procurement.docs.upcoming")
|
||||
: t(STATUS_LABEL_KEY[doc.status])}
|
||||
</StatusBadge>
|
||||
{actionable && (
|
||||
<Button
|
||||
variant={variant}
|
||||
accent={accent}
|
||||
size="sm"
|
||||
onClick={() => onAction(doc)}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DocumentLedger } from "@portal/components/procurement/DocumentLedger";
|
||||
import { buildProcurement } from "@portal/mocks/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const data = buildProcurement("enterprise");
|
||||
|
||||
const meta: Meta<typeof DocumentLedger> = {
|
||||
title: "Portal/Procurement/DocumentLedger",
|
||||
component: DocumentLedger,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
groups: data.ledger,
|
||||
supporting: data.supporting,
|
||||
journey: data.journey,
|
||||
currentStage: data.deal?.currentStage ?? "trial",
|
||||
onAction: () => {},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DocumentLedger>;
|
||||
|
||||
// Mid-journey: the Agreement stage is open, earlier stages read as done, later
|
||||
// stages are locked previews, and the supporting pool sits collapsed below.
|
||||
export const Default: Story = {};
|
||||
|
||||
// Day one: only the Trial stage has been reached; everything ahead is locked.
|
||||
export const AtTrial: Story = {
|
||||
args: { currentStage: "trial" },
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Card, Chip, Collapsible } from "@shared/components";
|
||||
import type {
|
||||
DealStage,
|
||||
JourneyStep,
|
||||
LedgerDoc,
|
||||
LedgerGroup,
|
||||
SupportingGroup,
|
||||
} from "@portal/api/procurement";
|
||||
import { DocRow } from "@portal/components/procurement/DocRow";
|
||||
|
||||
/**
|
||||
* The "Documents" card: every artifact the deal needs, as a stage accordion
|
||||
* that mirrors the journey. Only the current stage is open by default; earlier
|
||||
* stages read as done, later stages are locked previews. A collapsed-by-default
|
||||
* "Supporting your evaluation" pool holds the stage-agnostic paperwork.
|
||||
*/
|
||||
export function DocumentLedger({
|
||||
groups,
|
||||
supporting,
|
||||
journey,
|
||||
currentStage,
|
||||
onAction,
|
||||
}: {
|
||||
groups: LedgerGroup[];
|
||||
supporting: SupportingGroup[];
|
||||
journey: JourneyStep[];
|
||||
currentStage: DealStage;
|
||||
onAction: (doc: LedgerDoc) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const order = journey.map((s) => s.stage);
|
||||
const curIdx = order.indexOf(currentStage);
|
||||
// Follow the deal: the stage you're in opens first; any other stage can be
|
||||
// peeked. null collapses them all. Advancing moves the open section along.
|
||||
const [openStage, setOpenStage] = useState<DealStage | null>(currentStage);
|
||||
const [supportingOpen, setSupportingOpen] = useState(false);
|
||||
useEffect(() => setOpenStage(currentStage), [currentStage]);
|
||||
|
||||
return (
|
||||
<Card padding="none" className="portal-proc__docs">
|
||||
<div className="portal-proc__docs-head">
|
||||
<h2 className="portal-proc__docs-title">
|
||||
{t("procurement.docs.title")}
|
||||
</h2>
|
||||
<p className="portal-proc__docs-sub">
|
||||
{t("procurement.docs.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="portal-proc__docs-body">
|
||||
{groups.map((group) => {
|
||||
const idx = order.indexOf(group.stage);
|
||||
const done = idx < curIdx;
|
||||
const cur = group.stage === currentStage;
|
||||
const locked = idx > curIdx;
|
||||
const blurb = journey.find((s) => s.stage === group.stage)?.blurb;
|
||||
const open = openStage === group.stage;
|
||||
const count = group.docs.length;
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.stage}
|
||||
open={open}
|
||||
onToggle={() => setOpenStage(open ? null : group.stage)}
|
||||
header={
|
||||
<>
|
||||
<span
|
||||
className="portal-proc__stage-dot"
|
||||
data-state={done ? "done" : cur ? "current" : "upcoming"}
|
||||
aria-hidden
|
||||
/>
|
||||
<span
|
||||
className="portal-proc__stage-label"
|
||||
data-current={cur || undefined}
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
{blurb && (
|
||||
<span className="portal-proc__stage-hint">· {blurb}</span>
|
||||
)}
|
||||
{cur && (
|
||||
<Chip tone="purple" size="sm">
|
||||
{t("procurement.docs.here")}
|
||||
</Chip>
|
||||
)}
|
||||
{done && (
|
||||
<Chip tone="green" size="sm">
|
||||
{t("procurement.docs.done")}
|
||||
</Chip>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
aside={
|
||||
<span className="portal-proc__stage-count">
|
||||
{t("procurement.docs.count", { count })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="portal-proc__doc-list">
|
||||
{group.docs.map((doc) => (
|
||||
<DocRow
|
||||
key={doc.id}
|
||||
doc={doc}
|
||||
onAction={onAction}
|
||||
locked={locked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
|
||||
{supporting.length > 0 && (
|
||||
<Collapsible
|
||||
className="portal-proc__supporting-acc"
|
||||
open={supportingOpen}
|
||||
onToggle={() => setSupportingOpen((o) => !o)}
|
||||
header={
|
||||
<span className="portal-proc__supporting-head">
|
||||
<span className="portal-proc__stage-label">
|
||||
{t("procurement.docs.supportingTitle")}
|
||||
</span>
|
||||
<span className="portal-proc__supporting-sub">
|
||||
{t("procurement.docs.supportingSubtitle")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
aside={
|
||||
<span className="portal-proc__acc-toggle-label">
|
||||
{supportingOpen
|
||||
? t("procurement.docs.hide")
|
||||
: t("procurement.docs.show")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="portal-proc__supporting-groups">
|
||||
{supporting.map((group) => (
|
||||
<div
|
||||
key={group.category}
|
||||
className="portal-proc__supporting-group"
|
||||
>
|
||||
<div className="portal-proc__group-label">{group.label}</div>
|
||||
<div className="portal-proc__doc-list portal-proc__doc-list--boxed">
|
||||
{group.docs.map((doc) => (
|
||||
<DocRow key={doc.id} doc={doc} onAction={onAction} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LockedState } from "@portal/components/procurement/LockedState";
|
||||
import { JOURNEY } from "@portal/mocks/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof LockedState> = {
|
||||
title: "Portal/Procurement/LockedState",
|
||||
component: LockedState,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onTalkToSales: () => {} },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LockedState>;
|
||||
|
||||
// Shown to free/pro buyers, the journey preview behind the upgrade prompt.
|
||||
export const Default: Story = {
|
||||
args: { journey: JOURNEY },
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card, EmptyState } from "@shared/components";
|
||||
import type { JourneyStep } from "@portal/api/procurement";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
|
||||
/**
|
||||
* Enterprise-only gate for free/pro buyers. Shows the journey as a greyed
|
||||
* preview behind an upgrade prompt so the buyer understands what the
|
||||
* commercial track looks like before they talk to sales.
|
||||
*/
|
||||
export function LockedState({
|
||||
journey,
|
||||
onTalkToSales,
|
||||
}: {
|
||||
journey: JourneyStep[];
|
||||
onTalkToSales: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-proc__locked">
|
||||
<EmptyState
|
||||
eyebrow={t("procurement.locked.eyebrow")}
|
||||
title={t("procurement.locked.title")}
|
||||
description={t("procurement.locked.description")}
|
||||
actions={
|
||||
<Button variant="gradient" accent="purple" onClick={onTalkToSales}>
|
||||
{t("procurement.locked.talkToSales")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Card padding="loose" className="portal-proc__journey-stepper">
|
||||
<StageStepper journey={journey} currentStage="trial" locked />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import { JOURNEY } from "@portal/mocks/procurement";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const meta: Meta<typeof StageStepper> = {
|
||||
title: "Portal/Procurement/StageStepper",
|
||||
component: StageStepper,
|
||||
parameters: { layout: "padded" },
|
||||
args: { journey: JOURNEY },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof StageStepper>;
|
||||
|
||||
export const AtAgreement: Story = { args: { currentStage: "security" } };
|
||||
|
||||
export const AtTrial: Story = { args: { currentStage: "trial" } };
|
||||
|
||||
// Greyed preview for the free/pro upgrade gate, no stage is current.
|
||||
export const Locked: Story = { args: { currentStage: "trial", locked: true } };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Fragment } from "react";
|
||||
import type { DealStage, JourneyStep } from "@portal/api/procurement";
|
||||
|
||||
/** Status of a step relative to the deal's current stage. */
|
||||
type StepState = "complete" | "current" | "upcoming";
|
||||
|
||||
/**
|
||||
* The five-stage commercial journey as a horizontal band of labelled dots with
|
||||
* connectors between them. Purely presentational, the gating action lives in
|
||||
* the journey card's next-step row, not on the dots. `locked` greys the whole
|
||||
* band for the free/pro upgrade preview, where no stage is current.
|
||||
*/
|
||||
export function StageStepper({
|
||||
journey,
|
||||
currentStage,
|
||||
locked = false,
|
||||
}: {
|
||||
journey: JourneyStep[];
|
||||
currentStage: DealStage;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const order = journey.map((s) => s.stage);
|
||||
const curIdx = locked ? -1 : order.indexOf(currentStage);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`portal-proc__steps${locked ? " portal-proc__steps--locked" : ""}`}
|
||||
>
|
||||
{journey.map((step, i) => {
|
||||
const state: StepState =
|
||||
i < curIdx ? "complete" : i === curIdx ? "current" : "upcoming";
|
||||
return (
|
||||
<Fragment key={step.stage}>
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="portal-proc__step-line"
|
||||
data-filled={i <= curIdx}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div className={`portal-proc__step portal-proc__step--${state}`}>
|
||||
<span className="portal-proc__step-dot" aria-hidden />
|
||||
<span className="portal-proc__step-label">{step.label}</span>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Shared formatting + status/action mappings for the Procurement surface. */
|
||||
|
||||
import type { StatusTone } from "@shared/components";
|
||||
import type { DocAction, DocStatus } from "@portal/api/procurement";
|
||||
|
||||
export const USD = new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
/** Document status → badge tone. Action items lean amber to pull the eye. */
|
||||
export const STATUS_TONE: Record<DocStatus, StatusTone> = {
|
||||
available: "success",
|
||||
action: "warning",
|
||||
pending: "info",
|
||||
request: "neutral",
|
||||
complete: "neutral",
|
||||
};
|
||||
|
||||
/** Document status → translation key for the short badge label. */
|
||||
export const STATUS_LABEL_KEY: Record<DocStatus, string> = {
|
||||
available: "procurement.status.available",
|
||||
action: "procurement.status.action",
|
||||
pending: "procurement.status.pending",
|
||||
request: "procurement.status.request",
|
||||
complete: "procurement.status.complete",
|
||||
};
|
||||
|
||||
/** Action → translation key for the button label (the fee, when present, is appended by the caller). */
|
||||
export const ACTION_LABEL_KEY: Record<DocAction, string> = {
|
||||
download: "procurement.action.download",
|
||||
sign: "procurement.action.sign",
|
||||
pay: "procurement.action.pay",
|
||||
upload: "procurement.action.upload",
|
||||
request: "procurement.action.request",
|
||||
};
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard";
|
||||
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
|
||||
|
||||
const SAMPLE_SERIES = sampleDailySeries(330);
|
||||
|
||||
const IN_USE: SourceView = {
|
||||
id: "src-claims",
|
||||
@@ -16,7 +19,9 @@ const IN_USE: SourceView = {
|
||||
{ label: "Directory", value: "/data/claims-intake" },
|
||||
{ label: "Mode", value: "consume" },
|
||||
],
|
||||
docsTotal: null,
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const ORPHANED: SourceView = {
|
||||
@@ -27,7 +32,9 @@ const ORPHANED: SourceView = {
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/data/archive" }],
|
||||
docsTotal: null,
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SourceDetailCard> = {
|
||||
@@ -35,6 +42,7 @@ const meta: Meta<typeof SourceDetailCard> = {
|
||||
component: SourceDetailCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
docSeries: SAMPLE_SERIES,
|
||||
onClose: () => {},
|
||||
onEdit: () => {},
|
||||
onTogglePause: () => {},
|
||||
|
||||
@@ -7,6 +7,7 @@ import "@portal/views/Sources.css";
|
||||
|
||||
interface SourceDetailCardProps {
|
||||
source: SourceView;
|
||||
docSeries: number[];
|
||||
onClose: () => void;
|
||||
onEdit: (source: SourceView) => void;
|
||||
onTogglePause: (source: SourceView) => void;
|
||||
@@ -18,6 +19,7 @@ interface SourceDetailCardProps {
|
||||
/** Expanded detail for the selected source row, with edit/pause/delete actions. */
|
||||
export function SourceDetailCard({
|
||||
source,
|
||||
docSeries,
|
||||
onClose,
|
||||
onEdit,
|
||||
onTogglePause,
|
||||
@@ -55,7 +57,7 @@ export function SourceDetailCard({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<SourceDetailPanel source={source} />
|
||||
<SourceDetailPanel source={source} docSeries={docSeries} />
|
||||
|
||||
<div className="portal-sources__detail-actions">
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
|
||||
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
|
||||
|
||||
const SAMPLE_SERIES = sampleDailySeries(330);
|
||||
|
||||
const IN_USE: SourceView = {
|
||||
id: "src-claims",
|
||||
@@ -16,7 +19,9 @@ const IN_USE: SourceView = {
|
||||
{ label: "Directory", value: "/data/claims-intake" },
|
||||
{ label: "Mode", value: "consume" },
|
||||
],
|
||||
docsTotal: null,
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
};
|
||||
|
||||
const ORPHANED: SourceView = {
|
||||
@@ -27,7 +32,9 @@ const ORPHANED: SourceView = {
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/data/archive" }],
|
||||
docsTotal: null,
|
||||
docsTotal: 1180,
|
||||
docs24h: 0,
|
||||
docs30d: 0,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof SourceDetailPanel> = {
|
||||
@@ -45,6 +52,8 @@ const meta: Meta<typeof SourceDetailPanel> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SourceDetailPanel>;
|
||||
|
||||
export const InUse: Story = { args: { source: IN_USE } };
|
||||
export const InUse: Story = {
|
||||
args: { source: IN_USE, docSeries: SAMPLE_SERIES },
|
||||
};
|
||||
/** A source no policy references is called out as safe to delete. */
|
||||
export const Orphaned: Story = { args: { source: ORPHANED } };
|
||||
export const Orphaned: Story = { args: { source: ORPHANED, docSeries: [] } };
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Chip, StatTile } from "@shared/components";
|
||||
import type { SourceView } from "@portal/api/sources";
|
||||
import { Sparkline } from "@portal/components/sources/Sparkline";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
interface SourceDetailPanelProps {
|
||||
source: SourceView;
|
||||
/** The 30-day daily series for the sparkline, fetched per source when expanded. */
|
||||
docSeries: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Expanded detail for a source row: its config (key/value) plus which policies
|
||||
* reference it. A 0-reference source is called out as safe to delete.
|
||||
* Expanded detail for a source row: its config (key/value), the documents it has
|
||||
* fed into runs, and which policies reference it (a 0-reference source is called
|
||||
* out as safe to delete).
|
||||
*/
|
||||
export function SourceDetailPanel({ source }: { source: SourceView }) {
|
||||
export function SourceDetailPanel({
|
||||
source,
|
||||
docSeries,
|
||||
}: SourceDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="portal-sources__detail">
|
||||
@@ -38,9 +49,31 @@ export function SourceDetailPanel({ source }: { source: SourceView }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="portal-sources__muted">
|
||||
{t("sources.detail.docsUntracked")}
|
||||
</p>
|
||||
<div className="portal-sources__detail-section">
|
||||
<span className="portal-sources__detail-heading">
|
||||
{t("sources.detail.documents")}
|
||||
</span>
|
||||
<div className="portal-sources__stat-grid">
|
||||
<StatTile
|
||||
label={t("sources.detail.docsTotal")}
|
||||
value={source.docsTotal.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.detail.docs24h")}
|
||||
value={source.docs24h.toLocaleString()}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("sources.detail.docs30d")}
|
||||
value={source.docs30d.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{docSeries.length > 0 && (
|
||||
<Sparkline
|
||||
data={docSeries}
|
||||
ariaLabel={t("sources.detail.docsTrend")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,9 @@ const SOURCES: SourceView[] = [
|
||||
{ label: "Directory", value: "/data/claims-intake" },
|
||||
{ label: "Mode", value: "consume" },
|
||||
],
|
||||
docsTotal: null,
|
||||
docsTotal: 45230,
|
||||
docs24h: 312,
|
||||
docs30d: 9870,
|
||||
},
|
||||
{
|
||||
id: "src-archive",
|
||||
@@ -27,7 +29,9 @@ const SOURCES: SourceView[] = [
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/data/archive" }],
|
||||
docsTotal: null,
|
||||
docsTotal: 1180,
|
||||
docs24h: 0,
|
||||
docs30d: 0,
|
||||
},
|
||||
{
|
||||
id: "src-legacy",
|
||||
@@ -37,7 +41,9 @@ const SOURCES: SourceView[] = [
|
||||
referenceCount: 0,
|
||||
referencingPolicies: [],
|
||||
config: [{ label: "Directory", value: "/mnt/legacy" }],
|
||||
docsTotal: null,
|
||||
docsTotal: 48600,
|
||||
docs24h: 0,
|
||||
docs30d: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user