merge main

This commit is contained in:
Frooodle
2026-07-02 09:26:45 +01:00
138 changed files with 7743 additions and 1048 deletions
+27
View File
@@ -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:
@@ -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
@@ -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());
}
}
}
+4
View File
@@ -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}"
@@ -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();
}
}
@@ -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) {}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.access.model;
/** Permission level a grant confers. MANAGE implies USE. */
public enum AccessPermission {
USE,
MANAGE
}
@@ -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
}
@@ -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;
}
}
@@ -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
}
@@ -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
}
@@ -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;
}
@@ -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
}
@@ -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);
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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");
}
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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) {}
@@ -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) {}
@@ -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;
}
@@ -0,0 +1,8 @@
package stirling.software.proprietary.integration.model;
/** Kind of external integration a stored config describes. */
public enum IntegrationType {
S3,
MCP,
API
}
@@ -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);
}
@@ -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);
}
}
@@ -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;
}
@@ -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);
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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",
@@ -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) {}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
@@ -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);
}
@@ -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) {}
@@ -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;
}
}
@@ -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);
}
@@ -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();
}
}
@@ -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". */
@@ -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) {}
@@ -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 {
@@ -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
@@ -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;
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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
@@ -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"));
}
}
@@ -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 {}
}
@@ -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);
@@ -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) {
@@ -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();
}
@@ -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"
+2
View File
@@ -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 />} />
+7 -1
View File
@@ -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);
}
+198 -39
View File
@@ -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" },
);
+94
View File
@@ -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 } },
);
}
+15 -2
View File
@@ -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", {
+5 -15
View File
@@ -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>
);
}
+10 -1
View File
@@ -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>
+10
View File
@@ -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