Merge remote-tracking branch 'origin/main' into multi-api-keys-team-scope
# Conflicts: # frontend/editor/src/portal/components/sources/sourceTypes.ts
This commit is contained in:
@@ -246,6 +246,14 @@ public class ApplicationProperties {
|
||||
* and paused runs are kept regardless of age.
|
||||
*/
|
||||
private int runExpiryMinutes = 30;
|
||||
|
||||
/**
|
||||
* Whether a policy S3 source's custom endpoint may resolve to a loopback, link-local, or
|
||||
* private address. Off by default so a user-supplied endpoint cannot be pointed at internal
|
||||
* services (e.g. the cloud metadata address); enable for a self-hosted MinIO or other
|
||||
* in-network object store.
|
||||
*/
|
||||
private boolean allowPrivateS3Endpoints = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
+21
-5
@@ -133,29 +133,45 @@ public final class S3Clients {
|
||||
* storage.s3.allow-private-endpoints=true}.
|
||||
*/
|
||||
static void validateEndpointHost(URI endpoint, boolean allowPrivate) {
|
||||
validateEndpointHost(
|
||||
endpoint,
|
||||
allowPrivate,
|
||||
"storage.s3.endpoint",
|
||||
"set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
}
|
||||
|
||||
/**
|
||||
* The same private-address guard for S3 endpoints configured outside the {@code storage.s3.*}
|
||||
* block (e.g. per-source policy config), with the setting named in messages supplied by the
|
||||
* caller.
|
||||
*/
|
||||
public static void validateEndpointHost(
|
||||
URI endpoint, boolean allowPrivate, String settingName, String optInHint) {
|
||||
if (allowPrivate) {
|
||||
return;
|
||||
}
|
||||
String host = endpoint.getHost();
|
||||
if (host == null || host.isBlank()) {
|
||||
throw new IllegalStateException("storage.s3.endpoint must include a host: " + endpoint);
|
||||
throw new IllegalStateException(settingName + " must include a host: " + endpoint);
|
||||
}
|
||||
InetAddress[] addresses;
|
||||
try {
|
||||
addresses = InetAddress.getAllByName(host);
|
||||
} catch (UnknownHostException e) {
|
||||
throw new IllegalStateException(
|
||||
"Unable to resolve storage.s3.endpoint host '" + host + "'", e);
|
||||
"Unable to resolve " + settingName + " host '" + host + "'", e);
|
||||
}
|
||||
for (InetAddress address : addresses) {
|
||||
if (isPrivateOrLocal(address)) {
|
||||
throw new IllegalStateException(
|
||||
"storage.s3.endpoint host '"
|
||||
settingName
|
||||
+ " host '"
|
||||
+ host
|
||||
+ "' resolves to private/link-local address "
|
||||
+ address.getHostAddress()
|
||||
+ "; set storage.s3.allow-private-endpoints=true to opt in"
|
||||
+ " (e.g. for MinIO or in-cluster S3).");
|
||||
+ "; "
|
||||
+ optInHint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import jakarta.persistence.AttributeConverter;
|
||||
import jakarta.persistence.Converter;
|
||||
|
||||
/**
|
||||
* {@link EncryptedStringConverter} for columns that held plaintext before encryption shipped:
|
||||
* writes are always encrypted, but a stored value that is not valid ciphertext is returned as-is,
|
||||
* so pre-encryption rows keep loading and become encrypted on their next save. The discrimination
|
||||
* is exact for JSON payloads, which can never be mistaken for ciphertext ('{' is not in the Base64
|
||||
* alphabet). The trade-off is that a genuinely corrupted ciphertext surfaces as garbage to the
|
||||
* caller's parser instead of failing here.
|
||||
*/
|
||||
@Converter
|
||||
public class LenientEncryptedStringConverter implements AttributeConverter<String, String> {
|
||||
|
||||
@Override
|
||||
public String convertToDatabaseColumn(String attribute) {
|
||||
return CredentialEncryption.encrypt(attribute);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String convertToEntityAttribute(String dbData) {
|
||||
try {
|
||||
return CredentialEncryption.decrypt(dbData);
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
// Not ciphertext: legacy plaintext from before encryption shipped.
|
||||
return dbData;
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-8
@@ -48,7 +48,9 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
import stirling.software.proprietary.policy.engine.PolicyValidator;
|
||||
import stirling.software.proprietary.policy.engine.SweepOutcome;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
@@ -64,6 +66,7 @@ import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTrigger;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.policy.trigger.TriggerInfo;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* Policy CRUD plus pipeline runs (stored or ad-hoc). Runs are async: returns a run id, poll {@code
|
||||
@@ -202,7 +205,7 @@ public class PolicyController {
|
||||
+ " assigned; returns the stored policy with its id.")
|
||||
public ResponseEntity<Policy> savePolicy(@RequestBody Policy policy) {
|
||||
requirePolicyEditingAllowed();
|
||||
Policy owned = resolveOwnership(policy);
|
||||
Policy owned = withStoredOutputSecrets(resolveOwnership(policy));
|
||||
requireAccessibleSources(owned);
|
||||
try {
|
||||
policyValidator.validate(owned);
|
||||
@@ -213,7 +216,7 @@ public class PolicyController {
|
||||
// Re-sync trigger registrations now so a new/changed folder-watch policy starts being
|
||||
// watched immediately instead of after the next reconcile sweep.
|
||||
policyTriggerManager.notifyPoliciesChanged();
|
||||
return ResponseEntity.ok(saved);
|
||||
return ResponseEntity.ok(withMaskedOutputSecrets(saved));
|
||||
}
|
||||
|
||||
@PutMapping("/order")
|
||||
@@ -282,6 +285,50 @@ public class PolicyController {
|
||||
teamId);
|
||||
}
|
||||
|
||||
/** Output secrets never leave the server: reads return the redaction sentinel instead. */
|
||||
private static Policy withMaskedOutputSecrets(Policy policy) {
|
||||
return withOutput(
|
||||
policy,
|
||||
new OutputSpec(
|
||||
policy.output().type(), SecretMasker.mask(policy.output().options())));
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit that round-trips a masked read sends output secrets back as the sentinel; restore
|
||||
* them from the stored policy so saving without re-typing keeps them (validation then runs
|
||||
* against the real values).
|
||||
*/
|
||||
private Policy withStoredOutputSecrets(Policy incoming) {
|
||||
if (incoming.id() == null || incoming.id().isBlank()) {
|
||||
return incoming;
|
||||
}
|
||||
return policyStore
|
||||
.get(incoming.id())
|
||||
.map(
|
||||
existing ->
|
||||
withOutput(
|
||||
incoming,
|
||||
new OutputSpec(
|
||||
incoming.output().type(),
|
||||
SecretMasker.restoreRedacted(
|
||||
incoming.output().options(),
|
||||
existing.output().options()))))
|
||||
.orElse(incoming);
|
||||
}
|
||||
|
||||
private static Policy withOutput(Policy policy, OutputSpec output) {
|
||||
return new Policy(
|
||||
policy.id(),
|
||||
policy.name(),
|
||||
policy.owner(),
|
||||
policy.enabled(),
|
||||
policy.trigger(),
|
||||
policy.sourceIds(),
|
||||
policy.steps(),
|
||||
output,
|
||||
policy.teamId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating, editing, pausing/resuming, and deleting policies requires the editor role for the
|
||||
* caller's team — a team leader on SaaS (see {@link PolicyManagementAuthority}); the global
|
||||
@@ -306,9 +353,14 @@ public class PolicyController {
|
||||
@GetMapping
|
||||
@Operation(
|
||||
summary = "List policies",
|
||||
description = "Lists the policies belonging to the caller's team.")
|
||||
description =
|
||||
"Lists the policies belonging to the caller's team. Secret-bearing output"
|
||||
+ " options are returned as a redaction sentinel, never their stored"
|
||||
+ " values.")
|
||||
public List<Policy> listPolicies() {
|
||||
return policyAccessGuard.visibleFrom(policyStore);
|
||||
return policyAccessGuard.visibleFrom(policyStore).stream()
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@GetMapping("/overview")
|
||||
@@ -337,11 +389,17 @@ public class PolicyController {
|
||||
}
|
||||
|
||||
@GetMapping("/{policyId}")
|
||||
@Operation(summary = "Get a policy by id")
|
||||
@Operation(
|
||||
summary = "Get a policy by id",
|
||||
description =
|
||||
"Secret-bearing output options are returned as a redaction sentinel, never"
|
||||
+ " their stored values; an edit that sends the sentinel back keeps"
|
||||
+ " them.")
|
||||
public ResponseEntity<Policy> getPolicy(@PathVariable String policyId) {
|
||||
return policyStore
|
||||
.get(policyId)
|
||||
.filter(policyAccessGuard::canAccess)
|
||||
.map(PolicyController::withMaskedOutputSecrets)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
@@ -412,9 +470,10 @@ public class PolicyController {
|
||||
description =
|
||||
"Pulls the policy's configured sources and runs the pipeline now, regardless of"
|
||||
+ " the enabled flag (which only gates automatic triggering). Returns"
|
||||
+ " the ids of the runs started; poll the run-status endpoint for each."
|
||||
+ " Empty when the sources yielded no work to do.")
|
||||
public ResponseEntity<List<String>> trigger(@PathVariable String policyId) {
|
||||
+ " the ids of the runs started (poll the run-status endpoint for each)"
|
||||
+ " plus what the sweep skipped - already-processed, parked-by-failure,"
|
||||
+ " and in-flight counts - so an empty result explains itself.")
|
||||
public ResponseEntity<SweepOutcome> trigger(@PathVariable String policyId) {
|
||||
Policy policy =
|
||||
policyStore
|
||||
.get(policyId)
|
||||
|
||||
+5
-5
@@ -44,7 +44,7 @@ public class PolicyRunner {
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
/** Full-listing sweep: resolve every source, then reconcile the ledger. */
|
||||
public List<String> run(Policy policy) {
|
||||
public SweepOutcome run(Policy policy) {
|
||||
return run(policy, SweepKind.FULL);
|
||||
}
|
||||
|
||||
@@ -52,10 +52,10 @@ public class PolicyRunner {
|
||||
* Trigger entry point. Pulls every referenced source; each yielded unit becomes its own run so
|
||||
* one failure does not affect the others. No sources means one run with no input (generator
|
||||
* pipeline). Missing or disabled sources are skipped so one broken reference does not stop the
|
||||
* rest. Returns the ids of the runs it started (empty when sources yielded no work), so a
|
||||
* manual trigger can report back which runs to follow.
|
||||
* rest. Returns the ids of the runs it started plus what the sweep skipped, so a manual trigger
|
||||
* can report which runs to follow or why nothing ran.
|
||||
*/
|
||||
public List<String> run(Policy policy, SweepKind sweep) {
|
||||
public SweepOutcome run(Policy policy, SweepKind sweep) {
|
||||
long sweepStart = System.currentTimeMillis();
|
||||
PolicySweep context = new PolicySweep(policy.id(), sweep, processedLedger);
|
||||
List<String> runIds = new ArrayList<>();
|
||||
@@ -95,7 +95,7 @@ public class PolicyRunner {
|
||||
policy.id());
|
||||
}
|
||||
}
|
||||
return runIds;
|
||||
return context.outcome(runIds);
|
||||
}
|
||||
|
||||
/** Run a stored policy on caller-supplied files (e.g. manual upload), bypassing its sources. */
|
||||
|
||||
+28
@@ -86,4 +86,32 @@ final class PolicySweep implements ResolveContext {
|
||||
synchronized Set<String> presentIdentities() {
|
||||
return Set.copyOf(present);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise the sweep from state already in hand (no extra ledger reads): the prefetched rows
|
||||
* were loaded before claiming, and successful claims flipped their entries to PROCESSING, so
|
||||
* what remains DONE or ERROR is exactly what this sweep skipped.
|
||||
*/
|
||||
synchronized SweepOutcome outcome(List<String> runIds) {
|
||||
int alreadyProcessed = 0;
|
||||
int parked = 0;
|
||||
int processing = 0;
|
||||
for (String identity : present) {
|
||||
ClaimState state = prefetched.get(identity);
|
||||
if (state == null) {
|
||||
continue;
|
||||
}
|
||||
switch (state.status()) {
|
||||
case DONE -> alreadyProcessed++;
|
||||
case ERROR -> parked++;
|
||||
case PROCESSING, INTERRUPTED -> processing++;
|
||||
}
|
||||
}
|
||||
return new SweepOutcome(
|
||||
runIds,
|
||||
present.size(),
|
||||
alreadyProcessed,
|
||||
parked,
|
||||
Math.max(0, processing - runIds.size()));
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* What one policy sweep found and started, so a manual trigger can explain an empty result instead
|
||||
* of a blanket "nothing to do": how many files the sources listed, how many were skipped because
|
||||
* they are already processed at their current version, how many are parked by a failed run (not
|
||||
* retried until they change or history is cleared), and how many are still in flight from an
|
||||
* earlier sweep. Counts are zero for {@link SweepKind#LIGHT} sweeps, which do not take a full
|
||||
* listing.
|
||||
*/
|
||||
public record SweepOutcome(
|
||||
List<String> runIds, int filesListed, int alreadyProcessed, int parked, int inFlight) {
|
||||
|
||||
public SweepOutcome {
|
||||
runIds = runIds == null ? List.of() : List.copyOf(runIds);
|
||||
}
|
||||
}
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.AbstractResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.PolicyInputs;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
/**
|
||||
* Reads input files from an Amazon S3 (or S3-compatible) bucket; each listed object is its own unit
|
||||
* of work, claimed through the {@link ResolveContext} ledger and tracked in place. Identity and
|
||||
* version gate come from {@link S3Identities}, so the steady-state sweep never downloads content.
|
||||
* Options (see {@link S3Config}): "bucket" (required), "region" (default us-east-1), "prefix" (only
|
||||
* keys starting with it are read), "endpoint" (S3-compatible stores such as MinIO; path-style
|
||||
* addressing is used automatically), "accessKeyId" and "secretAccessKey" (required; requests are
|
||||
* never signed with the server's own AWS identity), and "mode" which is "consume" (default: a
|
||||
* processed object is deleted once every policy that claimed it has settled successfully and it is
|
||||
* still the version that ran; failures stay in place and are not retried until they change) or
|
||||
* "snapshot" (stateless, every run sees the full set). Keys ending in "/" (folder placeholders) and
|
||||
* keys with a dot-prefixed path segment are never picked up, mirroring the folder source's
|
||||
* hidden-file rule.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3InputSource implements InputSource {
|
||||
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(InputSpec spec) {
|
||||
return spec != null && TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or
|
||||
* a bucket the supplied credentials cannot list.
|
||||
*/
|
||||
@Override
|
||||
public void validate(InputSpec spec) {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
try {
|
||||
connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build());
|
||||
} catch (SdkException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"cannot access s3://"
|
||||
+ config.bucket()
|
||||
+ "/"
|
||||
+ config.prefix()
|
||||
+ ": "
|
||||
+ e.getMessage(),
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResolvedInput> resolve(InputSpec spec, ResolveContext ctx) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
// A listing failure propagates so the sweep reads it as "could not list" (which vetoes
|
||||
// presence cleanup), never as "verifiably no objects".
|
||||
List<S3Object> objects = listObjects(client, config);
|
||||
|
||||
if (config.snapshot()) {
|
||||
return objects.stream()
|
||||
.map(
|
||||
object ->
|
||||
ResolvedInput.of(
|
||||
PolicyInputs.of(
|
||||
List.of(
|
||||
objectResource(
|
||||
client, config, object)))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
ctx.reportPresent(
|
||||
objects.stream()
|
||||
.map(object -> S3Identities.identity(config.bucket(), object.key()))
|
||||
.toList());
|
||||
|
||||
List<ResolvedInput> work = new ArrayList<>();
|
||||
for (S3Object object : objects) {
|
||||
String identity = S3Identities.identity(config.bucket(), object.key());
|
||||
String gate = S3Identities.gate(object.eTag(), object.size(), object.lastModified());
|
||||
if (!ctx.claim(identity, gate, null)) {
|
||||
continue;
|
||||
}
|
||||
work.add(
|
||||
new ResolvedInput(
|
||||
PolicyInputs.of(List.of(objectResource(client, config, object))),
|
||||
success ->
|
||||
completeConsumed(
|
||||
ctx,
|
||||
client,
|
||||
config,
|
||||
object.key(),
|
||||
identity,
|
||||
gate,
|
||||
success)));
|
||||
}
|
||||
return work;
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle at the version this run claimed, then remove the object only when it still carries
|
||||
* that version and every policy that claimed it has settled DONE, mirroring the folder source's
|
||||
* consensus delete. A failed run settles ERROR and never deletes; the DONE row of an object
|
||||
* that could not be deleted still stops reprocessing.
|
||||
*/
|
||||
private void completeConsumed(
|
||||
ResolveContext ctx,
|
||||
S3Client client,
|
||||
S3Config config,
|
||||
String key,
|
||||
String identity,
|
||||
String claimGate,
|
||||
boolean success) {
|
||||
ctx.settle(identity, claimGate, null, success);
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
HeadObjectResponse head =
|
||||
client.headObject(
|
||||
HeadObjectRequest.builder().bucket(config.bucket()).key(key).build());
|
||||
String currentGate =
|
||||
S3Identities.gate(head.eTag(), head.contentLength(), head.lastModified());
|
||||
if (currentGate.equals(claimGate) && ctx.allSettledDone(identity)) {
|
||||
client.deleteObject(
|
||||
DeleteObjectRequest.builder().bucket(config.bucket()).key(key).build());
|
||||
}
|
||||
} catch (NoSuchKeyException alreadyGone) {
|
||||
// Removed by the user or a co-watching policy's own consensus delete: nothing to do.
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return;
|
||||
}
|
||||
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
|
||||
} catch (SdkException e) {
|
||||
log.warn("Could not remove consumed S3 object {}: {}", identity, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Every ingestible object under the configured prefix, across all listing pages. */
|
||||
private static List<S3Object> listObjects(S3Client client, S3Config config) {
|
||||
List<S3Object> objects = new ArrayList<>();
|
||||
String continuationToken = null;
|
||||
do {
|
||||
ListObjectsV2Request.Builder request = listRequest(config);
|
||||
if (continuationToken != null) {
|
||||
request.continuationToken(continuationToken);
|
||||
}
|
||||
ListObjectsV2Response page = client.listObjectsV2(request.build());
|
||||
for (S3Object object : page.contents()) {
|
||||
if (ingestible(object)) {
|
||||
objects.add(object);
|
||||
}
|
||||
}
|
||||
continuationToken = page.nextContinuationToken();
|
||||
} while (continuationToken != null);
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static ListObjectsV2Request.Builder listRequest(S3Config config) {
|
||||
ListObjectsV2Request.Builder request =
|
||||
ListObjectsV2Request.builder().bucket(config.bucket());
|
||||
if (!config.prefix().isEmpty()) {
|
||||
request.prefix(config.prefix());
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder-placeholder keys (ending "/") and keys with a dot-prefixed segment are skipped, so a
|
||||
* hidden convention (e.g. a future output sink's staging prefix) is never re-ingested.
|
||||
*/
|
||||
private static boolean ingestible(S3Object object) {
|
||||
String key = object.key();
|
||||
if (key.isEmpty() || key.endsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
for (String segment : key.split("/")) {
|
||||
if (segment.startsWith(".")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Resource objectResource(S3Client client, S3Config config, S3Object object) {
|
||||
return new S3ObjectResource(client, config.bucket(), object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the object on demand, pinned to the ETag observed at listing time so a run never
|
||||
* reads a different version than the sweep claimed (a swapped object fails the read with a
|
||||
* precondition error and the new version is claimed by a later sweep).
|
||||
*/
|
||||
private static final class S3ObjectResource extends AbstractResource {
|
||||
|
||||
private final S3Client client;
|
||||
private final String bucket;
|
||||
private final String key;
|
||||
private final String eTag;
|
||||
private final Long size;
|
||||
|
||||
private S3ObjectResource(S3Client client, String bucket, S3Object object) {
|
||||
this.client = client;
|
||||
this.bucket = bucket;
|
||||
this.key = object.key();
|
||||
this.eTag = object.eTag();
|
||||
this.size = object.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() throws IOException {
|
||||
GetObjectRequest.Builder request = GetObjectRequest.builder().bucket(bucket).key(key);
|
||||
if (eTag != null && !eTag.isBlank()) {
|
||||
request.ifMatch(eTag);
|
||||
}
|
||||
try {
|
||||
return client.getObject(request.build());
|
||||
} catch (NoSuchKeyException e) {
|
||||
throw new FileNotFoundException(getDescription() + " no longer exists");
|
||||
} catch (SdkException e) {
|
||||
throw new IOException(
|
||||
"Could not read " + getDescription() + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Listed just now; readers get a precise error from {@link #getInputStream} instead. */
|
||||
@Override
|
||||
public boolean exists() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long contentLength() {
|
||||
return size == null ? -1 : size;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return key.substring(key.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "S3 object " + S3Identities.identity(bucket, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-18
@@ -15,7 +15,6 @@ import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -82,7 +81,7 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
Resource resource = outputs.get(i);
|
||||
String name = safeName(resource.getFilename(), i);
|
||||
String name = OutputNames.safeName(resource.getFilename(), i);
|
||||
Path staged = tmpDir.resolve(UUID.randomUUID().toString());
|
||||
String contentHash = stage(resource, staged, delivery.policyId() != null);
|
||||
long size = Files.size(staged);
|
||||
@@ -198,29 +197,14 @@ public class FolderOutputSink implements PolicyOutputSink {
|
||||
return Path.of(directory.toString());
|
||||
}
|
||||
|
||||
// Strip any directory component / "../" so a crafted output name cannot escape targetDir.
|
||||
private static String safeName(String filename, int index) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "output-" + index;
|
||||
}
|
||||
String name = FilenameUtils.getName(filename);
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return "output-" + index;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
// Non-colliding path, appending " (n)" before the extension.
|
||||
private static Path uniqueTarget(Path dir, String filename) {
|
||||
Path candidate = dir.resolve(filename);
|
||||
if (!Files.exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
String base = FilenameUtils.getBaseName(filename);
|
||||
String ext = FilenameUtils.getExtension(filename);
|
||||
String suffix = ext.isEmpty() ? "" : "." + ext;
|
||||
for (int n = 1; ; n++) {
|
||||
Path next = dir.resolve(base + " (" + n + ")" + suffix);
|
||||
Path next = dir.resolve(OutputNames.numbered(filename, n));
|
||||
if (!Files.exists(next)) {
|
||||
return next;
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
|
||||
/** Output file naming shared by the sinks: sanitised base names and collision suffixes. */
|
||||
final class OutputNames {
|
||||
|
||||
private OutputNames() {}
|
||||
|
||||
/** Strip any directory component / "../" so a crafted output name cannot escape the target. */
|
||||
static String safeName(String filename, int index) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "output-" + index;
|
||||
}
|
||||
String name = FilenameUtils.getName(filename);
|
||||
if (name.isBlank() || ".".equals(name) || "..".equals(name)) {
|
||||
return "output-" + index;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** The nth alternative for a taken name, appending " (n)" before the extension. */
|
||||
static String numbered(String filename, int n) {
|
||||
String base = FilenameUtils.getBaseName(filename);
|
||||
String ext = FilenameUtils.getExtension(filename);
|
||||
String suffix = ext.isEmpty() ? "" : "." + ext;
|
||||
return base + " (" + n + ")" + suffix;
|
||||
}
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.DigestOutputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3Config;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
import stirling.software.proprietary.policy.s3.S3Identities;
|
||||
|
||||
import software.amazon.awssdk.core.exception.SdkException;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/**
|
||||
* Uploads a run's outputs to the bucket and key prefix given in the {@link OutputSpec} (same
|
||||
* connection options as the S3 input source; "prefix" is the destination folder). The
|
||||
* record-before-visible obligation is met without a rename step: a single-part PUT's ETag is the
|
||||
* MD5 of its content on plain and SSE-S3 buckets, so the ledger row is recorded at that predicted
|
||||
* gate BEFORE the upload, and the object is claimed under exactly the gate the next listing
|
||||
* returns. Stores where the returned ETag differs (e.g. SSE-KMS) are re-recorded at the actual gate
|
||||
* immediately after the PUT - a narrow race those buckets accept rather than a broken loop. Names
|
||||
* never overwrite: uploads are conditional on the key not existing ({@code If-None-Match: *}),
|
||||
* re-picking "name (n).ext" on collision exactly like the folder sink; stores without
|
||||
* conditional-write support fall back to an existence check per candidate.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3OutputSink implements PolicyOutputSink {
|
||||
|
||||
private static final String TYPE = "s3";
|
||||
|
||||
private final S3ConnectionPool connectionPool;
|
||||
private final ProcessedLedger processedLedger;
|
||||
|
||||
@Override
|
||||
public String type() {
|
||||
return TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supports(OutputSpec spec) {
|
||||
return spec != null && TYPE.equals(spec.type());
|
||||
}
|
||||
|
||||
/**
|
||||
* Config shape and endpoint guard only - no network probe, since write-only credentials
|
||||
* (s3:PutObject without s3:ListBucket) are a legitimate setup for an output bucket and a
|
||||
* listing probe would wrongly reject them.
|
||||
*/
|
||||
@Override
|
||||
public void validate(OutputSpec spec) {
|
||||
connectionPool.clientFor(S3Config.from(spec.options()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ResultFile> deliver(
|
||||
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
|
||||
S3Config config = S3Config.from(spec.options());
|
||||
S3Client client = connectionPool.clientFor(config);
|
||||
|
||||
List<ResultFile> results = new ArrayList<>();
|
||||
for (int i = 0; i < outputs.size(); i++) {
|
||||
Resource resource = outputs.get(i);
|
||||
String name = OutputNames.safeName(resource.getFilename(), i);
|
||||
Path staged = Files.createTempFile("s3-output-", ".tmp");
|
||||
try {
|
||||
String predictedGate = stage(resource, staged, delivery.policyId() != null);
|
||||
long size = Files.size(staged);
|
||||
String key = upload(delivery, client, config, name, staged, predictedGate);
|
||||
String contentType =
|
||||
MediaTypeFactory.getMediaType(name)
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.toString();
|
||||
results.add(
|
||||
ResultFile.builder()
|
||||
.fileId(UUID.randomUUID().toString())
|
||||
.fileName(S3Identities.identity(config.bucket(), key))
|
||||
.contentType(contentType)
|
||||
.fileSize(size)
|
||||
.build());
|
||||
log.debug(
|
||||
"Wrote policy run {} output to {}",
|
||||
delivery.runId(),
|
||||
S3Identities.identity(config.bucket(), key));
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(staged);
|
||||
} catch (IOException e) {
|
||||
log.warn("Could not remove S3 staging file {}: {}", staged, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spool the output to a local staging file (S3 needs a known content length, and the body must
|
||||
* be re-readable across collision retries). For a recorded delivery the MD5 - the predicted
|
||||
* single-part ETag - is digested in the same pass; ad-hoc runs record nothing and skip it.
|
||||
*/
|
||||
private static String stage(Resource resource, Path staged, boolean recorded)
|
||||
throws IOException {
|
||||
if (!recorded) {
|
||||
try (InputStream is = resource.getInputStream();
|
||||
OutputStream out = Files.newOutputStream(staged)) {
|
||||
is.transferTo(out);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
MessageDigest digest = newMd5();
|
||||
try (InputStream is = resource.getInputStream();
|
||||
DigestOutputStream out =
|
||||
new DigestOutputStream(Files.newOutputStream(staged), digest)) {
|
||||
is.transferTo(out);
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
/**
|
||||
* The S3 shape of the folder sink's record-then-rename loop. The ledger row must exist before
|
||||
* the object is visible, so it is recorded at the predicted gate before the PUT; losing the
|
||||
* chosen key to a concurrent writer (the conditional PUT fails) forgets the just-recorded row -
|
||||
* whatever object actually owns that key must stay claimable at any version - then re-picks. A
|
||||
* PUT that never made the object visible also forgets its row.
|
||||
*/
|
||||
private String upload(
|
||||
OutputDelivery delivery,
|
||||
S3Client client,
|
||||
S3Config config,
|
||||
String name,
|
||||
Path staged,
|
||||
String predictedGate)
|
||||
throws IOException {
|
||||
String keyPrefix = keyPrefix(config);
|
||||
boolean conditionalPuts = true;
|
||||
for (int attempt = 0; ; attempt++) {
|
||||
String key = keyPrefix + (attempt == 0 ? name : OutputNames.numbered(name, attempt));
|
||||
String identity = S3Identities.identity(config.bucket(), key);
|
||||
if (!conditionalPuts && exists(client, config.bucket(), key)) {
|
||||
continue;
|
||||
}
|
||||
if (delivery.policyId() != null) {
|
||||
processedLedger.recordOutput(delivery.policyId(), identity, predictedGate, null);
|
||||
}
|
||||
PutObjectRequest.Builder put =
|
||||
PutObjectRequest.builder().bucket(config.bucket()).key(key);
|
||||
if (conditionalPuts) {
|
||||
put.ifNoneMatch("*");
|
||||
}
|
||||
try {
|
||||
PutObjectResponse response =
|
||||
client.putObject(put.build(), RequestBody.fromFile(staged));
|
||||
reRecordIfGateDiffers(delivery, identity, predictedGate, response);
|
||||
return key;
|
||||
} catch (S3Exception e) {
|
||||
forgetRecorded(delivery, identity, predictedGate);
|
||||
if (conditionalPuts && e.statusCode() == 412) {
|
||||
// Known edge: if our own PUT succeeded server-side but the response was lost
|
||||
// and the SDK retried, that retry 412s here too - we then upload under the
|
||||
// next name, leaving the first object row-less (claimable, single duplicate).
|
||||
// Requires a response-lost network flake at exactly this moment; accepted.
|
||||
log.debug("Output key {} taken concurrently; re-picking", identity);
|
||||
continue;
|
||||
}
|
||||
if (conditionalPuts && e.statusCode() == 501) {
|
||||
// Store without conditional-write support: retry this candidate with a plain
|
||||
// existence check instead.
|
||||
log.debug(
|
||||
"Conditional PUT unsupported by {}; falling back to existence checks",
|
||||
config.bucket());
|
||||
conditionalPuts = false;
|
||||
attempt--;
|
||||
continue;
|
||||
}
|
||||
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
|
||||
} catch (SdkException e) {
|
||||
forgetRecorded(delivery, identity, predictedGate);
|
||||
throw new IOException("Could not upload " + identity + ": " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On buckets where a PUT's ETag is not the content MD5 (e.g. SSE-KMS), re-record at the gate
|
||||
* listings will actually return. The row is briefly at the wrong gate while the object is
|
||||
* already visible - the narrow race such stores trade for a working self-output skip.
|
||||
*/
|
||||
private void reRecordIfGateDiffers(
|
||||
OutputDelivery delivery,
|
||||
String identity,
|
||||
String predictedGate,
|
||||
PutObjectResponse response) {
|
||||
if (delivery.policyId() == null) {
|
||||
return;
|
||||
}
|
||||
String actualGate = S3Identities.gate(response.eTag(), null, null);
|
||||
if (!actualGate.equals(predictedGate)) {
|
||||
log.debug(
|
||||
"PUT ETag for {} differs from content MD5 (encrypted bucket?); re-recording",
|
||||
identity);
|
||||
processedLedger.recordOutput(delivery.policyId(), identity, actualGate, null);
|
||||
}
|
||||
}
|
||||
|
||||
private void forgetRecorded(OutputDelivery delivery, String identity, String predictedGate) {
|
||||
if (delivery.policyId() != null) {
|
||||
processedLedger.forgetOutput(delivery.policyId(), identity, predictedGate);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean exists(S3Client client, String bucket, String key) {
|
||||
try {
|
||||
client.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
return true;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return false;
|
||||
} catch (S3Exception e) {
|
||||
if (e.statusCode() == 404) {
|
||||
return false;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** The configured prefix as a key-path prefix: "processed" and "processed/" mean the same. */
|
||||
private static String keyPrefix(S3Config config) {
|
||||
String prefix = config.prefix();
|
||||
if (prefix.isEmpty() || prefix.endsWith("/")) {
|
||||
return prefix;
|
||||
}
|
||||
return prefix + "/";
|
||||
}
|
||||
|
||||
private static MessageDigest newMd5() {
|
||||
try {
|
||||
return MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("MD5 unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Connection settings shared by the S3 input source and output sink, parsed from a spec's options
|
||||
* map. Credentials are required: there is deliberately no fallback to the server's own AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. {@code snapshot}
|
||||
* is input-only and ignored by the sink.
|
||||
*/
|
||||
public record S3Config(
|
||||
String bucket,
|
||||
String region,
|
||||
String prefix,
|
||||
String endpoint,
|
||||
String accessKeyId,
|
||||
String secretAccessKey,
|
||||
boolean snapshot) {
|
||||
|
||||
private static final String BUCKET_OPTION = "bucket";
|
||||
private static final String REGION_OPTION = "region";
|
||||
private static final String PREFIX_OPTION = "prefix";
|
||||
private static final String ENDPOINT_OPTION = "endpoint";
|
||||
private static final String ACCESS_KEY_ID_OPTION = "accessKeyId";
|
||||
private static final String SECRET_ACCESS_KEY_OPTION = "secretAccessKey";
|
||||
private static final String MODE_OPTION = "mode";
|
||||
private static final String MODE_CONSUME = "consume";
|
||||
private static final String MODE_SNAPSHOT = "snapshot";
|
||||
|
||||
public static S3Config from(Map<String, Object> options) {
|
||||
String bucket = trimmed(options.get(BUCKET_OPTION));
|
||||
if (bucket == null) {
|
||||
throw new IllegalArgumentException("s3 config requires a 'bucket' option");
|
||||
}
|
||||
String region = trimmed(options.get(REGION_OPTION));
|
||||
String prefix = trimmed(options.get(PREFIX_OPTION));
|
||||
if (prefix != null && prefix.startsWith("/")) {
|
||||
prefix = prefix.substring(1);
|
||||
}
|
||||
String endpoint = validEndpoint(trimmed(options.get(ENDPOINT_OPTION)));
|
||||
String accessKeyId = trimmed(options.get(ACCESS_KEY_ID_OPTION));
|
||||
String secretAccessKey = trimmed(options.get(SECRET_ACCESS_KEY_OPTION));
|
||||
if (accessKeyId == null || secretAccessKey == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 config requires an 'accessKeyId' and 'secretAccessKey'");
|
||||
}
|
||||
String mode = trimmed(options.get(MODE_OPTION));
|
||||
if (mode != null && !MODE_CONSUME.equals(mode) && !MODE_SNAPSHOT.equals(mode)) {
|
||||
throw new IllegalArgumentException("s3 config 'mode' must be 'consume' or 'snapshot'");
|
||||
}
|
||||
return new S3Config(
|
||||
bucket,
|
||||
region == null ? "us-east-1" : region,
|
||||
prefix == null ? "" : prefix,
|
||||
endpoint,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
MODE_SNAPSHOT.equals(mode));
|
||||
}
|
||||
|
||||
private static String validEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(endpoint);
|
||||
} catch (URISyntaxException e) {
|
||||
throw new IllegalArgumentException("s3 config 'endpoint' is not a valid URL", e);
|
||||
}
|
||||
if (!"http".equals(uri.getScheme()) && !"https".equals(uri.getScheme())) {
|
||||
throw new IllegalArgumentException(
|
||||
"s3 config 'endpoint' must be an http(s) URL, e.g. https://s3.example.com");
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
private static String trimmed(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String text = value.toString().trim();
|
||||
return text.isEmpty() ? null : text;
|
||||
}
|
||||
|
||||
/** Never prints the credentials, so an accidental log line cannot leak them. */
|
||||
@Override
|
||||
public String toString() {
|
||||
return "S3Config[bucket="
|
||||
+ bucket
|
||||
+ ", region="
|
||||
+ region
|
||||
+ ", prefix="
|
||||
+ prefix
|
||||
+ ", endpoint="
|
||||
+ endpoint
|
||||
+ ", snapshot="
|
||||
+ snapshot
|
||||
+ "]";
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.cluster.s3.S3Clients;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3ClientBuilder;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
|
||||
/**
|
||||
* Long-lived {@link S3Client}s for policy S3 sources and sinks, one per distinct {@link S3Config},
|
||||
* closed at shutdown. An edited spec simply maps to a new entry, and a stale entry costs nothing
|
||||
* (the URL-connection HTTP client holds no pooled sockets or threads). Clients sign exclusively
|
||||
* with the spec's own credentials - there is deliberately no fallback to the server's AWS
|
||||
* credential chain, so user-supplied config can never borrow the host's identity. Endpoints are
|
||||
* guarded against private addresses before a client is ever built, since they come from portal
|
||||
* users rather than the operator.
|
||||
*/
|
||||
@Service
|
||||
@ConditionalOnBooleanProperty(name = "policies.enabled")
|
||||
public class S3ConnectionPool {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final Function<S3Config, S3Client> clientFactory;
|
||||
private final Map<S3Config, S3Client> clients = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
public S3ConnectionPool(ApplicationProperties applicationProperties) {
|
||||
this(applicationProperties, S3ConnectionPool::buildClient);
|
||||
}
|
||||
|
||||
/** Factory-injecting constructor for tests. */
|
||||
public S3ConnectionPool(
|
||||
ApplicationProperties applicationProperties,
|
||||
Function<S3Config, S3Client> clientFactory) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.clientFactory = clientFactory;
|
||||
}
|
||||
|
||||
public S3Client clientFor(S3Config config) {
|
||||
return clients.computeIfAbsent(
|
||||
config,
|
||||
c -> {
|
||||
requirePermittedEndpoint(c);
|
||||
return clientFactory.apply(c);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-supplied endpoint must not reach loopback, link-local, or private addresses unless the
|
||||
* operator has opted in via {@code policies.allowPrivateS3Endpoints}.
|
||||
*/
|
||||
private void requirePermittedEndpoint(S3Config config) {
|
||||
if (config.endpoint() == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
S3Clients.validateEndpointHost(
|
||||
URI.create(config.endpoint()),
|
||||
applicationProperties.getPolicies().isAllowPrivateS3Endpoints(),
|
||||
"S3 source endpoint",
|
||||
"set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local"
|
||||
+ " MinIO).");
|
||||
} catch (IllegalStateException e) {
|
||||
throw new IllegalArgumentException(e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static S3Client buildClient(S3Config config) {
|
||||
S3ClientBuilder builder =
|
||||
S3Client.builder()
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.of(config.region()))
|
||||
// Path-style addressing whenever a custom endpoint is set: S3-compatible
|
||||
// stores rarely support virtual-hosted bucket DNS.
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(config.endpoint() != null)
|
||||
.build())
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(
|
||||
config.accessKeyId(), config.secretAccessKey())));
|
||||
if (config.endpoint() != null) {
|
||||
builder.endpointOverride(URI.create(config.endpoint()));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void closeClients() {
|
||||
clients.values().forEach(S3Client::close);
|
||||
clients.clear();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package stirling.software.proprietary.policy.s3;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The S3 backend's identity and version scheme, shared by {@code S3InputSource} and {@code
|
||||
* S3OutputSink} so outputs are recorded under exactly the identity and gate the next listing
|
||||
* derives. Identity is {@code s3://bucket/key}; the gate is the ETag every listing returns for free
|
||||
* (multipart ETags are not content hashes, so any ETag change simply reads as a new version).
|
||||
*/
|
||||
public final class S3Identities {
|
||||
|
||||
private S3Identities() {}
|
||||
|
||||
public static String identity(String bucket, String key) {
|
||||
return "s3://" + bucket + "/" + key;
|
||||
}
|
||||
|
||||
/** ETag stripped of its quotes; falls back to size:lastModified for stores that omit it. */
|
||||
public static String gate(String eTag, Long size, Instant lastModified) {
|
||||
if (eTag != null && !eTag.isBlank()) {
|
||||
return eTag.replace("\"", "");
|
||||
}
|
||||
return (size == null ? -1 : size)
|
||||
+ ":"
|
||||
+ (lastModified == null ? 0 : lastModified.toEpochMilli());
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -1,6 +1,7 @@
|
||||
package stirling.software.proprietary.policy.source;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -29,6 +30,7 @@ import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* CRUD for persisted, reusable input connections plus the Sources overview for the admin portal. A
|
||||
@@ -65,11 +67,16 @@ public class SourceController {
|
||||
}
|
||||
|
||||
@GetMapping("/{sourceId}")
|
||||
@Operation(summary = "Get a source by id")
|
||||
@Operation(
|
||||
summary = "Get a source by id",
|
||||
description =
|
||||
"Secret-bearing options are returned as a redaction sentinel, never their"
|
||||
+ " stored values; an edit that sends the sentinel back keeps them.")
|
||||
public ResponseEntity<Source> get(@PathVariable String sourceId) {
|
||||
return sourceStore
|
||||
.get(sourceId)
|
||||
.filter(sourceAccessGuard::canAccess)
|
||||
.map(SourceController::withMaskedSecrets)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseGet(() -> ResponseEntity.notFound().build());
|
||||
}
|
||||
@@ -97,7 +104,7 @@ public class SourceController {
|
||||
+ " matching source type.")
|
||||
public ResponseEntity<Source> save(@RequestBody Source source) {
|
||||
requireSourceEditingAllowed();
|
||||
Source owned = resolveOwnership(source);
|
||||
Source owned = withStoredSecrets(resolveOwnership(source));
|
||||
try {
|
||||
validateConfig(owned);
|
||||
} catch (IllegalArgumentException e) {
|
||||
@@ -107,7 +114,7 @@ public class SourceController {
|
||||
// An edited folder source can change which directory needs watching, so re-sync trigger
|
||||
// registrations now instead of waiting for the next reconcile.
|
||||
policyTriggerManager.notifyPoliciesChanged();
|
||||
return ResponseEntity.ok(saved);
|
||||
return ResponseEntity.ok(withMaskedSecrets(saved));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{sourceId}")
|
||||
@@ -169,6 +176,42 @@ public class SourceController {
|
||||
teamId);
|
||||
}
|
||||
|
||||
private static Source withOptions(Source source, Map<String, Object> options) {
|
||||
return new Source(
|
||||
source.id(),
|
||||
source.name(),
|
||||
source.type(),
|
||||
options,
|
||||
source.enabled(),
|
||||
source.owner(),
|
||||
source.teamId());
|
||||
}
|
||||
|
||||
/** Secrets never leave the server: reads return the redaction sentinel in their place. */
|
||||
private static Source withMaskedSecrets(Source source) {
|
||||
return withOptions(source, SecretMasker.mask(source.options()));
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit that round-trips a masked read sends secrets back as the sentinel; restore them from
|
||||
* the stored source so saving without re-typing keeps them (validation then runs against the
|
||||
* real values).
|
||||
*/
|
||||
private Source withStoredSecrets(Source incoming) {
|
||||
if (incoming.id() == null || incoming.id().isBlank()) {
|
||||
return incoming;
|
||||
}
|
||||
return sourceStore
|
||||
.get(incoming.id())
|
||||
.map(
|
||||
existing ->
|
||||
withOptions(
|
||||
incoming,
|
||||
SecretMasker.restoreRedacted(
|
||||
incoming.options(), existing.options())))
|
||||
.orElse(incoming);
|
||||
}
|
||||
|
||||
/** Validate the config against the bean that handles the source's type, as the engine will. */
|
||||
private void validateConfig(Source source) {
|
||||
InputSpec spec = source.toInputSpec();
|
||||
|
||||
+6
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.source;
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
@@ -11,6 +12,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
|
||||
|
||||
/**
|
||||
* JPA row for a {@link Source}. The whole source lives as JSON in {@code sourceJson} (authoritative
|
||||
* on read); the scalar columns are denormalized copies for querying. {@code owner} and {@code
|
||||
@@ -45,6 +48,9 @@ public class SourceEntity implements Serializable {
|
||||
@Column(name = "enabled")
|
||||
private boolean enabled;
|
||||
|
||||
// Encrypted at rest: source options carry user-supplied credentials (e.g. an S3 secret
|
||||
// access key). Lenient so rows written before encryption shipped still load.
|
||||
@Convert(converter = LenientEncryptedStringConverter.class)
|
||||
@Column(name = "source_json", columnDefinition = "text")
|
||||
private String sourceJson;
|
||||
}
|
||||
|
||||
+10
-4
@@ -14,6 +14,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.proprietary.policy.config.PolicyAccessGuard;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* Builds the Sources overview: every persisted source the caller's team owns, shown exactly once,
|
||||
@@ -104,13 +105,18 @@ public class SourceOverviewService {
|
||||
return referenceCount == 0 ? "unused" : "active";
|
||||
}
|
||||
|
||||
/** Generic key/value view of the source's config - works for any source type. */
|
||||
/**
|
||||
* Generic key/value view of the source's config - works for any source type. Secret-bearing
|
||||
* options (e.g. an S3 secret access key) are redacted, not omitted, so the overview still shows
|
||||
* that a credential is configured.
|
||||
*/
|
||||
private static List<SourceView.DetailRow> configRows(Source source) {
|
||||
return source.options().entrySet().stream()
|
||||
Map<String, Object> masked = SecretMasker.mask(source.options());
|
||||
return source.options().keySet().stream()
|
||||
.map(
|
||||
entry ->
|
||||
key ->
|
||||
new SourceView.DetailRow(
|
||||
humanize(entry.getKey()), String.valueOf(entry.getValue())))
|
||||
humanize(key), String.valueOf(masked.get(key))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -3,6 +3,7 @@ package stirling.software.proprietary.policy.store;
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Convert;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
@@ -11,6 +12,8 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
import stirling.software.proprietary.integration.crypto.LenientEncryptedStringConverter;
|
||||
|
||||
/**
|
||||
* JPA row for a {@link stirling.software.proprietary.policy.model.Policy}. The whole policy lives
|
||||
* as JSON in {@code policyJson} (authoritative on read); the scalar columns are denormalized copies
|
||||
@@ -55,6 +58,9 @@ public class PolicyEntity implements Serializable {
|
||||
@Column(name = "sort_order")
|
||||
private Integer sortOrder;
|
||||
|
||||
// Encrypted at rest: output options carry user-supplied credentials (e.g. an S3 secret
|
||||
// access key). Lenient so rows written before encryption shipped still load.
|
||||
@Convert(converter = LenientEncryptedStringConverter.class)
|
||||
@Column(name = "policy_json", columnDefinition = "text")
|
||||
private String policyJson;
|
||||
}
|
||||
|
||||
+10
-4
@@ -99,11 +99,17 @@ public class ScheduleTrigger implements PolicyTrigger {
|
||||
// Baseline a newly-seen policy to now so it does not fire immediately.
|
||||
Instant last = lastFiredByPolicy.computeIfAbsent(policy.id(), id -> now);
|
||||
ZonedDateTime next = config.schedule().nextAfter(last.atZone(config.zone()));
|
||||
if (!next.toInstant().isAfter(now)) {
|
||||
lastFiredByPolicy.put(policy.id(), now);
|
||||
log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name());
|
||||
policyRunner.run(policy);
|
||||
if (next.toInstant().isAfter(now)) {
|
||||
continue;
|
||||
}
|
||||
ZonedDateTime later = config.schedule().nextAfter(next);
|
||||
while (!later.toInstant().isAfter(now)) {
|
||||
next = later;
|
||||
later = config.schedule().nextAfter(later);
|
||||
}
|
||||
lastFiredByPolicy.put(policy.id(), next.toInstant());
|
||||
log.info("Scheduled policy {} ({}) is due", policy.id(), policy.name());
|
||||
policyRunner.run(policy);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.util;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
@@ -13,10 +14,15 @@ import stirling.software.common.util.RegexPatternUtils;
|
||||
@Slf4j
|
||||
public final class SecretMasker {
|
||||
|
||||
/** The placeholder masked values are replaced with; reads as "a secret is set". */
|
||||
public static final String REDACTED = "********";
|
||||
|
||||
private static final Pattern SENSITIVE =
|
||||
RegexPatternUtils.getInstance()
|
||||
.getPattern(
|
||||
"(?i)\\b(password|token|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
// secret[_-]?access[_-]?key precedes plain secret so camelCase keys
|
||||
// like secretAccessKey (no word boundary after "secret") still match.
|
||||
"(?i)\\b(password|token|secret[_-]?access[_-]?key|secret|api[_-]?key|authorization|auth|jwt|cred|cert)\\b");
|
||||
|
||||
private SecretMasker() {}
|
||||
|
||||
@@ -47,8 +53,28 @@ public final class SecretMasker {
|
||||
|
||||
private static Object deepMaskValue(String key, Object value) {
|
||||
if (key != null && SENSITIVE.matcher(key).find()) {
|
||||
return "***REDACTED***";
|
||||
return REDACTED;
|
||||
}
|
||||
return deepMask(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore top-level values the caller sent back as the {@link #REDACTED} sentinel from the
|
||||
* stored map, so a masked read can round-trip through an edit without re-typing secrets. A
|
||||
* sentinel with no stored counterpart is left as-is (it fails whatever validates it, rather
|
||||
* than silently passing an unset secret).
|
||||
*/
|
||||
public static Map<String, Object> restoreRedacted(
|
||||
Map<String, Object> incoming, Map<String, Object> stored) {
|
||||
if (incoming == null || stored == null) {
|
||||
return incoming;
|
||||
}
|
||||
Map<String, Object> merged = new LinkedHashMap<>(incoming);
|
||||
merged.replaceAll(
|
||||
(key, value) ->
|
||||
REDACTED.equals(value) && stored.containsKey(key)
|
||||
? stored.get(key)
|
||||
: value);
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package stirling.software.proprietary.integration.crypto;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import javax.crypto.KeyGenerator;
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class LenientEncryptedStringConverterTest {
|
||||
|
||||
private final LenientEncryptedStringConverter converter = new LenientEncryptedStringConverter();
|
||||
|
||||
@BeforeAll
|
||||
static void initKey() throws Exception {
|
||||
KeyGenerator generator = KeyGenerator.getInstance("AES");
|
||||
generator.init(256);
|
||||
SecretKey key = generator.generateKey();
|
||||
CredentialEncryption.initialiseForTesting(key);
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripsThroughCiphertext() {
|
||||
String json = "{\"bucket\":\"inbox\",\"secretAccessKey\":\"shh\"}";
|
||||
|
||||
String stored = converter.convertToDatabaseColumn(json);
|
||||
|
||||
assertThat(stored).isNotEqualTo(json).doesNotContain("shh");
|
||||
assertThat(converter.convertToEntityAttribute(stored)).isEqualTo(json);
|
||||
}
|
||||
|
||||
@Test
|
||||
void legacyPlaintextRowsPassThroughOnRead() {
|
||||
String legacy = "{\"bucket\":\"inbox\",\"mode\":\"consume\"}";
|
||||
|
||||
assertThat(converter.convertToEntityAttribute(legacy)).isEqualTo(legacy);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullsPassThrough() {
|
||||
assertThat(converter.convertToDatabaseColumn(null)).isNull();
|
||||
assertThat(converter.convertToEntityAttribute(null)).isNull();
|
||||
}
|
||||
}
|
||||
+56
-4
@@ -9,6 +9,7 @@ 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.concurrent.CompletableFuture;
|
||||
|
||||
@@ -17,6 +18,7 @@ import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -34,7 +36,9 @@ import stirling.software.proprietary.policy.engine.PolicyRunHandle;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunRegistry;
|
||||
import stirling.software.proprietary.policy.engine.PolicyRunner;
|
||||
import stirling.software.proprietary.policy.engine.PolicyValidator;
|
||||
import stirling.software.proprietary.policy.engine.SweepOutcome;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.model.PipelineDefinition;
|
||||
import stirling.software.proprietary.policy.model.PipelineStep;
|
||||
import stirling.software.proprietary.policy.model.Policy;
|
||||
@@ -44,6 +48,7 @@ import stirling.software.proprietary.policy.progress.PolicyProgressListener;
|
||||
import stirling.software.proprietary.policy.source.SourceAccessGuard;
|
||||
import stirling.software.proprietary.policy.source.SourceStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("PolicyController")
|
||||
@@ -128,6 +133,17 @@ class PolicyControllerTest {
|
||||
return new Policy(id, "name", "owner", true, null, List.of(), List.of(), null, teamId);
|
||||
}
|
||||
|
||||
private static Policy s3OutputPolicy(String id, String secret) {
|
||||
OutputSpec output =
|
||||
new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "outbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", secret));
|
||||
return new Policy(id, "name", "owner", true, null, List.of(), List.of(), output, 1L);
|
||||
}
|
||||
|
||||
private static PolicyRunHandle handle(String runId) {
|
||||
PolicyRun run = new PolicyRun(runId, null, definitionWithStep());
|
||||
return new PolicyRunHandle(runId, CompletableFuture.completedFuture(run));
|
||||
@@ -263,6 +279,27 @@ class PolicyControllerTest {
|
||||
verify(policyTriggerManager).notifyPoliciesChanged();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("saving the sentinel back keeps the stored output secret")
|
||||
void saveRestoresOutputSecrets() {
|
||||
applicationProperties.getSecurity().setEnableLogin(false);
|
||||
Policy existing = s3OutputPolicy("p1", "shh");
|
||||
when(policyStore.get("p1")).thenReturn(Optional.of(existing));
|
||||
when(policyAccessGuard.canAccess(existing)).thenReturn(true);
|
||||
when(policyStore.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||
|
||||
ResponseEntity<Policy> response =
|
||||
controller.savePolicy(s3OutputPolicy("p1", SecretMasker.REDACTED));
|
||||
|
||||
ArgumentCaptor<Policy> stored = ArgumentCaptor.forClass(Policy.class);
|
||||
verify(policyStore).save(stored.capture());
|
||||
assertThat(stored.getValue().output().options().get("secretAccessKey"))
|
||||
.isEqualTo("shh");
|
||||
// The save response is masked again; only the store sees the real value.
|
||||
assertThat(response.getBody().output().options().get("secretAccessKey"))
|
||||
.isEqualTo(SecretMasker.REDACTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("forbidden when login enabled and caller cannot edit")
|
||||
void forbidden() {
|
||||
@@ -363,6 +400,20 @@ class PolicyControllerTest {
|
||||
assertThat(response.getBody().id()).isEqualTo("a");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getPolicy returns output secrets as the redaction sentinel")
|
||||
void getMasksOutputSecrets() {
|
||||
Policy p = s3OutputPolicy("a", "shh");
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
|
||||
Policy read = controller.getPolicy("a").getBody();
|
||||
|
||||
assertThat(read.output().options().get("secretAccessKey"))
|
||||
.isEqualTo(SecretMasker.REDACTED);
|
||||
assertThat(read.output().options().get("bucket")).isEqualTo("outbox");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("getPolicy returns 404 when not accessible")
|
||||
void getNotAccessible() {
|
||||
@@ -536,17 +587,18 @@ class PolicyControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("trigger runs an accessible policy against its sources and returns run ids")
|
||||
@DisplayName("trigger runs an accessible policy against its sources and returns the sweep")
|
||||
void triggersRun() {
|
||||
Policy p = policy("a", 1L);
|
||||
when(policyStore.get("a")).thenReturn(Optional.of(p));
|
||||
when(policyAccessGuard.canAccess(p)).thenReturn(true);
|
||||
when(policyRunner.run(p)).thenReturn(List.of("run-a", "run-b"));
|
||||
SweepOutcome outcome = new SweepOutcome(List.of("run-a", "run-b"), 3, 1, 0, 0);
|
||||
when(policyRunner.run(p)).thenReturn(outcome);
|
||||
|
||||
ResponseEntity<List<String>> response = controller.trigger("a");
|
||||
ResponseEntity<SweepOutcome> response = controller.trigger("a");
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
assertThat(response.getBody()).containsExactly("run-a", "run-b");
|
||||
assertThat(response.getBody()).isEqualTo(outcome);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+38
@@ -1,5 +1,6 @@
|
||||
package stirling.software.proprietary.policy.engine;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -30,6 +31,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import stirling.software.proprietary.policy.input.InputSource;
|
||||
import stirling.software.proprietary.policy.input.ResolveContext;
|
||||
import stirling.software.proprietary.policy.input.ResolvedInput;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
@@ -85,6 +87,42 @@ class PolicyRunnerTest {
|
||||
verify(processedLedger).deleteUnseen(eq("p1"), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsWhatTheSweepSkippedSoAnEmptyTriggerExplainsItself() throws Exception {
|
||||
InProcessProcessedLedger ledger = new InProcessProcessedLedger();
|
||||
PolicyRunner reporting =
|
||||
new PolicyRunner(
|
||||
policyEngine,
|
||||
List.of(folderSource),
|
||||
sourceStore,
|
||||
new InProcessSourceDocCounter(),
|
||||
ledger);
|
||||
InputSpec spec = InputSpec.folder("/in");
|
||||
Policy policy = policy(List.of(spec));
|
||||
// One file already processed at its current version, one parked by a failed run.
|
||||
ledger.claim("p1", "/in/done.pdf", "g1", null);
|
||||
ledger.settle("p1", "/in/done.pdf", "g1", null, true);
|
||||
ledger.claim("p1", "/in/failed.pdf", "g2", null);
|
||||
ledger.settle("p1", "/in/failed.pdf", "g2", null, false);
|
||||
when(folderSource.supports(spec)).thenReturn(true);
|
||||
when(folderSource.resolve(eq(spec), any()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
ResolveContext ctx = invocation.getArgument(1);
|
||||
ctx.reportPresent(List.of("/in/done.pdf", "/in/failed.pdf"));
|
||||
// Both are at their settled versions, so neither claims.
|
||||
return List.of();
|
||||
});
|
||||
|
||||
SweepOutcome outcome = reporting.run(policy);
|
||||
|
||||
assertTrue(outcome.runIds().isEmpty());
|
||||
assertEquals(2, outcome.filesListed());
|
||||
assertEquals(1, outcome.alreadyProcessed());
|
||||
assertEquals(1, outcome.parked());
|
||||
assertEquals(0, outcome.inFlight());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pullsEverySourceAndRunsOnePerUnitOfWork() throws Exception {
|
||||
InputSpec spec = InputSpec.folder("/in");
|
||||
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
/**
|
||||
* End-to-end {@link S3InputSource} test against a real S3 API (MinIO), through the production
|
||||
* client factory: listing, claiming, streaming, consensus delete, and save-time validation.
|
||||
*/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3InputSourceMinioTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
@Container
|
||||
static MinIOContainer minio =
|
||||
new MinIOContainer("minio/minio:latest")
|
||||
.withUserName(ACCESS_KEY)
|
||||
.withPassword(SECRET_KEY);
|
||||
|
||||
private static S3Client adminClient;
|
||||
private static int bucketCounter;
|
||||
|
||||
private String bucket;
|
||||
private S3InputSource source;
|
||||
private InProcessProcessedLedger ledger;
|
||||
private RecordingContext ctx;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
if (adminClient == null) {
|
||||
adminClient =
|
||||
S3Client.builder()
|
||||
.endpointOverride(java.net.URI.create(minio.getS3URL()))
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.build();
|
||||
}
|
||||
bucket = "policy-inbox-" + ++bucketCounter;
|
||||
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
|
||||
// The MinIO endpoint resolves to loopback, so the operator opt-in must be on.
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
source = new S3InputSource(new S3ConnectionPool(properties));
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumeListsStreamsAndDeletesByConsensus() throws IOException {
|
||||
put("incoming/doc.pdf", "pdf bytes");
|
||||
put("incoming/other.txt", "text");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx);
|
||||
|
||||
assertThat(work).hasSize(2);
|
||||
assertThat(ctx.present)
|
||||
.containsExactlyInAnyOrder(
|
||||
"s3://" + bucket + "/incoming/doc.pdf",
|
||||
"s3://" + bucket + "/incoming/other.txt");
|
||||
assertThat(read(work.get(0))).isIn("pdf bytes", "text");
|
||||
// In flight: nothing to claim on a second sweep.
|
||||
assertThat(source.resolve(spec(Map.of("prefix", "incoming/")), ctx)).isEmpty();
|
||||
|
||||
work.forEach(unit -> unit.onComplete().accept(true));
|
||||
assertThat(exists("incoming/doc.pdf")).isFalse();
|
||||
assertThat(exists("incoming/other.txt")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedObjectStaysInTheBucket() throws IOException {
|
||||
put("doc.pdf", "data");
|
||||
|
||||
source.resolve(spec(Map.of()), ctx).get(0).onComplete().accept(false);
|
||||
|
||||
assertThat(exists("doc.pdf")).isTrue();
|
||||
assertThat(source.resolve(spec(Map.of()), ctx)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectOverwrittenMidRunSurvivesTheDeleteAndRunsAgain() throws IOException {
|
||||
put("doc.pdf", "v1");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(Map.of()), ctx);
|
||||
put("doc.pdf", "v2 with a different etag");
|
||||
work.get(0).onComplete().accept(true);
|
||||
|
||||
assertThat(exists("doc.pdf")).isTrue();
|
||||
assertThat(source.resolve(spec(Map.of()), ctx)).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void prefixLimitsWhatIsRead() throws IOException {
|
||||
put("incoming/doc.pdf", "data");
|
||||
put("archive/old.pdf", "data");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(Map.of("prefix", "incoming/")), ctx);
|
||||
|
||||
assertThat(work).hasSize(1);
|
||||
assertThat(ctx.present).containsExactly("s3://" + bucket + "/incoming/doc.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateAcceptsAReachableBucketAndRejectsBadCredentials() {
|
||||
source.validate(spec(Map.of()));
|
||||
|
||||
Map<String, Object> wrongSecret = new HashMap<>(baseOptions());
|
||||
wrongSecret.put("secretAccessKey", "not-the-secret");
|
||||
assertThatThrownBy(() -> source.validate(new InputSpec("s3", wrongSecret)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("cannot access");
|
||||
|
||||
Map<String, Object> missingBucket = new HashMap<>(baseOptions());
|
||||
missingBucket.put("bucket", "no-such-bucket-here");
|
||||
assertThatThrownBy(() -> source.validate(new InputSpec("s3", missingBucket)))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("cannot access");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() {
|
||||
S3InputSource guarded =
|
||||
new S3InputSource(new S3ConnectionPool(new ApplicationProperties()));
|
||||
|
||||
assertThatThrownBy(() -> guarded.validate(spec(Map.of())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("policies.allowPrivateS3Endpoints");
|
||||
}
|
||||
|
||||
private Map<String, Object> baseOptions() {
|
||||
return Map.of(
|
||||
"bucket", bucket,
|
||||
"endpoint", minio.getS3URL(),
|
||||
"accessKeyId", ACCESS_KEY,
|
||||
"secretAccessKey", SECRET_KEY);
|
||||
}
|
||||
|
||||
private InputSpec spec(Map<String, Object> extra) {
|
||||
Map<String, Object> options = new HashMap<>(baseOptions());
|
||||
options.putAll(extra);
|
||||
return new InputSpec("s3", options);
|
||||
}
|
||||
|
||||
private void put(String key, String content) {
|
||||
adminClient.putObject(
|
||||
PutObjectRequest.builder().bucket(bucket).key(key).build(),
|
||||
RequestBody.fromString(content, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private boolean exists(String key) {
|
||||
try {
|
||||
adminClient.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
|
||||
return true;
|
||||
} catch (NoSuchKeyException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String read(ResolvedInput unit) throws IOException {
|
||||
try (InputStream stream = unit.inputs().primary().get(0).getInputStream()) {
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final List<String> present = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(POLICY, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(POLICY, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {
|
||||
present.addAll(identities);
|
||||
}
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
package stirling.software.proprietary.policy.input;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
import software.amazon.awssdk.http.AbortableInputStream;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
|
||||
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;
|
||||
import software.amazon.awssdk.services.s3.model.S3Object;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3InputSource}: consume mode tracks objects in place through the ledger and
|
||||
* removes them by consensus, snapshot stays stateless, and discovery skips folder placeholders and
|
||||
* dot-prefixed keys.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class S3InputSourceTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String BUCKET = "inbox-bucket";
|
||||
|
||||
@Mock private S3Client s3Client;
|
||||
|
||||
private S3InputSource source;
|
||||
private InProcessProcessedLedger ledger;
|
||||
private RecordingContext ctx;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
source =
|
||||
new S3InputSource(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client));
|
||||
ledger = new InProcessProcessedLedger();
|
||||
ctx = new RecordingContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumeRemovesTheObjectOnceProcessed() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
headReturns("doc.pdf", "\"etag-1\"");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(), ctx);
|
||||
|
||||
assertEquals(1, work.size());
|
||||
assertEquals(1, work.get(0).inputs().primary().size());
|
||||
// In flight: a second sweep does not pick it up again.
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
|
||||
work.get(0).onComplete().accept(true);
|
||||
verify(s3Client).deleteObject(any(DeleteObjectRequest.class));
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anObjectReplacedMidRunSurvivesTheDelete() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
// The object is overwritten while the run is executing.
|
||||
headReturns("doc.pdf", "\"etag-2\"");
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(), ctx);
|
||||
work.get(0).onComplete().accept(true);
|
||||
|
||||
// The delete is version-guarded: the replacement is not the object that ran, so it stays
|
||||
// and is claimed as fresh work instead of being marked processed.
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
listingReturns(object("doc.pdf", "\"etag-2\""));
|
||||
assertEquals(1, source.resolve(spec(), ctx).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aSharedObjectIsRemovedOnlyOnceEveryPolicyHasProcessedIt() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
headReturns("doc.pdf", "\"etag-1\"");
|
||||
RecordingContext other = new RecordingContext("p2");
|
||||
|
||||
List<ResolvedInput> mine = source.resolve(spec(), ctx);
|
||||
List<ResolvedInput> theirs = source.resolve(spec(), other);
|
||||
assertEquals(1, mine.size());
|
||||
assertEquals(1, theirs.size());
|
||||
|
||||
mine.get(0).onComplete().accept(true);
|
||||
// The other policy's claim is still in flight, so the first finisher must not delete.
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
|
||||
theirs.get(0).onComplete().accept(true);
|
||||
verify(s3Client).deleteObject(any(DeleteObjectRequest.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedObjectStaysAndIsNotRetriedUntilItChanges() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
|
||||
source.resolve(spec(), ctx).get(0).onComplete().accept(false);
|
||||
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
|
||||
// A new upload carries a new ETag, which reads as a new version and retries.
|
||||
listingReturns(object("doc.pdf", "\"etag-2\""));
|
||||
assertEquals(1, source.resolve(spec(), ctx).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotReadsStatelesslyEverySweep() throws IOException {
|
||||
listingReturns(object("doc.pdf", "\"etag-1\""));
|
||||
InputSpec spec = new InputSpec("s3", options(Map.of("mode", "snapshot")));
|
||||
|
||||
List<ResolvedInput> first = source.resolve(spec, ctx);
|
||||
first.get(0).onComplete().accept(true);
|
||||
List<ResolvedInput> second = source.resolve(spec, ctx);
|
||||
|
||||
assertEquals(1, first.size());
|
||||
assertEquals(1, second.size());
|
||||
verify(s3Client, never()).deleteObject(any(DeleteObjectRequest.class));
|
||||
assertTrue(ctx.present.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void folderPlaceholdersAndDotPrefixedKeysAreSkipped() throws IOException {
|
||||
listingReturns(
|
||||
object("doc.pdf", "\"etag-1\""),
|
||||
object("incoming/", "\"etag-2\""),
|
||||
object(".stirling/tmp/staged.pdf", "\"etag-3\""),
|
||||
object("incoming/.hidden.pdf", "\"etag-4\""));
|
||||
|
||||
List<ResolvedInput> work = source.resolve(spec(), ctx);
|
||||
|
||||
assertEquals(1, work.size());
|
||||
assertEquals(List.of("s3://" + BUCKET + "/doc.pdf"), ctx.present);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listingPagesAreAllRead() throws IOException {
|
||||
ListObjectsV2Response firstPage =
|
||||
ListObjectsV2Response.builder()
|
||||
.contents(object("a.pdf", "\"etag-a\""))
|
||||
.nextContinuationToken("next")
|
||||
.build();
|
||||
ListObjectsV2Response secondPage =
|
||||
ListObjectsV2Response.builder().contents(object("b.pdf", "\"etag-b\"")).build();
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenReturn(firstPage, secondPage);
|
||||
|
||||
assertEquals(2, source.resolve(spec(), ctx).size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aListingFailurePropagatesSoTheSweepVetoesCleanup() {
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenThrow(SdkClientException.create("connection refused"));
|
||||
|
||||
assertThrows(SdkClientException.class, () -> source.resolve(spec(), ctx));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resourceStreamsTheObjectAndNamesItByKeyBasename() throws IOException {
|
||||
listingReturns(object("incoming/doc.pdf", "\"etag-1\""));
|
||||
byte[] payload = "data".getBytes(StandardCharsets.UTF_8);
|
||||
when(s3Client.getObject(any(GetObjectRequest.class)))
|
||||
.thenReturn(
|
||||
new ResponseInputStream<>(
|
||||
GetObjectResponse.builder().build(),
|
||||
AbortableInputStream.create(new ByteArrayInputStream(payload))));
|
||||
|
||||
var resource = source.resolve(spec(), ctx).get(0).inputs().primary().get(0);
|
||||
|
||||
assertEquals("doc.pdf", resource.getFilename());
|
||||
// Content length comes from the listing, not a download.
|
||||
assertEquals(4, resource.contentLength());
|
||||
try (var stream = resource.getInputStream()) {
|
||||
assertEquals("data", new String(stream.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingETagFallsBackToSizeAndLastModified() throws IOException {
|
||||
Instant modified = Instant.parse("2026-01-01T00:00:00Z");
|
||||
listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build());
|
||||
|
||||
assertEquals(1, source.resolve(spec(), ctx).size());
|
||||
// The same gate on the next sweep reads as already claimed.
|
||||
listingReturns(S3Object.builder().key("doc.pdf").size(4L).lastModified(modified).build());
|
||||
assertTrue(source.resolve(spec(), ctx).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateRejectsBadConfig() {
|
||||
// No bucket.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("s3", Map.of())));
|
||||
// Credentials are required, never the server's own identity - together and individually.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("s3", Map.of("bucket", BUCKET))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
source.validate(
|
||||
new InputSpec(
|
||||
"s3", Map.of("bucket", BUCKET, "accessKeyId", "AKIA"))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> source.validate(new InputSpec("s3", options(Map.of("mode", "sideways")))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
source.validate(
|
||||
new InputSpec(
|
||||
"s3", options(Map.of("endpoint", "ftp://example.com")))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateRejectsAnUnreachableBucket() {
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenThrow(SdkClientException.create("connection refused"));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> source.validate(spec()));
|
||||
}
|
||||
|
||||
private static InputSpec spec() {
|
||||
return new InputSpec("s3", options(Map.of()));
|
||||
}
|
||||
|
||||
/** The required options (bucket + credentials) plus any extras under test. */
|
||||
private static Map<String, Object> options(Map<String, Object> extra) {
|
||||
Map<String, Object> options = new HashMap<>(extra);
|
||||
options.put("bucket", BUCKET);
|
||||
options.put("accessKeyId", "AKIAEXAMPLE");
|
||||
options.put("secretAccessKey", "shh");
|
||||
return options;
|
||||
}
|
||||
|
||||
private static S3Object object(String key, String eTag) {
|
||||
return S3Object.builder()
|
||||
.key(key)
|
||||
.eTag(eTag)
|
||||
.size(4L)
|
||||
.lastModified(Instant.parse("2026-01-01T00:00:00Z"))
|
||||
.build();
|
||||
}
|
||||
|
||||
private void listingReturns(S3Object... objects) {
|
||||
when(s3Client.listObjectsV2(any(ListObjectsV2Request.class)))
|
||||
.thenReturn(ListObjectsV2Response.builder().contents(objects).build());
|
||||
}
|
||||
|
||||
private void headReturns(String key, String eTag) {
|
||||
when(s3Client.headObject(any(HeadObjectRequest.class)))
|
||||
.thenReturn(
|
||||
HeadObjectResponse.builder()
|
||||
.eTag(eTag)
|
||||
.contentLength(4L)
|
||||
.lastModified(Instant.parse("2026-01-01T00:00:00Z"))
|
||||
.build());
|
||||
}
|
||||
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final String policyId;
|
||||
private final List<String> present = new ArrayList<>();
|
||||
|
||||
private RecordingContext() {
|
||||
this(POLICY);
|
||||
}
|
||||
|
||||
private RecordingContext(String policyId) {
|
||||
this.policyId = policyId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(policyId, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(policyId, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {
|
||||
present.addAll(identities);
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.testcontainers.containers.MinIOContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.proprietary.policy.input.ResolveContext;
|
||||
import stirling.software.proprietary.policy.input.ResolvedInput;
|
||||
import stirling.software.proprietary.policy.input.S3InputSource;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.model.InputSpec;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
|
||||
import software.amazon.awssdk.core.ResponseInputStream;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
|
||||
import software.amazon.awssdk.regions.Region;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.S3Configuration;
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
|
||||
/**
|
||||
* End-to-end {@link S3OutputSink} test against a real S3 API (MinIO): uploads, collision renaming,
|
||||
* and - composed with {@link S3InputSource} - the loop-safety guarantee that a policy writing into
|
||||
* a bucket it also watches never re-ingests its own outputs, while a second policy still can.
|
||||
*/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class S3OutputSinkMinioTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String ACCESS_KEY = "minioadmin";
|
||||
private static final String SECRET_KEY = "minioadmin";
|
||||
|
||||
@Container
|
||||
static MinIOContainer minio =
|
||||
new MinIOContainer("minio/minio:latest")
|
||||
.withUserName(ACCESS_KEY)
|
||||
.withPassword(SECRET_KEY);
|
||||
|
||||
private static S3Client adminClient;
|
||||
private static int bucketCounter;
|
||||
|
||||
private String bucket;
|
||||
private S3OutputSink sink;
|
||||
private S3InputSource source;
|
||||
private InProcessProcessedLedger ledger;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
if (adminClient == null) {
|
||||
adminClient =
|
||||
S3Client.builder()
|
||||
.endpointOverride(URI.create(minio.getS3URL()))
|
||||
.httpClient(UrlConnectionHttpClient.create())
|
||||
.region(Region.US_EAST_1)
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(ACCESS_KEY, SECRET_KEY)))
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.build();
|
||||
}
|
||||
bucket = "policy-outbox-" + ++bucketCounter;
|
||||
adminClient.createBucket(CreateBucketRequest.builder().bucket(bucket).build());
|
||||
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
properties.getPolicies().setAllowPrivateS3Endpoints(true);
|
||||
S3ConnectionPool pool = new S3ConnectionPool(properties);
|
||||
ledger = new InProcessProcessedLedger();
|
||||
sink = new S3OutputSink(pool, ledger);
|
||||
source = new S3InputSource(pool);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadsOutputsUnderThePrefix() throws IOException {
|
||||
List<ResultFile> results =
|
||||
sink.deliver(
|
||||
new OutputDelivery("run-1", POLICY),
|
||||
List.of(output("doc.pdf", "pdf bytes")),
|
||||
outputSpec("processed/"));
|
||||
|
||||
assertThat(results).hasSize(1);
|
||||
assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/processed/doc.pdf");
|
||||
assertThat(objectContent("processed/doc.pdf")).isEqualTo("pdf bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anExistingKeyIsNeverOverwritten() throws IOException {
|
||||
adminClient.putObject(
|
||||
PutObjectRequest.builder().bucket(bucket).key("doc.pdf").build(),
|
||||
RequestBody.fromString("theirs", StandardCharsets.UTF_8));
|
||||
|
||||
List<ResultFile> results =
|
||||
sink.deliver(
|
||||
new OutputDelivery("run-1", POLICY),
|
||||
List.of(output("doc.pdf", "ours")),
|
||||
outputSpec(""));
|
||||
|
||||
assertThat(results.get(0).getFileName()).isEqualTo("s3://" + bucket + "/doc (1).pdf");
|
||||
assertThat(objectContent("doc.pdf")).isEqualTo("theirs");
|
||||
assertThat(objectContent("doc (1).pdf")).isEqualTo("ours");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aPolicyWritingIntoItsWatchedBucketSkipsItsOwnOutputsButAnotherPolicyChains()
|
||||
throws IOException {
|
||||
sink.deliver(
|
||||
new OutputDelivery("run-1", POLICY),
|
||||
List.of(output("result.pdf", "produced")),
|
||||
outputSpec(""));
|
||||
|
||||
// The producing policy's sweep sees its own output at the recorded gate and skips it.
|
||||
assertThat(source.resolve(inputSpec(), new RecordingContext(POLICY))).isEmpty();
|
||||
|
||||
// A different policy watching the same bucket has no row and processes it - chaining.
|
||||
List<ResolvedInput> chained = source.resolve(inputSpec(), new RecordingContext("p2"));
|
||||
assertThat(chained).hasSize(1);
|
||||
try (InputStream stream = chained.get(0).inputs().primary().get(0).getInputStream()) {
|
||||
assertThat(new String(stream.readAllBytes(), StandardCharsets.UTF_8))
|
||||
.isEqualTo("produced");
|
||||
}
|
||||
}
|
||||
|
||||
private OutputSpec outputSpec(String prefix) {
|
||||
return new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", bucket,
|
||||
"prefix", prefix,
|
||||
"endpoint", minio.getS3URL(),
|
||||
"accessKeyId", ACCESS_KEY,
|
||||
"secretAccessKey", SECRET_KEY));
|
||||
}
|
||||
|
||||
private InputSpec inputSpec() {
|
||||
return new InputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", bucket,
|
||||
"endpoint", minio.getS3URL(),
|
||||
"accessKeyId", ACCESS_KEY,
|
||||
"secretAccessKey", SECRET_KEY));
|
||||
}
|
||||
|
||||
private String objectContent(String key) throws IOException {
|
||||
try (ResponseInputStream<GetObjectResponse> stream =
|
||||
adminClient.getObject(GetObjectRequest.builder().bucket(bucket).key(key).build())) {
|
||||
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static Resource output(String name, String content) {
|
||||
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private class RecordingContext implements ResolveContext {
|
||||
|
||||
private final String policyId;
|
||||
|
||||
private RecordingContext(String policyId) {
|
||||
this.policyId = policyId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean claim(String identity, String gate, Supplier<String> contentHash) {
|
||||
return ledger.claim(policyId, identity, gate, contentHash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void settle(
|
||||
String identity, String finalGate, String finalContentHash, boolean success) {
|
||||
ledger.settle(policyId, identity, finalGate, finalContentHash, success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean allSettledDone(String identity) {
|
||||
return ledger.allSettledDone(identity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportPresent(Collection<String> identities) {}
|
||||
}
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package stirling.software.proprietary.policy.output;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.job.ResultFile;
|
||||
import stirling.software.proprietary.policy.ledger.ClaimState;
|
||||
import stirling.software.proprietary.policy.ledger.InProcessProcessedLedger;
|
||||
import stirling.software.proprietary.policy.ledger.ProcessedFileStatus;
|
||||
import stirling.software.proprietary.policy.model.OutputSpec;
|
||||
import stirling.software.proprietary.policy.s3.S3ConnectionPool;
|
||||
|
||||
import software.amazon.awssdk.awscore.exception.AwsServiceException;
|
||||
import software.amazon.awssdk.core.exception.SdkClientException;
|
||||
import software.amazon.awssdk.core.sync.RequestBody;
|
||||
import software.amazon.awssdk.services.s3.S3Client;
|
||||
import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
|
||||
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||
|
||||
/**
|
||||
* Tests for {@link S3OutputSink}: the ledger row exists before the object is visible, collisions
|
||||
* re-pick names, ad-hoc runs record nothing, and encrypted-bucket ETags are re-recorded.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class S3OutputSinkTest {
|
||||
|
||||
private static final String POLICY = "p1";
|
||||
private static final String BUCKET = "outbox-bucket";
|
||||
private static final OutputDelivery DELIVERY = new OutputDelivery("run-1", POLICY);
|
||||
private static final OutputDelivery AD_HOC = new OutputDelivery("run-2", null);
|
||||
|
||||
@Mock private S3Client s3Client;
|
||||
|
||||
private S3OutputSink sink;
|
||||
private InProcessProcessedLedger ledger;
|
||||
private final List<PutObjectRequest> puts = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
ledger = new InProcessProcessedLedger();
|
||||
sink =
|
||||
new S3OutputSink(
|
||||
new S3ConnectionPool(new ApplicationProperties(), config -> s3Client),
|
||||
ledger);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsTheRowBeforeTheObjectBecomesVisible() throws IOException {
|
||||
// The row for the exact key must already be settled DONE at the moment the PUT runs -
|
||||
// record-before-visible, asserted from inside the upload itself.
|
||||
List<ClaimState> stateAtPutTime = new ArrayList<>();
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
PutObjectRequest request = invocation.getArgument(0);
|
||||
puts.add(request);
|
||||
stateAtPutTime.add(stateFor(identity(request.key())));
|
||||
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
|
||||
});
|
||||
|
||||
List<ResultFile> results =
|
||||
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
|
||||
|
||||
assertEquals(1, results.size());
|
||||
assertEquals("s3://" + BUCKET + "/processed/doc.pdf", results.get(0).getFileName());
|
||||
assertEquals(4, results.get(0).getFileSize());
|
||||
assertNotNull(stateAtPutTime.get(0));
|
||||
assertEquals(ProcessedFileStatus.DONE, stateAtPutTime.get(0).status());
|
||||
assertEquals(md5("data"), stateAtPutTime.get(0).gate());
|
||||
assertTrue(puts.get(0).ifNoneMatch() != null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTakenKeyIsForgottenAndRePicked() throws IOException {
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
PutObjectRequest request = invocation.getArgument(0);
|
||||
puts.add(request);
|
||||
if (puts.size() == 1) {
|
||||
throw s3Error(412, "PreconditionFailed");
|
||||
}
|
||||
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
|
||||
});
|
||||
|
||||
List<ResultFile> results =
|
||||
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
|
||||
|
||||
assertEquals("s3://" + BUCKET + "/processed/doc (1).pdf", results.get(0).getFileName());
|
||||
// The lost candidate's row is gone; only the delivered key is recorded.
|
||||
assertNull(stateFor(identity("processed/doc.pdf")));
|
||||
assertNotNull(stateFor(identity("processed/doc (1).pdf")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEncryptedBucketETagIsReRecordedAtTheActualGate() throws IOException {
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenReturn(PutObjectResponse.builder().eTag("\"kms-opaque-etag\"").build());
|
||||
|
||||
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
|
||||
|
||||
assertEquals("kms-opaque-etag", stateFor(identity("processed/doc.pdf")).gate());
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAdHocDeliveryRecordsNothing() throws IOException {
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenReturn(PutObjectResponse.builder().eTag(quotedMd5("data")).build());
|
||||
|
||||
sink.deliver(AD_HOC, List.of(output("doc.pdf", "data")), spec());
|
||||
|
||||
assertNull(stateFor(identity("processed/doc.pdf")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFailedUploadForgetsItsRowAndThrows() {
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenThrow(SdkClientException.create("connection refused"));
|
||||
|
||||
assertThrows(
|
||||
IOException.class,
|
||||
() -> sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec()));
|
||||
|
||||
assertNull(stateFor(identity("processed/doc.pdf")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aStoreWithoutConditionalPutsFallsBackToExistenceChecks() throws IOException {
|
||||
when(s3Client.headObject(any(HeadObjectRequest.class))).thenThrow(s3Error(404, "NotFound"));
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
PutObjectRequest request = invocation.getArgument(0);
|
||||
puts.add(request);
|
||||
if (request.ifNoneMatch() != null) {
|
||||
throw s3Error(501, "NotImplemented");
|
||||
}
|
||||
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
|
||||
});
|
||||
|
||||
List<ResultFile> results =
|
||||
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec());
|
||||
|
||||
// Same key, second attempt unconditional.
|
||||
assertEquals("s3://" + BUCKET + "/processed/doc.pdf", results.get(0).getFileName());
|
||||
assertEquals(2, puts.size());
|
||||
assertNull(puts.get(1).ifNoneMatch());
|
||||
assertNotNull(stateFor(identity("processed/doc.pdf")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBarePrefixGetsItsSlash() throws IOException {
|
||||
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class)))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
puts.add(invocation.getArgument(0));
|
||||
return PutObjectResponse.builder().eTag(quotedMd5("data")).build();
|
||||
});
|
||||
|
||||
sink.deliver(DELIVERY, List.of(output("doc.pdf", "data")), spec("processed"));
|
||||
|
||||
assertEquals("processed/doc.pdf", puts.get(0).key());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateRejectsBadConfigShape() {
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sink.validate(new OutputSpec("s3", Map.of())));
|
||||
// Credentials are required, never the server's own identity.
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() -> sink.validate(new OutputSpec("s3", Map.of("bucket", BUCKET))));
|
||||
assertThrows(
|
||||
IllegalArgumentException.class,
|
||||
() ->
|
||||
sink.validate(
|
||||
new OutputSpec(
|
||||
"s3", Map.of("bucket", BUCKET, "accessKeyId", "AKIA"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsOnlyS3Specs() {
|
||||
assertTrue(sink.supports(spec()));
|
||||
assertFalse(sink.supports(OutputSpec.inline()));
|
||||
assertFalse(sink.supports(null));
|
||||
}
|
||||
|
||||
private static OutputSpec spec() {
|
||||
return spec("processed/");
|
||||
}
|
||||
|
||||
private static OutputSpec spec(String prefix) {
|
||||
return new OutputSpec(
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket",
|
||||
BUCKET,
|
||||
"prefix",
|
||||
prefix,
|
||||
"accessKeyId",
|
||||
"AKIAEXAMPLE",
|
||||
"secretAccessKey",
|
||||
"shh"));
|
||||
}
|
||||
|
||||
private static String identity(String key) {
|
||||
return "s3://" + BUCKET + "/" + key;
|
||||
}
|
||||
|
||||
private ClaimState stateFor(String identity) {
|
||||
return ledger.statesFor(POLICY, List.of(identity)).get(identity);
|
||||
}
|
||||
|
||||
private static Resource output(String name, String content) {
|
||||
return new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static String md5(String content) {
|
||||
try {
|
||||
return HexFormat.of()
|
||||
.formatHex(
|
||||
MessageDigest.getInstance("MD5")
|
||||
.digest(content.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String quotedMd5(String content) {
|
||||
return "\"" + md5(content) + "\"";
|
||||
}
|
||||
|
||||
private static AwsServiceException s3Error(int status, String code) {
|
||||
return S3Exception.builder().statusCode(status).message(code).build();
|
||||
}
|
||||
}
|
||||
+75
@@ -28,6 +28,7 @@ import stirling.software.proprietary.policy.model.Policy;
|
||||
import stirling.software.proprietary.policy.store.InProcessPolicyStore;
|
||||
import stirling.software.proprietary.policy.store.PolicyStore;
|
||||
import stirling.software.proprietary.policy.trigger.PolicyTriggerManager;
|
||||
import stirling.software.proprietary.util.SecretMasker;
|
||||
|
||||
/**
|
||||
* Tests for {@link SourceController}'s delete guard: a source still referenced by a policy is
|
||||
@@ -109,6 +110,80 @@ class SourceControllerTest {
|
||||
assertEquals(404, controller.delete("nope").getStatusCode().value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void readsReturnSecretsAsTheRedactionSentinel() {
|
||||
Source saved = sourceStore.save(s3Source("shh"));
|
||||
|
||||
Source read = controller.get(saved.id()).getBody();
|
||||
|
||||
assertEquals(SecretMasker.REDACTED, read.options().get("secretAccessKey"));
|
||||
assertEquals("AKIAEXAMPLE", read.options().get("accessKeyId"));
|
||||
// The store itself keeps the real value.
|
||||
assertEquals(
|
||||
"shh", sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingTheSentinelBackKeepsTheStoredSecret() {
|
||||
Source saved = sourceStore.save(s3Source("shh"));
|
||||
|
||||
Source edited =
|
||||
new Source(
|
||||
saved.id(),
|
||||
"Renamed",
|
||||
saved.type(),
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", SecretMasker.REDACTED),
|
||||
true,
|
||||
saved.owner(),
|
||||
saved.teamId());
|
||||
Source response = controller.save(edited).getBody();
|
||||
|
||||
assertEquals(
|
||||
"shh", sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey"));
|
||||
// The save response is masked too; only the store sees the real value.
|
||||
assertEquals(SecretMasker.REDACTED, response.options().get("secretAccessKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void savingANewSecretReplacesTheStoredOne() {
|
||||
Source saved = sourceStore.save(s3Source("old-secret"));
|
||||
|
||||
Source edited =
|
||||
new Source(
|
||||
saved.id(),
|
||||
saved.name(),
|
||||
saved.type(),
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", "new-secret"),
|
||||
true,
|
||||
saved.owner(),
|
||||
saved.teamId());
|
||||
controller.save(edited);
|
||||
|
||||
assertEquals(
|
||||
"new-secret",
|
||||
sourceStore.get(saved.id()).orElseThrow().options().get("secretAccessKey"));
|
||||
}
|
||||
|
||||
private static Source s3Source(String secret) {
|
||||
return new Source(
|
||||
null,
|
||||
"Bucket intake",
|
||||
"s3",
|
||||
Map.of(
|
||||
"bucket", "inbox",
|
||||
"accessKeyId", "AKIAEXAMPLE",
|
||||
"secretAccessKey", secret),
|
||||
true,
|
||||
"owner",
|
||||
null);
|
||||
}
|
||||
|
||||
private static Source folderSource() {
|
||||
return new Source(
|
||||
null, "Claims intake", "folder", Map.of("directory", "/in"), true, "owner", null);
|
||||
|
||||
+35
@@ -70,6 +70,41 @@ class ScheduleTriggerTest {
|
||||
verify(policyRunner, times(1)).run(eq(policy));
|
||||
}
|
||||
|
||||
@Test
|
||||
void anIntervalMatchingTheSweepPeriodFiresEverySweepDespiteJitter() {
|
||||
Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES));
|
||||
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
|
||||
|
||||
Instant t0 = Instant.parse("2026-06-05T10:00:00Z");
|
||||
trigger.sweep(t0); // baseline
|
||||
// The sweep that fires runs a few ms late (scheduler jitter)...
|
||||
trigger.sweep(t0.plusSeconds(60).plusMillis(5));
|
||||
verify(policyRunner, times(1)).run(eq(policy));
|
||||
|
||||
// ...and the next sweep lands exactly on the 60s grid. Anchoring lastFired to the due
|
||||
// time (not the jittered observation) means this must still fire, not alias to skip.
|
||||
trigger.sweep(t0.plusSeconds(120));
|
||||
verify(policyRunner, times(2)).run(eq(policy));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aGapFiresOnceNotOncePerMissedInterval() {
|
||||
Policy policy = scheduled("p1", new Schedule.Every(1, Schedule.Unit.MINUTES));
|
||||
when(policyStore.findByTriggerType("schedule")).thenReturn(List.of(policy));
|
||||
|
||||
Instant t0 = Instant.parse("2026-06-05T10:00:00Z");
|
||||
trigger.sweep(t0); // baseline
|
||||
// Ten minutes of downtime: nine missed due points collapse into one firing.
|
||||
trigger.sweep(t0.plusSeconds(600));
|
||||
verify(policyRunner, times(1)).run(eq(policy));
|
||||
|
||||
// Not due again until a full interval after the latest due point.
|
||||
trigger.sweep(t0.plusSeconds(630));
|
||||
verify(policyRunner, times(1)).run(eq(policy));
|
||||
trigger.sweep(t0.plusSeconds(660));
|
||||
verify(policyRunner, times(2)).run(eq(policy));
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotFireBeforeTheNextScheduledTime() {
|
||||
Policy policy = scheduled("p1", new Schedule.Daily(LocalTime.of(3, 0))); // 03:00 UTC daily
|
||||
|
||||
+53
-9
@@ -14,8 +14,8 @@ import org.junit.jupiter.api.Test;
|
||||
* Unit tests for {@link SecretMasker}.
|
||||
*
|
||||
* <p>Assumptions: - Key matching is case-insensitive via the pattern in SENSITIVE. - If the key
|
||||
* matches a sensitive pattern, the value is replaced with "***REDACTED***". - Nested maps and lists
|
||||
* are searched recursively. - Null maps and null values are ignored or returned as null. -
|
||||
* matches a sensitive pattern, the value is replaced with SecretMasker.REDACTED. - Nested maps and
|
||||
* lists are searched recursively. - Null maps and null values are ignored or returned as null. -
|
||||
* Non-sensitive keys/values remain unchanged.
|
||||
*/
|
||||
class SecretMaskerTest {
|
||||
@@ -40,7 +40,7 @@ class SecretMaskerTest {
|
||||
|
||||
Map<String, Object> result = SecretMasker.mask(input);
|
||||
|
||||
assertEquals("***REDACTED***", result.get("password"));
|
||||
assertEquals(SecretMasker.REDACTED, result.get("password"));
|
||||
assertEquals("john", result.get("username"));
|
||||
}
|
||||
|
||||
@@ -55,11 +55,54 @@ class SecretMaskerTest {
|
||||
|
||||
Map<String, Object> result = SecretMasker.mask(input);
|
||||
|
||||
assertEquals("***REDACTED***", result.get("Api-Key"));
|
||||
assertEquals("***REDACTED***", result.get("TOKEN"));
|
||||
assertEquals(SecretMasker.REDACTED, result.get("Api-Key"));
|
||||
assertEquals(SecretMasker.REDACTED, result.get("TOKEN"));
|
||||
assertEquals("keepme", result.get("normal"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("restoreRedacted swaps sentinels for stored values, leaves the rest")
|
||||
void restoreRedactedRoundTripsAnEdit() {
|
||||
Map<String, Object> stored =
|
||||
Map.of("secretAccessKey", "shh", "accessKeyId", "AKIAEXAMPLE");
|
||||
Map<String, Object> incoming =
|
||||
Map.of(
|
||||
"secretAccessKey", SecretMasker.REDACTED,
|
||||
"accessKeyId", "AKIA-NEW",
|
||||
"bucket", "inbox");
|
||||
|
||||
Map<String, Object> merged = SecretMasker.restoreRedacted(incoming, stored);
|
||||
|
||||
assertEquals("shh", merged.get("secretAccessKey"));
|
||||
assertEquals("AKIA-NEW", merged.get("accessKeyId"));
|
||||
assertEquals("inbox", merged.get("bucket"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("restoreRedacted leaves a sentinel with no stored counterpart in place")
|
||||
void restoreRedactedWithoutStoredValueStaysSentinel() {
|
||||
Map<String, Object> merged =
|
||||
SecretMasker.restoreRedacted(
|
||||
Map.of("secretAccessKey", SecretMasker.REDACTED), Map.of());
|
||||
|
||||
assertEquals(SecretMasker.REDACTED, merged.get("secretAccessKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should mask camelCase secretAccessKey despite no word boundary")
|
||||
void shouldMaskCamelCaseSecretAccessKey() {
|
||||
Map<String, Object> input =
|
||||
Map.of(
|
||||
"secretAccessKey", "shh",
|
||||
"accessKeyId", "AKIAEXAMPLE");
|
||||
|
||||
Map<String, Object> result = SecretMasker.mask(input);
|
||||
|
||||
assertEquals(SecretMasker.REDACTED, result.get("secretAccessKey"));
|
||||
// Access key ids are username-like, not secrets.
|
||||
assertEquals("AKIAEXAMPLE", result.get("accessKeyId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("should mask nested map sensitive keys")
|
||||
void shouldMaskNestedMapSensitiveKeys() {
|
||||
@@ -77,9 +120,9 @@ class SecretMaskerTest {
|
||||
Map<String, Object> result = SecretMasker.mask(input);
|
||||
|
||||
Map<String, Object> outer = (Map<String, Object>) result.get("outer");
|
||||
assertEquals("***REDACTED***", outer.get("jwt"));
|
||||
assertEquals(SecretMasker.REDACTED, outer.get("jwt"));
|
||||
Map<String, Object> inner = (Map<String, Object>) outer.get("inner");
|
||||
assertEquals("***REDACTED***", inner.get("secret"));
|
||||
assertEquals(SecretMasker.REDACTED, inner.get("secret"));
|
||||
assertEquals("ok", inner.get("other"));
|
||||
}
|
||||
|
||||
@@ -98,7 +141,7 @@ class SecretMaskerTest {
|
||||
|
||||
List<?> list = (List<?>) result.get("list");
|
||||
Map<String, Object> first = (Map<String, Object>) list.get(0);
|
||||
assertEquals("***REDACTED***", first.get("token"));
|
||||
assertEquals(SecretMasker.REDACTED, first.get("token"));
|
||||
Map<String, Object> second = (Map<String, Object>) list.get(1);
|
||||
assertEquals("john", second.get("username"));
|
||||
assertEquals("stringValue", list.get(2));
|
||||
@@ -170,7 +213,8 @@ class SecretMaskerTest {
|
||||
Map<String, Object> outer = (Map<String, Object>) result.get("outer");
|
||||
assertTrue(outer.containsKey(null), "Null key should be preserved");
|
||||
assertEquals("plainText", outer.get(null), "Value for null key must not be masked");
|
||||
assertEquals("***REDACTED***", outer.get("password"), "Sensitive keys must be masked");
|
||||
assertEquals(
|
||||
SecretMasker.REDACTED, outer.get("password"), "Sensitive keys must be masked");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
-16
@@ -32,6 +32,8 @@ import stirling.software.proprietary.security.repository.TeamMembershipRepositor
|
||||
import stirling.software.saas.procurement.config.ProcurementConfigurationProperties;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.model.QuoteDetails;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
import stirling.software.saas.procurement.pricing.QuoteLineItem;
|
||||
import stirling.software.saas.procurement.service.ProcurementService;
|
||||
@@ -56,16 +58,19 @@ public class ProcurementController {
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final ProcurementService procurement;
|
||||
private final ProcurementPricingService pricing;
|
||||
private final TeamMembershipRepository memberRepo;
|
||||
private final UserRepository userRepository;
|
||||
private final ProcurementConfigurationProperties config;
|
||||
|
||||
public ProcurementController(
|
||||
ProcurementService procurement,
|
||||
ProcurementPricingService pricing,
|
||||
TeamMembershipRepository memberRepo,
|
||||
UserRepository userRepository,
|
||||
ProcurementConfigurationProperties config) {
|
||||
this.procurement = Objects.requireNonNull(procurement);
|
||||
this.pricing = Objects.requireNonNull(pricing);
|
||||
this.memberRepo = Objects.requireNonNull(memberRepo);
|
||||
this.userRepository = Objects.requireNonNull(userRepository);
|
||||
this.config = Objects.requireNonNull(config);
|
||||
@@ -77,29 +82,53 @@ public class ProcurementController {
|
||||
long volume,
|
||||
int users,
|
||||
int intensity, // policy posture (runs/PDF): 2 / 4 / 7; 0 → default Governed
|
||||
double sizeMult, // PDF-size tier multiplier: 1.0 / 1.4 / 2.4; 0 → no uplift
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
String currency,
|
||||
String businessName) {
|
||||
String businessName,
|
||||
// Buyer / AP details (all optional). Country + currency intentionally out of scope.
|
||||
String contactName,
|
||||
String contactEmail,
|
||||
String addressLine1,
|
||||
String addressLine2,
|
||||
String city,
|
||||
String region,
|
||||
String postalCode,
|
||||
String poNumber,
|
||||
String taxId) {
|
||||
QuoteConfig toConfig() {
|
||||
return new QuoteConfig(
|
||||
volume,
|
||||
users,
|
||||
intensity,
|
||||
sizeMult,
|
||||
deployment,
|
||||
termYears,
|
||||
serviceLevel,
|
||||
indemnification,
|
||||
training,
|
||||
qbr,
|
||||
offlineLicense,
|
||||
currency);
|
||||
}
|
||||
|
||||
QuoteDetails toDetails() {
|
||||
return new QuoteDetails(
|
||||
businessName,
|
||||
contactName,
|
||||
contactEmail,
|
||||
addressLine1,
|
||||
addressLine2,
|
||||
city,
|
||||
region,
|
||||
postalCode,
|
||||
poNumber,
|
||||
taxId);
|
||||
}
|
||||
}
|
||||
|
||||
public record QuoteResponse(
|
||||
@@ -109,10 +138,15 @@ public class ProcurementController {
|
||||
String currency,
|
||||
long annualNetMinor,
|
||||
long tcvMinor,
|
||||
// First post-term renewal fee after the CPI escalator, and that escalator as a whole
|
||||
// percent — the committed term is flat, so these describe only the auto-renewal.
|
||||
long renewalAnnualNetMinor,
|
||||
int cpiRatePct,
|
||||
List<QuoteLineItem> lineItems,
|
||||
String validUntil,
|
||||
String stripeQuoteId,
|
||||
String invoiceUrl,
|
||||
String invoicePdf,
|
||||
QuoteConfigEcho config) {}
|
||||
|
||||
/**
|
||||
@@ -124,19 +158,33 @@ public class ProcurementController {
|
||||
long volume,
|
||||
int users,
|
||||
int intensity,
|
||||
double sizeMult,
|
||||
String deployment,
|
||||
int termYears,
|
||||
String serviceLevel,
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense,
|
||||
String currency,
|
||||
String businessName) {}
|
||||
String businessName,
|
||||
String contactName,
|
||||
String contactEmail,
|
||||
String addressLine1,
|
||||
String addressLine2,
|
||||
String city,
|
||||
String region,
|
||||
String postalCode,
|
||||
String poNumber,
|
||||
String taxId) {}
|
||||
|
||||
/** Trial setup captured before the trial starts: deployment target + seat count. */
|
||||
public record StartTrialRequest(String deployment, int users) {}
|
||||
|
||||
public record SnapshotResponse(
|
||||
Long dealId,
|
||||
String stage,
|
||||
String deployment,
|
||||
int seats,
|
||||
String trialStartedAt,
|
||||
String trialEndsAt,
|
||||
int trialExtensionsUsed,
|
||||
@@ -165,12 +213,12 @@ public class ProcurementController {
|
||||
}
|
||||
|
||||
private static final SnapshotResponse EMPTY_SNAPSHOT =
|
||||
new SnapshotResponse(null, null, null, null, 0, false, null, null);
|
||||
new SnapshotResponse(null, null, null, 0, null, null, 0, false, null, null);
|
||||
|
||||
/**
|
||||
* Download the offline / air-gapped licence file (.lic) for the team, when the paid offline
|
||||
* add-on was purchased. 404 when there's no licence or the add-on wasn't taken — we don't leak
|
||||
* that a licence exists to a team without the add-on.
|
||||
* Download the offline / air-gapped licence file (.lic) for the team — available for an
|
||||
* air-gapped deployment from the trial licence onward. 404 when there's no licence yet or the
|
||||
* deployment isn't air-gapped, so we don't leak that a licence exists.
|
||||
*/
|
||||
@GetMapping("/license/file")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
@@ -193,10 +241,15 @@ public class ProcurementController {
|
||||
|
||||
@PostMapping("/trial/start")
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public ResponseEntity<SnapshotResponse> startTrial(Authentication auth) {
|
||||
public ResponseEntity<SnapshotResponse> startTrial(
|
||||
@RequestBody(required = false) StartTrialRequest request, Authentication auth) {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(toSnapshot(procurement.startTrial(teamId), true));
|
||||
// Body is optional so an older client (no setup step) still starts a cloud trial.
|
||||
String deployment = request != null ? request.deployment() : null;
|
||||
int seats = request != null ? request.users() : 0;
|
||||
return ResponseEntity.ok(
|
||||
toSnapshot(procurement.startTrial(teamId, deployment, seats), true));
|
||||
}
|
||||
|
||||
@PostMapping("/trial/extend")
|
||||
@@ -218,9 +271,7 @@ public class ProcurementController {
|
||||
Long teamId = requireLeader(auth);
|
||||
if (teamId == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
return ResponseEntity.ok(
|
||||
toQuote(
|
||||
procurement.buildQuote(
|
||||
teamId, request.toConfig(), request.businessName())));
|
||||
toQuote(procurement.buildQuote(teamId, request.toConfig(), request.toDetails())));
|
||||
}
|
||||
|
||||
// Issue + accept are Supabase edge functions (they own Stripe): issue-procurement-quote turns a
|
||||
@@ -323,6 +374,8 @@ public class ProcurementController {
|
||||
return new SnapshotResponse(
|
||||
deal.getDealId(),
|
||||
deal.getStage(),
|
||||
deal.getDeployment(),
|
||||
deal.getSeats(),
|
||||
str(deal.getTrialStartedAt()),
|
||||
str(deal.getTrialEndsAt()),
|
||||
deal.getTrialExtensionsUsed(),
|
||||
@@ -339,23 +392,40 @@ public class ProcurementController {
|
||||
q.getCurrency(),
|
||||
q.getAnnualNetMinor(),
|
||||
q.getTcvMinor(),
|
||||
// Prefer the renewal locked at quote time; fall back to a live projection for
|
||||
// quotes
|
||||
// priced before the column existed.
|
||||
q.getRenewalAnnualMinor() > 0
|
||||
? q.getRenewalAnnualMinor()
|
||||
: pricing.renewalAnnualMinor(q.getAnnualNetMinor()),
|
||||
pricing.cpiRatePct(),
|
||||
parseLineItems(q.getLineItemsJson()),
|
||||
q.getValidUntil() == null ? null : q.getValidUntil().toString(),
|
||||
q.getStripeQuoteId(),
|
||||
q.getStripeInvoiceUrl(),
|
||||
q.getStripeInvoicePdf(),
|
||||
new QuoteConfigEcho(
|
||||
q.getVolume(),
|
||||
0,
|
||||
q.getIntensity(),
|
||||
q.getSizeMult(),
|
||||
q.getDeployment(),
|
||||
q.getTermYears(),
|
||||
q.getServiceLevel(),
|
||||
q.isIndemnification(),
|
||||
q.isTraining(),
|
||||
q.isQbr(),
|
||||
q.isOfflineLicense(),
|
||||
q.getCurrency(),
|
||||
q.getBusinessName()));
|
||||
q.getBusinessName(),
|
||||
q.getContactName(),
|
||||
q.getContactEmail(),
|
||||
q.getAddressLine1(),
|
||||
q.getAddressLine2(),
|
||||
q.getCity(),
|
||||
q.getRegion(),
|
||||
q.getPostalCode(),
|
||||
q.getPoNumber(),
|
||||
q.getTaxId()));
|
||||
}
|
||||
|
||||
private List<QuoteLineItem> parseLineItems(String json) {
|
||||
|
||||
@@ -51,6 +51,15 @@ public class ProcurementDeal implements Serializable {
|
||||
@Column(name = "stage", nullable = false, length = 32)
|
||||
private String stage = STAGE_TRIAL;
|
||||
|
||||
// Deployment target + seat count captured at trial start (the setup step); they seed the quote
|
||||
// builder so it opens on the buyer's real environment. The quote remains the commercial source
|
||||
// of truth — these are just the starting point, editable when the quote is built.
|
||||
@Column(name = "deployment", nullable = false, length = 16)
|
||||
private String deployment = "cloud";
|
||||
|
||||
@Column(name = "seats", nullable = false)
|
||||
private int seats;
|
||||
|
||||
@Column(name = "trial_started_at")
|
||||
private LocalDateTime trialStartedAt;
|
||||
|
||||
|
||||
+46
-3
@@ -66,6 +66,10 @@ public class ProcurementQuote implements Serializable {
|
||||
@Column(name = "intensity", nullable = false)
|
||||
private int intensity = 4;
|
||||
|
||||
/** File-size tier multiplier on the rate (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */
|
||||
@Column(name = "size_mult", nullable = false)
|
||||
private double sizeMult = 1.0;
|
||||
|
||||
@Column(name = "deployment", length = 24)
|
||||
private String deployment;
|
||||
|
||||
@@ -84,15 +88,17 @@ public class ProcurementQuote implements Serializable {
|
||||
@Column(name = "qbr", nullable = false)
|
||||
private boolean qbr;
|
||||
|
||||
@Column(name = "offline_license", nullable = false)
|
||||
private boolean offlineLicense;
|
||||
|
||||
@Column(name = "annual_net_minor", nullable = false)
|
||||
private long annualNetMinor;
|
||||
|
||||
@Column(name = "tcv_minor", nullable = false)
|
||||
private long tcvMinor;
|
||||
|
||||
// First post-term renewal fee (annual net + one CPI step), locked at quote time so the buyer's
|
||||
// quoted renewal doesn't drift if the rate card changes later.
|
||||
@Column(name = "renewal_annual_minor", nullable = false)
|
||||
private long renewalAnnualMinor;
|
||||
|
||||
@Column(name = "line_items", columnDefinition = "text")
|
||||
private String lineItemsJson;
|
||||
|
||||
@@ -105,10 +111,47 @@ public class ProcurementQuote implements Serializable {
|
||||
@Column(name = "stripe_invoice_url", columnDefinition = "text")
|
||||
private String stripeInvoiceUrl;
|
||||
|
||||
// Direct PDF link for that first invoice (Stripe invoice_pdf), set at accept alongside the URL;
|
||||
// persisted so the portal's download button works after a reload, not just in the accept
|
||||
// response.
|
||||
@Column(name = "stripe_invoice_pdf", columnDefinition = "text")
|
||||
private String stripeInvoicePdf;
|
||||
|
||||
// Buyer's company name (shown on the quote/agreement); echoed back so an edit remembers it.
|
||||
@Column(name = "business_name", length = 255)
|
||||
private String businessName;
|
||||
|
||||
// Buyer/AP details captured on the quote's "Your details" step. All optional — they never gate
|
||||
// quote generation; they flow onto the Stripe customer (name + bill-to address) and the invoice
|
||||
// (PO number + tax id as invoice custom fields), and seed the builder on a re-edit. Country and
|
||||
// currency are intentionally out of scope for now.
|
||||
@Column(name = "contact_name", length = 255)
|
||||
private String contactName;
|
||||
|
||||
@Column(name = "contact_email", length = 255)
|
||||
private String contactEmail;
|
||||
|
||||
@Column(name = "address_line1", length = 255)
|
||||
private String addressLine1;
|
||||
|
||||
@Column(name = "address_line2", length = 255)
|
||||
private String addressLine2;
|
||||
|
||||
@Column(name = "city", length = 128)
|
||||
private String city;
|
||||
|
||||
@Column(name = "region", length = 128)
|
||||
private String region;
|
||||
|
||||
@Column(name = "postal_code", length = 32)
|
||||
private String postalCode;
|
||||
|
||||
@Column(name = "po_number", length = 128)
|
||||
private String poNumber;
|
||||
|
||||
@Column(name = "tax_id", length = 64)
|
||||
private String taxId;
|
||||
|
||||
@Column(name = "valid_until")
|
||||
private LocalDate validUntil;
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package stirling.software.saas.procurement.model;
|
||||
|
||||
/**
|
||||
* Buyer / AP details captured on the quote's "Your details" step: the company and signatory
|
||||
* contact, a billing address, and a PO number / tax id for the invoice. These are not pricing
|
||||
* inputs (they never touch {@link stirling.software.saas.procurement.pricing.QuoteConfig}); they
|
||||
* ride alongside the priced config so the quote can be re-seeded on an edit and the fields can flow
|
||||
* onto the Stripe customer and invoice. All fields are optional. Country and currency are out of
|
||||
* scope for now.
|
||||
*/
|
||||
public record QuoteDetails(
|
||||
String businessName,
|
||||
String contactName,
|
||||
String contactEmail,
|
||||
String addressLine1,
|
||||
String addressLine2,
|
||||
String city,
|
||||
String region,
|
||||
String postalCode,
|
||||
String poNumber,
|
||||
String taxId) {}
|
||||
@@ -21,7 +21,8 @@ public record PricingRates(
|
||||
long selfHostDeployMinor, // flat: self-hosted deployment
|
||||
long airgapDeployMinor, // flat: air-gapped deployment
|
||||
long qbrAnnualMinor, // flat: quarterly business reviews
|
||||
long trainingOneTimeMinor) { // one-time: onboarding & training
|
||||
long trainingOneTimeMinor, // one-time: onboarding & training
|
||||
double cpiEscalator) { // fixed CPI uplift on the annual fee at each post-term renewal
|
||||
|
||||
public static PricingRates defaults() {
|
||||
return new PricingRates(
|
||||
@@ -34,7 +35,8 @@ public record PricingRates(
|
||||
1_200_000, // self-hosted $12,000 / yr
|
||||
3_600_000, // air-gapped $36,000 / yr
|
||||
800_000, // QBRs $8,000 / yr
|
||||
750_000); // onboarding & training $7,500 one-time
|
||||
750_000, // onboarding & training $7,500 one-time
|
||||
0.03); // 3% CPI escalator per renewal (committed term stays flat)
|
||||
}
|
||||
|
||||
public double termDiscount(int termYears) {
|
||||
|
||||
+29
-1
@@ -61,6 +61,11 @@ public class ProcurementPricingService {
|
||||
* (Math.log(runVol / (double) RUN_CURVE_KNEE) / LOG2))
|
||||
: 0.0;
|
||||
double rate = Math.max(rates.floorRatePerRun(), rates.listRatePerRun() * (1.0 - volDisc));
|
||||
// File-size multiplier (D93): larger, image-heavy PDFs cost more OCR/compute/storage. Folds
|
||||
// into the per-run rate after the floor, so it flows through the meter, TCV and renewal.
|
||||
// QuoteConfig has already snapped it to a known tier, so a tampered request can't sneak a
|
||||
// cheaper factor in.
|
||||
rate *= cfg.sizeMult();
|
||||
double termDisc = rates.termDiscount(cfg.termYears());
|
||||
|
||||
// The meter is a whole-dollar figure (the quote reads in dollars), then minor units.
|
||||
@@ -82,6 +87,7 @@ public class ProcurementPricingService {
|
||||
|
||||
long annualNet = meterNetMinor + support + deploy + indemnity + qbr;
|
||||
long tcv = annualNet * cfg.termYears() + training;
|
||||
long renewalAnnual = renewalAnnualMinor(annualNet, rates);
|
||||
|
||||
double effectivePerPdf = rate * intensity; // quotes speak per-PDF-at-posture, never per-run
|
||||
|
||||
@@ -151,7 +157,29 @@ public class ProcurementPricingService {
|
||||
QuoteLineItem.Kind.ONE_TIME,
|
||||
training));
|
||||
}
|
||||
return new QuoteBreakdown(lines, annualNet, tcv, cfg.currency());
|
||||
return new QuoteBreakdown(lines, annualNet, tcv, renewalAnnual, cfg.currency());
|
||||
}
|
||||
|
||||
/** The default CPI escalator (fraction) applied to the annual fee on each post-term renewal. */
|
||||
public double cpiEscalator() {
|
||||
return PricingRates.defaults().cpiEscalator();
|
||||
}
|
||||
|
||||
/** The CPI escalator as a whole-percent figure for buyer-facing copy (3% → 3). */
|
||||
public int cpiRatePct() {
|
||||
return (int) Math.round(cpiEscalator() * 100.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* The escalated annual fee at the first renewal: the committed annual plus one CPI step. Used
|
||||
* both when pricing a fresh quote and when echoing a stored one, so the two always agree.
|
||||
*/
|
||||
public long renewalAnnualMinor(long annualNetMinor) {
|
||||
return renewalAnnualMinor(annualNetMinor, PricingRates.defaults());
|
||||
}
|
||||
|
||||
private static long renewalAnnualMinor(long annualNetMinor, PricingRates rates) {
|
||||
return Math.round(annualNetMinor * (1.0 + rates.cpiEscalator()));
|
||||
}
|
||||
|
||||
private static long deployFeeMinor(String deployment, PricingRates rates) {
|
||||
|
||||
+9
-4
@@ -3,10 +3,15 @@ package stirling.software.saas.procurement.pricing;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The priced result of a {@link QuoteConfig}: the itemised lines plus the two headline figures the
|
||||
* The priced result of a {@link QuoteConfig}: the itemised lines plus the headline figures the
|
||||
* order form and Stripe checkout are built from. {@code annualNetMinor} is the recurring annual fee
|
||||
* after the multi-year discount; {@code tcvMinor} is total contract value across the term including
|
||||
* one-time fees. Minor units (cents).
|
||||
* after the multi-year discount; {@code tcvMinor} is total contract value across the committed term
|
||||
* including one-time fees; {@code renewalAnnualNetMinor} is the annual fee at the first post-term
|
||||
* renewal after the fixed CPI escalator (the committed term itself is flat). Minor units (cents).
|
||||
*/
|
||||
public record QuoteBreakdown(
|
||||
List<QuoteLineItem> lineItems, long annualNetMinor, long tcvMinor, String currency) {}
|
||||
List<QuoteLineItem> lineItems,
|
||||
long annualNetMinor,
|
||||
long tcvMinor,
|
||||
long renewalAnnualNetMinor,
|
||||
String currency) {}
|
||||
|
||||
@@ -10,23 +10,35 @@ public record QuoteConfig(
|
||||
long volume, // committed PDFs per year
|
||||
int users, // seats (drives the volume auto-estimate when the buyer hasn't overridden)
|
||||
int intensity, // policy posture: runs per PDF — Essentials 2, Governed 4, Regulated 7
|
||||
double sizeMult, // file-size tier multiplier on the rate — Compact 1.0 / Standard 1.4 /
|
||||
// Heavy 2.4
|
||||
String deployment, // cloud | selfhost | airgap (priced flat; inherited from the trial)
|
||||
int termYears, // 1..5
|
||||
String serviceLevel, // standard | priority (both included) | dedicated (flat SE/CSM fee)
|
||||
boolean indemnification,
|
||||
boolean training,
|
||||
boolean qbr,
|
||||
boolean offlineLicense, // offline .lic availability (gates download; no longer priced here)
|
||||
String currency) { // USD only for now
|
||||
|
||||
/** Default posture when none is chosen — Governed (x4), per the pricing alignment decision. */
|
||||
public static final int DEFAULT_INTENSITY = 4;
|
||||
|
||||
/** Known file-size tier multipliers (D93): Compact 1.0, Standard 1.4, Heavy 2.4. */
|
||||
private static final double[] SIZE_MULTS = {1.0, 1.4, 2.4};
|
||||
|
||||
public QuoteConfig {
|
||||
if (termYears < 1) termYears = 1;
|
||||
if (termYears > 5) termYears = 5;
|
||||
if (intensity < 1) intensity = DEFAULT_INTENSITY;
|
||||
if (serviceLevel == null || serviceLevel.isBlank()) serviceLevel = "standard";
|
||||
if (currency == null || currency.isBlank()) currency = "USD";
|
||||
// Snap the file-size multiplier to a known tier so a tampered request can't invent a
|
||||
// cheaper
|
||||
// one; absent (0.0) or unknown falls back to 1.0 (no uplift).
|
||||
double snapped = 1.0;
|
||||
for (double s : SIZE_MULTS) {
|
||||
if (Math.abs(s - sizeMult) < 1e-9) snapped = s;
|
||||
}
|
||||
sizeMult = snapped;
|
||||
}
|
||||
}
|
||||
|
||||
+48
-23
@@ -24,6 +24,7 @@ import stirling.software.saas.procurement.license.EnterpriseLicenseService;
|
||||
import stirling.software.saas.procurement.license.LicenseEntitlements;
|
||||
import stirling.software.saas.procurement.model.ProcurementDeal;
|
||||
import stirling.software.saas.procurement.model.ProcurementQuote;
|
||||
import stirling.software.saas.procurement.model.QuoteDetails;
|
||||
import stirling.software.saas.procurement.pricing.ProcurementPricingService;
|
||||
import stirling.software.saas.procurement.pricing.QuoteBreakdown;
|
||||
import stirling.software.saas.procurement.pricing.QuoteConfig;
|
||||
@@ -96,28 +97,46 @@ public class ProcurementService {
|
||||
/**
|
||||
* Start (or restart) the free trial for a team: issue a mock trial licence and stamp the trial
|
||||
* window on the deal. No Stripe: a no-card trial has no subscription; the entitlement is the
|
||||
* Keygen licence, and the deal row is the journey state.
|
||||
* Keygen licence, and the deal row is the journey state. The buyer's chosen deployment target
|
||||
* ({@code cloud}/{@code selfhost}/{@code airgap}) and seat count are captured here so the quote
|
||||
* builder opens seeded to their environment; both are still editable when the quote is built.
|
||||
*/
|
||||
@Transactional
|
||||
public ProcurementDeal startTrial(Long teamId) {
|
||||
public ProcurementDeal startTrial(Long teamId, String deployment, int seats) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
LocalDateTime ends = now.plusDays(config.getTrialDurationDays());
|
||||
deal.setStage(ProcurementDeal.STAGE_TRIAL);
|
||||
deal.setDeployment(normalizeDeployment(deployment));
|
||||
deal.setSeats(Math.max(0, seats));
|
||||
deal.setTrialStartedAt(now);
|
||||
deal.setTrialEndsAt(ends);
|
||||
deal.setTrialExtensionsUsed(0);
|
||||
deal.setLicenseRef(licenses.issueTrialLicense(teamId, leaderEmail(teamId), ends));
|
||||
deal = dealRepo.save(deal);
|
||||
log.info(
|
||||
"[procurement] trial started team={} deal={} ends={}",
|
||||
"[procurement] trial started team={} deal={} deployment={} seats={} ends={}",
|
||||
teamId,
|
||||
deal.getDealId(),
|
||||
deal.getDeployment(),
|
||||
deal.getSeats(),
|
||||
ends);
|
||||
return deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrain a caller-supplied deployment to the known set; anything else falls back to cloud.
|
||||
*/
|
||||
private static String normalizeDeployment(String deployment) {
|
||||
if (deployment == null) return "cloud";
|
||||
String d = deployment.trim().toLowerCase(Locale.ROOT);
|
||||
return switch (d) {
|
||||
case "selfhost", "airgap", "cloud" -> d;
|
||||
default -> "cloud";
|
||||
};
|
||||
}
|
||||
|
||||
/** Extend the current trial by the configured increment, up to the cap. */
|
||||
@Transactional
|
||||
public ProcurementDeal extendTrial(Long teamId) {
|
||||
@@ -145,7 +164,7 @@ public class ProcurementService {
|
||||
|
||||
/** Price a quote config server-side and persist it as a draft against the team's deal. */
|
||||
@Transactional
|
||||
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, String businessName) {
|
||||
public ProcurementQuote buildQuote(Long teamId, QuoteConfig cfg, QuoteDetails details) {
|
||||
ProcurementDeal deal =
|
||||
dealRepo.findByTeamId(teamId).orElseGet(() -> new ProcurementDeal(teamId));
|
||||
if (ProcurementDeal.STAGE_LIVE.equals(deal.getStage())) {
|
||||
@@ -168,16 +187,26 @@ public class ProcurementService {
|
||||
quote.setVolume(cfg.volume());
|
||||
quote.setSeats(cfg.users() > 0 ? cfg.users() : null);
|
||||
quote.setIntensity(cfg.intensity());
|
||||
quote.setSizeMult(cfg.sizeMult());
|
||||
quote.setDeployment(cfg.deployment());
|
||||
quote.setTermYears(cfg.termYears());
|
||||
quote.setServiceLevel(cfg.serviceLevel());
|
||||
quote.setIndemnification(cfg.indemnification());
|
||||
quote.setTraining(cfg.training());
|
||||
quote.setQbr(cfg.qbr());
|
||||
quote.setOfflineLicense(cfg.offlineLicense());
|
||||
quote.setBusinessName(businessName);
|
||||
quote.setBusinessName(details.businessName());
|
||||
quote.setContactName(details.contactName());
|
||||
quote.setContactEmail(details.contactEmail());
|
||||
quote.setAddressLine1(details.addressLine1());
|
||||
quote.setAddressLine2(details.addressLine2());
|
||||
quote.setCity(details.city());
|
||||
quote.setRegion(details.region());
|
||||
quote.setPostalCode(details.postalCode());
|
||||
quote.setPoNumber(details.poNumber());
|
||||
quote.setTaxId(details.taxId());
|
||||
quote.setAnnualNetMinor(breakdown.annualNetMinor());
|
||||
quote.setTcvMinor(breakdown.tcvMinor());
|
||||
quote.setRenewalAnnualMinor(breakdown.renewalAnnualNetMinor());
|
||||
quote.setLineItemsJson(writeLineItems(breakdown));
|
||||
quote.setValidUntil(LocalDate.now().plusDays(30));
|
||||
quote = quoteRepo.save(quote);
|
||||
@@ -275,7 +304,7 @@ public class ProcurementService {
|
||||
q != null && q.isIndemnification(),
|
||||
q != null && q.isTraining(),
|
||||
q != null && q.isQbr(),
|
||||
q != null && q.isOfflineLicense(),
|
||||
"airgap".equalsIgnoreCase(deployment), // offline .lic = air-gapped deploy
|
||||
deal.getDealId(),
|
||||
deal.getSubscriptionId());
|
||||
return licenses.issueAnnualLicense(
|
||||
@@ -287,30 +316,26 @@ public class ProcurementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check out the offline/air-gapped licence file for a team, when the offline add-on was
|
||||
* purchased. Requires an issued licence on the deal and the accepted quote to carry the offline
|
||||
* add-on; returns empty otherwise (so the controller can 404 rather than leak that a licence
|
||||
* exists). The certificate is generated on demand by Keygen and never stored.
|
||||
* Check out the offline/air-gapped licence file (.lic) for a team. Available for an air-gapped
|
||||
* deployment (chosen at trial setup) from the trial licence onward — cloud/self-hosted verify
|
||||
* online against Keygen and don't get a file. Returns empty when there's no licence yet or the
|
||||
* deployment isn't air-gapped, so the controller can 404 rather than leak that a licence
|
||||
* exists. The certificate is generated on demand by Keygen (from whatever licence the deal
|
||||
* currently holds — trial or committed annual) and never stored.
|
||||
*
|
||||
* <p>By design a team can self-select air-gapped at trial and download a real signed .lic
|
||||
* before paying — that's bounded: the trial licence carries {@code expiry = trialEndsAt}, so
|
||||
* the file the verifier accepts self-expires at trial end. The buyer must re-download after
|
||||
* provisioning to get the committed-term file (the portal warns about this).
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<String> offlineLicenseFile(Long teamId) {
|
||||
ProcurementDeal deal = dealRepo.findByTeamId(teamId).orElse(null);
|
||||
if (deal == null || deal.getLicenseRef() == null) return Optional.empty();
|
||||
if (!hasOfflineAddOn(deal)) return Optional.empty();
|
||||
if (!"airgap".equalsIgnoreCase(deal.getDeployment())) return Optional.empty();
|
||||
return Optional.of(licenses.checkOutLicenseFile(deal.getLicenseRef()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the deal's <b>accepted</b> quote carries the paid offline-licence add-on. Gated on
|
||||
* the accepted quote (not the latest) so merely toggling the add-on on an unaccepted draft
|
||||
* can't unlock the offline file — it's only available once the add-on has actually been bought.
|
||||
*/
|
||||
private boolean hasOfflineAddOn(ProcurementDeal deal) {
|
||||
if (deal.getAcceptedQuoteId() == null) return false;
|
||||
ProcurementQuote quote = quoteRepo.findById(deal.getAcceptedQuoteId()).orElse(null);
|
||||
return quote != null && quote.isOfflineLicense();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a team's procurement: delete the deal (quotes + activity cascade). For
|
||||
* re-demos/testing.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Direct PDF link for a procurement quote's first invoice (Stripe invoice_pdf), stored at accept
|
||||
-- alongside stripe_invoice_url so the portal's "Download invoice" button survives a reload instead
|
||||
-- of relying on the transient accept response. Written by the accept edge function via the
|
||||
-- procurement_set_quote_accepted RPC; read by the Java backend via JPA. A Supabase twin mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS stripe_invoice_pdf TEXT;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Deployment target + seat count captured at the trial-start step (the setup dialog the demo shows
|
||||
-- before a trial begins), stored on the deal so the quote builder seeds from the buyer's real
|
||||
-- environment instead of a hardcoded default. deployment: cloud | selfhost | airgap. seats: 0 =
|
||||
-- unspecified. Written and read by the Java backend via JPA. A Supabase twin migration mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_deal
|
||||
ADD COLUMN IF NOT EXISTS deployment VARCHAR(16) NOT NULL DEFAULT 'cloud';
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_deal
|
||||
ADD COLUMN IF NOT EXISTS seats INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Persist the first post-term renewal fee (annual net + one CPI step) computed at quote time, so the
|
||||
-- figure shown to the buyer is locked to what they were quoted rather than recomputed from the
|
||||
-- current rate card on every read. Minor units. Written and read by the Java backend via JPA; a
|
||||
-- Supabase twin migration mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS renewal_annual_minor BIGINT NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- File-size tier multiplier on the quote (D93): larger, image-heavy PDFs cost more, so the buyer
|
||||
-- picks a size tier (Compact 1.0 / Standard 1.4 / Heavy 2.4) that scales the per-run rate. Persisted
|
||||
-- so the quote re-prices and re-seeds the builder consistently. Defaults to 1.0 (no uplift) for rows
|
||||
-- that predate the column. Written and read by the Java backend via JPA. A Supabase twin mirrors it.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS size_mult DOUBLE PRECISION NOT NULL DEFAULT 1.0;
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
-- Buyer / AP details captured on the quote's "Your details" step: the signatory contact and a
|
||||
-- billing address, plus a PO number and tax id for the invoice. All optional (never gate quote
|
||||
-- generation). Persisted so the quote re-seeds the builder on a re-edit and so the issue edge
|
||||
-- function can put them on the Stripe customer (name + bill-to address) and invoice (PO / tax id
|
||||
-- as custom fields). Country and currency are intentionally out of scope for now. Written and read
|
||||
-- by the Java backend via JPA. A Supabase twin mirrors these columns.
|
||||
|
||||
ALTER TABLE stirling_pdf.procurement_quote
|
||||
ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS address_line2 VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS city VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS region VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS postal_code VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS po_number VARCHAR(128),
|
||||
ADD COLUMN IF NOT EXISTS tax_id VARCHAR(64);
|
||||
+44
-3
@@ -18,8 +18,13 @@ class ProcurementPricingServiceTest {
|
||||
|
||||
private static QuoteConfig cfg(
|
||||
long volume, int intensity, String deployment, int term, String sla) {
|
||||
return cfgSize(volume, intensity, deployment, term, sla, 1.0);
|
||||
}
|
||||
|
||||
private static QuoteConfig cfgSize(
|
||||
long volume, int intensity, String deployment, int term, String sla, double sizeMult) {
|
||||
return new QuoteConfig(
|
||||
volume, 0, intensity, deployment, term, sla, false, false, false, false, "USD");
|
||||
volume, 0, intensity, sizeMult, deployment, term, sla, false, false, false, "USD");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -30,6 +35,7 @@ class ProcurementPricingServiceTest {
|
||||
|
||||
assertThat(q.annualNetMinor()).isEqualTo(175_200_000L); // $1,752,000
|
||||
assertThat(q.tcvMinor()).isEqualTo(525_600_000L); // $5,256,000
|
||||
assertThat(q.renewalAnnualNetMinor()).isEqualTo(180_456_000L); // $1,752,000 + 3% CPI
|
||||
assertThat(lineAmount(q, "support")).isEqualTo(3_000_000L); // dedicated SE/CSM $30K
|
||||
assertThat(lineAmount(q, "deployment")).isEqualTo(1_200_000L); // self-hosted $12K
|
||||
}
|
||||
@@ -46,6 +52,41 @@ class ProcurementPricingServiceTest {
|
||||
assertThat(q.lineItems()).noneMatch(l -> l.key().equals("support"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renewalAppliesCpiEscalatorAfterAFlatTerm() {
|
||||
// The committed term is flat (TCV = annual × years, asserted above). The 3% CPI escalator
|
||||
// describes only the first post-term renewal: annual + one 3% step. It never touches TCV.
|
||||
QuoteBreakdown q = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard"));
|
||||
assertThat(q.renewalAnnualNetMinor())
|
||||
.isEqualTo(Math.round(q.annualNetMinor() * 1.03)); // 16,527,800 → 17,023,634
|
||||
assertThat(q.tcvMinor()).isEqualTo(q.annualNetMinor() * 3); // renewal is outside the TCV
|
||||
assertThat(pricing.cpiRatePct()).isEqualTo(3);
|
||||
assertThat(pricing.renewalAnnualMinor(q.annualNetMinor()))
|
||||
.isEqualTo(q.renewalAnnualNetMinor()); // stored-quote echo agrees with pricing
|
||||
}
|
||||
|
||||
@Test
|
||||
void fileSizeTierMultipliesTheMeter() {
|
||||
// D93: the size tier scales the per-run rate, so the meter (hence annual/TCV/renewal) grows
|
||||
// while flat fees stay put. Compact (1.0) is the anchor; Standard is ×1.4, Heavy ×2.4.
|
||||
long compact =
|
||||
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.0)).annualNetMinor();
|
||||
long standard =
|
||||
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 1.4)).annualNetMinor();
|
||||
long heavy =
|
||||
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 2.4)).annualNetMinor();
|
||||
|
||||
assertThat(compact).isEqualTo(16_527_800L); // == the Northwind anchor (size 1.0)
|
||||
assertThat(standard).isEqualTo(23_138_900L); // rate ×1.4
|
||||
assertThat(compact).isLessThan(standard);
|
||||
assertThat(standard).isLessThan(heavy);
|
||||
// An unknown/tampered multiplier snaps back to 1.0 (no cheaper factor sneaks through).
|
||||
assertThat(
|
||||
pricing.price(cfgSize(6_000_000, 4, "cloud", 3, "standard", 0.3))
|
||||
.annualNetMinor())
|
||||
.isEqualTo(compact);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rateFloorsAtHalfACent() {
|
||||
// 100M × Regulated(×7) = 700M runs — deep past the knee, so the per-run rate is pinned to
|
||||
@@ -104,7 +145,7 @@ class ProcurementPricingServiceTest {
|
||||
long base = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
QuoteConfig c =
|
||||
new QuoteConfig(
|
||||
6_000_000, 0, 4, "cloud", 3, "standard", true, false, false, false, "USD");
|
||||
6_000_000, 0, 4, 1.0, "cloud", 3, "standard", true, false, false, "USD");
|
||||
QuoteBreakdown q = pricing.price(c);
|
||||
assertThat(lineAmount(q, "indemnification")).isEqualTo(Math.round(base * 0.05));
|
||||
}
|
||||
@@ -113,7 +154,7 @@ class ProcurementPricingServiceTest {
|
||||
void trainingIsOneTimeOutsideTheAnnual() {
|
||||
QuoteConfig withTraining =
|
||||
new QuoteConfig(
|
||||
6_000_000, 0, 4, "cloud", 3, "standard", false, true, false, false, "USD");
|
||||
6_000_000, 0, 4, 1.0, "cloud", 3, "standard", false, true, false, "USD");
|
||||
QuoteBreakdown q = pricing.price(withTraining);
|
||||
long baseAnnual = pricing.price(cfg(6_000_000, 4, "cloud", 3, "standard")).annualNetMinor();
|
||||
|
||||
|
||||
@@ -5937,59 +5937,12 @@ keyDescription = "Paste the license key from your email"
|
||||
success = "License Activated!"
|
||||
successMessage = "Your license has been successfully activated. You can now close this window."
|
||||
|
||||
[policies]
|
||||
deleteConfirmBody = "This removes the policy and its workflow. Documents already processed are not affected."
|
||||
deleteConfirmTitle = "Delete {{label}} policy?"
|
||||
|
||||
[policies.activity]
|
||||
enforced = "enforced"
|
||||
enforcing = "Enforcing..."
|
||||
failed = "Enforcement failed"
|
||||
outputsUnavailable = "Policy outputs are no longer available to download."
|
||||
partialOutputsUnavailable = "Some policy outputs are no longer available to download."
|
||||
retrying = "Busy, retrying..."
|
||||
runNotFound = "The enforcement run could no longer be found."
|
||||
step = "step {{current}}/{{total}}"
|
||||
timedOut = "Enforcement timed out before the run could finish."
|
||||
|
||||
[policies.catalog]
|
||||
compliance = "Compliance"
|
||||
ingestion = "Ingestion"
|
||||
retention = "Retention"
|
||||
routing = "Routing"
|
||||
security = "Security"
|
||||
|
||||
[policies.detail]
|
||||
close = "Close"
|
||||
editSettings = "Edit Settings"
|
||||
enforces = "Enforces"
|
||||
managedByOrg = "Managed by your organization. Contact a team leader to change this policy."
|
||||
noActivityDescription = "Documents will appear here once this policy runs."
|
||||
noActivityTitle = "No activity yet"
|
||||
onEveryUpload = "On every upload"
|
||||
originalsNote = "Originals stay untouched • Enforced version saved alongside"
|
||||
pause = "Pause"
|
||||
recentActivity = "Recent Activity"
|
||||
resume = "Resume"
|
||||
retry = "Retry"
|
||||
showLess = "Show less"
|
||||
showMore = "Show more"
|
||||
statActive = "Active"
|
||||
statDataProcessed = "Data processed"
|
||||
statDocsEnforced = "Docs enforced"
|
||||
statusActive = "Active"
|
||||
statusPaused = "Paused"
|
||||
|
||||
[policies.docType]
|
||||
Contracts = "Contracts"
|
||||
"Financial reports" = "Financial reports"
|
||||
"HR records" = "HR records"
|
||||
Insurance = "Insurance"
|
||||
Invoices = "Invoices"
|
||||
"Legal filings" = "Legal filings"
|
||||
"Medical / PHI" = "Medical / PHI"
|
||||
"Tax documents" = "Tax documents"
|
||||
|
||||
[policies.enforcement]
|
||||
applying = "Applying {{names}}"
|
||||
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
|
||||
@@ -5999,32 +5952,10 @@ failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were
|
||||
failureTitle = "Exported without full enforcement"
|
||||
printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again."
|
||||
printPolicyAppliedTitle = "Policy applied before printing"
|
||||
queued = "+{{count}} queued"
|
||||
successTitle = "{{names}} applied"
|
||||
summaryMore = "{{first}}, {{second}} and {{more}} more"
|
||||
summaryTwo = "{{first}} and {{second}}"
|
||||
|
||||
[policies.enforcement.triggerVerb]
|
||||
convert = "Enforcing before convert"
|
||||
default = "Enforcing"
|
||||
export = "Enforcing before export"
|
||||
input = "Enforcing on import"
|
||||
print = "Enforcing before print"
|
||||
|
||||
[policies.field]
|
||||
accessLog = "Access log"
|
||||
archiveAfter = "Archive after"
|
||||
auditTrail = "Audit trail"
|
||||
belowThreshold = "Below threshold"
|
||||
destination = "Destination"
|
||||
frameworks = "Frameworks"
|
||||
immutableHold = "Immutable hold"
|
||||
keepFor = "Keep for"
|
||||
minConfidence = "Min confidence"
|
||||
notify = "Notify on route"
|
||||
onViolation = "When non-compliant"
|
||||
webhookUrl = "Webhook URL"
|
||||
|
||||
[policies.fieldOption.archiveAfter]
|
||||
"1 year" = "1 year"
|
||||
"30 days" = "30 days"
|
||||
@@ -6070,9 +6001,6 @@ Indefinite = "Indefinite"
|
||||
"Flag for review" = "Flag for review"
|
||||
"Quarantine document" = "Quarantine document"
|
||||
|
||||
[policies.fields]
|
||||
selectedCount = "{{count}} selected"
|
||||
|
||||
[policies.labels]
|
||||
add = "Add"
|
||||
addPlaceholder = "Add a label…"
|
||||
@@ -6111,90 +6039,6 @@ placeholder = "Select PII types"
|
||||
routing = "US routing numbers (ABA)"
|
||||
ssn = "Social Security numbers"
|
||||
|
||||
[policies.settings]
|
||||
noneExport = "No policies currently run on export."
|
||||
noneUpload = "No policies currently run on upload."
|
||||
onExport = "On export"
|
||||
onUpload = "On upload"
|
||||
reorderHandle = "Drag to reorder"
|
||||
runOrderDesc = "When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder."
|
||||
title = "Policy settings"
|
||||
|
||||
[policies.sidebar]
|
||||
activeCount = "{{count}} active"
|
||||
infoTooltip = "A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps."
|
||||
loading = "Loading…"
|
||||
optionsAriaLabel = "Policy options"
|
||||
policySettings = "Policy settings"
|
||||
railAriaLabel = "{{label}} policy — {{status}}"
|
||||
railSuffixActive = " (Active)"
|
||||
railSuffixPaused = " (Paused)"
|
||||
retryFailed = "Retry failed policies ({{count}})"
|
||||
rowProgress = "{{completed}} of {{total}} files processed"
|
||||
setUp = "Set up"
|
||||
title = "Policies"
|
||||
upgradeToEnterprise = "Upgrade to enterprise"
|
||||
whatIsPolicy = "What is a policy?"
|
||||
|
||||
[policies.status]
|
||||
active = "Active"
|
||||
paused = "Paused"
|
||||
setup = "Set up"
|
||||
|
||||
[policies.toolConfig]
|
||||
enableAriaLabel = "Enable {{tool}}"
|
||||
infoAriaLabel = "What does {{tool}} do?"
|
||||
|
||||
[policies.toolConfig.info]
|
||||
redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document."
|
||||
sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it."
|
||||
watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page."
|
||||
|
||||
[policies.wizard]
|
||||
allDocTypesDescription = "Enable the Classification policy to filter by document type."
|
||||
allDocTypesTitle = "All document types"
|
||||
back = "Back"
|
||||
builderDesc = "Build the sequence of tools this policy runs on each document."
|
||||
clear = "Clear"
|
||||
close = "Close"
|
||||
continue = "Continue"
|
||||
docTypesLabel = "Document types"
|
||||
edit = "Edit"
|
||||
editTitle = "Edit {{label}} Policy"
|
||||
enablePolicy = "Enable Policy"
|
||||
filenameAutoNumber = "Auto-number"
|
||||
filenamePositionAria = "Filename position"
|
||||
filenamePrefix = "Prefix"
|
||||
filenameSuffix = "Suffix"
|
||||
filenameTextAria = "Filename text"
|
||||
filenameTextPlaceholder = "Text to add (optional)"
|
||||
lockedDescription = "Contact a team leader to change this policy."
|
||||
lockedTitle = "Managed by your organization"
|
||||
maxRetriesLabel = "Max retries"
|
||||
noToolsError = "Add at least one configured tool to the workflow first."
|
||||
outputAsLabel = "Output as"
|
||||
outputFilenameSubhead = "Output filename"
|
||||
outputModeAria = "Output mode"
|
||||
outputNewFile = "New file"
|
||||
outputNewVersion = "New version"
|
||||
outputRetriesLabel = "Output & retries"
|
||||
outputSubhead = "Output"
|
||||
retryDelayAria = "Retry delay minutes"
|
||||
retryDelayLabel = "Retry delay (min)"
|
||||
runOnExport = "Export"
|
||||
runOnLabel = "Run on"
|
||||
runOnSubhead = "Run on"
|
||||
runOnUpload = "Upload"
|
||||
saveChanges = "Save Changes"
|
||||
saveError = "Couldn't save the policy. Please try again."
|
||||
setupClassification = "Set up Classification"
|
||||
setupTitle = "Set up {{label}} Policy"
|
||||
sourcesDesc = "Choose where this policy runs and which document types it applies to."
|
||||
sourcesLabel = "Sources"
|
||||
stepOf = "Step {{step}} of {{total}}"
|
||||
toolChainDesc = "Configure the tools this policy runs on each document."
|
||||
typesSelected = "{{count}} types selected"
|
||||
|
||||
[policy]
|
||||
badgeEnforcing = "{{name}} enforcing..."
|
||||
badgeRan = "{{name}} policy ran on this file"
|
||||
@@ -7403,6 +7247,11 @@ operations_one = "Operation ({{count}})"
|
||||
operations_other = "Operations ({{count}})"
|
||||
output = "Output"
|
||||
removeStep = "Remove operation"
|
||||
s3Configure = "Configure"
|
||||
s3Done = "Done"
|
||||
s3ModalTitle = "Amazon S3 output"
|
||||
s3NotConfigured = "Not configured"
|
||||
s3PrefixHelp = "Outputs are uploaded under this key prefix."
|
||||
save = "Save changes"
|
||||
scheduleEvery = "Run every"
|
||||
sources = "Sources"
|
||||
@@ -7422,6 +7271,7 @@ confirm = "Delete"
|
||||
title = "Delete pipeline?"
|
||||
|
||||
[portal.pipelines.detail]
|
||||
clearHistory = "Clear history"
|
||||
delete = "Delete pipeline"
|
||||
run = "Run now"
|
||||
|
||||
@@ -7439,12 +7289,19 @@ total = "Pipelines"
|
||||
[portal.pipelines.output]
|
||||
folder = "Write to folder"
|
||||
inline = "Return files"
|
||||
s3 = "Write to Amazon S3"
|
||||
|
||||
[portal.pipelines.run]
|
||||
allProcessed_one = "Nothing to run: the source's {{count}} document has already been processed."
|
||||
allProcessed_other = "Nothing to run: all {{count}} documents in the sources have already been processed."
|
||||
completed_one = "Run completed."
|
||||
completed_other = "All {{count}} runs completed."
|
||||
empty = "Nothing to run: the sources had no documents to process."
|
||||
failed = "Run failed: {{error}}"
|
||||
historyCleared = "History cleared. The next run reprocesses everything currently in the sources."
|
||||
inFlight = "Nothing new to run: documents are still being processed from an earlier run."
|
||||
parked_one = "Nothing to run: {{count}} document failed previously and is parked. Fix the cause, then clear history to retry it."
|
||||
parked_other = "Nothing to run: {{count}} documents failed previously and are parked. Fix the cause, then clear history to retry them."
|
||||
running = "Run started; still in progress."
|
||||
timeout = "Run is taking longer than expected; it may still finish in the background."
|
||||
|
||||
@@ -7728,7 +7585,6 @@ managePlan = "Manage plan"
|
||||
volumeSuffix = "PDFs processed · last 30 days"
|
||||
|
||||
[portal.procurement]
|
||||
reset = "Reset procurement (demo)"
|
||||
subtitle = "Get your team evaluated, contracted, and onboarded. Every document in one place."
|
||||
title = "Procurement"
|
||||
|
||||
@@ -7748,23 +7604,42 @@ title = "Review your enterprise agreement"
|
||||
|
||||
[portal.procurement.builder]
|
||||
addons = "Add-ons"
|
||||
addressLine1 = "Address line 1"
|
||||
addressLine1Placeholder = "500 Howard St"
|
||||
addressLine2 = "Address line 2"
|
||||
addressLine2Placeholder = "Suite, floor, building"
|
||||
back = "Back"
|
||||
businessName = "Business name"
|
||||
businessNamePlaceholder = "Your company"
|
||||
city = "City"
|
||||
cityPlaceholder = "San Francisco"
|
||||
contactEmail = "Contact email"
|
||||
contactEmailPlaceholder = "jane@acme.com"
|
||||
contactName = "Contact name"
|
||||
contactNamePlaceholder = "Jane Doe"
|
||||
continue = "Continue"
|
||||
country = "Country"
|
||||
countryEuro = "Eurozone (EUR €)"
|
||||
countryUK = "United Kingdom (GBP £)"
|
||||
countryUS = "United States (USD $)"
|
||||
eula = "I have read and agree to the Stirling Enterprise EULA. It governs the agreement generated from this quote."
|
||||
generate = "Generate quote"
|
||||
included = "Included"
|
||||
indemnification = "IP indemnification"
|
||||
indemnificationSub = "We defend qualifying IP claims, per the EULA"
|
||||
offlineLicense = "Offline / air-gapped licence"
|
||||
offlineLicenseSub = "A downloadable licence file for an air-gapped self-hosted instance"
|
||||
pdfSize = "PDF size"
|
||||
poNumber = "PO number"
|
||||
poNumberPlaceholder = "Optional"
|
||||
postalCode = "Postal code"
|
||||
postalCodePlaceholder = "94105"
|
||||
posture = "Governance"
|
||||
posture_count = "~{{count}} policies"
|
||||
posture_essentials = "Essentials"
|
||||
posture_essentialsSub = "Classification + Sharing — the defaults"
|
||||
posture_governed = "Governed"
|
||||
posture_governedSub = "Adds Security and Routing"
|
||||
posture_regulated = "Regulated"
|
||||
posture_regulatedSub = "Every category — Compliance, Retention, Ingestion"
|
||||
qbr = "Quarterly business reviews"
|
||||
qbrSub = "Your SE reviews usage and roadmap each quarter"
|
||||
region = "State / region"
|
||||
regionPlaceholder = "California"
|
||||
running = "{{annual}} / yr · {{years}}-yr {{tcv}}"
|
||||
s1Sub = "Your team, and the PDFs you expect to run each year."
|
||||
# Step 1 — volume
|
||||
@@ -7776,13 +7651,21 @@ s3Sub = "For the quote and the agreement it generates."
|
||||
# Step 3 — details
|
||||
s3Title = "Your details"
|
||||
serviceLevel = "Service level"
|
||||
size_compact = "Compact"
|
||||
size_compactSub = "Mostly text, under 1 MB"
|
||||
size_heavy = "Heavy"
|
||||
size_heavySub = "Scanned or image-heavy, 5 MB+"
|
||||
size_standard = "Standard"
|
||||
size_standardSub = "Mixed text and images, 1 to 5 MB"
|
||||
slDedicated = "Dedicated"
|
||||
slDedicatedSub = "4 business hours · dedicated account manager · +30%"
|
||||
slDedicatedSub = "4 business hours · dedicated SE / CSM · +$30,000/yr"
|
||||
slPriority = "Priority"
|
||||
slPrioritySub = "Same business day · named CSM · +15%"
|
||||
slPrioritySub = "Same business day · named CSM · included"
|
||||
slStandard = "Standard"
|
||||
slStandardSub = "Next business day · shared CSM · included"
|
||||
stepOf = "Step {{n}} of {{total}}"
|
||||
taxId = "VAT / Tax ID"
|
||||
taxIdPlaceholder = "Optional"
|
||||
term = "Term"
|
||||
termDiscount = "{{pct}}% multi-year commitment discount applied"
|
||||
title = "Build your quote"
|
||||
@@ -7818,14 +7701,13 @@ title = "Something went wrong"
|
||||
|
||||
[portal.procurement.hero]
|
||||
company = "Your enterprise deal"
|
||||
ctaAgreement = "Review & sign agreement"
|
||||
ctaLive = "You're live"
|
||||
ctaPayment = "Add payment"
|
||||
ctaQuote = "Review your quote"
|
||||
ctaTrial = "Build your quote"
|
||||
eyebrow = "Enterprise procurement"
|
||||
inviteTeammates = "Invite teammates"
|
||||
keyDocs = "Key documents"
|
||||
licenseKey = "Licence key"
|
||||
nextStep = "Next step: {{action}}"
|
||||
notStarted = "Not started"
|
||||
open = "Open procurement"
|
||||
@@ -7873,56 +7755,6 @@ blurb = "Evaluate Stirling against your documents and workflows."
|
||||
gatingAction = "Build your quote"
|
||||
label = "Trial"
|
||||
|
||||
[portal.procurement.keyDocs]
|
||||
oneTimeFee = " · one-time {{amount}}"
|
||||
subtitle = "Everything for each stage of your rollout, in one place."
|
||||
title = "Key documents"
|
||||
|
||||
[portal.procurement.keyDocs.docs.baa]
|
||||
name = "Business Associate Agreement"
|
||||
sub = "HIPAA · available on request"
|
||||
|
||||
[portal.procurement.keyDocs.docs.bankTransfer]
|
||||
name = "Bank transfer instructions"
|
||||
sub = "Wire details for your AP team"
|
||||
|
||||
[portal.procurement.keyDocs.docs.coi]
|
||||
name = "Certificate of Insurance"
|
||||
sub = "Cyber + E&O · current policy"
|
||||
|
||||
[portal.procurement.keyDocs.docs.formalQuote]
|
||||
name = "Formal quote"
|
||||
sub = "Built to your volume, term, and service level"
|
||||
|
||||
[portal.procurement.keyDocs.docs.msa]
|
||||
name = "Master Services Agreement"
|
||||
sub = "One signature - MSA, order form, EULA, and DPA combined"
|
||||
|
||||
[portal.procurement.keyDocs.docs.purchaseOrder]
|
||||
name = "Purchase order"
|
||||
sub = "Issuing a PO? Upload it and we invoice against it"
|
||||
|
||||
[portal.procurement.keyDocs.docs.securityReview]
|
||||
name = "Custom security review"
|
||||
sub = "We complete your questionnaire and join your review call"
|
||||
|
||||
[portal.procurement.keyDocs.docs.soc2]
|
||||
name = "SOC 2 Type II report"
|
||||
sub = "Audited · NDA-gated"
|
||||
|
||||
[portal.procurement.keyDocs.docs.w9]
|
||||
name = "IRS Form W-9"
|
||||
sub = "Stirling PDF Inc."
|
||||
|
||||
[portal.procurement.keyDocs.groups]
|
||||
evaluation = "Supporting your evaluation"
|
||||
yourDeal = "Your deal"
|
||||
|
||||
[portal.procurement.keyDocs.status]
|
||||
action = "Action needed"
|
||||
available = "Download"
|
||||
request = "Request"
|
||||
|
||||
[portal.procurement.license]
|
||||
copied = "Copied"
|
||||
copy = "Copy key"
|
||||
@@ -7930,6 +7762,9 @@ downloadError = "Could not generate the offline licence file just yet — please
|
||||
downloadOffline = "Download offline licence (.lic)"
|
||||
hint = "Paste this key into a self-hosted instance to activate it, or keep it for your records. Keep it safe."
|
||||
label = "Your licence key"
|
||||
subtitle = "Activate a self-hosted instance with this key, or keep it for your records."
|
||||
title = "Your licence key"
|
||||
trialFileHint = "This is your trial licence file. Once your agreement is in place, come back and download it again — unlike the online licence key, the .lic file won't update on its own."
|
||||
|
||||
[portal.procurement.link]
|
||||
cta = "Link account"
|
||||
@@ -7949,16 +7784,9 @@ talkToSales = "Talk to sales"
|
||||
title = "The procurement track opens with Enterprise"
|
||||
|
||||
[portal.procurement.milestone]
|
||||
accept = "Accept & continue"
|
||||
description = "Download the PDF to share it with your team, come back to accept when you're ready, or make changes."
|
||||
download = "Download PDF"
|
||||
downloadError = "Could not download the quote PDF just yet — please try again in a moment."
|
||||
edit = "Edit quote"
|
||||
eyebrow = "Quote {{number}}"
|
||||
perYear = " / yr"
|
||||
preparedFor = "Prepared for {{company}}"
|
||||
tcv = "{{value}} total contract value"
|
||||
title = "Your quote is ready"
|
||||
|
||||
[portal.procurement.modal]
|
||||
cancel = "Cancel"
|
||||
@@ -7985,7 +7813,6 @@ uploadTitle = "Upload your purchase order"
|
||||
[portal.procurement.payment]
|
||||
description = "Your quote is accepted and your licence is already active — your team can start right away. Pay the first invoice when you're ready; you can pay or download it here, no email needed."
|
||||
downloadInvoice = "Download invoice"
|
||||
simulate = "Simulate payment received (demo)"
|
||||
title = "Subscription created"
|
||||
viewInvoice = "View & pay invoice"
|
||||
|
||||
@@ -7995,6 +7822,21 @@ fallbackLink = "Open scheduling in a new tab"
|
||||
subtitle = "Your solutions engineer will walk your team through the rollout. Pick a time that suits you."
|
||||
title = "Schedule a call"
|
||||
|
||||
[portal.procurement.setup]
|
||||
airgap = "Air-gapped"
|
||||
airgapSub = "Fully offline, isolated network. Includes a downloadable licence file."
|
||||
cloud = "Cloud"
|
||||
cloudSub = "Fully managed by Stirling. Nothing for you to run."
|
||||
deployment = "Where will you run Stirling?"
|
||||
seats = "Team size"
|
||||
seatsHint = "Roughly how many people will use it. You can refine this when you build your quote."
|
||||
seatsPlaceholder = "e.g. 250"
|
||||
selfhost = "Self-hosted"
|
||||
selfhostSub = "Run it in your own cloud or data centre."
|
||||
start = "Start trial"
|
||||
subtitle = "Tell us how you plan to run Stirling so we can tailor your trial and quote. No card required."
|
||||
title = "Set up your trial"
|
||||
|
||||
[portal.procurement.status]
|
||||
action = "Action needed"
|
||||
available = "Available"
|
||||
@@ -8153,6 +7995,42 @@ label = "Folder depth"
|
||||
all = "Include subfolders"
|
||||
top = "Top level only"
|
||||
|
||||
[portal.sources.types.s3]
|
||||
description = "Pull documents from an Amazon S3 or S3-compatible bucket."
|
||||
label = "Amazon S3"
|
||||
|
||||
[portal.sources.types.s3.fields.accessKeyId]
|
||||
label = "Access key ID"
|
||||
|
||||
[portal.sources.types.s3.fields.bucket]
|
||||
label = "Bucket"
|
||||
placeholder = "my-company-inbox"
|
||||
|
||||
[portal.sources.types.s3.fields.endpoint]
|
||||
helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO."
|
||||
label = "Custom endpoint"
|
||||
placeholder = "https://s3.example.com"
|
||||
|
||||
[portal.sources.types.s3.fields.mode]
|
||||
helperText = "Consume removes each object from the bucket once every policy has processed it."
|
||||
label = "Read mode"
|
||||
|
||||
[portal.sources.types.s3.fields.mode.options]
|
||||
consume = "Consume: process each object once"
|
||||
snapshot = "Snapshot: re-read the bucket every run"
|
||||
|
||||
[portal.sources.types.s3.fields.prefix]
|
||||
helperText = "Only objects whose keys start with this prefix are processed."
|
||||
label = "Key prefix"
|
||||
placeholder = "incoming/"
|
||||
|
||||
[portal.sources.types.s3.fields.region]
|
||||
label = "Region"
|
||||
placeholder = "us-east-1"
|
||||
|
||||
[portal.sources.types.s3.fields.secretAccessKey]
|
||||
label = "Secret access key"
|
||||
|
||||
[portal.sources.types.unknown]
|
||||
label = "Source"
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Core stubs for the right-rail Policies UI.
|
||||
*
|
||||
* The real implementations live in {@code proprietary/components/policies/PoliciesSidebar.tsx}
|
||||
* and shadow these stubs via the {@code @app/*} alias cascade when the proprietary
|
||||
* build is active. Core builds render nothing, so the right rail shows only the
|
||||
* tool list unchanged.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Whether the right rail should host the Policies section. False in core. */
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether the Policies list should appear for the current user. False in core. */
|
||||
export function usePoliciesVisible(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a policy is open (its detail should take over the rail). Always false
|
||||
* in core; proprietary bridges to the policy-selection store.
|
||||
*/
|
||||
export function usePolicyDetailActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Collapsible policy list rendered above the Tools section. Null in core. */
|
||||
export function PoliciesSection(_props: { leadingControl?: ReactNode } = {}) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Open-policy detail/wizard/settings that replaces the tool area. Null in core. */
|
||||
export function PolicyDetailTakeover() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Collapsed-rail policy icons. Null in core; proprietary renders the rail. */
|
||||
export function PoliciesCollapsedButton(_props: { onExpand: () => void }) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub — whether policy enforcement is active for this build. Gates
|
||||
* mounting the headless PolicyAutoRunController. Always false in core; the
|
||||
* proprietary and desktop builds shadow this via the {@code @app/*} alias.
|
||||
*/
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -5,14 +5,7 @@ import { useSidebarContext } from "@app/contexts/SidebarContext";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ToolPanel from "@app/components/tools/ToolPanel";
|
||||
import ToolSearch from "@app/components/tools/toolPicker/ToolSearch";
|
||||
import {
|
||||
PoliciesCollapsedButton,
|
||||
PoliciesSection,
|
||||
PolicyDetailTakeover,
|
||||
usePoliciesEnabled,
|
||||
usePoliciesVisible,
|
||||
usePolicyDetailActive,
|
||||
} from "@app/components/policies/PoliciesSidebar";
|
||||
import { usePoliciesEnabled } from "@app/components/policies/usePoliciesEnabled";
|
||||
import { PolicyAutoRunController } from "@app/components/policies/PolicyAutoRunController";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import { useToolSections } from "@app/hooks/useToolSections";
|
||||
@@ -66,8 +59,6 @@ export default function RightSidebar() {
|
||||
} = useToolWorkflow();
|
||||
|
||||
const policiesEnabled = usePoliciesEnabled();
|
||||
const policiesVisible = usePoliciesVisible();
|
||||
const rawPolicyDetailActive = usePolicyDetailActive();
|
||||
const fullscreenExpanded = useIsFullscreenExpanded();
|
||||
const fullscreenGeometry = useToolPanelGeometry({
|
||||
enabled: fullscreenExpanded,
|
||||
@@ -100,39 +91,12 @@ export default function RightSidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Opening a policy (e.g. from the collapsed rail) lands the rail in the clean
|
||||
// default tool-picker view — the only view the policy takeover renders in — so
|
||||
// it never collides with an open tool or the all-tools/search view.
|
||||
const handleOpenPolicy = () => {
|
||||
withViewTransition(() => {
|
||||
if (readerMode) setReaderMode(false);
|
||||
setLeftPanelView("toolPicker");
|
||||
if (!sidebarsVisible) setSidebarsVisible(true);
|
||||
setAllToolsView(false);
|
||||
setSearchQuery("");
|
||||
});
|
||||
};
|
||||
|
||||
// The header shows [back] [search] when we have somewhere to go back to —
|
||||
// i.e. the user is in a specific tool, or already in the all-tools/search view.
|
||||
const inToolView = leftPanelView !== "toolPicker";
|
||||
// Show X (close) button only when there's somewhere to go back to.
|
||||
const showCloseButton = inToolView || allToolsView;
|
||||
// Policies sit above the tool list in the default tool-picker view — but only
|
||||
// when the current user actually has policies to see (see usePoliciesVisible),
|
||||
// so regular users with none get the plain tool picker with no empty block.
|
||||
const showPolicies =
|
||||
policiesEnabled &&
|
||||
policiesVisible &&
|
||||
!allToolsView &&
|
||||
leftPanelView === "toolPicker";
|
||||
// When Policies are shown, the search moves OUT of the header to sit between
|
||||
// the Policies and Tools sections (separating them); otherwise it stays in the
|
||||
// header. Show the header search when there's a close button, or in the
|
||||
// default tool-picker view.
|
||||
const showInlineSearch = showPolicies && !showCloseButton;
|
||||
const showHeaderSearch =
|
||||
!showInlineSearch && (showCloseButton || leftPanelView === "toolPicker");
|
||||
const showHeaderSearch = showCloseButton || leftPanelView === "toolPicker";
|
||||
|
||||
const handleHeaderBack = () => {
|
||||
if (inToolView) {
|
||||
@@ -165,15 +129,7 @@ export default function RightSidebar() {
|
||||
? (toolRegistry[selectedToolKey as ToolId] ?? null)
|
||||
: null;
|
||||
|
||||
// The detail takeover replaces the tool list ONLY in the same default view —
|
||||
// never over an open tool or the all-tools view (which must keep priority).
|
||||
// A lingering selection is harmless: it stays hidden behind a tool and the
|
||||
// list/takeover reappears on return to the picker (as in the prototype).
|
||||
const policyDetailActive = rawPolicyDetailActive && showPolicies;
|
||||
|
||||
// The rail widens when a policy detail takes it over — the tool list is fine
|
||||
// at 18.5rem, but the policy detail/wizard/settings need more breathing room.
|
||||
const expandedWidth = policyDetailActive ? "25rem" : "18.5rem";
|
||||
const expandedWidth = "18.5rem";
|
||||
|
||||
const computedWidth = () => {
|
||||
if (isMobile) return "100%";
|
||||
@@ -236,9 +192,6 @@ export default function RightSidebar() {
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
{policiesEnabled && (
|
||||
<PoliciesCollapsedButton onExpand={handleOpenPolicy} />
|
||||
)}
|
||||
<div className="tool-panel__collapsed-tools">
|
||||
{collapsedRailItems.map(({ id, tool }) => (
|
||||
<AppTooltip
|
||||
@@ -283,109 +236,74 @@ export default function RightSidebar() {
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{policyDetailActive ? (
|
||||
<div className="pol-takeover">
|
||||
<PolicyDetailTakeover />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!showPolicies &&
|
||||
(activeTool ? (
|
||||
<ToolPanelHeader
|
||||
icon={
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="currentColor"
|
||||
/>
|
||||
}
|
||||
title={activeTool.name}
|
||||
onClose={handleHeaderBack}
|
||||
closeLabel={
|
||||
<>
|
||||
{activeTool ? (
|
||||
<ToolPanelHeader
|
||||
icon={
|
||||
<ToolIcon
|
||||
icon={activeTool.icon}
|
||||
marginRight="0"
|
||||
color="currentColor"
|
||||
/>
|
||||
}
|
||||
title={activeTool.name}
|
||||
onClose={handleHeaderBack}
|
||||
closeLabel={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="tool-panel__compact-header">
|
||||
{showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
/>
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<div className="tool-panel__compact-header">
|
||||
{showHeaderSearch ? (
|
||||
<div className="tool-panel__compact-header-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
autoFocus={allToolsView && !inToolView}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showCloseButton ? (
|
||||
<ActionIcon
|
||||
variant="tertiary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleHeaderBack}
|
||||
aria-label={
|
||||
inToolView
|
||||
? t("toolPanel.backToAllTools", "Back to all tools")
|
||||
: t("toolPanel.goBack", "Go back")
|
||||
}
|
||||
className="tool-panel__expand-btn"
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<ActionIcon
|
||||
variant="secondary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleCollapse}
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPolicies && (
|
||||
<PoliciesSection
|
||||
leadingControl={
|
||||
<ActionIcon
|
||||
aria-label={t("toolPanel.collapse", "Collapse panel")}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
shape="circle"
|
||||
onClick={handleCollapse}
|
||||
className="tool-panel__expand-btn tool-panel__toggle-vt"
|
||||
>
|
||||
<ChevronRightIcon sx={{ fontSize: "1.1rem" }} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInlineSearch && (
|
||||
<div className="tool-panel__between-search">
|
||||
<ToolSearch
|
||||
value={searchQuery}
|
||||
onChange={handleHeaderSearchChange}
|
||||
toolRegistry={toolRegistry}
|
||||
mode="filter"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={false}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ToolPanel
|
||||
allToolsView={allToolsView}
|
||||
onShowAllTools={handleShowAllTools}
|
||||
onToolSelect={handleToolSelectWithTransition}
|
||||
compact={false}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { test, expect } from "@app/tests/helpers/stub-test-base";
|
||||
|
||||
/**
|
||||
* Policy editing is admin-only (mirrors the backend's enforcement on the
|
||||
* save/delete endpoints). The frontend gate is `canConfigure = !enableLogin ||
|
||||
* isAdmin`, surfaced through `usePolicies()`:
|
||||
* - login enabled + non-admin → read-only: the setup wizard shows the
|
||||
* "Managed by your organization" locked state instead of the steps.
|
||||
* - login enabled + admin → full setup flow ("Step 1 of 2").
|
||||
* - login disabled (single-user/desktop) → open to the local operator.
|
||||
*
|
||||
* Backend-free: the app-config (`enableLogin`/`isAdmin`) and the empty policy
|
||||
* list are stubbed via `mockAppApis`, so this asserts the gating wiring without
|
||||
* a live Spring Boot server. "Security" is the only non-coming-soon policy, so
|
||||
* it's the one we open.
|
||||
*/
|
||||
|
||||
const LOCKED_TITLE = "Managed by your organization";
|
||||
const LOCKED_DESC = "Contact a team leader to change this policy.";
|
||||
|
||||
/** Open the Security policy from the right-sidebar Policies list. */
|
||||
async function openSecurityPolicy(page: import("@playwright/test").Page) {
|
||||
const row = page.locator("button.pol-row").filter({ hasText: "Security" });
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await row.click();
|
||||
// The wizard header confirms we opened the right policy in either state.
|
||||
await expect(page.getByText("Set up Security Policy")).toBeVisible();
|
||||
}
|
||||
|
||||
// Policies are a SaaS-only feature: POLICIES_ENABLED is off in the proprietary
|
||||
// and core builds this stubbed suite runs against, so the policy UI never
|
||||
// renders here. Skip unless the app under test is built with policies enabled
|
||||
// and the runner opts in via POLICIES_E2E=1.
|
||||
test.beforeEach(() => {
|
||||
const enabled = ["1", "true"].includes(process.env.POLICIES_E2E ?? "");
|
||||
test.skip(!enabled, "Policies are SaaS-only; set POLICIES_E2E=1 to run");
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — non-admin (login on)", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: false },
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("non-admin gets the read-only locked state", async ({ page }) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(LOCKED_TITLE)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_DESC)).toBeVisible();
|
||||
// The editable flow must NOT be reachable.
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — admin (login on)", () => {
|
||||
test.use({
|
||||
stubOptions: { enableLogin: true, isAdmin: true },
|
||||
seedJwt: true,
|
||||
});
|
||||
|
||||
test("admin can reach the setup wizard", async ({ page }) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Policy editing gate — single-user (login off)", () => {
|
||||
test.use({ stubOptions: { enableLogin: false, isAdmin: false } });
|
||||
|
||||
test("local operator can reach the setup wizard with no admin role", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSecurityPolicy(page);
|
||||
await expect(page.getByText(/Step \d+ of \d+/)).toBeVisible();
|
||||
await expect(page.getByText(LOCKED_TITLE)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Desktop shadow of the proprietary Policies right-rail.
|
||||
*
|
||||
* Re-exports the proprietary module unchanged EXCEPT `usePoliciesEnabled`, which
|
||||
* additionally requires an active SaaS connection. Policy runs execute + bill via
|
||||
* the cloud (POST /api/v1/policies/.../run hits the SaaS backend), so on desktop
|
||||
* the feature must be hidden in local ("disconnected") and self-hosted modes —
|
||||
* otherwise the panel + auto-run controller would fire policy runs against a
|
||||
* backend that doesn't serve them. On web the build flavor already gates it.
|
||||
*
|
||||
* `usePoliciesEnabled` is the single gate RightSidebar uses for BOTH the rail
|
||||
* section and mounting PolicyAutoRunController, so this one override covers both.
|
||||
*/
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
|
||||
|
||||
export * from "@proprietary/components/policies/PoliciesSidebar";
|
||||
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
// Pessimistic SaaS-mode check (starts false): this gate also controls whether
|
||||
// PolicyAutoRunController mounts, and that fires GET /api/v1/policies on mount.
|
||||
// useSaaSMode()'s optimistic-true default would leak that request against the
|
||||
// local/self-hosted backend on cold start before the mode resolves.
|
||||
return POLICIES_ENABLED && useConfirmedSaaSMode();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import { useConfirmedSaaSMode } from "@app/hooks/useConfirmedSaaSMode";
|
||||
|
||||
/**
|
||||
* Desktop shadow: policy runs execute + bill via the cloud (POST
|
||||
* /api/v1/policies/.../run hits the SaaS backend), so the feature must stay
|
||||
* off in local ("disconnected") and self-hosted modes — otherwise the
|
||||
* auto-run controller would fire policy runs against a backend that doesn't
|
||||
* serve them.
|
||||
*
|
||||
* Pessimistic SaaS-mode check (starts false): this gate controls whether
|
||||
* PolicyAutoRunController mounts, and that fires GET /api/v1/policies on
|
||||
* mount. useSaaSMode()'s optimistic-true default would leak that request
|
||||
* against the local/self-hosted backend on cold start before the mode
|
||||
* resolves.
|
||||
*/
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return POLICIES_ENABLED && useConfirmedSaaSMode();
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* Desktop-build feature gates. Shadows `proprietary/constants/featureFlags.ts`
|
||||
* (the desktop `@app/*` alias has no saas layer). Re-exports the proprietary
|
||||
* flags and re-enables Policies: the desktop Policies gate additionally requires
|
||||
* an active SaaS connection (see desktop PoliciesSidebar's `usePoliciesEnabled`),
|
||||
* so the flag must be on for that runtime check to ever apply.
|
||||
* an active SaaS connection (see the desktop `usePoliciesEnabled` shadow), so
|
||||
* the flag must be on for that runtime check to ever apply.
|
||||
*/
|
||||
export * from "@proprietary/constants/featureFlags";
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// Resolves to the SaaS override (src/portal-saas) via the @portal cascade.
|
||||
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
|
||||
|
||||
describe("availableOutputModes (SaaS)", () => {
|
||||
it("offers only s3: no server filesystem, and inline results would expire unseen", () => {
|
||||
expect(availableOutputModes()).toEqual(["s3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PipelineOutputMode } from "@portal/api/pipelines";
|
||||
|
||||
/**
|
||||
* Hosted deployments never write to the server's filesystem (the backend's
|
||||
* FolderAccessGuard denies it outright), so folder outputs are not offered in
|
||||
* the pipeline builder. Inline is not offered either: inline results live in
|
||||
* transient job storage with no portal download surface, so for an unattended
|
||||
* pipeline they would simply expire unseen.
|
||||
*/
|
||||
export function availableOutputModes(): PipelineOutputMode[] {
|
||||
return ["s3"];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
// Resolves to the SaaS override (src/portal-saas) via the @portal cascade.
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
|
||||
describe("creatableSourceTypes (SaaS)", () => {
|
||||
it("never offers folder sources: hosted deployments do not read the server filesystem", () => {
|
||||
expect(creatableSourceTypes().map((t) => t.type)).not.toContain("folder");
|
||||
});
|
||||
|
||||
it("still offers the cloud source types", () => {
|
||||
expect(creatableSourceTypes().map((t) => t.type)).toContain("s3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
CREATABLE_SOURCE_TYPES,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
|
||||
/**
|
||||
* Hosted deployments never read the server's filesystem (the backend's
|
||||
* FolderAccessGuard denies it outright), so folder connections are not offered
|
||||
* in the connect wizard.
|
||||
*/
|
||||
export function creatableSourceTypes(): CreatableSourceType[] {
|
||||
return CREATABLE_SOURCE_TYPES.filter((type) => type.type !== "folder");
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export interface OutputSpec {
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The output destinations the pipeline builder can offer. */
|
||||
export type PipelineOutputMode = "inline" | "folder" | "s3";
|
||||
|
||||
/**
|
||||
* The stored policy record: the create/update body (`id` blank on create) and what
|
||||
* the backend returns from GET/POST. Mirrors Policy.java exactly; `owner`/`teamId`
|
||||
@@ -150,12 +153,26 @@ export async function fetchTriggers(): Promise<TriggerInfo[]> {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
|
||||
* sources, regardless of the enabled flag. Returns the ids of the runs started
|
||||
* (empty when the sources yielded no work); poll {@link fetchRun} for each.
|
||||
* What a manual trigger found and started. Mirrors the backend `SweepOutcome`:
|
||||
* when `runIds` is empty, the counts say why - no files listed, all already
|
||||
* processed at their current version, parked by a failed run, or still in
|
||||
* flight from an earlier sweep.
|
||||
*/
|
||||
export async function triggerPipeline(id: string): Promise<string[]> {
|
||||
return apiClient.local.json<string[]>(
|
||||
export interface TriggerOutcome {
|
||||
runIds: string[];
|
||||
filesListed: number;
|
||||
alreadyProcessed: number;
|
||||
parked: number;
|
||||
inFlight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/v1/policies/{id}/trigger: run the pipeline now against its configured
|
||||
* sources, regardless of the enabled flag. Returns the runs started plus what the
|
||||
* sweep skipped; poll {@link fetchRun} for each run id.
|
||||
*/
|
||||
export async function triggerPipeline(id: string): Promise<TriggerOutcome> {
|
||||
return apiClient.local.json<TriggerOutcome>(
|
||||
`/api/v1/policies/${encodeURIComponent(id)}/trigger`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
|
||||
@@ -73,6 +73,16 @@ export const JOURNEY: JourneyStep[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* The commercial flow's stepper stages. The real backend collapses quote + agreement into one
|
||||
* accept step (accepting the issued quote is accepting the agreement), so the flow shows one fewer
|
||||
* step than the mock ledger's {@link JOURNEY} — the separate "Agreement" step is dropped. Reuses
|
||||
* JOURNEY's i18n keys.
|
||||
*/
|
||||
export const FLOW_JOURNEY: JourneyStep[] = JOURNEY.filter(
|
||||
(s) => s.stage !== "security",
|
||||
);
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
/* Deal header */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -284,12 +294,18 @@ export interface QuoteResult {
|
||||
currency: string;
|
||||
annualNetMinor: number;
|
||||
tcvMinor: number;
|
||||
/** First post-term renewal fee after the CPI escalator; the committed term itself is flat. */
|
||||
renewalAnnualNetMinor: number;
|
||||
/** The fixed CPI escalator applied per renewal, as a whole percent (e.g. 3). */
|
||||
cpiRatePct: number;
|
||||
lineItems: QuoteLineItem[];
|
||||
validUntil: string | null;
|
||||
/** The Stripe Quote id once issued; null while still a local draft. */
|
||||
stripeQuoteId: string | null;
|
||||
/** Hosted Stripe invoice URL, present once the quote is accepted and the subscription invoice exists. */
|
||||
invoiceUrl: string | null;
|
||||
/** Direct PDF link for that invoice; persisted so the download button survives a reload. */
|
||||
invoicePdf: string | null;
|
||||
/** The inputs this quote was priced from, so the builder can seed itself on re-edit. */
|
||||
config: QuoteConfigInput;
|
||||
}
|
||||
@@ -306,6 +322,10 @@ export interface AcceptResult {
|
||||
export interface ProcurementSnapshot {
|
||||
dealId: number | null;
|
||||
stage: DealStage | null;
|
||||
/** cloud | selfhost | airgap — chosen at the trial-setup step; seeds the quote builder. */
|
||||
deployment: string;
|
||||
/** Seat count captured at trial setup (0 = unspecified); seeds the builder's volume estimate. */
|
||||
seats: number;
|
||||
trialStartedAt: string | null;
|
||||
trialEndsAt: string | null;
|
||||
trialExtensionsUsed: number;
|
||||
@@ -318,17 +338,35 @@ export interface ProcurementSnapshot {
|
||||
export interface QuoteConfigInput {
|
||||
volume: number;
|
||||
users: number;
|
||||
/** Policy posture (governance) as runs per PDF: Essentials 2, Governed 4, Regulated 7. */
|
||||
intensity: number;
|
||||
/** PDF-size tier multiplier on the rate: Compact 1.0, Standard 1.4, Heavy 2.4. */
|
||||
sizeMult: number;
|
||||
/** cloud | selfhost | airgap — set at the trial; drives the flat deployment fee + offline .lic. */
|
||||
deployment: string;
|
||||
termYears: number;
|
||||
serviceLevel: string;
|
||||
indemnification: boolean;
|
||||
training: boolean;
|
||||
qbr: boolean;
|
||||
/** Offline / air-gapped licence file — a paid add-on. */
|
||||
offlineLicense: boolean;
|
||||
currency: string;
|
||||
/** Buyer's company name — shown on the quote/agreement and remembered when re-editing. */
|
||||
businessName: string;
|
||||
// Buyer / AP details (all optional). They flow onto the Stripe customer (name + bill-to address)
|
||||
// and the invoice (PO number + tax id as custom fields). Country + currency are out of scope.
|
||||
/** Signatory / main contact name. */
|
||||
contactName?: string;
|
||||
/** Contact email (billing / signatory). */
|
||||
contactEmail?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
/** State / province / region. */
|
||||
region?: string;
|
||||
postalCode?: string;
|
||||
/** Purchase-order number, shown on the invoice for AP matching. */
|
||||
poNumber?: string;
|
||||
/** VAT / Tax ID, shown on the invoice. */
|
||||
taxId?: string;
|
||||
}
|
||||
|
||||
export function fetchSnapshot(): Promise<ProcurementSnapshot> {
|
||||
@@ -343,10 +381,17 @@ export function fetchLicenseFile(): Promise<string> {
|
||||
return apiClient.saas.text("/api/v1/procurement/license/file");
|
||||
}
|
||||
|
||||
export function startTrial(): Promise<ProcurementSnapshot> {
|
||||
/**
|
||||
* Start the trial with the buyer's chosen deployment target and seat count (captured in the setup
|
||||
* step). These seed the quote builder; both remain editable when the quote is built.
|
||||
*/
|
||||
export function startTrial(
|
||||
deployment: string,
|
||||
seats: number,
|
||||
): Promise<ProcurementSnapshot> {
|
||||
return apiClient.saas.json<ProcurementSnapshot>(
|
||||
"/api/v1/procurement/trial/start",
|
||||
{ method: "POST" },
|
||||
{ method: "POST", body: { deployment, users: seats } },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { WelcomeBanner } from "@portal/components/WelcomeBanner";
|
||||
import { EditorStatusCard } from "@portal/components/EditorStatusCard";
|
||||
import { SetupChecklist } from "@portal/components/SetupChecklist";
|
||||
@@ -19,14 +20,22 @@ import { useProcurement } from "@portal/components/procurement/useProcurement";
|
||||
* open them.
|
||||
*/
|
||||
export function HomeHero({ tier }: { tier: Tier }) {
|
||||
const { openLinkModal } = useUI();
|
||||
const procurement = useProcurement();
|
||||
const dealActive =
|
||||
procurement.isLinked && procurement.started && !!procurement.data;
|
||||
|
||||
// Start the enterprise flow right here on Home: open the trial-setup modal when the account is
|
||||
// linked, otherwise prompt to link first — no navigating off to the procurement view.
|
||||
const onStartEnterprise = () => {
|
||||
if (procurement.isLinked) procurement.onStartTrial();
|
||||
else openLinkModal();
|
||||
};
|
||||
|
||||
const footer = dealActive ? (
|
||||
<ControlledDealStatusHero controller={procurement} />
|
||||
) : (
|
||||
<SetupChecklist />
|
||||
<SetupChecklist onStartEnterprise={onStartEnterprise} />
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -18,10 +18,17 @@ const EDITOR_DOWNLOAD_URL = "https://stirling.com/download";
|
||||
|
||||
/**
|
||||
* Enterprise on-ramp rung. The CTA differs by tier: free orgs start a guided
|
||||
* trial, subscribed (paying) orgs jump straight to a quote — both land in the
|
||||
* procurement flow.
|
||||
* trial, subscribed (paying) orgs jump straight to a quote — both open the
|
||||
* procurement flow. When {@code onStart} is given the CTA opens the flow's setup
|
||||
* modal over Home; otherwise it falls back to navigating to the procurement view.
|
||||
*/
|
||||
function EnterpriseRung({ paying }: { paying: boolean }) {
|
||||
function EnterpriseRung({
|
||||
paying,
|
||||
onStart,
|
||||
}: {
|
||||
paying: boolean;
|
||||
onStart?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { setActiveView } = useView();
|
||||
return (
|
||||
@@ -38,7 +45,7 @@ function EnterpriseRung({ paying }: { paying: boolean }) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setActiveView("procurement")}
|
||||
onClick={onStart ?? (() => setActiveView("procurement"))}
|
||||
rightSection={<span aria-hidden>→</span>}
|
||||
>
|
||||
{t(
|
||||
@@ -77,7 +84,13 @@ interface Step {
|
||||
* (the same data the Policies / Sources pages show). The header doubles as a
|
||||
* dismiss control; the Enterprise rung persists regardless.
|
||||
*/
|
||||
export function SetupChecklist() {
|
||||
export function SetupChecklist({
|
||||
onStartEnterprise,
|
||||
}: {
|
||||
/** Start the enterprise flow in place (opens the setup modal over Home). Falls back to
|
||||
* navigating to the procurement view when omitted (e.g. in isolated stories). */
|
||||
onStartEnterprise?: () => void;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const { setActiveView } = useView();
|
||||
@@ -212,7 +225,7 @@ export function SetupChecklist() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<EnterpriseRung paying={tier !== "free"} />
|
||||
<EnterpriseRung paying={tier !== "free"} onStart={onStartEnterprise} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { PipelineOutputMode } from "@portal/api/pipelines";
|
||||
|
||||
/**
|
||||
* The output destinations the pipeline builder offers. An extension point:
|
||||
* deployments where a destination cannot work shadow this module and filter
|
||||
* the list (e.g. hosted deployments never write to the server's filesystem,
|
||||
* so folder outputs are not offered there).
|
||||
*/
|
||||
export function availableOutputModes(): PipelineOutputMode[] {
|
||||
return ["inline", "folder", "s3"];
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { ProcurementSnapshot } from "@portal/api/procurement";
|
||||
const base: ProcurementSnapshot = {
|
||||
dealId: 1,
|
||||
stage: "trial",
|
||||
deployment: "cloud",
|
||||
seats: 250,
|
||||
trialStartedAt: "2026-06-25T00:00:00Z",
|
||||
trialEndsAt: "2026-07-09T00:00:00Z",
|
||||
trialExtensionsUsed: 0,
|
||||
@@ -21,7 +23,7 @@ const meta: Meta<typeof DealStatusHero> = {
|
||||
args: {
|
||||
canSchedule: true,
|
||||
onExpand: () => {},
|
||||
onKeyDocs: () => {},
|
||||
onLicense: () => {},
|
||||
onInvite: () => {},
|
||||
onSchedule: () => {},
|
||||
onManageTrial: () => {},
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { ViewId } from "@portal/contexts/ViewContext";
|
||||
import { JOURNEY, type ProcurementSnapshot } from "@portal/api/procurement";
|
||||
import {
|
||||
FLOW_JOURNEY,
|
||||
type ProcurementSnapshot,
|
||||
} from "@portal/api/procurement";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* The enterprise deal-status hero on Home (procurement lives here, not as a nav tab). Adapts to the
|
||||
* deal stage: quick-action chips (trial countdown → manage, key documents, invite teammates,
|
||||
* deal stage: quick-action chips (trial countdown → manage, licence key, invite teammates,
|
||||
* schedule a call), a rollout checklist during the trial, and a stage-specific primary CTA that
|
||||
* expands the flow into the takeover modal. Matches the marketing prototype.
|
||||
*/
|
||||
@@ -16,7 +19,7 @@ export function DealStatusHero({
|
||||
busy = false,
|
||||
canSchedule,
|
||||
onExpand,
|
||||
onKeyDocs,
|
||||
onLicense,
|
||||
onInvite,
|
||||
onSchedule,
|
||||
onManageTrial,
|
||||
@@ -28,7 +31,7 @@ export function DealStatusHero({
|
||||
* "Schedule a call" action only appears when the org has linked its account. */
|
||||
canSchedule: boolean;
|
||||
onExpand: () => void;
|
||||
onKeyDocs: () => void;
|
||||
onLicense: () => void;
|
||||
onInvite: () => void;
|
||||
onSchedule: () => void;
|
||||
onManageTrial: () => void;
|
||||
@@ -42,11 +45,9 @@ export function DealStatusHero({
|
||||
? t("portal.procurement.hero.ctaTrial")
|
||||
: stage === "quote"
|
||||
? t("portal.procurement.hero.ctaQuote")
|
||||
: stage === "security"
|
||||
? t("portal.procurement.hero.ctaAgreement")
|
||||
: stage === "procurement"
|
||||
? t("portal.procurement.hero.ctaPayment")
|
||||
: t("portal.procurement.hero.ctaLive");
|
||||
: stage === "procurement"
|
||||
? t("portal.procurement.hero.ctaPayment")
|
||||
: t("portal.procurement.hero.ctaLive");
|
||||
|
||||
const setupSteps: { title: string; sub: string; view: ViewId }[] = [
|
||||
{
|
||||
@@ -89,13 +90,13 @@ export function DealStatusHero({
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
{stage !== "active" && (
|
||||
{snapshot.licenseKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="portal-hero__chip portal-hero__chip--action"
|
||||
onClick={onKeyDocs}
|
||||
onClick={onLicense}
|
||||
>
|
||||
{t("portal.procurement.hero.keyDocs")}
|
||||
{t("portal.procurement.hero.licenseKey")}
|
||||
</button>
|
||||
)}
|
||||
{stage !== "active" && (
|
||||
@@ -120,7 +121,7 @@ export function DealStatusHero({
|
||||
</div>
|
||||
|
||||
<div className="portal-hero__stepper">
|
||||
<StageStepper journey={JOURNEY} currentStage={stage} />
|
||||
<StageStepper journey={FLOW_JOURNEY} currentStage={stage} />
|
||||
</div>
|
||||
|
||||
{inTrial && (
|
||||
|
||||
@@ -15,16 +15,24 @@ import "@portal/views/Procurement.css";
|
||||
export function ProcurementAgreement({
|
||||
quote,
|
||||
busy,
|
||||
downloading,
|
||||
onAgree,
|
||||
onDownload,
|
||||
onEdit,
|
||||
}: {
|
||||
quote: QuoteResult;
|
||||
busy: boolean;
|
||||
downloading: boolean;
|
||||
/** Accept the quote straight into a committed subscription (this is also the agreement). */
|
||||
onAgree: () => void;
|
||||
onDownload: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [checked, setChecked] = useState(false);
|
||||
const annual = money(quote.annualNetMinor, quote.currency);
|
||||
const tcv = money(quote.tcvMinor, quote.currency);
|
||||
const renewal = money(quote.renewalAnnualNetMinor, quote.currency);
|
||||
const years = quote.config.termYears;
|
||||
|
||||
return (
|
||||
@@ -73,7 +81,19 @@ export function ProcurementAgreement({
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<h4>3. End-User License Agreement</h4>
|
||||
<h4>3. Term, renewal and annual fee adjustment</h4>
|
||||
<p>
|
||||
This Agreement runs for the committed {years}-year term set out in the
|
||||
Order Form. It then renews automatically for successive one-year terms
|
||||
unless either party gives written notice of non-renewal at least 30
|
||||
days before the end of the then-current term. On each renewal the
|
||||
annual fee increases by {quote.cpiRatePct}%, a fixed CPI adjustment.
|
||||
Based on this quote, the first renewal year would be approximately{" "}
|
||||
<strong>{renewal}</strong> per year; the committed term above is
|
||||
billed at the rate in the Order Form and is not affected.
|
||||
</p>
|
||||
|
||||
<h4>4. End-User License Agreement</h4>
|
||||
<p>
|
||||
Subject to the terms of this Agreement, Stirling grants Customer a
|
||||
non-exclusive, non-transferable right to use the Service for its
|
||||
@@ -82,7 +102,7 @@ export function ProcurementAgreement({
|
||||
Service, and all intellectual property in it, remains Stirling's.
|
||||
</p>
|
||||
|
||||
<h4>4. Data Processing Agreement</h4>
|
||||
<h4>5. Data Processing Agreement</h4>
|
||||
<p>
|
||||
Where Stirling processes personal data on Customer's behalf, it does
|
||||
so only on Customer's documented instructions and applies appropriate
|
||||
@@ -92,7 +112,7 @@ export function ProcurementAgreement({
|
||||
reference.
|
||||
</p>
|
||||
|
||||
<h4>5. Acceptance</h4>
|
||||
<h4>6. Acceptance</h4>
|
||||
<p>
|
||||
By agreeing below, Customer accepts this Agreement and the Order Form.
|
||||
On acceptance, Stirling will issue the committed annual subscription
|
||||
@@ -120,6 +140,12 @@ export function ProcurementAgreement({
|
||||
>
|
||||
{t("portal.procurement.agreement.agreeCta")}
|
||||
</Button>
|
||||
<Button variant="secondary" loading={downloading} onClick={onDownload}>
|
||||
{t("portal.procurement.milestone.download")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onClick={onEdit}>
|
||||
{t("portal.procurement.milestone.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ControlledDealStatusHero({
|
||||
busy={controller.busy}
|
||||
canSchedule={controller.isLinked}
|
||||
onExpand={() => controller.setOpen(true)}
|
||||
onKeyDocs={() => controller.setExtra("docs")}
|
||||
onLicense={() => controller.setExtra("license")}
|
||||
onInvite={() => setActiveView("users")}
|
||||
onSchedule={() => controller.setExtra("schedule")}
|
||||
onManageTrial={() => controller.setExtra("trial")}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui";
|
||||
import type { ProcurementSnapshot } from "@portal/api/procurement";
|
||||
import { CalendlyInline } from "@portal/components/procurement/CalendlyInline";
|
||||
import { LicensePanel } from "@portal/components/procurement/ProcurementStages";
|
||||
import { useFocusTrap } from "@portal/components/procurement/ProcurementModal";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
* Small centred dialogs that hang off the deal-status hero's quick actions — Key documents, Schedule
|
||||
* a call, and trial management. Schedule a call embeds the live Calendly scheduler; Key documents is
|
||||
* still mocked for the pilot (static demo data). The shells and wiring are real so the hero behaves
|
||||
* like the marketing prototype.
|
||||
* Small centred dialogs that hang off the deal-status hero's quick actions — the licence key,
|
||||
* schedule a call, trial management, and trial setup. Schedule a call embeds the live Calendly
|
||||
* scheduler. The shells and wiring are real so the hero behaves like the marketing prototype.
|
||||
*/
|
||||
|
||||
function SideModal({
|
||||
@@ -74,124 +74,45 @@ function SideModal({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Key documents ────────────────────────────────────────────────────────────
|
||||
type DocStatus = "available" | "action" | "request";
|
||||
interface DocRow {
|
||||
nameKey: string;
|
||||
subKey: string;
|
||||
status: DocStatus;
|
||||
fee?: number;
|
||||
}
|
||||
// Static demo catalogue for the pilot; the copy lives in the locale under
|
||||
// portal.procurement.keyDocs and is resolved via t() at render time.
|
||||
const STAGE_DOCS: { groupKey: string; docs: DocRow[] }[] = [
|
||||
{
|
||||
groupKey: "portal.procurement.keyDocs.groups.yourDeal",
|
||||
docs: [
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.formalQuote.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.formalQuote.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.msa.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.msa.sub",
|
||||
status: "action",
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.bankTransfer.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.bankTransfer.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.purchaseOrder.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.purchaseOrder.sub",
|
||||
status: "request",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupKey: "portal.procurement.keyDocs.groups.evaluation",
|
||||
docs: [
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.soc2.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.soc2.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.securityReview.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.securityReview.sub",
|
||||
status: "request",
|
||||
fee: 5000,
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.baa.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.baa.sub",
|
||||
status: "request",
|
||||
fee: 2500,
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.w9.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.w9.sub",
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
nameKey: "portal.procurement.keyDocs.docs.coi.name",
|
||||
subKey: "portal.procurement.keyDocs.docs.coi.sub",
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const STATUS_LABEL: Record<DocStatus, string> = {
|
||||
available: "portal.procurement.keyDocs.status.available",
|
||||
action: "portal.procurement.keyDocs.status.action",
|
||||
request: "portal.procurement.keyDocs.status.request",
|
||||
};
|
||||
|
||||
export function KeyDocumentsModal({
|
||||
// ── Licence key ──────────────────────────────────────────────────────────────
|
||||
export function LicenseModal({
|
||||
open,
|
||||
onClose,
|
||||
licenseKey,
|
||||
offlineAvailable,
|
||||
downloadingLicense,
|
||||
onDownloadOffline,
|
||||
trial = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
licenseKey: string;
|
||||
offlineAvailable: boolean;
|
||||
downloadingLicense: boolean;
|
||||
onDownloadOffline: () => void;
|
||||
/** Licence is still the trial one (not yet upgraded on accept) — the downloadable .lic is a
|
||||
* snapshot, so warn that it must be re-downloaded once the agreement is in place. */
|
||||
trial?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("portal.procurement.keyDocs.title")}
|
||||
subtitle={t("portal.procurement.keyDocs.subtitle")}
|
||||
title={t("portal.procurement.license.title")}
|
||||
subtitle={t("portal.procurement.license.subtitle")}
|
||||
>
|
||||
{STAGE_DOCS.map((g) => (
|
||||
<div key={g.groupKey} className="portal-docs__group">
|
||||
<div className="portal-docs__group-title">{t(g.groupKey)}</div>
|
||||
<ul className="portal-docs__list">
|
||||
{g.docs.map((d) => (
|
||||
<li key={d.nameKey} className="portal-docs__row">
|
||||
<div className="portal-docs__row-text">
|
||||
<span className="portal-docs__row-name">{t(d.nameKey)}</span>
|
||||
<span className="portal-docs__row-sub">
|
||||
{t(d.subKey)}
|
||||
{d.fee
|
||||
? t("portal.procurement.keyDocs.oneTimeFee", {
|
||||
amount: `$${d.fee.toLocaleString()}`,
|
||||
})
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="portal-docs__row-action"
|
||||
data-status={d.status}
|
||||
>
|
||||
{t(STATUS_LABEL[d.status])}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<LicensePanel
|
||||
licenseKey={licenseKey}
|
||||
offlineAvailable={offlineAvailable}
|
||||
downloadingLicense={downloadingLicense}
|
||||
onDownloadOffline={onDownloadOffline}
|
||||
/>
|
||||
{offlineAvailable && trial && (
|
||||
<p className="portal-proc__license-hint">
|
||||
{t("portal.procurement.license.trialFileHint")}
|
||||
</p>
|
||||
)}
|
||||
</SideModal>
|
||||
);
|
||||
}
|
||||
@@ -221,6 +142,97 @@ export function ScheduleCallModal({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trial setup ────────────────────────────────────────────────────────────
|
||||
const DEPLOYMENTS = ["cloud", "selfhost", "airgap"] as const;
|
||||
|
||||
/**
|
||||
* Captured before the trial starts: where the buyer will run Stirling (which drives the deployment
|
||||
* fee and, for air-gapped, the offline licence) and their team size. Both seed the quote builder so
|
||||
* it opens on their real environment; the trial only begins once this is confirmed.
|
||||
*/
|
||||
export function TrialSetupModal({
|
||||
open,
|
||||
onClose,
|
||||
busy,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
busy: boolean;
|
||||
onConfirm: (deployment: string, seats: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [deployment, setDeployment] = useState<string>("cloud");
|
||||
const [seats, setSeats] = useState("");
|
||||
|
||||
// Reset to defaults each time the dialog opens, so a cancelled setup doesn't linger.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDeployment("cloud");
|
||||
setSeats("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SideModal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("portal.procurement.setup.title")}
|
||||
subtitle={t("portal.procurement.setup.subtitle")}
|
||||
footer={
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
onClick={() => onConfirm(deployment, Math.max(0, Number(seats) || 0))}
|
||||
>
|
||||
{t("portal.procurement.setup.start")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.deployment")}
|
||||
</span>
|
||||
<div className="portal-qb__opts">
|
||||
{DEPLOYMENTS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
className="portal-qb__opt"
|
||||
data-on={deployment === d || undefined}
|
||||
onClick={() => setDeployment(d)}
|
||||
>
|
||||
<span className="portal-qb__opt-title">
|
||||
{t(`portal.procurement.setup.${d}`)}
|
||||
</span>
|
||||
<span className="portal-qb__opt-sub">
|
||||
{t(`portal.procurement.setup.${d}Sub`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="portal-qb__field">
|
||||
<span className="portal-qb__field-label">
|
||||
{t("portal.procurement.setup.seats")}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("portal.procurement.setup.seatsPlaceholder")}
|
||||
value={seats}
|
||||
onChange={(e) => setSeats(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="portal-sidemodal__text">
|
||||
{t("portal.procurement.setup.seatsHint")}
|
||||
</p>
|
||||
</SideModal>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trial management ─────────────────────────────────────────────────────────
|
||||
export function TrialManageModal({
|
||||
open,
|
||||
|
||||
@@ -2,19 +2,18 @@ import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, EmptyState, Skeleton } from "@app/ui";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useLinkedAccountEmail } from "@portal/hooks/useLinkedAccountEmail";
|
||||
import { JOURNEY } from "@portal/api/procurement";
|
||||
import { FLOW_JOURNEY } from "@portal/api/procurement";
|
||||
import { ProcurementAgreement } from "@portal/components/procurement/ProcurementAgreement";
|
||||
import {
|
||||
KeyDocumentsModal,
|
||||
LicenseModal,
|
||||
ScheduleCallModal,
|
||||
TrialManageModal,
|
||||
TrialSetupModal,
|
||||
} from "@portal/components/procurement/ProcurementExtras";
|
||||
import { ProcurementModal } from "@portal/components/procurement/ProcurementModal";
|
||||
import {
|
||||
LicensePanel,
|
||||
LiveStageCard,
|
||||
PaymentStageCard,
|
||||
QuoteMilestoneCard,
|
||||
} from "@portal/components/procurement/ProcurementStages";
|
||||
import { QuoteBuilder } from "@portal/components/procurement/QuoteBuilder";
|
||||
import { StageStepper } from "@portal/components/procurement/StageStepper";
|
||||
@@ -22,7 +21,7 @@ import type { ProcurementController } from "@portal/components/procurement/usePr
|
||||
|
||||
/**
|
||||
* The procurement takeover flow: the full-screen journey modal (quote builder →
|
||||
* milestone → agreement → payment → live) plus the key-documents, schedule-call,
|
||||
* quote & agreement → payment → live) plus the licence-key, schedule-call, trial-setup,
|
||||
* and trial-management modals. Driven entirely by a shared ProcurementController
|
||||
* so it can sit next to a deal-status hero rendered elsewhere (e.g. inside the
|
||||
* tier hero card on Home).
|
||||
@@ -56,12 +55,11 @@ export function ProcurementFlow({
|
||||
extra,
|
||||
setExtra,
|
||||
invoicePdf,
|
||||
onConfirmSetup,
|
||||
onExtendTrial,
|
||||
onReset,
|
||||
onGenerate,
|
||||
onAcceptQuote,
|
||||
onAgree,
|
||||
onGoLive,
|
||||
onDownloadPdf,
|
||||
onDownloadOfflineLicense,
|
||||
} = controller;
|
||||
@@ -106,70 +104,66 @@ export function ProcurementFlow({
|
||||
{isLinked && started && (
|
||||
<>
|
||||
<div className="portal-proc__modal-stepper">
|
||||
<StageStepper journey={JOURNEY} currentStage={stage!} />
|
||||
<StageStepper journey={FLOW_JOURNEY} currentStage={stage!} />
|
||||
</div>
|
||||
|
||||
{(editing ||
|
||||
(isDraft && (stage === "trial" || stage === "quote"))) && (
|
||||
<QuoteBuilder
|
||||
deployment="cloud"
|
||||
deployment={data?.deployment ?? "cloud"}
|
||||
seats={data?.seats ?? 0}
|
||||
initial={latest?.config}
|
||||
onGenerate={onGenerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && isIssued && stage === "quote" && latest && (
|
||||
<QuoteMilestoneCard
|
||||
quote={latest}
|
||||
busy={busy}
|
||||
downloading={downloading}
|
||||
onAccept={onAcceptQuote}
|
||||
onDownload={onDownloadPdf}
|
||||
onEdit={() => setEditing(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "security" && latest && (
|
||||
<ProcurementAgreement
|
||||
quote={latest}
|
||||
busy={busy}
|
||||
onAgree={onAgree}
|
||||
/>
|
||||
)}
|
||||
{/* Quote + agreement are one step: review the itemised quote and the agreement, then
|
||||
accept straight into a committed subscription. Once accepted you can't go back.
|
||||
("security" is the retired agreement stage — still handled so an older deal that
|
||||
stopped there isn't left blank.) */}
|
||||
{!editing &&
|
||||
isIssued &&
|
||||
(stage === "quote" || stage === "security") &&
|
||||
latest && (
|
||||
<ProcurementAgreement
|
||||
quote={latest}
|
||||
busy={busy}
|
||||
downloading={downloading}
|
||||
onAgree={onAgree}
|
||||
onDownload={onDownloadPdf}
|
||||
onEdit={() => setEditing(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "procurement" && latest && (
|
||||
<PaymentStageCard
|
||||
invoiceUrl={latest.invoiceUrl}
|
||||
invoicePdf={invoicePdf}
|
||||
busy={busy}
|
||||
onSimulate={onGoLive}
|
||||
invoicePdf={latest.invoicePdf ?? invoicePdf}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!editing && stage === "active" && <LiveStageCard />}
|
||||
|
||||
{data?.licenseKey && (
|
||||
<LicensePanel
|
||||
licenseKey={data.licenseKey}
|
||||
offlineAvailable={!!latest?.config.offlineLicense}
|
||||
downloadingLicense={downloadingLicense}
|
||||
onDownloadOffline={onDownloadOfflineLicense}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="portal-proc__reset">
|
||||
<button type="button" onClick={onReset} disabled={busy}>
|
||||
{t("portal.procurement.reset")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ProcurementModal>
|
||||
|
||||
<KeyDocumentsModal
|
||||
open={extra === "docs"}
|
||||
<TrialSetupModal
|
||||
open={extra === "setup"}
|
||||
onClose={() => setExtra(null)}
|
||||
busy={busy}
|
||||
onConfirm={onConfirmSetup}
|
||||
/>
|
||||
{data?.licenseKey && (
|
||||
<LicenseModal
|
||||
open={extra === "license"}
|
||||
onClose={() => setExtra(null)}
|
||||
licenseKey={data.licenseKey}
|
||||
offlineAvailable={data.deployment === "airgap"}
|
||||
downloadingLicense={downloadingLicense}
|
||||
onDownloadOffline={onDownloadOfflineLicense}
|
||||
trial={data.stage !== "procurement" && data.stage !== "active"}
|
||||
/>
|
||||
)}
|
||||
<ScheduleCallModal
|
||||
open={extra === "schedule"}
|
||||
onClose={() => setExtra(null)}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button, Card } from "@app/ui";
|
||||
import type { QuoteResult } from "@portal/api/procurement";
|
||||
import { money } from "@portal/components/procurement/format";
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
/**
|
||||
@@ -11,97 +9,13 @@ import "@portal/views/Procurement.css";
|
||||
* a pure presentational view driven by props; ProcurementHome owns the state and the actions.
|
||||
*/
|
||||
|
||||
/** The issued Stripe Quote as a shareable milestone: itemised, with accept / download / edit. */
|
||||
export function QuoteMilestoneCard({
|
||||
quote,
|
||||
busy,
|
||||
downloading,
|
||||
onAccept,
|
||||
onDownload,
|
||||
onEdit,
|
||||
}: {
|
||||
quote: QuoteResult;
|
||||
busy: boolean;
|
||||
downloading: boolean;
|
||||
onAccept: () => void;
|
||||
onDownload: () => void;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card padding="loose">
|
||||
<span className="portal-proc__eyebrow">
|
||||
{t("portal.procurement.milestone.eyebrow", {
|
||||
number: quote.quoteNumber,
|
||||
})}
|
||||
</span>
|
||||
<h3 className="portal-proc__builder-title">
|
||||
{t("portal.procurement.milestone.title")}
|
||||
</h3>
|
||||
{quote.config.businessName && (
|
||||
<p className="portal-proc__milestone-for">
|
||||
{t("portal.procurement.milestone.preparedFor", {
|
||||
company: quote.config.businessName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="portal-proc__subtitle">
|
||||
{t("portal.procurement.milestone.description")}
|
||||
</p>
|
||||
<ul className="portal-qb__lines portal-proc__milestone-lines">
|
||||
{quote.lineItems.map((li) => (
|
||||
<li key={li.key} data-kind={li.kind}>
|
||||
<span>{li.label}</span>
|
||||
<span>
|
||||
{li.kind === "INCLUDED"
|
||||
? t("portal.procurement.builder.included")
|
||||
: money(li.amountMinor, quote.currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="portal-proc__milestone-totals">
|
||||
<span className="portal-proc__milestone-annual">
|
||||
{money(quote.annualNetMinor, quote.currency)}
|
||||
<small>{t("portal.procurement.milestone.perYear")}</small>
|
||||
</span>
|
||||
<span className="portal-proc__milestone-tcv">
|
||||
{t("portal.procurement.milestone.tcv", {
|
||||
value: money(quote.tcvMinor, quote.currency),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="portal-proc__payment-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
accent="premium"
|
||||
loading={busy}
|
||||
onClick={onAccept}
|
||||
>
|
||||
{t("portal.procurement.milestone.accept")}
|
||||
</Button>
|
||||
<Button variant="secondary" loading={downloading} onClick={onDownload}>
|
||||
{t("portal.procurement.milestone.download")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onClick={onEdit}>
|
||||
{t("portal.procurement.milestone.edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** The subscription-created step: pay/download the first invoice, or (demo) simulate payment. */
|
||||
/** The subscription-created step: pay or download the first invoice. */
|
||||
export function PaymentStageCard({
|
||||
invoiceUrl,
|
||||
invoicePdf,
|
||||
busy,
|
||||
onSimulate,
|
||||
}: {
|
||||
invoiceUrl?: string | null;
|
||||
invoicePdf?: string | null;
|
||||
busy: boolean;
|
||||
onSimulate: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -133,11 +47,6 @@ export function PaymentStageCard({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-proc__reset">
|
||||
<button type="button" onClick={onSimulate} disabled={busy}>
|
||||
{t("portal.procurement.payment.simulate")}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,12 +15,19 @@ import {
|
||||
import "@portal/views/Procurement.css";
|
||||
|
||||
const STEPS = ["volume", "plan", "details"] as const;
|
||||
const TERM_DISCOUNT = [0, 0.05, 0.1, 0.12, 0.15]; // 1..5 years
|
||||
const SLA_UPLIFT: Record<string, number> = {
|
||||
standard: 0,
|
||||
priority: 0.15,
|
||||
dedicated: 0.3,
|
||||
};
|
||||
const TERM_DISCOUNT = [0, 0.03, 0.05, 0.06, 0.07]; // 1..5 years — meter-only discount (D71)
|
||||
// Governance posture: the intensity (runs per PDF) fed to the committed-volume curve.
|
||||
const POSTURES = [
|
||||
{ intensity: 2, key: "essentials" },
|
||||
{ intensity: 4, key: "governed" },
|
||||
{ intensity: 7, key: "regulated" },
|
||||
] as const;
|
||||
// PDF-size tiers (D93): a multiplier on the rate. Default Standard (×1.4). Mirrors the server.
|
||||
const SIZE_TIERS = [
|
||||
{ mult: 1.0, key: "compact" },
|
||||
{ mult: 1.4, key: "standard" },
|
||||
{ mult: 2.4, key: "heavy" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* The enterprise quote builder — volume → commitment & service → details. A client-side preview
|
||||
@@ -30,10 +37,13 @@ const SLA_UPLIFT: Record<string, number> = {
|
||||
*/
|
||||
export function QuoteBuilder({
|
||||
deployment,
|
||||
seats = 0,
|
||||
initial,
|
||||
onGenerate,
|
||||
}: {
|
||||
deployment: string;
|
||||
/** Seat count from the trial setup; seeds the users field + volume estimate on a fresh quote. */
|
||||
seats?: number;
|
||||
/** Seed the builder from an existing quote's config (re-editing a quote). */
|
||||
initial?: QuoteConfigInput;
|
||||
/** Called with the priced DRAFT quote; the parent issues it as a Stripe Quote. */
|
||||
@@ -43,17 +53,28 @@ export function QuoteBuilder({
|
||||
const [step, setStep] = useState(0);
|
||||
const [cfg, setCfg] = useState<QuoteConfigInput>(
|
||||
initial ?? {
|
||||
volume: 1_000_000,
|
||||
users: 0,
|
||||
// Users-first: with no seats from the trial, leave volume empty so entering the team size
|
||||
// auto-fills it (rather than pre-seeding a figure that hides the users → volume estimate).
|
||||
volume: seats > 0 ? estimateVolume(seats) : 0,
|
||||
users: Math.max(0, seats),
|
||||
intensity: 4, // Governed — the default governance posture per the pricing alignment
|
||||
sizeMult: 1.4, // Standard — the default PDF-size tier (D93)
|
||||
deployment,
|
||||
termYears: 3,
|
||||
serviceLevel: "priority",
|
||||
indemnification: false,
|
||||
training: false,
|
||||
qbr: false,
|
||||
offlineLicense: false,
|
||||
currency: "USD",
|
||||
businessName: "",
|
||||
contactName: "",
|
||||
contactEmail: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
region: "",
|
||||
postalCode: "",
|
||||
poNumber: "",
|
||||
taxId: "",
|
||||
},
|
||||
);
|
||||
// A seeded quote carries a volume but no user count, so treat it as manually set.
|
||||
@@ -159,6 +180,36 @@ export function QuoteBuilder({
|
||||
title={t("portal.procurement.builder.s2Title")}
|
||||
sub={t("portal.procurement.builder.s2Sub")}
|
||||
>
|
||||
<Field label={t("portal.procurement.builder.posture")}>
|
||||
<div className="portal-qb__opts">
|
||||
{POSTURES.map((p) => (
|
||||
<OptCard
|
||||
key={p.key}
|
||||
on={cfg.intensity === p.intensity}
|
||||
title={t(`portal.procurement.builder.posture_${p.key}`)}
|
||||
sub={`${t("portal.procurement.builder.posture_count", {
|
||||
count: p.intensity,
|
||||
})} · ${t(`portal.procurement.builder.posture_${p.key}Sub`)}`}
|
||||
onClick={() => set("intensity", p.intensity)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label={t("portal.procurement.builder.pdfSize")}>
|
||||
<div className="portal-qb__opts">
|
||||
{SIZE_TIERS.map((s) => (
|
||||
<OptCard
|
||||
key={s.key}
|
||||
on={cfg.sizeMult === s.mult}
|
||||
title={t(`portal.procurement.builder.size_${s.key}`)}
|
||||
sub={`×${s.mult} · ${t(`portal.procurement.builder.size_${s.key}Sub`)}`}
|
||||
onClick={() => set("sizeMult", s.mult)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label={t("portal.procurement.builder.term")}>
|
||||
<div className="portal-qb__pills">
|
||||
{[1, 2, 3, 4, 5].map((y) => (
|
||||
@@ -224,12 +275,6 @@ export function QuoteBuilder({
|
||||
sub={t("portal.procurement.builder.qbrSub")}
|
||||
onClick={() => set("qbr", !cfg.qbr)}
|
||||
/>
|
||||
<AddOn
|
||||
on={cfg.offlineLicense}
|
||||
title={t("portal.procurement.builder.offlineLicense")}
|
||||
sub={t("portal.procurement.builder.offlineLicenseSub")}
|
||||
onClick={() => set("offlineLicense", !cfg.offlineLicense)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</Step>
|
||||
@@ -241,31 +286,97 @@ export function QuoteBuilder({
|
||||
title={t("portal.procurement.builder.s3Title")}
|
||||
sub={t("portal.procurement.builder.s3Sub")}
|
||||
>
|
||||
<Field label={t("portal.procurement.builder.businessName")}>
|
||||
<div className="portal-qb__row">
|
||||
<Field label={t("portal.procurement.builder.businessName")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.businessNamePlaceholder",
|
||||
)}
|
||||
value={cfg.businessName}
|
||||
onChange={(e) => set("businessName", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.contactName")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.contactNamePlaceholder",
|
||||
)}
|
||||
value={cfg.contactName ?? ""}
|
||||
onChange={(e) => set("contactName", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t("portal.procurement.builder.contactEmail")}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.contactEmailPlaceholder",
|
||||
)}
|
||||
value={cfg.contactEmail ?? ""}
|
||||
onChange={(e) => set("contactEmail", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.addressLine1")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.businessNamePlaceholder",
|
||||
"portal.procurement.builder.addressLine1Placeholder",
|
||||
)}
|
||||
value={cfg.businessName}
|
||||
onChange={(e) => set("businessName", e.target.value)}
|
||||
value={cfg.addressLine1 ?? ""}
|
||||
onChange={(e) => set("addressLine1", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.addressLine2")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.addressLine2Placeholder",
|
||||
)}
|
||||
value={cfg.addressLine2 ?? ""}
|
||||
onChange={(e) => set("addressLine2", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<div className="portal-qb__row">
|
||||
<Field label={t("portal.procurement.builder.country")}>
|
||||
<select
|
||||
value={cfg.currency}
|
||||
onChange={(e) => set("currency", e.target.value)}
|
||||
>
|
||||
<option value="USD">
|
||||
{t("portal.procurement.builder.countryUS")}
|
||||
</option>
|
||||
<option value="GBP">
|
||||
{t("portal.procurement.builder.countryUK")}
|
||||
</option>
|
||||
<option value="EUR">
|
||||
{t("portal.procurement.builder.countryEuro")}
|
||||
</option>
|
||||
</select>
|
||||
<Field label={t("portal.procurement.builder.city")}>
|
||||
<input
|
||||
placeholder={t("portal.procurement.builder.cityPlaceholder")}
|
||||
value={cfg.city ?? ""}
|
||||
onChange={(e) => set("city", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.region")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.regionPlaceholder",
|
||||
)}
|
||||
value={cfg.region ?? ""}
|
||||
onChange={(e) => set("region", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.postalCode")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.postalCodePlaceholder",
|
||||
)}
|
||||
value={cfg.postalCode ?? ""}
|
||||
onChange={(e) => set("postalCode", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="portal-qb__row">
|
||||
<Field label={t("portal.procurement.builder.poNumber")}>
|
||||
<input
|
||||
placeholder={t(
|
||||
"portal.procurement.builder.poNumberPlaceholder",
|
||||
)}
|
||||
value={cfg.poNumber ?? ""}
|
||||
onChange={(e) => set("poNumber", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("portal.procurement.builder.taxId")}>
|
||||
<input
|
||||
placeholder={t("portal.procurement.builder.taxIdPlaceholder")}
|
||||
value={cfg.taxId ?? ""}
|
||||
onChange={(e) => set("taxId", e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<label className="portal-qb__eula">
|
||||
@@ -283,9 +394,9 @@ export function QuoteBuilder({
|
||||
<div className="portal-qb__foot">
|
||||
<span className="portal-qb__running">
|
||||
{t("portal.procurement.builder.running", {
|
||||
annual: money(preview, cfg.currency),
|
||||
annual: money(preview),
|
||||
years: cfg.termYears,
|
||||
tcv: money(tcvPreview, cfg.currency),
|
||||
tcv: money(tcvPreview),
|
||||
})}
|
||||
</span>
|
||||
<div className="portal-qb__foot-btns">
|
||||
@@ -431,20 +542,31 @@ function estimateVolume(users: number): number {
|
||||
return Math.round(raw / stepSize) * stepSize;
|
||||
}
|
||||
|
||||
function previewAnnualMinor(cfg: QuoteConfigInput): number {
|
||||
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
|
||||
const usage = Math.round(cfg.volume * perPdf);
|
||||
const withSla = Math.round(usage * (1 + (SLA_UPLIFT[cfg.serviceLevel] ?? 0)));
|
||||
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
|
||||
const disc = Math.round(
|
||||
withInd * TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
|
||||
);
|
||||
// Flat annual add-ons (QBR, offline licence) sit outside the multi-year discount, mirroring the
|
||||
// server (PricingRates: qbr 800_000, offline licence 1_200_000). TCV preview derives from this.
|
||||
return (
|
||||
withInd -
|
||||
disc +
|
||||
(cfg.qbr ? 800_000 : 0) +
|
||||
(cfg.offlineLicense ? 1_200_000 : 0)
|
||||
);
|
||||
// Client mirror of the server pricing curve (ProcurementPricingService / quotePricing). The server
|
||||
// is authoritative; this only drives the live footer estimate. Minor units (cents); the meter
|
||||
// rounds to whole dollars, exactly like the backend, so the preview matches the issued quote.
|
||||
// Exported for the pricing-parity test, which pins this client estimate to the mock and the
|
||||
// server's published fixtures so a rate-card change can't silently desync the footer from the
|
||||
// issued quote. This copy stays non-authoritative — the backend prices the real quote.
|
||||
export function previewAnnualMinor(cfg: QuoteConfigInput): number {
|
||||
const LIST = 0.01;
|
||||
const FLOOR = 0.005;
|
||||
const runVol = Math.max(0, cfg.volume) * Math.max(1, cfg.intensity);
|
||||
const volDisc =
|
||||
runVol > 1_000_000
|
||||
? Math.min(0.5, 0.06 * Math.log2(runVol / 1_000_000))
|
||||
: 0;
|
||||
const rate = Math.max(FLOOR, LIST * (1 - volDisc)) * (cfg.sizeMult || 1);
|
||||
const termDisc = TERM_DISCOUNT[Math.min(Math.max(cfg.termYears, 1), 5) - 1];
|
||||
const meterNet = Math.round(runVol * rate * (1 - termDisc)) * 100; // whole $ → minor units
|
||||
const support = cfg.serviceLevel === "dedicated" ? 3_000_000 : 0; // std + priority included
|
||||
const deploy =
|
||||
cfg.deployment === "airgap"
|
||||
? 3_600_000
|
||||
: cfg.deployment === "selfhost"
|
||||
? 1_200_000
|
||||
: 0;
|
||||
const indemnity = cfg.indemnification ? Math.round(meterNet * 0.05) : 0;
|
||||
const qbr = cfg.qbr ? 800_000 : 0;
|
||||
return meterNet + support + deploy + indemnity + qbr;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ export const USD = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
|
||||
/** Format a minor-unit (cents) amount in the given currency, whole units (no decimals). */
|
||||
export function money(minor: number, currency: string): string {
|
||||
/** Format a minor-unit (cents) amount as whole USD (no decimals). USD only for now. */
|
||||
export function money(minor: number, currency: string = "USD"): string {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: currency || "USD",
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { QuoteConfigInput } from "@portal/api/procurement";
|
||||
import { previewAnnualMinor } from "@portal/components/procurement/QuoteBuilder";
|
||||
import { priceQuote } from "@portal/mocks/handlers/procurementSaas";
|
||||
|
||||
/**
|
||||
* The D71 run-based curve is written three times: the authoritative Java engine
|
||||
* (ProcurementPricingService), the client footer estimate (QuoteBuilder.previewAnnualMinor), and
|
||||
* the MSW mock (procurementSaas.priceQuote). The client + mock are deliberately non-authoritative,
|
||||
* but nothing stopped them silently drifting from the server.
|
||||
*
|
||||
* This pins both TypeScript copies to each other AND to the exact fixtures locked in the Java
|
||||
* ProcurementPricingServiceTest. If the rate card changes on the server, that Java test breaks
|
||||
* first; updating these fixtures to match is the reminder to keep the client/mock in step. If the
|
||||
* client and mock diverge from one another, this breaks on its own.
|
||||
*
|
||||
* Keep these numbers identical to ProcurementPricingServiceTest.
|
||||
*/
|
||||
function cfg(overrides: Partial<QuoteConfigInput>): QuoteConfigInput {
|
||||
return {
|
||||
volume: 0,
|
||||
users: 0,
|
||||
intensity: 4,
|
||||
sizeMult: 1.0,
|
||||
deployment: "cloud",
|
||||
termYears: 3,
|
||||
serviceLevel: "standard",
|
||||
indemnification: false,
|
||||
training: false,
|
||||
qbr: false,
|
||||
businessName: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// annualNetMinor (USD minor units) — each mirrors an assertion in ProcurementPricingServiceTest.
|
||||
const FIXTURES: {
|
||||
name: string;
|
||||
cfg: QuoteConfigInput;
|
||||
annualNetMinor: number;
|
||||
}[] = [
|
||||
{
|
||||
name: "Northwind — 6M · Governed · cloud · standard · 3yr",
|
||||
cfg: cfg({ volume: 6_000_000 }),
|
||||
annualNetMinor: 16_527_800,
|
||||
},
|
||||
{
|
||||
name: "Northwind, Standard PDF size (rate ×1.4)",
|
||||
cfg: cfg({ volume: 6_000_000, sizeMult: 1.4 }),
|
||||
annualNetMinor: 23_138_900,
|
||||
},
|
||||
{
|
||||
name: "acme — 90M · Governed · self-hosted · dedicated · 3yr",
|
||||
cfg: cfg({
|
||||
volume: 90_000_000,
|
||||
deployment: "selfhost",
|
||||
serviceLevel: "dedicated",
|
||||
}),
|
||||
annualNetMinor: 175_200_000,
|
||||
},
|
||||
{
|
||||
name: "1-year term — no meter discount",
|
||||
cfg: cfg({ volume: 6_000_000, termYears: 1 }),
|
||||
annualNetMinor: 17_397_700,
|
||||
},
|
||||
{
|
||||
name: "2-year term — 3% off the meter",
|
||||
cfg: cfg({ volume: 6_000_000, termYears: 2 }),
|
||||
annualNetMinor: 16_875_700,
|
||||
},
|
||||
{
|
||||
name: "rate floors at half a cent — 100M · Regulated · 1yr",
|
||||
cfg: cfg({ volume: 100_000_000, intensity: 7, termYears: 1 }),
|
||||
annualNetMinor: 350_000_000,
|
||||
},
|
||||
];
|
||||
|
||||
describe("procurement pricing parity (client ↔ mock ↔ server fixtures)", () => {
|
||||
for (const f of FIXTURES) {
|
||||
it(`agrees on ${f.name}`, () => {
|
||||
expect(previewAnnualMinor(f.cfg)).toBe(f.annualNetMinor);
|
||||
expect(priceQuote(f.cfg).annualNetMinor).toBe(f.annualNetMinor);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -8,16 +8,19 @@ import {
|
||||
fetchLicenseFile,
|
||||
fetchQuotePdf,
|
||||
fetchSnapshot,
|
||||
goLive,
|
||||
issueQuote,
|
||||
resetProcurement,
|
||||
startAgreement,
|
||||
startTrial,
|
||||
type ProcurementSnapshot,
|
||||
type QuoteResult,
|
||||
} from "@portal/api/procurement";
|
||||
|
||||
export type ProcurementExtra = null | "docs" | "schedule" | "trial";
|
||||
export type ProcurementExtra =
|
||||
| null
|
||||
| "license"
|
||||
| "schedule"
|
||||
| "trial"
|
||||
| "setup";
|
||||
|
||||
/**
|
||||
* Owns the procurement deal state and actions shared by the Home hero footer
|
||||
@@ -46,13 +49,14 @@ export interface ProcurementController {
|
||||
extra: ProcurementExtra;
|
||||
setExtra: (e: ProcurementExtra) => void;
|
||||
invoicePdf: string | null;
|
||||
/** Open the trial-setup dialog (deployment + seats) — the trial only starts once it's confirmed. */
|
||||
onStartTrial: () => void;
|
||||
/** Confirm the setup dialog: start the trial with the chosen deployment/seats, then open the flow. */
|
||||
onConfirmSetup: (deployment: string, seats: number) => void;
|
||||
onExtendTrial: () => void;
|
||||
onReset: () => void;
|
||||
onGenerate: (draft: QuoteResult) => void;
|
||||
onAcceptQuote: () => void;
|
||||
onAgree: () => void;
|
||||
onGoLive: () => void;
|
||||
onDownloadPdf: () => Promise<void>;
|
||||
onDownloadOfflineLicense: () => Promise<void>;
|
||||
}
|
||||
@@ -99,7 +103,14 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
}
|
||||
}
|
||||
|
||||
const onStartTrial = () => run(startTrial);
|
||||
// The setup dialog collects deployment + seats first; the trial starts on confirm.
|
||||
const onStartTrial = () => setExtra("setup");
|
||||
const onConfirmSetup = (deployment: string, seats: number) =>
|
||||
run(async () => {
|
||||
await startTrial(deployment, seats);
|
||||
setExtra(null);
|
||||
setOpen(true);
|
||||
});
|
||||
const onExtendTrial = () => run(extendTrial);
|
||||
const onReset = () =>
|
||||
run(async () => {
|
||||
@@ -112,15 +123,14 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
await issueQuote(draft.quoteId);
|
||||
setEditing(false);
|
||||
});
|
||||
// Milestone → agreement (security) stage; then agreeing accepts into a subscription.
|
||||
const onAcceptQuote = () => run(startAgreement);
|
||||
// Quote + agreement are one step now: agreeing accepts the issued quote straight into a
|
||||
// committed subscription (Stripe), and provisioning upgrades the licence server-side.
|
||||
const onAgree = () =>
|
||||
run(async () => {
|
||||
if (!latest) return;
|
||||
const res = await acceptQuote(latest.quoteId);
|
||||
setInvoicePdf(res.invoicePdf);
|
||||
});
|
||||
const onGoLive = () => run(goLive);
|
||||
|
||||
async function onDownloadPdf() {
|
||||
if (!latest) return;
|
||||
@@ -194,12 +204,11 @@ export function useProcurement(autoOpen = false): ProcurementController {
|
||||
setExtra,
|
||||
invoicePdf,
|
||||
onStartTrial,
|
||||
onConfirmSetup,
|
||||
onExtendTrial,
|
||||
onReset,
|
||||
onGenerate,
|
||||
onAcceptQuote,
|
||||
onAgree,
|
||||
onGoLive,
|
||||
onDownloadPdf,
|
||||
onDownloadOfflineLicense,
|
||||
};
|
||||
|
||||
@@ -74,6 +74,53 @@ describe("ConnectWizard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates an s3 source with a masked secret in review", async () => {
|
||||
createSource.mockResolvedValue({ id: "src-2" });
|
||||
|
||||
renderWithMantine(
|
||||
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
|
||||
);
|
||||
|
||||
// Step 0: pick the S3 type card.
|
||||
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 1: name, bucket, credentials. The secret renders as a password
|
||||
// input, so it is not part of the textbox roles.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
fireEvent.change(inputs[0], { target: { value: "Claims bucket" } });
|
||||
fireEvent.change(inputs[1], { target: { value: "claims-inbox" } });
|
||||
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
|
||||
const secret = document.querySelector(
|
||||
'input[type="password"]',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(secret, { target: { value: "shh-secret" } });
|
||||
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
|
||||
|
||||
// Step 2: the secret is masked in review, never echoed.
|
||||
expect(screen.queryByText("shh-secret")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("********")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
|
||||
await waitFor(() => {
|
||||
expect(createSource).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(createSource).toHaveBeenCalledWith({
|
||||
name: "Claims bucket",
|
||||
type: "s3",
|
||||
options: {
|
||||
bucket: "claims-inbox",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "shh-secret",
|
||||
endpoint: "",
|
||||
mode: "consume",
|
||||
},
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("edits an existing source: prefilled, skips type, submits with its id", async () => {
|
||||
createSource.mockResolvedValue({ id: "s1" });
|
||||
const onCreated = vi.fn();
|
||||
|
||||
@@ -11,15 +11,16 @@ import {
|
||||
} from "@app/ui";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import { createSource, type Source } from "@portal/api/sources";
|
||||
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
|
||||
import {
|
||||
CREATABLE_SOURCE_TYPES,
|
||||
defaultOptions,
|
||||
sourceTypeMeta,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
import "@portal/views/Sources.css";
|
||||
|
||||
const DEFAULT_TYPE = CREATABLE_SOURCE_TYPES[0];
|
||||
const OFFERED_TYPES = creatableSourceTypes();
|
||||
const DEFAULT_TYPE = OFFERED_TYPES[0];
|
||||
|
||||
/** Wizard steps. Editing skips type selection (the type is fixed once created). */
|
||||
type StepId = "type" | "configure" | "review";
|
||||
@@ -35,9 +36,9 @@ interface ConnectWizardProps {
|
||||
source?: Source;
|
||||
}
|
||||
|
||||
/** The creatable-type metadata for a source's stored type, falling back to folder. */
|
||||
/** The creatable-type metadata for a source's stored type, falling back to the first offered. */
|
||||
function typeFor(type: string | undefined): CreatableSourceType {
|
||||
return CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
|
||||
return OFFERED_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
|
||||
}
|
||||
|
||||
/** Source options coerced to strings for the form, defaulted from the type's fields. */
|
||||
@@ -199,7 +200,7 @@ export function ConnectWizard({
|
||||
|
||||
{stepId === "type" && (
|
||||
<div className="portal-sources__type-grid">
|
||||
{CREATABLE_SOURCE_TYPES.map((ct) => (
|
||||
{OFFERED_TYPES.map((ct) => (
|
||||
<Button
|
||||
key={ct.type}
|
||||
variant="tertiary"
|
||||
@@ -251,6 +252,7 @@ export function ConnectWizard({
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.control === "password" ? "password" : undefined}
|
||||
value={options[field.key] ?? ""}
|
||||
placeholder={
|
||||
field.placeholderKey ? t(field.placeholderKey) : undefined
|
||||
@@ -280,7 +282,11 @@ export function ConnectWizard({
|
||||
<StatTile
|
||||
key={field.key}
|
||||
label={t(field.labelKey)}
|
||||
value={options[field.key] || "—"}
|
||||
value={
|
||||
field.control === "password" && options[field.key]
|
||||
? "********"
|
||||
: options[field.key] || "—"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
CREATABLE_SOURCE_TYPES,
|
||||
type CreatableSourceType,
|
||||
} from "@portal/components/sources/sourceTypes";
|
||||
|
||||
/**
|
||||
* The source types the connect wizard offers. An extension point: deployments
|
||||
* where a type cannot work shadow this module and filter the list (e.g. hosted
|
||||
* deployments never read the server's filesystem, so folder sources are not
|
||||
* offered there).
|
||||
*/
|
||||
export function creatableSourceTypes(): CreatableSourceType[] {
|
||||
return CREATABLE_SOURCE_TYPES;
|
||||
}
|
||||
@@ -26,6 +26,11 @@ const SOURCE_TYPE_META: Record<string, SourceTypeMeta> = {
|
||||
icon: "✏",
|
||||
accent: "success",
|
||||
},
|
||||
s3: {
|
||||
labelKey: "portal.sources.types.s3.label",
|
||||
icon: "☁",
|
||||
accent: "brand",
|
||||
},
|
||||
// Read-only rows surfaced from the Infrastructure API keys - shown for visibility
|
||||
// and usage stats, never creatable or usable as a policy input.
|
||||
apikey: {
|
||||
@@ -49,7 +54,7 @@ export function sourceTypeMeta(type: string): SourceTypeMeta {
|
||||
export interface SourceFieldDef {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
control: "text" | "select";
|
||||
control: "text" | "password" | "select";
|
||||
required?: boolean;
|
||||
placeholderKey?: string;
|
||||
helperTextKey?: string;
|
||||
@@ -137,6 +142,70 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "s3",
|
||||
labelKey: "portal.sources.types.s3.label",
|
||||
descriptionKey: "portal.sources.types.s3.description",
|
||||
fields: [
|
||||
{
|
||||
key: "bucket",
|
||||
labelKey: "portal.sources.types.s3.fields.bucket.label",
|
||||
control: "text",
|
||||
required: true,
|
||||
placeholderKey: "portal.sources.types.s3.fields.bucket.placeholder",
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
labelKey: "portal.sources.types.s3.fields.region.label",
|
||||
control: "text",
|
||||
defaultValue: "us-east-1",
|
||||
placeholderKey: "portal.sources.types.s3.fields.region.placeholder",
|
||||
},
|
||||
{
|
||||
key: "prefix",
|
||||
labelKey: "portal.sources.types.s3.fields.prefix.label",
|
||||
control: "text",
|
||||
placeholderKey: "portal.sources.types.s3.fields.prefix.placeholder",
|
||||
helperTextKey: "portal.sources.types.s3.fields.prefix.helperText",
|
||||
},
|
||||
{
|
||||
key: "accessKeyId",
|
||||
labelKey: "portal.sources.types.s3.fields.accessKeyId.label",
|
||||
control: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "secretAccessKey",
|
||||
labelKey: "portal.sources.types.s3.fields.secretAccessKey.label",
|
||||
control: "password",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "endpoint",
|
||||
labelKey: "portal.sources.types.s3.fields.endpoint.label",
|
||||
control: "text",
|
||||
placeholderKey: "portal.sources.types.s3.fields.endpoint.placeholder",
|
||||
helperTextKey: "portal.sources.types.s3.fields.endpoint.helperText",
|
||||
},
|
||||
{
|
||||
key: "mode",
|
||||
labelKey: "portal.sources.types.s3.fields.mode.label",
|
||||
control: "select",
|
||||
defaultValue: "consume",
|
||||
helperTextKey: "portal.sources.types.s3.fields.mode.helperText",
|
||||
options: [
|
||||
{
|
||||
value: "consume",
|
||||
labelKey: "portal.sources.types.s3.fields.mode.options.consume",
|
||||
},
|
||||
{
|
||||
value: "snapshot",
|
||||
labelKey: "portal.sources.types.s3.fields.mode.options.snapshot",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Default option values for a type's create form. */
|
||||
|
||||
@@ -165,7 +165,13 @@ export const pipelinesHandlers = [
|
||||
http.post("/api/v1/policies/:id/trigger", async ({ params }) => {
|
||||
if (!store.some((p) => p.id === params.id)) return undefined;
|
||||
await delay(120);
|
||||
return HttpResponse.json([`run_${Date.now().toString(36)}`]);
|
||||
return HttpResponse.json({
|
||||
runIds: [`run_${Date.now().toString(36)}`],
|
||||
filesListed: 1,
|
||||
alreadyProcessed: 0,
|
||||
parked: 0,
|
||||
inFlight: 0,
|
||||
});
|
||||
}),
|
||||
|
||||
// Raw policy by id. Only our pipeline ids are served here; everything else falls
|
||||
|
||||
@@ -10,6 +10,8 @@ const SAAS = "http://saas.mock";
|
||||
const EMPTY = {
|
||||
dealId: null,
|
||||
stage: null,
|
||||
deployment: "cloud",
|
||||
seats: 0,
|
||||
trialStartedAt: null,
|
||||
trialEndsAt: null,
|
||||
trialExtensionsUsed: 0,
|
||||
@@ -20,41 +22,79 @@ const EMPTY = {
|
||||
|
||||
interface Cfg {
|
||||
volume: number;
|
||||
users?: number;
|
||||
intensity: number;
|
||||
sizeMult: number;
|
||||
deployment: string;
|
||||
serviceLevel: string;
|
||||
termYears: number;
|
||||
indemnification: boolean;
|
||||
training: boolean;
|
||||
qbr: boolean;
|
||||
offlineLicense: boolean;
|
||||
currency: string;
|
||||
businessName?: string;
|
||||
contactName?: string;
|
||||
contactEmail?: string;
|
||||
addressLine1?: string;
|
||||
addressLine2?: string;
|
||||
city?: string;
|
||||
region?: string;
|
||||
postalCode?: string;
|
||||
poNumber?: string;
|
||||
taxId?: string;
|
||||
}
|
||||
|
||||
let deal: typeof EMPTY | (Record<string, unknown> & { latestQuote: unknown }) =
|
||||
EMPTY;
|
||||
let seq = 0;
|
||||
|
||||
const SLA: Record<string, number> = {
|
||||
standard: 0,
|
||||
priority: 0.15,
|
||||
dedicated: 0.3,
|
||||
};
|
||||
const TERM = [0, 0.05, 0.1, 0.12, 0.15];
|
||||
const TERM = [0, 0.03, 0.05, 0.06, 0.07]; // meter-only, 1..5 years
|
||||
|
||||
function priceQuote(cfg: Cfg) {
|
||||
const perPdf = cfg.volume >= 5_000_000 ? 3 : cfg.volume >= 1_000_000 ? 4 : 5;
|
||||
const usage = Math.round(cfg.volume * perPdf);
|
||||
const withSla = Math.round(usage * (1 + (SLA[cfg.serviceLevel] ?? 0)));
|
||||
const withInd = cfg.indemnification ? Math.round(withSla * 1.05) : withSla;
|
||||
const disc = Math.round(
|
||||
withInd * TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1],
|
||||
);
|
||||
// Mirror of ProcurementPricingService (D71): run-based curve, flat priced needs, USD.
|
||||
// Exported for the pricing-parity test (see pricingParity.test.ts).
|
||||
export function priceQuote(cfg: Cfg) {
|
||||
const LIST = 0.01;
|
||||
const FLOOR = 0.005;
|
||||
const intensity = Math.max(1, cfg.intensity || 4);
|
||||
const runVol = Math.max(0, cfg.volume) * intensity;
|
||||
const volDisc =
|
||||
runVol > 1_000_000
|
||||
? Math.min(0.5, 0.06 * Math.log2(runVol / 1_000_000))
|
||||
: 0;
|
||||
// File-size tier (D93) scales the rate after the floor; snap to a known multiplier.
|
||||
const sizeMult = [1.0, 1.4, 2.4].includes(cfg.sizeMult) ? cfg.sizeMult : 1.0;
|
||||
const rate = Math.max(FLOOR, LIST * (1 - volDisc)) * sizeMult;
|
||||
const termDisc = TERM[Math.min(Math.max(cfg.termYears, 1), 5) - 1];
|
||||
const annualBase = Math.round(runVol * rate) * 100; // whole $ → minor
|
||||
const meterNet = Math.round(runVol * rate * (1 - termDisc)) * 100;
|
||||
const termDiscount = meterNet - annualBase; // <= 0
|
||||
const support = cfg.serviceLevel === "dedicated" ? 3_000_000 : 0;
|
||||
const deploy =
|
||||
cfg.deployment === "airgap"
|
||||
? 3_600_000
|
||||
: cfg.deployment === "selfhost"
|
||||
? 1_200_000
|
||||
: 0;
|
||||
const indemnity = cfg.indemnification ? Math.round(meterNet * 0.05) : 0;
|
||||
const qbr = cfg.qbr ? 800_000 : 0;
|
||||
const offline = cfg.offlineLicense ? 1_200_000 : 0;
|
||||
const training = cfg.training ? 750_000 : 0;
|
||||
const annualNetMinor = withInd - disc + qbr + offline;
|
||||
const annualNetMinor = meterNet + support + deploy + indemnity + qbr;
|
||||
const tcvMinor = annualNetMinor * cfg.termYears + training;
|
||||
|
||||
const posture =
|
||||
intensity === 2
|
||||
? "Essentials"
|
||||
: intensity === 7
|
||||
? "Regulated"
|
||||
: intensity === 4
|
||||
? "Governed"
|
||||
: `${intensity}-policy`;
|
||||
const deployName =
|
||||
cfg.deployment === "airgap"
|
||||
? "Air-gapped"
|
||||
: cfg.deployment === "selfhost"
|
||||
? "Self-hosted"
|
||||
: "Stirling Cloud";
|
||||
|
||||
type Kind = "RECURRING" | "ONE_TIME" | "DISCOUNT" | "INCLUDED";
|
||||
const lines: {
|
||||
key: string;
|
||||
@@ -64,33 +104,44 @@ function priceQuote(cfg: Cfg) {
|
||||
}[] = [
|
||||
{
|
||||
key: "usage",
|
||||
label: "PDF processing",
|
||||
label: `PDF processing — ${cfg.volume.toLocaleString()} PDFs/yr at $${(rate * intensity).toFixed(4)}/PDF (${posture} posture)`,
|
||||
kind: "RECURRING",
|
||||
amountMinor: usage,
|
||||
amountMinor: annualBase,
|
||||
},
|
||||
{
|
||||
key: "seats",
|
||||
label: "Unlimited users + SSO / SCIM / RBAC",
|
||||
label: "Unlimited users + SSO / SCIM / RBAC / audit",
|
||||
kind: "INCLUDED",
|
||||
amountMinor: 0,
|
||||
},
|
||||
];
|
||||
if (withSla !== usage)
|
||||
if (termDiscount < 0)
|
||||
lines.push({
|
||||
key: "service-level",
|
||||
label:
|
||||
cfg.serviceLevel === "dedicated"
|
||||
? "Dedicated service level"
|
||||
: "Priority service level",
|
||||
kind: "RECURRING",
|
||||
amountMinor: withSla - usage,
|
||||
key: "multi-year",
|
||||
label: `${cfg.termYears}-year commitment`,
|
||||
kind: "DISCOUNT",
|
||||
amountMinor: termDiscount,
|
||||
});
|
||||
if (withInd !== withSla)
|
||||
if (support > 0)
|
||||
lines.push({
|
||||
key: "support",
|
||||
label: "Dedicated SE / CSM",
|
||||
kind: "RECURRING",
|
||||
amountMinor: support,
|
||||
});
|
||||
if (deploy > 0)
|
||||
lines.push({
|
||||
key: "deployment",
|
||||
label: `${deployName} deployment`,
|
||||
kind: "RECURRING",
|
||||
amountMinor: deploy,
|
||||
});
|
||||
if (indemnity > 0)
|
||||
lines.push({
|
||||
key: "indemnification",
|
||||
label: "IP indemnification",
|
||||
kind: "RECURRING",
|
||||
amountMinor: withInd - withSla,
|
||||
amountMinor: indemnity,
|
||||
});
|
||||
if (qbr > 0)
|
||||
lines.push({
|
||||
@@ -99,20 +150,6 @@ function priceQuote(cfg: Cfg) {
|
||||
kind: "RECURRING",
|
||||
amountMinor: qbr,
|
||||
});
|
||||
if (offline > 0)
|
||||
lines.push({
|
||||
key: "offline-license",
|
||||
label: "Offline / air-gapped licence",
|
||||
kind: "RECURRING",
|
||||
amountMinor: offline,
|
||||
});
|
||||
if (disc > 0)
|
||||
lines.push({
|
||||
key: "multi-year",
|
||||
label: `${cfg.termYears}-year commitment`,
|
||||
kind: "DISCOUNT",
|
||||
amountMinor: -disc,
|
||||
});
|
||||
if (training > 0)
|
||||
lines.push({
|
||||
key: "training",
|
||||
@@ -126,25 +163,37 @@ function priceQuote(cfg: Cfg) {
|
||||
quoteId: seq,
|
||||
quoteNumber: `QT-DEMO-${String(seq).padStart(4, "0")}`,
|
||||
status: "draft",
|
||||
currency: cfg.currency || "USD",
|
||||
currency: "USD",
|
||||
annualNetMinor,
|
||||
tcvMinor,
|
||||
renewalAnnualNetMinor: Math.round(annualNetMinor * 1.03), // +3% CPI on renewal
|
||||
cpiRatePct: 3,
|
||||
lineItems: lines,
|
||||
validUntil: "2026-07-31",
|
||||
stripeQuoteId: null,
|
||||
invoiceUrl: null,
|
||||
invoicePdf: null,
|
||||
config: {
|
||||
volume: cfg.volume,
|
||||
users: 0,
|
||||
deployment: "cloud",
|
||||
intensity,
|
||||
sizeMult,
|
||||
deployment: cfg.deployment || "cloud",
|
||||
termYears: cfg.termYears,
|
||||
serviceLevel: cfg.serviceLevel,
|
||||
indemnification: cfg.indemnification,
|
||||
training: cfg.training,
|
||||
qbr: cfg.qbr,
|
||||
offlineLicense: cfg.offlineLicense,
|
||||
currency: cfg.currency || "USD",
|
||||
businessName: cfg.businessName ?? "",
|
||||
contactName: cfg.contactName ?? "",
|
||||
contactEmail: cfg.contactEmail ?? "",
|
||||
addressLine1: cfg.addressLine1 ?? "",
|
||||
addressLine2: cfg.addressLine2 ?? "",
|
||||
city: cfg.city ?? "",
|
||||
region: cfg.region ?? "",
|
||||
postalCode: cfg.postalCode ?? "",
|
||||
poNumber: cfg.poNumber ?? "",
|
||||
taxId: cfg.taxId ?? "",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -156,11 +205,20 @@ export function resetProcurementSaasStore() {
|
||||
|
||||
export const procurementSaasHandlers = [
|
||||
http.get(`${SAAS}/api/v1/procurement`, () => HttpResponse.json(deal)),
|
||||
http.post(`${SAAS}/api/v1/procurement/trial/start`, () => {
|
||||
http.post(`${SAAS}/api/v1/procurement/trial/start`, async ({ request }) => {
|
||||
const body = (await request.json().catch(() => ({}))) as Partial<{
|
||||
deployment: string;
|
||||
users: number;
|
||||
}>;
|
||||
const allowed = ["cloud", "selfhost", "airgap"];
|
||||
const now = Date.now();
|
||||
deal = {
|
||||
dealId: 1,
|
||||
stage: "trial",
|
||||
deployment: allowed.includes(body.deployment ?? "")
|
||||
? body.deployment
|
||||
: "cloud",
|
||||
seats: Math.max(0, Number(body.users) || 0),
|
||||
trialStartedAt: new Date(now).toISOString(),
|
||||
trialEndsAt: new Date(now + 14 * 86_400_000).toISOString(),
|
||||
trialExtensionsUsed: 0,
|
||||
@@ -210,7 +268,8 @@ export const procurementSaasHandlers = [
|
||||
}),
|
||||
http.get(`${SAAS}/api/v1/procurement/license/file`, () => {
|
||||
const q = (deal as { latestQuote: { config?: Cfg } | null }).latestQuote;
|
||||
if (!q?.config?.offlineLicense) {
|
||||
// Offline .lic is available only for an air-gapped deployment (matches the Java backend).
|
||||
if (q?.config?.deployment !== "airgap") {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
return new HttpResponse(
|
||||
@@ -237,16 +296,18 @@ export const procurementSaasHandlers = [
|
||||
const q = (deal as { latestQuote: Record<string, unknown> | null })
|
||||
.latestQuote;
|
||||
const invoiceUrl = "https://invoice.stripe.com/i/mock_procurement";
|
||||
const invoicePdf = "https://invoice.stripe.com/i/mock_procurement/pdf";
|
||||
if (q) {
|
||||
q.status = "accepted";
|
||||
q.invoiceUrl = invoiceUrl;
|
||||
q.invoicePdf = invoicePdf;
|
||||
(deal as Record<string, unknown>).stage = "procurement";
|
||||
}
|
||||
return HttpResponse.json({
|
||||
status: "accepted",
|
||||
subscriptionId: "sub_mock_procurement",
|
||||
invoiceUrl,
|
||||
invoicePdf: "https://invoice.stripe.com/i/mock_procurement/pdf",
|
||||
invoicePdf,
|
||||
});
|
||||
}),
|
||||
http.post(`${SAAS}/functions/v1/get-procurement-quote-pdf`, () => {
|
||||
|
||||
@@ -28,6 +28,8 @@ export const SubscribedInProcurement: Story = {
|
||||
HttpResponse.json({
|
||||
dealId: 1,
|
||||
stage: "trial",
|
||||
deployment: "cloud",
|
||||
seats: 250,
|
||||
trialStartedAt: "2026-07-01T00:00:00.000Z",
|
||||
trialEndsAt: "2026-07-21T00:00:00.000Z",
|
||||
trialExtensionsUsed: 0,
|
||||
|
||||
@@ -392,3 +392,27 @@
|
||||
min-width: 1.5rem;
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
.portal-builder__s3-output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.portal-builder__s3-summary {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.portal-builder__s3-summary.is-unset {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
.portal-builder__s3-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { Policy } from "@portal/api/pipelines";
|
||||
import type { Policy, TriggerOutcome } from "@portal/api/pipelines";
|
||||
import type { ToolRegistryCatalog } from "@app/contexts/ToolRegistryContext";
|
||||
import type { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
|
||||
@@ -45,6 +45,11 @@ vi.mock("@portal/api/sources", () => ({
|
||||
fetchSources: () => fetchSources(),
|
||||
}));
|
||||
|
||||
const clearProcessedHistory = vi.fn();
|
||||
vi.mock("@portal/api/policies", () => ({
|
||||
clearProcessedHistory: (id: string) => clearProcessedHistory(id),
|
||||
}));
|
||||
|
||||
// One editable tool, Compress, so the picker and step settings have something to render.
|
||||
vi.mock("@app/contexts/ToolRegistryContext", () => {
|
||||
const compress = {
|
||||
@@ -100,6 +105,17 @@ const POLICY: Policy = {
|
||||
output: { type: "inline", options: {} },
|
||||
};
|
||||
|
||||
function outcome(overrides: Partial<TriggerOutcome>): TriggerOutcome {
|
||||
return {
|
||||
runIds: [],
|
||||
filesListed: 0,
|
||||
alreadyProcessed: 0,
|
||||
parked: 0,
|
||||
inFlight: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBuilder(initial: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initial]}>
|
||||
@@ -129,8 +145,10 @@ describe("PipelineBuilder", () => {
|
||||
fetchSources.mockResolvedValue({ kpis: [], sources: [] });
|
||||
savePipeline.mockResolvedValue({});
|
||||
deletePipeline.mockResolvedValue(undefined);
|
||||
triggerPipeline.mockResolvedValue(["run-1"]);
|
||||
triggerPipeline.mockResolvedValue(outcome({ runIds: ["run-1"] }));
|
||||
fetchRun.mockResolvedValue({ status: "COMPLETED" });
|
||||
clearProcessedHistory.mockReset();
|
||||
clearProcessedHistory.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("builds a new pipeline: name it, add a tool, and save", async () => {
|
||||
@@ -159,6 +177,60 @@ describe("PipelineBuilder", () => {
|
||||
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves an s3 output with its connection options", async () => {
|
||||
renderBuilder("/processor/pipelines/new");
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox"), {
|
||||
target: { value: "Bucket to bucket" },
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("portal.pipelines.output.s3"));
|
||||
|
||||
// With s3 selected but no bucket, saving is blocked and the summary reads
|
||||
// unconfigured; the connection fields live behind the Configure modal.
|
||||
expect(
|
||||
screen.getByText("portal.pipelines.composer.create").closest("button"),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText("portal.pipelines.composer.s3NotConfigured"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("portal.pipelines.composer.s3Configure"));
|
||||
|
||||
// Textboxes: name, then the modal's bucket, region, prefix, access key id,
|
||||
// endpoint; the secret renders as a password input outside the textbox role.
|
||||
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
|
||||
fireEvent.change(inputs[1], { target: { value: "claims-processed" } });
|
||||
fireEvent.change(inputs[3], { target: { value: "processed/" } });
|
||||
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
|
||||
const secret = document.querySelector(
|
||||
'input[type="password"]',
|
||||
) as HTMLInputElement;
|
||||
fireEvent.change(secret, { target: { value: "shh-secret" } });
|
||||
fireEvent.click(screen.getByText("portal.pipelines.composer.s3Done"));
|
||||
|
||||
// The summary now shows the configured destination.
|
||||
expect(
|
||||
screen.getByText("s3://claims-processed/processed/"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
|
||||
|
||||
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
|
||||
expect(savePipeline).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
output: {
|
||||
type: "s3",
|
||||
options: {
|
||||
bucket: "claims-processed",
|
||||
region: "us-east-1",
|
||||
prefix: "processed/",
|
||||
endpoint: "",
|
||||
accessKeyId: "AKIAEXAMPLE",
|
||||
secretAccessKey: "shh-secret",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an existing pipeline and reports success", async () => {
|
||||
renderBuilder("/processor/pipelines/plc-1");
|
||||
|
||||
@@ -170,6 +242,45 @@ describe("PipelineBuilder", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains an empty trigger when files are parked by a failed run", async () => {
|
||||
triggerPipeline.mockResolvedValue(outcome({ filesListed: 2, parked: 2 }));
|
||||
renderBuilder("/processor/pipelines/plc-1");
|
||||
|
||||
fireEvent.click(await screen.findByText("portal.pipelines.detail.run"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("portal.pipelines.run.parked"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains an empty trigger when everything is already processed", async () => {
|
||||
triggerPipeline.mockResolvedValue(
|
||||
outcome({ filesListed: 3, alreadyProcessed: 3 }),
|
||||
);
|
||||
renderBuilder("/processor/pipelines/plc-1");
|
||||
|
||||
fireEvent.click(await screen.findByText("portal.pipelines.detail.run"));
|
||||
|
||||
expect(
|
||||
await screen.findByText("portal.pipelines.run.allProcessed"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears processed history from the header and confirms", async () => {
|
||||
renderBuilder("/processor/pipelines/plc-1");
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByText("portal.pipelines.detail.clearHistory"),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(clearProcessedHistory).toHaveBeenCalledWith("plc-1"),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText("portal.pipelines.run.historyCleared"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks saving a step that needs an uploaded file", async () => {
|
||||
renderBuilder("/processor/pipelines/new");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded";
|
||||
import KeyboardArrowUpRoundedIcon from "@mui/icons-material/KeyboardArrowUpRounded";
|
||||
import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded";
|
||||
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
|
||||
import HistoryRoundedIcon from "@mui/icons-material/HistoryRounded";
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
|
||||
import {
|
||||
@@ -42,9 +43,13 @@ import {
|
||||
type OutputSpec,
|
||||
type Policy,
|
||||
type PolicyRunView,
|
||||
type PipelineOutputMode,
|
||||
type TriggerConfig,
|
||||
type TriggerInfo,
|
||||
type TriggerOutcome,
|
||||
} from "@portal/api/pipelines";
|
||||
import { clearProcessedHistory } from "@portal/api/policies";
|
||||
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
|
||||
import { fetchSources, type SourceView } from "@portal/api/sources";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
|
||||
@@ -53,7 +58,29 @@ import { PipelineStepSettings } from "@portal/components/pipelines/PipelineStepS
|
||||
import { ToolPicker } from "@portal/components/pipelines/ToolPicker";
|
||||
import "@portal/views/PipelineBuilder.css";
|
||||
|
||||
type OutputMode = "inline" | "folder";
|
||||
type OutputMode = PipelineOutputMode;
|
||||
|
||||
/** New pipelines (and specs of unoffered types) start on the first offered destination. */
|
||||
const DEFAULT_OUTPUT_MODE = availableOutputModes()[0];
|
||||
|
||||
/** The s3 output's connection fields, mirrored from the OutputSpec options. */
|
||||
interface S3OutputOptions {
|
||||
bucket: string;
|
||||
region: string;
|
||||
prefix: string;
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
}
|
||||
|
||||
const EMPTY_S3_OUTPUT: S3OutputOptions = {
|
||||
bucket: "",
|
||||
region: "us-east-1",
|
||||
prefix: "",
|
||||
endpoint: "",
|
||||
accessKeyId: "",
|
||||
secretAccessKey: "",
|
||||
};
|
||||
type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
|
||||
|
||||
const SCHEDULE_UNITS: ScheduleUnit[] = ["MINUTES", "HOURS", "DAYS"];
|
||||
@@ -95,14 +122,32 @@ function parseTrigger(trigger: TriggerConfig | null): {
|
||||
function parseOutput(output: OutputSpec | undefined): {
|
||||
mode: OutputMode;
|
||||
directory: string;
|
||||
s3: S3OutputOptions;
|
||||
} {
|
||||
if (output?.type === "folder") {
|
||||
return {
|
||||
mode: "folder",
|
||||
directory: String(output.options?.directory ?? ""),
|
||||
s3: EMPTY_S3_OUTPUT,
|
||||
};
|
||||
}
|
||||
return { mode: "inline", directory: "" };
|
||||
if (output?.type === "s3") {
|
||||
const option = (key: keyof S3OutputOptions, fallback = "") =>
|
||||
String(output.options?.[key] ?? fallback);
|
||||
return {
|
||||
mode: "s3",
|
||||
directory: "",
|
||||
s3: {
|
||||
bucket: option("bucket"),
|
||||
region: option("region", "us-east-1"),
|
||||
prefix: option("prefix"),
|
||||
endpoint: option("endpoint"),
|
||||
accessKeyId: option("accessKeyId"),
|
||||
secretAccessKey: option("secretAccessKey"),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { mode: DEFAULT_OUTPUT_MODE, directory: "", s3: EMPTY_S3_OUTPUT };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,12 +194,15 @@ export function PipelineBuilder() {
|
||||
const [triggerType, setTriggerType] = useState<string>(MANUAL);
|
||||
const [scheduleCount, setScheduleCount] = useState("1");
|
||||
const [scheduleUnit, setScheduleUnit] = useState<ScheduleUnit>("HOURS");
|
||||
const [outputMode, setOutputMode] = useState<OutputMode>("inline");
|
||||
const [outputMode, setOutputMode] = useState<OutputMode>(DEFAULT_OUTPUT_MODE);
|
||||
const [outputDirectory, setOutputDirectory] = useState("");
|
||||
const [outputS3, setOutputS3] = useState<S3OutputOptions>(EMPTY_S3_OUTPUT);
|
||||
const [s3ConfigOpen, setS3ConfigOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [seeded, setSeeded] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [clearingHistory, setClearingHistory] = useState(false);
|
||||
const [runResult, setRunResult] = useState<RunResult | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -186,6 +234,7 @@ export function PipelineBuilder() {
|
||||
setScheduleUnit(trigger.unit);
|
||||
setOutputMode(output.mode);
|
||||
setOutputDirectory(output.directory);
|
||||
setOutputS3(output.s3);
|
||||
setSeeded(true);
|
||||
}, [isEdit, policyState.data, allTools, seeded]);
|
||||
|
||||
@@ -222,6 +271,10 @@ export function PipelineBuilder() {
|
||||
if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL);
|
||||
}, [triggerType, triggers, triggerAvailable]);
|
||||
|
||||
function setS3Field(key: keyof S3OutputOptions, value: string) {
|
||||
setOutputS3((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function toggleSource(sourceId: string, checked: boolean) {
|
||||
setSourceIds((ids) =>
|
||||
checked
|
||||
@@ -286,6 +339,7 @@ export function PipelineBuilder() {
|
||||
scheduleUnit,
|
||||
outputMode,
|
||||
outputDirectory,
|
||||
outputS3,
|
||||
});
|
||||
const baseline = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -295,7 +349,13 @@ export function PipelineBuilder() {
|
||||
|
||||
const scheduleCountValid =
|
||||
triggerType !== "schedule" || Number(scheduleCount) > 0;
|
||||
const outputValid = outputMode !== "folder" || outputDirectory.trim() !== "";
|
||||
const s3OutputValid =
|
||||
outputMode !== "s3" ||
|
||||
(outputS3.bucket.trim() !== "" &&
|
||||
outputS3.accessKeyId.trim() !== "" &&
|
||||
outputS3.secretAccessKey.trim() !== "");
|
||||
const outputValid =
|
||||
(outputMode !== "folder" || outputDirectory.trim() !== "") && s3OutputValid;
|
||||
const canSave =
|
||||
name.trim() !== "" &&
|
||||
scheduleCountValid &&
|
||||
@@ -357,7 +417,9 @@ export function PipelineBuilder() {
|
||||
const output: OutputSpec =
|
||||
outputMode === "folder"
|
||||
? { type: "folder", options: { directory: outputDirectory.trim() } }
|
||||
: { type: "inline", options: {} };
|
||||
: outputMode === "s3"
|
||||
? { type: "s3", options: { ...outputS3 } }
|
||||
: { type: "inline", options: {} };
|
||||
const policy: Policy = {
|
||||
id: policyState.data?.id ?? undefined,
|
||||
name: name.trim(),
|
||||
@@ -387,15 +449,37 @@ export function PipelineBuilder() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Explain an empty trigger: parked files outrank blander reasons. */
|
||||
function emptySweepResult(outcome: TriggerOutcome): RunResult {
|
||||
if (outcome.parked > 0) {
|
||||
return {
|
||||
tone: "warning",
|
||||
text: t("portal.pipelines.run.parked", { count: outcome.parked }),
|
||||
};
|
||||
}
|
||||
if (outcome.inFlight > 0) {
|
||||
return { tone: "info", text: t("portal.pipelines.run.inFlight") };
|
||||
}
|
||||
if (outcome.alreadyProcessed > 0) {
|
||||
return {
|
||||
tone: "info",
|
||||
text: t("portal.pipelines.run.allProcessed", {
|
||||
count: outcome.alreadyProcessed,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { tone: "info", text: t("portal.pipelines.run.empty") };
|
||||
}
|
||||
|
||||
async function handleRun() {
|
||||
if (running || !id) return;
|
||||
setRunning(true);
|
||||
setRunResult(null);
|
||||
try {
|
||||
const runIds = await triggerPipeline(id);
|
||||
const outcome = await triggerPipeline(id);
|
||||
const runIds = outcome.runIds;
|
||||
if (runIds.length === 0) {
|
||||
if (mounted.current)
|
||||
setRunResult({ tone: "info", text: t("portal.pipelines.run.empty") });
|
||||
if (mounted.current) setRunResult(emptySweepResult(outcome));
|
||||
return;
|
||||
}
|
||||
const finals = await Promise.all(runIds.map((runId) => awaitRun(runId)));
|
||||
@@ -428,6 +512,30 @@ export function PipelineBuilder() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget which source files this pipeline has processed, so the next sweep
|
||||
* reprocesses everything currently in its sources (the standard retry for a
|
||||
* parked-by-failure file). Does not touch the files themselves.
|
||||
*/
|
||||
async function handleClearHistory() {
|
||||
if (clearingHistory || !id) return;
|
||||
setClearingHistory(true);
|
||||
setRunResult(null);
|
||||
try {
|
||||
await clearProcessedHistory(id);
|
||||
if (mounted.current)
|
||||
setRunResult({
|
||||
tone: "success",
|
||||
text: t("portal.pipelines.run.historyCleared"),
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted.current)
|
||||
setRunResult({ tone: "danger", text: errorMessage(e) });
|
||||
} finally {
|
||||
if (mounted.current) setClearingHistory(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!id || deleting) return;
|
||||
setDeleting(true);
|
||||
@@ -494,6 +602,17 @@ export function PipelineBuilder() {
|
||||
>
|
||||
{t("portal.pipelines.detail.run")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={clearingHistory}
|
||||
onClick={handleClearHistory}
|
||||
leftSection={
|
||||
<HistoryRoundedIcon style={{ fontSize: "1.125rem" }} />
|
||||
}
|
||||
>
|
||||
{t("portal.pipelines.detail.clearHistory")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -630,10 +749,10 @@ export function PipelineBuilder() {
|
||||
name="pipeline-output"
|
||||
value={outputMode}
|
||||
onChange={setOutputMode}
|
||||
options={[
|
||||
{ value: "inline", label: t("portal.pipelines.output.inline") },
|
||||
{ value: "folder", label: t("portal.pipelines.output.folder") },
|
||||
]}
|
||||
options={availableOutputModes().map((mode) => ({
|
||||
value: mode,
|
||||
label: t(`portal.pipelines.output.${mode}`),
|
||||
}))}
|
||||
/>
|
||||
{outputMode === "folder" && (
|
||||
<FormField
|
||||
@@ -648,6 +767,27 @@ export function PipelineBuilder() {
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{outputMode === "s3" && (
|
||||
<div className="portal-builder__s3-output">
|
||||
<span
|
||||
className={
|
||||
"portal-builder__s3-summary" +
|
||||
(outputS3.bucket ? "" : " is-unset")
|
||||
}
|
||||
>
|
||||
{outputS3.bucket
|
||||
? `s3://${outputS3.bucket}/${outputS3.prefix}`
|
||||
: t("portal.pipelines.composer.s3NotConfigured")}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setS3ConfigOpen(true)}
|
||||
>
|
||||
{t("portal.pipelines.composer.s3Configure")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -860,6 +1000,78 @@ export function PipelineBuilder() {
|
||||
>
|
||||
<p>{t("portal.pipelines.builder.unsavedBody")}</p>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={s3ConfigOpen}
|
||||
onClose={() => setS3ConfigOpen(false)}
|
||||
title={t("portal.pipelines.composer.s3ModalTitle")}
|
||||
footer={
|
||||
<div className="portal-pipelines__composer-footer">
|
||||
<Button size="sm" onClick={() => setS3ConfigOpen(false)}>
|
||||
{t("portal.pipelines.composer.s3Done")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="portal-builder__s3-fields">
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.bucket.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={outputS3.bucket}
|
||||
placeholder="my-company-inbox"
|
||||
onChange={(e) => setS3Field("bucket", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
|
||||
<Input
|
||||
value={outputS3.region}
|
||||
placeholder="us-east-1"
|
||||
onChange={(e) => setS3Field("region", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.prefix.label")}
|
||||
helperText={t("portal.pipelines.composer.s3PrefixHelp")}
|
||||
>
|
||||
<Input
|
||||
value={outputS3.prefix}
|
||||
placeholder="processed/"
|
||||
onChange={(e) => setS3Field("prefix", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
value={outputS3.accessKeyId}
|
||||
onChange={(e) => setS3Field("accessKeyId", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
|
||||
required
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
value={outputS3.secretAccessKey}
|
||||
onChange={(e) => setS3Field("secretAccessKey", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t("portal.sources.types.s3.fields.endpoint.label")}
|
||||
helperText={t("portal.sources.types.s3.fields.endpoint.helperText")}
|
||||
>
|
||||
<Input
|
||||
value={outputS3.endpoint}
|
||||
placeholder="https://s3.example.com"
|
||||
onChange={(e) => setS3Field("endpoint", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Compact status row for the {@link enforcementQueue}, shown in the Policies
|
||||
* panel whenever enforcement jobs are pending or running. The queue is serial,
|
||||
* so a slow policy run would otherwise be invisible — this surfaces what's being
|
||||
* enforced (before export, print, convert, …) and how many jobs are waiting.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Group, Text, Loader } from "@mantine/core";
|
||||
import { useEnforcementQueue } from "@app/components/policies/enforcementQueue";
|
||||
|
||||
export function EnforcementQueueStatus() {
|
||||
const { t } = useTranslation();
|
||||
const jobs = useEnforcementQueue();
|
||||
const active = jobs.filter(
|
||||
(j) => j.status === "pending" || j.status === "running",
|
||||
);
|
||||
if (active.length === 0) return null;
|
||||
|
||||
// The running job leads the row; everything else is still queued behind it.
|
||||
const lead = active.find((j) => j.status === "running") ?? active[0];
|
||||
const queued = active.length - 1;
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py={6}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(`policies.enforcement.triggerVerb.${lead.trigger}`, {
|
||||
defaultValue: t("policies.enforcement.triggerVerb.default"),
|
||||
})}
|
||||
: {lead.label}
|
||||
{queued > 0
|
||||
? ` · ${t("policies.enforcement.queued", { count: queued })}`
|
||||
: "…"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnforcementQueueStatus;
|
||||
@@ -1,709 +0,0 @@
|
||||
/* ============================ Policies ============================ */
|
||||
/* The Policies surface is docked in the right tool sidebar: a list section */
|
||||
/* above Tools, a detail takeover that replaces Tools when a policy is open, */
|
||||
/* and a collapsed-rail of policy icons. */
|
||||
/* */
|
||||
/* Chrome (headers, cards, buttons, badges, chips, lists, steps…) uses SUI */
|
||||
/* (@app/ui). The bespoke .pol-* bits below are thin layout */
|
||||
/* scaffolding + the collapsed rail; spacing snaps to the SUI --space-* */
|
||||
/* scale and colour to the SUI token set so it reads as one product. */
|
||||
|
||||
/* Dark mode only: remap SUI surface/border tokens to the app's neutral-grey values so policy cards read as one product with the rail; accent tokens are left alone. */
|
||||
[data-theme="dark"] .pol-list,
|
||||
[data-theme="dark"] .pol-takeover,
|
||||
[data-theme="dark"] .pol-detail,
|
||||
[data-theme="dark"] .pol-crail {
|
||||
--color-bg: var(--bg-toolbar);
|
||||
--color-bg-alt: var(--bg-toolbar);
|
||||
--color-bg-subtle: var(--bg-toolbar);
|
||||
--color-surface: var(--bg-surface);
|
||||
--color-surface-alt: #323942;
|
||||
--color-bg-hover: #323942;
|
||||
--color-bg-muted: var(--bg-surface);
|
||||
--color-border: var(--border-default);
|
||||
--color-border-light: var(--border-subtle);
|
||||
--color-border-input: var(--border-strong);
|
||||
--color-border-hover: var(--border-strong);
|
||||
--color-divider: var(--border-subtle);
|
||||
--color-dropdown-bg: var(--bg-surface);
|
||||
--color-dropdown-border: var(--border-default);
|
||||
}
|
||||
|
||||
/* ---- List ---- */
|
||||
.pol-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Header row: optional leading control (sidebar collapse) + the SectionHeader,
|
||||
mirroring the back-button + title layout inside an open policy. */
|
||||
.pol-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
.pol-list-head .sui-sectionhdr {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Small "what is a policy?" info button on the header. */
|
||||
/* Bare info icon matching the tool-step affordance (LocalIcon, no chrome). */
|
||||
.pol-info-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--motion-fast);
|
||||
}
|
||||
.pol-info-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.pol-list-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-1) var(--space-1_5);
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
/* A policy row: tinted icon tile + label + trailing status/CTA. */
|
||||
.pol-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-row:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
.pol-row:focus-visible {
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* Processing indicator: a spinning ring around the category icon while the
|
||||
policy has runs in flight. */
|
||||
.pol-row-icon {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
--pol-ring: var(--color-blue);
|
||||
}
|
||||
.pol-row-icon[data-accent="purple"] {
|
||||
--pol-ring: var(--color-purple);
|
||||
}
|
||||
.pol-row-icon[data-accent="green"] {
|
||||
--pol-ring: var(--color-green);
|
||||
}
|
||||
.pol-row-icon[data-accent="amber"] {
|
||||
--pol-ring: var(--color-amber);
|
||||
}
|
||||
.pol-row-icon[data-accent="red"] {
|
||||
--pol-ring: var(--color-red);
|
||||
}
|
||||
.pol-row-icon[data-accent="orange"] {
|
||||
--pol-ring: var(--color-orange);
|
||||
}
|
||||
.pol-row-icon.is-processing .sui-iconbadge {
|
||||
--ib-accent: var(--ib-base);
|
||||
}
|
||||
.pol-row-ring {
|
||||
position: absolute;
|
||||
inset: -3px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid color-mix(in srgb, var(--pol-ring) 20%, transparent);
|
||||
border-top-color: var(--pol-ring);
|
||||
animation: pol-ring-spin 0.7s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Global "Retry failed policies (N)" action under the panel header — one place
|
||||
to resume every file the chain stranded, whichever step failed. Amber, so it
|
||||
reads as "needs attention" without screaming error. */
|
||||
.pol-retry-failed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
width: calc(100% - 2 * var(--space-1_5));
|
||||
margin: 0 var(--space-1_5) var(--space-1);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--color-amber, #d97706);
|
||||
background: color-mix(in srgb, var(--color-amber, #d97706) 12%, transparent);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-retry-failed:hover {
|
||||
background: color-mix(in srgb, var(--color-amber, #d97706) 20%, transparent);
|
||||
}
|
||||
.pol-retry-failed:focus-visible {
|
||||
outline: 2px solid var(--color-amber, #d97706);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
@keyframes pol-ring-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pol-row-ring {
|
||||
animation-duration: 2s;
|
||||
}
|
||||
}
|
||||
|
||||
.pol-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Match a normal `sm` button label so icon-bearing rows aren't smaller. */
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
|
||||
.pol-row > .mantine-Button-inner {
|
||||
width: 100%;
|
||||
}
|
||||
.pol-row > .mantine-Button-inner > .mantine-Button-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
justify-content: flex-start;
|
||||
overflow: visible;
|
||||
}
|
||||
.pol-row-trail {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
margin-left: auto;
|
||||
}
|
||||
/* Unconfigured rows: a quiet blue "Set up" call-to-action instead of a status pill. */
|
||||
.pol-row-setup {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-blue);
|
||||
}
|
||||
/* Trailing drill-in chevron on each policy row (after the status / CTA). */
|
||||
.pol-row-chevron {
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Policy settings: per-trigger run-order lists ── */
|
||||
.pol-reorder-section {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
.pol-reorder-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
/* A reorder row: leading grip + tinted icon + label. Square (no radius) so the
|
||||
drop line reads as one straight rule across the list. */
|
||||
.pol-reorder-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
}
|
||||
.pol-reorder-row[data-dragging] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Straight, full-width blue insertion line at the drop position (no curves). */
|
||||
.pol-reorder-row[data-drop="above"]::before,
|
||||
.pol-reorder-row[data-drop="below"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--color-blue);
|
||||
pointer-events: none;
|
||||
}
|
||||
.pol-reorder-row[data-drop="above"]::before {
|
||||
top: -1px;
|
||||
}
|
||||
.pol-reorder-row[data-drop="below"]::after {
|
||||
bottom: -1px;
|
||||
}
|
||||
.pol-reorder-grip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
color: var(--color-text-4);
|
||||
cursor: grab;
|
||||
}
|
||||
.pol-reorder-grip:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.pol-reorder-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
/* The drag ghost (a cloned row): the whole row with a full blue outline, kept
|
||||
translucent so the list shows through as it moves. */
|
||||
.pol-reorder-row--ghost {
|
||||
border-radius: var(--radius-lg);
|
||||
outline: 2px solid var(--color-blue);
|
||||
outline-offset: -2px;
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-md);
|
||||
opacity: 0.55;
|
||||
}
|
||||
/* Empty-state line for a trigger with no policies. */
|
||||
.pol-reorder-empty {
|
||||
margin: var(--space-1) 0 0;
|
||||
padding: var(--space-1_5) var(--space-2);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
|
||||
/* Retry button on a failed activity row. */
|
||||
/* Expandable error text in the activity feed — long backend errors are clamped
|
||||
and collapsed by default so they don't blow up the row. */
|
||||
.pol-activity-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.pol-activity-error__text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pol-activity-error__text--clamped {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pol-activity-error__toggle {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--color-blue);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pol-activity-error__toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Enterprise-only ("coming soon") row — shown to admins / team leaders but not
|
||||
available on the current plan, so the whole box is dimmed to read as disabled.
|
||||
The row itself isn't a button; its trailing "Upgrade to enterprise" link is. */
|
||||
.pol-row--soon {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.pol-row--soon:hover {
|
||||
background: transparent;
|
||||
}
|
||||
/* Trailing "Upgrade to enterprise" link → contact us. Greyed to match the
|
||||
disabled row; still clickable for admins who want to enquire. */
|
||||
.pol-row-upgrade {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color var(--motion-fast);
|
||||
}
|
||||
.pol-row-upgrade:hover {
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-row-upgrade:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* In-progress activity icon spins gently. */
|
||||
.pol-spin {
|
||||
animation: pol-spin 1.2s linear infinite;
|
||||
}
|
||||
@keyframes pol-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Locked, per-tool config (PolicyToolConfig): one section per tool ---- */
|
||||
.pol-tool-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pol-tool-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.pol-tool-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-3);
|
||||
}
|
||||
.pol-tool-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
margin-right: auto;
|
||||
}
|
||||
.pol-tool-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* ---- Detail container (wizard / narrative / settings share this) ---- */
|
||||
.pol-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
max-width: 32rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ---- Step indicator (wraps a SUI StepIndicator) ---- */
|
||||
.pol-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0 var(--space-5) var(--space-3);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-step-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Scroll body ---- */
|
||||
.pol-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-3) var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.pol-desc {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-4);
|
||||
margin: 0;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.pol-section-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-4);
|
||||
/* Bottom gap so the label doesn't hug its card/chips. */
|
||||
margin: 0 0 var(--space-2);
|
||||
}
|
||||
|
||||
/* A section label rendered as a collapse toggle (Recent Activity): strip the
|
||||
button chrome but keep the .pol-section-label typography, chevron pushed right. */
|
||||
.pol-section-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.pol-section-chevron {
|
||||
margin-left: auto;
|
||||
color: var(--color-text-4);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.pol-section-chevron.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Recent-activity feed: cap to ~4.5 rows (and never more than ~45% of the
|
||||
viewport) then scroll, so a long history doesn't push the stats footer away. */
|
||||
.pol-activity-list {
|
||||
max-height: min(22rem, 45vh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Sub-section header inside a settings card (e.g. "Output filename"). The field
|
||||
directly below it carries data-first so the borders don't double up. */
|
||||
.pol-subhead {
|
||||
padding: 0.55rem 0.875rem 0.4rem;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Position dropdown + custom-text input sitting on one row under the
|
||||
"Output filename" subhead — the dropdown sizes to content, text fills. */
|
||||
.pol-name-row {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
align-items: center;
|
||||
}
|
||||
.pol-name-row > :first-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.pol-name-row > :last-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Fields (PolicyFieldRow) — row inset matches SUI ListRow ---- */
|
||||
.pol-field {
|
||||
padding: 0.7rem 0.875rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.pol-field:not([data-first]) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-field-label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-field-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.pol-field-chips-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pol-field-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
|
||||
/* ---- Sources ---- */
|
||||
.pol-source {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0.7rem 0.875rem;
|
||||
cursor: pointer;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.pol-source:not([data-first]) {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Doc types ---- */
|
||||
.pol-link {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-blue);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pol-doctypes-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.7rem 0.875rem;
|
||||
}
|
||||
.pol-doctypes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0.875rem var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* ---- Summary ---- */
|
||||
.pol-summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.pol-summary-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-summary-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1_5);
|
||||
}
|
||||
/* Muted placeholder for an unset summary value (e.g. no reviewer chosen). */
|
||||
.pol-muted {
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Enforces rule flow (wraps a SUI ChipFlow) ---- */
|
||||
.pol-rule-flow {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* ---- Meta / note ---- */
|
||||
.pol-meta-row {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1_5);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.pol-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-muted);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Stats: one grouped card with three divided columns. ---- */
|
||||
.pol-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.pol-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-0_5);
|
||||
padding: var(--space-3) var(--space-2);
|
||||
text-align: center;
|
||||
}
|
||||
.pol-stat:not(:first-child) {
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
.pol-stat-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.pol-stat-label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
|
||||
/* ---- Footer (hosts SUI Buttons) ---- */
|
||||
.pol-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pol-footer-end {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ---- Detail takeover (fills the rail when a policy is open) ---- */
|
||||
.pol-takeover {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- Collapsed rail policy icons ---- */
|
||||
.pol-crail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
flex-shrink: 0;
|
||||
/* The collapsed-rail dividers carry 8px of space below them only, so the
|
||||
divider above gives the row 8px of headroom while the one below (rendered
|
||||
straight after) hugs it. Match that 8px underneath so the row sits centred
|
||||
between the two dividers. */
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.pol-crail-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: var(--color-text-3);
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.pol-crail-btn:hover {
|
||||
background: var(--color-blue-light);
|
||||
}
|
||||
.pol-crail-btn[data-status="active"] {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
.pol-crail-btn[data-status="paused"] {
|
||||
color: var(--color-amber);
|
||||
}
|
||||
.pol-crail-dot {
|
||||
position: absolute;
|
||||
top: 0.1rem;
|
||||
right: 0.1rem;
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-toolbar, var(--color-surface));
|
||||
}
|
||||
.pol-crail-dot[data-status="active"] {
|
||||
background: var(--color-green);
|
||||
}
|
||||
.pol-crail-dot[data-status="paused"] {
|
||||
background: var(--color-amber);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
|
||||
import { PoliciesSection } from "@app/components/policies/PoliciesSidebar";
|
||||
import { POLICY_CATEGORIES, POLICY_CONFIG } from "@app/data/policyDefinitions";
|
||||
import type { PolicyActivityItem, PolicyStats } from "@app/types/policies";
|
||||
import "@app/components/policies/Policies.css";
|
||||
|
||||
/**
|
||||
* The Policies surface lives in the editor's right tool sidebar. These stories
|
||||
* render the three rich detail surfaces (narrative / setup wizard / settings)
|
||||
* inside a frame the width of the rail when a policy is open (25rem), so the
|
||||
* SUI composition can be reviewed in isolation — no app shell, login, or
|
||||
* backend required. Toggle the Storybook theme switcher to check dark mode.
|
||||
*/
|
||||
const RAIL_WIDTH = "25rem";
|
||||
|
||||
/** Frame that mimics the right rail's open width + surface so the panel reads true. */
|
||||
function RailFrame({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: RAIL_WIDTH,
|
||||
height: "780px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ingestion = POLICY_CATEGORIES.find((c) => c.id === "ingestion")!;
|
||||
const security = POLICY_CATEGORIES.find((c) => c.id === "security")!;
|
||||
|
||||
// Illustrative activity/stats for the static stories. In the app these are
|
||||
// derived live from the user's real uploaded files (see policyLiveData).
|
||||
const sampleActivity: PolicyActivityItem[] = [
|
||||
{
|
||||
doc: "MSA_Acme_2026.pdf",
|
||||
action: "1.2 MB • enforced on upload",
|
||||
time: "2h ago",
|
||||
status: "enforced",
|
||||
},
|
||||
{
|
||||
doc: "scan_002.pdf",
|
||||
action: "Low confidence • flagged for review",
|
||||
time: "Yesterday",
|
||||
status: "flagged",
|
||||
},
|
||||
];
|
||||
const sampleStats: PolicyStats = {
|
||||
enforced: 1284,
|
||||
dataProcessed: "3.2 GB",
|
||||
activeFor: "18d",
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Editor/Policies",
|
||||
parameters: { layout: "centered" },
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
/** Configured policy, running — Enforces / Activity / Stats narrative. */
|
||||
export const DetailActive: Story = {
|
||||
render: () => (
|
||||
<RailFrame>
|
||||
<PolicyDetailPanel
|
||||
category={ingestion}
|
||||
config={POLICY_CONFIG.ingestion}
|
||||
status="active"
|
||||
steps={POLICY_CONFIG.ingestion.defaultOperations}
|
||||
activity={[
|
||||
{
|
||||
doc: "Q4_Report.pdf",
|
||||
action: "Enforcing…",
|
||||
time: "Just now",
|
||||
status: "processing",
|
||||
},
|
||||
...sampleActivity,
|
||||
]}
|
||||
stats={sampleStats}
|
||||
canConfigure
|
||||
canDelete
|
||||
onBack={noop}
|
||||
onEditSettings={noop}
|
||||
onTogglePause={noop}
|
||||
onDelete={noop}
|
||||
/>
|
||||
</RailFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** Configured policy, paused — amber accent + warning badge. */
|
||||
export const DetailPaused: Story = {
|
||||
render: () => (
|
||||
<RailFrame>
|
||||
<PolicyDetailPanel
|
||||
category={security}
|
||||
config={POLICY_CONFIG.security}
|
||||
status="paused"
|
||||
activity={sampleActivity}
|
||||
stats={sampleStats}
|
||||
canConfigure
|
||||
canDelete
|
||||
onBack={noop}
|
||||
onEditSettings={noop}
|
||||
onTogglePause={noop}
|
||||
onDelete={noop}
|
||||
/>
|
||||
</RailFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** Read-only view for a member without configure permission. */
|
||||
export const DetailManaged: Story = {
|
||||
render: () => (
|
||||
<RailFrame>
|
||||
<PolicyDetailPanel
|
||||
category={ingestion}
|
||||
config={POLICY_CONFIG.ingestion}
|
||||
status="active"
|
||||
activity={sampleActivity}
|
||||
stats={sampleStats}
|
||||
canConfigure={false}
|
||||
canDelete={false}
|
||||
onBack={noop}
|
||||
onEditSettings={noop}
|
||||
onTogglePause={noop}
|
||||
onDelete={noop}
|
||||
/>
|
||||
</RailFrame>
|
||||
),
|
||||
};
|
||||
|
||||
/** The policy list section (above Tools). */
|
||||
export const ListSection: Story = {
|
||||
render: () => (
|
||||
<div
|
||||
style={{
|
||||
width: RAIL_WIDTH,
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
padding: "var(--space-2) 0",
|
||||
}}
|
||||
>
|
||||
<PoliciesSection />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// The setup + edit wizard embeds the Watch Folders automation builder (its
|
||||
// Workflow step), which needs the ToolWorkflow context - so the wizard is
|
||||
// exercised in-app, not via an isolated story here.
|
||||
@@ -1,179 +0,0 @@
|
||||
import "fake-indexeddb/auto";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// The global setup mock returns the i18n KEY (t: (key) => key); these tests
|
||||
// assert the rendered English copy, so override locally to return the default
|
||||
// value passed to t() — mirroring i18next's runtime fallback when a key is
|
||||
// missing. (Interpolated strings are returned raw; no test here asserts them.)
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, defaultValue?: string) =>
|
||||
typeof defaultValue === "string" ? defaultValue : key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
initReactI18next: { type: "3rdParty", init: vi.fn() },
|
||||
I18nextProvider: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// Policies ship gated SaaS-only via the build-flavor flag; these tests exercise
|
||||
// the component itself, so force the flag on regardless of the test build flavor.
|
||||
vi.mock("@app/constants/featureFlags", () => ({ POLICIES_ENABLED: true }));
|
||||
|
||||
// usePolicies derives `canConfigure` from app-config; with no AppConfigProvider
|
||||
// here `config` is null, which (tri-state gate) hides the edit affordances. Mock
|
||||
// app-config as a single-user deployment (login off) so the local operator can
|
||||
// configure and the narrative view's Edit/Pause/Delete actions render.
|
||||
vi.mock("@app/contexts/AppConfigContext", async (orig) => ({
|
||||
...(await orig<typeof import("@app/contexts/AppConfigContext")>()),
|
||||
useAppConfig: () => ({ config: { enableLogin: false } }),
|
||||
}));
|
||||
|
||||
// The shared Tooltip (used by the "what is a policy?" info button) pulls in
|
||||
// preferences/sidebar contexts we don't set up here — passthrough it.
|
||||
vi.mock("@app/components/shared/Tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
// The wizard's Workflow step embeds the Watch Folders automation builder, which
|
||||
// needs the ToolWorkflow context. Mock it: a stub that wires the save trigger to
|
||||
// hand back the seed automation, so the wizard's submit still completes.
|
||||
vi.mock("@app/components/policies/PolicyWorkflowStep", () => ({
|
||||
AutomationMode: { CREATE: "create", EDIT: "edit", SUGGESTED: "suggested" },
|
||||
PolicyWorkflowStep: (props: {
|
||||
automation: unknown;
|
||||
saveTriggerRef: { current: (() => void) | null };
|
||||
onComplete: (automation: unknown, toolRegistry: unknown) => void;
|
||||
}) => {
|
||||
props.saveTriggerRef.current = () => props.onComplete(props.automation, {});
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
// The backend is the source of truth, but these UI tests run offline: list
|
||||
// rejects (so the mount reconcile keeps the local cache), while save/delete
|
||||
// resolve so the enable flow completes.
|
||||
vi.mock("@app/services/policyApi", () => ({
|
||||
listPolicies: vi.fn().mockRejectedValue(new Error("offline")),
|
||||
savePolicy: vi.fn().mockImplementation(async (p: { id?: string }) => ({
|
||||
...p,
|
||||
id: p.id && p.id.length > 0 ? p.id : "be-test",
|
||||
})),
|
||||
getPolicy: vi.fn(),
|
||||
deletePolicy: vi.fn().mockResolvedValue(undefined),
|
||||
runStoredPolicy: vi.fn(),
|
||||
runPolicyPipeline: vi.fn(),
|
||||
getPolicyRun: vi.fn(),
|
||||
}));
|
||||
|
||||
// Enabling a policy creates its backing Watched Folders WatchedFolder (IndexedDB);
|
||||
// jsdom's crypto lacks randomUUID, which watchedFolderStorage uses for folder ids.
|
||||
if (typeof globalThis.crypto?.randomUUID !== "function") {
|
||||
const orig = globalThis.crypto;
|
||||
vi.stubGlobal("crypto", {
|
||||
getRandomValues: orig?.getRandomValues?.bind(orig),
|
||||
randomUUID: () =>
|
||||
`p-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`,
|
||||
});
|
||||
}
|
||||
import {
|
||||
PoliciesSection,
|
||||
PolicyDetailTakeover,
|
||||
usePolicyDetailActive,
|
||||
} from "@app/components/policies/PoliciesSidebar";
|
||||
import { resetPolicySelection } from "@app/components/policies/policySelectionStore";
|
||||
|
||||
/**
|
||||
* Mirrors how RightSidebar swaps the policy list for the detail takeover: the
|
||||
* list shows above Tools when nothing is open, the takeover replaces it when a
|
||||
* policy is selected.
|
||||
*/
|
||||
function PoliciesHost() {
|
||||
const active = usePolicyDetailActive();
|
||||
return active ? <PolicyDetailTakeover /> : <PoliciesSection />;
|
||||
}
|
||||
|
||||
function renderHost() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<PoliciesHost />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Policies right-sidebar surface", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// Seed a configured Security policy (the only live category) in the local
|
||||
// cache. The backend list is mocked to reject (offline), so the mount
|
||||
// reconcile leaves it in place — giving the narrative tests a policy to open.
|
||||
localStorage.setItem(
|
||||
"stirling-policies-state",
|
||||
JSON.stringify({
|
||||
security: {
|
||||
configured: true,
|
||||
status: "active",
|
||||
sources: ["editor"],
|
||||
scopeTypes: [],
|
||||
reviewerEmail: "",
|
||||
fieldValues: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
resetPolicySelection();
|
||||
});
|
||||
|
||||
it("renders the policy list with every category", () => {
|
||||
renderHost();
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
for (const label of [
|
||||
"Ingestion",
|
||||
"Security",
|
||||
"Compliance",
|
||||
"Routing",
|
||||
"Retention",
|
||||
]) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Security as active and the unbuilt categories as upgrade-gated", () => {
|
||||
renderHost();
|
||||
expect(screen.getAllByText("Active").length).toBeGreaterThanOrEqual(1);
|
||||
// Ingestion, Compliance, Routing, Retention are locked for this release.
|
||||
expect(screen.getAllByText("Upgrade to enterprise")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("does not open an upgrade-gated policy when its row is clicked", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Ingestion"));
|
||||
// The locked row isn't a button — we stay on the list, nothing opens.
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Enforces")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the narrative view when a live policy is clicked", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Security"));
|
||||
expect(screen.getByText("Enforces")).toBeInTheDocument();
|
||||
expect(screen.getByText("Edit Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an honest empty activity feed when no files have been uploaded", async () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Security"));
|
||||
expect(screen.getByText("Recent Activity")).toBeInTheDocument();
|
||||
// Activity is derived from real uploads; with none, the empty state shows.
|
||||
expect(await screen.findByText("No activity yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("returns to the list via the close button", () => {
|
||||
renderHost();
|
||||
fireEvent.click(screen.getByText("Security"));
|
||||
expect(screen.getByText("Enforces")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByLabelText("Close"));
|
||||
expect(screen.getByText("Policies")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,927 +0,0 @@
|
||||
/**
|
||||
* Proprietary implementation of the right-sidebar Policies surface.
|
||||
*
|
||||
* Shadows the core stubs at {@code core/components/policies/PoliciesSidebar.tsx}
|
||||
* via the {@code @app/*} alias cascade. Three slots, all driven by the shared
|
||||
* {@link policySelectionStore} so they stay in sync:
|
||||
* • {@link PoliciesSection} — the collapsible policy list, rendered above the
|
||||
* Tools section in {@code RightSidebar} when no policy is open.
|
||||
* • {@link PolicyDetailTakeover} — the detail / wizard / settings view, which
|
||||
* replaces the Tools area when a policy is open.
|
||||
* • {@link PoliciesCollapsedButton} — the icon rail shown when the sidebar is
|
||||
* collapsed; clicking an icon selects the policy and expands the rail.
|
||||
*/
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type DragEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Menu } from "@mantine/core";
|
||||
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||
import ReplayRounded from "@mui/icons-material/ReplayRounded";
|
||||
import DragIndicatorRounded from "@mui/icons-material/DragIndicatorRounded";
|
||||
import MoreHorizRounded from "@mui/icons-material/MoreHorizRounded";
|
||||
import TuneRounded from "@mui/icons-material/TuneRounded";
|
||||
import InfoOutlined from "@mui/icons-material/InfoOutlined";
|
||||
import { usePolicies } from "@app/hooks/usePolicies";
|
||||
import { usePolicyCatalog } from "@app/hooks/usePolicyCatalog";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { getPolicyAutomation } from "@app/services/policyFolders";
|
||||
import { watchedFolderStorage } from "@app/services/watchedFolderStorage";
|
||||
import {
|
||||
runsToActivity,
|
||||
runsToStats,
|
||||
progressByCategory,
|
||||
retryableFailedRuns,
|
||||
EMPTY_RUN_PROGRESS,
|
||||
} from "@app/services/policyLiveData";
|
||||
import {
|
||||
removeRun,
|
||||
usePolicyRuns,
|
||||
usePolicyWaveStart,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import { runPolicyOnFile } from "@app/components/policies/usePolicyAutoRun";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
AutomationOperation,
|
||||
} from "@app/types/automation";
|
||||
import type { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { IconBadge } from "@app/ui/IconBadge";
|
||||
import {
|
||||
deriveRowStatus,
|
||||
STATUS_LABEL,
|
||||
ROW_ACCENT,
|
||||
} from "@app/components/policies/policyStatus";
|
||||
import { StatusBadge } from "@app/ui/StatusBadge";
|
||||
import { SectionHeader } from "@app/ui/SectionHeader";
|
||||
import { PolicySetupWizard } from "@app/components/policies/PolicySetupWizard";
|
||||
import { PolicyDetailPanel } from "@app/components/policies/PolicyDetailPanel";
|
||||
import { PolicyDeleteConfirmModal } from "@app/components/policies/PolicyDeleteConfirmModal";
|
||||
import type { PolicyCategory, PolicyConfigResult } from "@app/types/policies";
|
||||
import { PanelHeader } from "@app/ui/PanelHeader";
|
||||
import {
|
||||
usePolicySelection,
|
||||
selectPolicy,
|
||||
setPolicyDetailView,
|
||||
closePolicy,
|
||||
openPolicySettings,
|
||||
closePolicySettings,
|
||||
} from "@app/components/policies/policySelectionStore";
|
||||
import "@app/components/policies/Policies.css";
|
||||
|
||||
/** localStorage key persisting the Policies section's expand/collapse state. */
|
||||
const POLICIES_COLLAPSED_KEY = "stirling-policies-section-collapsed";
|
||||
|
||||
/** Whether the right rail should host the Policies section. True in proprietary. */
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return POLICIES_ENABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the right rail should show the Policies section.
|
||||
*/
|
||||
export function usePoliciesVisible(): boolean {
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
if (!POLICIES_ENABLED) return false;
|
||||
return pol.canConfigure || categories.some((c) => !c.comingSoon);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current user is a guest who can't open or configure policies —
|
||||
* an anonymous user on a login-enabled deployment (i.e. a SaaS sign-up prompt
|
||||
* candidate). The policy list stays visible but its rows don't open; the guest
|
||||
* sign-up banner explains why. A login-disabled single-user deployment has an
|
||||
* anonymous local operator with full access, so it is not gated.
|
||||
*/
|
||||
export function usePolicyGuestBlocked(): boolean {
|
||||
const { config } = useAppConfig();
|
||||
const { user } = useAuth();
|
||||
return config?.enableLogin === true && user?.is_anonymous === true;
|
||||
}
|
||||
|
||||
/** Re-summon the guest sign-up banner (the saas GuestUserBanner listens for this;
|
||||
* a no-op on builds without it). Used when a guest clicks a gated policy. */
|
||||
function promptGuestSignup(): void {
|
||||
window.dispatchEvent(new CustomEvent("stirling:show-guest-banner"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a policy is open — i.e. its detail view should take over the rail in
|
||||
* place of the tool list. False when the feature is off or nothing is selected.
|
||||
*/
|
||||
export function usePolicyDetailActive(): boolean {
|
||||
const { selectedId, settingsOpen } = usePolicySelection();
|
||||
return POLICIES_ENABLED && (selectedId != null || settingsOpen);
|
||||
}
|
||||
|
||||
/** The collapsible policy list, rendered above the Tools section. */
|
||||
export function PoliciesSection({
|
||||
leadingControl,
|
||||
}: {
|
||||
/** Optional control rendered to the left of the header (e.g. the sidebar
|
||||
* collapse button), mirroring the back-button + title in a policy. */
|
||||
leadingControl?: ReactNode;
|
||||
} = {}) {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
const guestBlocked = usePolicyGuestBlocked();
|
||||
|
||||
// Live run tallies drive the per-row processing ring + the header summary,
|
||||
// scoped to the current upload wave so they don't accumulate across the whole
|
||||
// persisted run history.
|
||||
const runs = usePolicyRuns();
|
||||
const waveStart = usePolicyWaveStart();
|
||||
const progress = useMemo(
|
||||
() => progressByCategory(runs, waveStart),
|
||||
[runs, waveStart],
|
||||
);
|
||||
// Failed runs still worth retrying, across ALL policies — drives the single
|
||||
// global "Retry failed policies" action. Deliberately not per-policy: a chain
|
||||
// failure strands the file mid-pipeline whichever step failed, and retrying
|
||||
// globally resumes every stranded file (successful steps are never re-run;
|
||||
// completed runs chain onward automatically). NOT wave-scoped: failures from
|
||||
// earlier uploads still count.
|
||||
const retryableRuns = useMemo(() => retryableFailedRuns(runs), [runs]);
|
||||
const retryAllFailed = () => {
|
||||
// Same replace-in-place pattern as the queue-full auto-retry: drop the stale
|
||||
// failed row, fire a fresh run. Queue-full rejections during the burst are
|
||||
// absorbed by the existing backoff.
|
||||
for (const failed of retryableRuns) {
|
||||
const backendId = pol.policies[failed.categoryId]?.backendId;
|
||||
if (!backendId) continue;
|
||||
removeRun(failed.runId);
|
||||
void runPolicyOnFile(
|
||||
failed.categoryId,
|
||||
backendId,
|
||||
failed.fileId as FileId,
|
||||
failed.fileName,
|
||||
);
|
||||
}
|
||||
};
|
||||
// Persist the expand/collapse state across refreshes.
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(POLICIES_COLLAPSED_KEY) !== "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const toggleExpanded = () =>
|
||||
setExpanded((open) => {
|
||||
const next = !open;
|
||||
try {
|
||||
localStorage.setItem(POLICIES_COLLAPSED_KEY, next ? "0" : "1");
|
||||
} catch {
|
||||
// Best-effort; ignore quota/availability failures.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
|
||||
// Admins / team leads see the full catalogue (coming-soon rows greyed as an
|
||||
// enterprise upsell); regular users only see the live policies — the
|
||||
// coming-soon "Upgrade to enterprise" rows are hidden from them.
|
||||
const visibleCategories = pol.canConfigure
|
||||
? categories
|
||||
: categories.filter((c) => !c.comingSoon);
|
||||
if (visibleCategories.length === 0) return null;
|
||||
|
||||
// The header tally counts every CONFIGURED policy (active + paused), not just
|
||||
// the active ones.
|
||||
const configuredCount = categories.filter(
|
||||
(c) => pol.policies[c.id]?.configured,
|
||||
).length;
|
||||
|
||||
// Rows render in execution order (defaults to catalog order until reordered
|
||||
// on the Policy settings page).
|
||||
const displayCategories = [...visibleCategories].sort(
|
||||
(a, b) =>
|
||||
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pol-list">
|
||||
<div className="pol-list-head">
|
||||
{leadingControl}
|
||||
<SectionHeader
|
||||
title={t("policies.sidebar.title", "Policies")}
|
||||
count={t("policies.sidebar.activeCount", "{{count}} active", {
|
||||
count: configuredCount,
|
||||
})}
|
||||
collapsible
|
||||
expanded={expanded}
|
||||
onToggle={toggleExpanded}
|
||||
/>
|
||||
<Menu position="bottom-end" width="13rem" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="quiet"
|
||||
className="pol-info-btn"
|
||||
aria-label={t(
|
||||
"policies.sidebar.optionsAriaLabel",
|
||||
"Policy options",
|
||||
)}
|
||||
>
|
||||
<MoreHorizRounded sx={{ fontSize: "1.25rem" }} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{/* Hovering surfaces the same explanation the info tooltip used to show. */}
|
||||
<AppTooltip
|
||||
content={t(
|
||||
"policies.sidebar.infoTooltip",
|
||||
"A policy is a fixed set of tools that runs automatically whenever it's triggered — for example when a new document arrives — enforcing rules like redacting PII with no manual steps.",
|
||||
)}
|
||||
position="left"
|
||||
maxWidth="16rem"
|
||||
>
|
||||
<Menu.Item
|
||||
leftSection={<InfoOutlined sx={{ fontSize: "1rem" }} />}
|
||||
>
|
||||
{t("policies.sidebar.whatIsPolicy", "What is a policy?")}
|
||||
</Menu.Item>
|
||||
</AppTooltip>
|
||||
{pol.canConfigure && (
|
||||
<Menu.Item
|
||||
leftSection={<TuneRounded sx={{ fontSize: "1rem" }} />}
|
||||
onClick={() => openPolicySettings()}
|
||||
>
|
||||
{t("policies.sidebar.policySettings", "Policy settings")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</div>
|
||||
|
||||
{retryableRuns.length > 0 && !guestBlocked && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
className="pol-retry-failed"
|
||||
onClick={retryAllFailed}
|
||||
leftSection={<ReplayRounded sx={{ fontSize: "0.95rem" }} />}
|
||||
>
|
||||
{t(
|
||||
"policies.sidebar.retryFailed",
|
||||
"Retry failed policies ({{count}})",
|
||||
{
|
||||
count: retryableRuns.length,
|
||||
},
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
<div className="pol-list-rows">
|
||||
{displayCategories.map((cat) => {
|
||||
if (cat.comingSoon) {
|
||||
return (
|
||||
<div key={cat.id} className="pol-row pol-row--soon">
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-row-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
<span className="pol-row-trail">
|
||||
<Button
|
||||
variant="quiet"
|
||||
size="sm"
|
||||
fontSize="xs"
|
||||
accent="neutral"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://stirling.com/contact",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"policies.sidebar.upgradeToEnterprise",
|
||||
"Upgrade to enterprise",
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
const rowProgress = progress.get(cat.id) ?? EMPTY_RUN_PROGRESS;
|
||||
const isProcessing = rowProgress.running > 0;
|
||||
const icon = (
|
||||
<span
|
||||
className={`pol-row-icon${isProcessing ? " is-processing" : ""}`}
|
||||
data-accent={ROW_ACCENT[cat.id] ?? "blue"}
|
||||
>
|
||||
{isProcessing && (
|
||||
<span className="pol-row-ring" aria-hidden="true" />
|
||||
)}
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="pol-row"
|
||||
leftSection={
|
||||
rowProgress.total > 0 ? (
|
||||
<AppTooltip
|
||||
position="left"
|
||||
content={t(
|
||||
"policies.sidebar.rowProgress",
|
||||
"{{completed}} of {{total}} files processed",
|
||||
{
|
||||
completed: rowProgress.completed,
|
||||
total: rowProgress.total,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</AppTooltip>
|
||||
) : (
|
||||
icon
|
||||
)
|
||||
}
|
||||
rightSection={
|
||||
<span className="pol-row-trail">
|
||||
{status === "setup" ? (
|
||||
<span className="pol-row-setup">
|
||||
{t("policies.sidebar.setUp", "Set up")}
|
||||
</span>
|
||||
) : (
|
||||
<StatusBadge
|
||||
tone={status === "active" ? "success" : "warning"}
|
||||
size="sm"
|
||||
>
|
||||
{t(`policies.status.${status}`, STATUS_LABEL[status])}
|
||||
</StatusBadge>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
className="pol-row-chevron"
|
||||
sx={{ fontSize: "1rem" }}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
onClick={() =>
|
||||
guestBlocked ? promptGuestSignup() : selectPolicy(cat.id)
|
||||
}
|
||||
>
|
||||
<span className="pol-row-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takeover dispatcher: shows the policy-settings page (execution order), an open
|
||||
* policy's detail, or nothing — whichever the selection store currently holds.
|
||||
* The heavy per-policy hooks live in {@link PolicyOpenDetail}, so the settings
|
||||
* page doesn't pay for (or trip over) them.
|
||||
*/
|
||||
export function PolicyDetailTakeover() {
|
||||
const { selectedId, settingsOpen } = usePolicySelection();
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
if (settingsOpen && selectedId == null) return <PolicySettingsPanel />;
|
||||
if (selectedId == null) return null;
|
||||
return <PolicyOpenDetail />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-policy view — narrative detail, setup wizard, or edit-settings —
|
||||
* which replaces the Tools area while a policy is selected.
|
||||
*/
|
||||
function PolicyOpenDetail() {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories, configs, sources, docTypes } = usePolicyCatalog();
|
||||
const { selectedId, detailView } = usePolicySelection();
|
||||
|
||||
// The configured policy's backing folder + automation (its real, editable
|
||||
// pipeline). `reloadKey` bumps after the edit modal saves so the detail
|
||||
// reflects the new steps. Falls back to the preset's rules when unconfigured.
|
||||
const folderId = selectedId ? pol.policies[selectedId]?.folderId : undefined;
|
||||
const [steps, setSteps] = useState<AutomationOperation[]>([]);
|
||||
const [backingFolder, setBackingFolder] = useState<WatchedFolder | null>(
|
||||
null,
|
||||
);
|
||||
const [backingAutomation, setBackingAutomation] =
|
||||
useState<AutomationConfig | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!folderId) {
|
||||
setSteps([]);
|
||||
setBackingFolder(null);
|
||||
setBackingAutomation(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const [folder, automation] = await Promise.all([
|
||||
watchedFolderStorage.getFolder(folderId),
|
||||
getPolicyAutomation(folderId),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setBackingFolder(folder);
|
||||
setBackingAutomation(automation);
|
||||
setSteps(automation?.operations ?? []);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [folderId, reloadKey]);
|
||||
|
||||
// Activity/stats come from the real backend runs the auto-run controller fires
|
||||
// on every upload (policyRunStore), filtered to this policy's category. The
|
||||
// store is reactive, so the feed updates live as runs progress — no polling
|
||||
// here (the controller does the run-status polling).
|
||||
const allRuns = usePolicyRuns();
|
||||
const categoryRuns = useMemo(
|
||||
() => allRuns.filter((r) => r.categoryId === selectedId),
|
||||
[allRuns, selectedId],
|
||||
);
|
||||
|
||||
if (!POLICIES_ENABLED || selectedId == null) return null;
|
||||
|
||||
const category = categories.find((c) => c.id === selectedId);
|
||||
const state = pol.policies[selectedId];
|
||||
const config = configs[selectedId];
|
||||
if (!category || !state || !config) return null;
|
||||
// Coming-soon categories can't be opened (the list row is locked anyway).
|
||||
if (category.comingSoon) return null;
|
||||
|
||||
const status = deriveRowStatus(state);
|
||||
|
||||
const onSetupClassification = () => {
|
||||
const classifier = categories.find((c) => c.providesClassification);
|
||||
if (classifier) selectPolicy(classifier.id);
|
||||
};
|
||||
|
||||
// Preset (tool-chain) policies configure via the wizard's locked tool-config
|
||||
// step instead of the add/remove builder; the wizard fires onCommitConfig for
|
||||
// them (and onComplete for builder-based categories). One commit path serves
|
||||
// both first-time configure and edits.
|
||||
const commitConfig = (result: PolicyConfigResult) =>
|
||||
pol.commitPolicyConfig(selectedId, result).then(() => {
|
||||
setReloadKey((k) => k + 1);
|
||||
setPolicyDetailView("detail");
|
||||
});
|
||||
|
||||
// Setup: the shared wizard in create mode.
|
||||
if (!state.configured) {
|
||||
return (
|
||||
<PolicySetupWizard
|
||||
key={selectedId}
|
||||
category={category}
|
||||
config={config}
|
||||
initial={state}
|
||||
sources={sources}
|
||||
docTypes={docTypes}
|
||||
canConfigure={pol.canConfigure}
|
||||
// No standalone classification policy exists yet to enable doc-type
|
||||
// narrowing, so it stays off for this release.
|
||||
classificationEnabled={false}
|
||||
mode="create"
|
||||
onCancel={() => closePolicy()}
|
||||
onComplete={(result) =>
|
||||
pol
|
||||
.enablePolicy(selectedId, result)
|
||||
.then(() => setPolicyDetailView("detail"))
|
||||
}
|
||||
onCommitConfig={commitConfig}
|
||||
onSetupClassification={onSetupClassification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Edit: the same wizard in edit mode, pre-filled — so editing has the settings
|
||||
// steps (not just the workflow). Wait for the backing automation to load so
|
||||
// the workflow step edits the real pipeline.
|
||||
if (detailView === "settings" && pol.canConfigure) {
|
||||
if (!backingAutomation) {
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<div className="pol-scroll">
|
||||
<p className="pol-desc">
|
||||
{t("policies.sidebar.loading", "Loading…")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PolicySetupWizard
|
||||
key={`edit-${selectedId}`}
|
||||
category={category}
|
||||
config={config}
|
||||
initial={state}
|
||||
sources={sources}
|
||||
docTypes={docTypes}
|
||||
canConfigure={pol.canConfigure}
|
||||
classificationEnabled={false}
|
||||
mode="edit"
|
||||
existingAutomation={backingAutomation}
|
||||
initialFolder={backingFolder ?? undefined}
|
||||
onCancel={() => setPolicyDetailView("detail")}
|
||||
onComplete={(result) =>
|
||||
pol.savePolicyConfig(selectedId, result).then(() => {
|
||||
setReloadKey((k) => k + 1);
|
||||
setPolicyDetailView("detail");
|
||||
})
|
||||
}
|
||||
onCommitConfig={commitConfig}
|
||||
onSetupClassification={onSetupClassification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PolicyDetailPanel
|
||||
category={category}
|
||||
config={config}
|
||||
status={status}
|
||||
steps={steps}
|
||||
activity={runsToActivity(categoryRuns)}
|
||||
stats={runsToStats(categoryRuns, backingFolder?.createdAt)}
|
||||
canConfigure={pol.canConfigure}
|
||||
canDelete={!state.isDefault}
|
||||
onBack={() => closePolicy()}
|
||||
onEditSettings={() => {
|
||||
// Seeded/active policies may have no backing folder yet — create one
|
||||
// from the preset so there's a workflow to edit, then open settings.
|
||||
void pol
|
||||
.ensurePolicyFolder(selectedId)
|
||||
.then(() => setPolicyDetailView("settings"));
|
||||
}}
|
||||
onTogglePause={() =>
|
||||
status === "paused"
|
||||
? pol.resumePolicy(selectedId)
|
||||
: pol.pausePolicy(selectedId)
|
||||
}
|
||||
onDelete={() => setConfirmingDelete(true)}
|
||||
onRetry={(item) => {
|
||||
if (item.fileId && state.backendId) {
|
||||
void runPolicyOnFile(
|
||||
selectedId,
|
||||
state.backendId,
|
||||
item.fileId as FileId,
|
||||
item.doc,
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{confirmingDelete && (
|
||||
<PolicyDeleteConfirmModal
|
||||
opened
|
||||
label={category.label}
|
||||
onCancel={() => setConfirmingDelete(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmingDelete(false);
|
||||
closePolicy();
|
||||
void pol.deletePolicy(selectedId);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Policy settings takeover — reached from the section header's "…" menu.
|
||||
* Each trigger (upload / export) gets its own run-order list, since a chain only
|
||||
* spans policies that fire on the same trigger — reordering one never affects the
|
||||
* other. Admin-only (the menu entry is gated on canConfigure).
|
||||
*/
|
||||
function PolicySettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
|
||||
// Configured, live policies for a trigger, in execution order.
|
||||
const inOrder = (trigger: "upload" | "export") =>
|
||||
categories
|
||||
.filter(
|
||||
(c) =>
|
||||
pol.policies[c.id]?.configured &&
|
||||
!c.comingSoon &&
|
||||
(pol.policies[c.id]?.runOn ?? "upload") === trigger,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(pol.policies[a.id]?.order ?? 0) - (pol.policies[b.id]?.order ?? 0),
|
||||
);
|
||||
|
||||
const uploadCats = inOrder("upload");
|
||||
const exportCats = inOrder("export");
|
||||
|
||||
// Order is one global sort key, so persist both groups together (upload first)
|
||||
// to keep each group's members contiguous — the auto-run chain reads relative
|
||||
// order within a trigger.
|
||||
const persist = (uploadIds: string[], exportIds: string[]) =>
|
||||
pol.reorderPolicies([...uploadIds, ...exportIds]);
|
||||
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={<TuneRounded sx={{ fontSize: "1.1rem" }} />}
|
||||
title={t("policies.settings.title", "Policy settings")}
|
||||
onClose={() => closePolicySettings()}
|
||||
closeLabel={t("policies.detail.close", "Close")}
|
||||
/>
|
||||
<div className="pol-scroll">
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.settings.runOrderDesc",
|
||||
"When more than one policy runs on the same trigger, they run in this order — each on the previous policy's output. Drag to reorder.",
|
||||
)}
|
||||
</p>
|
||||
{/* Both triggers are always shown so the run order for each is explicit,
|
||||
with an empty note when a trigger has no policies. */}
|
||||
<PolicyReorderSection
|
||||
title={t("policies.settings.onUpload", "On upload")}
|
||||
cats={uploadCats}
|
||||
emptyText={t(
|
||||
"policies.settings.noneUpload",
|
||||
"No policies currently run on upload.",
|
||||
)}
|
||||
onReorder={(ids) =>
|
||||
persist(
|
||||
ids,
|
||||
exportCats.map((c) => c.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PolicyReorderSection
|
||||
title={t("policies.settings.onExport", "On export")}
|
||||
cats={exportCats}
|
||||
emptyText={t(
|
||||
"policies.settings.noneExport",
|
||||
"No policies currently run on export.",
|
||||
)}
|
||||
onReorder={(ids) =>
|
||||
persist(
|
||||
uploadCats.map((c) => c.id),
|
||||
ids,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One trigger's run-order list. Rows drag to reorder (only when there's more than
|
||||
* one to order); the drag ghost is the whole row with a blue outline, and a
|
||||
* straight blue line marks where the policy will land. Reorders in isolation and
|
||||
* hands the new id order back to the parent to persist.
|
||||
*/
|
||||
function PolicyReorderSection({
|
||||
title,
|
||||
cats,
|
||||
emptyText,
|
||||
onReorder,
|
||||
}: {
|
||||
title: string;
|
||||
cats: PolicyCategory[];
|
||||
emptyText: string;
|
||||
onReorder: (orderedIds: string[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [overId, setOverId] = useState<string | null>(null);
|
||||
// Whether the drop would land after (vs before) the hovered row.
|
||||
const [overBelow, setOverBelow] = useState(false);
|
||||
const draggable = cats.length >= 2;
|
||||
|
||||
const clear = () => {
|
||||
setDragId(null);
|
||||
setOverId(null);
|
||||
};
|
||||
|
||||
const handleDrop = (targetId: string) => {
|
||||
if (!dragId || dragId === targetId) return clear();
|
||||
const ids = cats.map((c) => c.id);
|
||||
const from = ids.indexOf(dragId);
|
||||
let to = ids.indexOf(targetId) + (overBelow ? 1 : 0);
|
||||
if (from < 0 || to < 0) return clear();
|
||||
ids.splice(from, 1);
|
||||
if (from < to) to -= 1;
|
||||
ids.splice(to, 0, dragId);
|
||||
onReorder(ids);
|
||||
clear();
|
||||
};
|
||||
|
||||
// The native drag image would be just the grip under the cursor; instead snapshot
|
||||
// the whole row (a styled clone) so the ghost that follows the mouse is the full
|
||||
// row with a blue outline.
|
||||
const startDrag = (e: DragEvent<HTMLSpanElement>, catId: string) => {
|
||||
setDragId(catId);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
const row = (e.currentTarget as HTMLElement).closest(".pol-reorder-row");
|
||||
if (row instanceof HTMLElement) {
|
||||
const clone = row.cloneNode(true) as HTMLElement;
|
||||
clone.classList.add("pol-reorder-row--ghost");
|
||||
clone.style.width = `${row.offsetWidth}px`;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.top = "-1000px";
|
||||
clone.style.left = "-1000px";
|
||||
clone.style.pointerEvents = "none";
|
||||
document.body.appendChild(clone);
|
||||
e.dataTransfer.setDragImage(clone, 24, row.offsetHeight / 2);
|
||||
window.setTimeout(() => clone.remove(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (cats.length === 0) {
|
||||
return (
|
||||
<section className="pol-reorder-section">
|
||||
<p className="pol-section-label">{title}</p>
|
||||
<p className="pol-reorder-empty">{emptyText}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="pol-reorder-section">
|
||||
<p className="pol-section-label">{title}</p>
|
||||
<div className="pol-reorder-list">
|
||||
{cats.map((cat) => (
|
||||
<div
|
||||
key={cat.id}
|
||||
className="pol-reorder-row"
|
||||
data-dragging={dragId === cat.id || undefined}
|
||||
data-drop={
|
||||
draggable && overId === cat.id && dragId !== cat.id
|
||||
? overBelow
|
||||
? "below"
|
||||
: "above"
|
||||
: undefined
|
||||
}
|
||||
onDragOver={
|
||||
draggable
|
||||
? (e) => {
|
||||
if (!dragId) return;
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setOverId(cat.id);
|
||||
setOverBelow(e.clientY > rect.top + rect.height / 2);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragLeave={
|
||||
draggable
|
||||
? () => setOverId((id) => (id === cat.id ? null : id))
|
||||
: undefined
|
||||
}
|
||||
onDrop={
|
||||
draggable
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
handleDrop(cat.id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{draggable && (
|
||||
<span
|
||||
className="pol-reorder-grip"
|
||||
draggable
|
||||
onDragStart={(e) => startDrag(e, cat.id)}
|
||||
onDragEnd={clear}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t(
|
||||
"policies.settings.reorderHandle",
|
||||
"Drag to reorder",
|
||||
)}
|
||||
>
|
||||
<DragIndicatorRounded sx={{ fontSize: "1rem" }} />
|
||||
</span>
|
||||
)}
|
||||
<IconBadge size="sm" accent={ROW_ACCENT[cat.id] ?? "blue"}>
|
||||
{cat.icon}
|
||||
</IconBadge>
|
||||
<span className="pol-reorder-label">
|
||||
{t(`policies.catalog.${cat.id}`, cat.label)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-rail policy icons. Each tints blue when active and carries a small
|
||||
* status dot (green active / amber paused). Clicking selects the policy and
|
||||
* expands the rail.
|
||||
*/
|
||||
export function PoliciesCollapsedButton({
|
||||
onExpand,
|
||||
}: {
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pol = usePolicies();
|
||||
const { categories } = usePolicyCatalog();
|
||||
const guestBlocked = usePolicyGuestBlocked();
|
||||
|
||||
if (!POLICIES_ENABLED) return null;
|
||||
|
||||
// Coming-soon policies are excluded; admins see all real policies, others only see configured ones — renders nothing when empty.
|
||||
const railCategories = categories.filter((cat) => {
|
||||
if (cat.comingSoon) return false;
|
||||
if (pol.canConfigure) return true;
|
||||
return pol.policies[cat.id]?.configured;
|
||||
});
|
||||
if (railCategories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pol-crail">
|
||||
{railCategories.map((cat) => {
|
||||
const status = deriveRowStatus(pol.policies[cat.id]);
|
||||
const label = t(`policies.catalog.${cat.id}`, cat.label);
|
||||
const statusLabel = t(
|
||||
`policies.status.${status}`,
|
||||
STATUS_LABEL[status],
|
||||
);
|
||||
const suffix =
|
||||
status === "active"
|
||||
? t("policies.sidebar.railSuffixActive", " (Active)")
|
||||
: status === "paused"
|
||||
? t("policies.sidebar.railSuffixPaused", " (Paused)")
|
||||
: "";
|
||||
return (
|
||||
<AppTooltip
|
||||
key={cat.id}
|
||||
content={`${label}${suffix}`}
|
||||
position="left"
|
||||
arrow
|
||||
delay={300}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
hover={false}
|
||||
className="pol-crail-btn"
|
||||
data-status={status}
|
||||
aria-label={t(
|
||||
"policies.sidebar.railAriaLabel",
|
||||
"{{label}} policy — {{status}}",
|
||||
{ label, status: statusLabel },
|
||||
)}
|
||||
onClick={() => {
|
||||
if (guestBlocked) {
|
||||
promptGuestSignup();
|
||||
return;
|
||||
}
|
||||
selectPolicy(cat.id);
|
||||
onExpand();
|
||||
}}
|
||||
>
|
||||
{cat.icon}
|
||||
{(status === "active" || status === "paused") && (
|
||||
<span className="pol-crail-dot" data-status={status} />
|
||||
)}
|
||||
</Button>
|
||||
</AppTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="tool-panel__collapsed-divider" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Modal, Text, Stack, Group } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@app/ui/Button";
|
||||
|
||||
interface PolicyDeleteConfirmModalProps {
|
||||
opened: boolean;
|
||||
/** The policy's display label (the category name). */
|
||||
label: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** Confirm before deleting a policy — removing it discards its backing workflow. */
|
||||
export function PolicyDeleteConfirmModal({
|
||||
opened,
|
||||
label,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: PolicyDeleteConfirmModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onCancel}
|
||||
title={t("policies.deleteConfirmTitle", "Delete {{label}} policy?", {
|
||||
label,
|
||||
})}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
"policies.deleteConfirmBody",
|
||||
"This removes the policy and its workflow. Documents already processed are not affected.",
|
||||
)}
|
||||
</Text>
|
||||
<Group gap="sm" justify="flex-end">
|
||||
<Button variant="secondary" size="sm" onClick={onCancel}>
|
||||
{t("cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button accent="danger" size="sm" onClick={onConfirm}>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PublicIcon from "@mui/icons-material/Public";
|
||||
import ScheduleIcon from "@mui/icons-material/Schedule";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import DescriptionIcon from "@mui/icons-material/Description";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import WarningAmberIcon from "@mui/icons-material/WarningAmber";
|
||||
import AutorenewIcon from "@mui/icons-material/Autorenew";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import LockIcon from "@mui/icons-material/Lock";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import { PanelHeader } from "@app/ui/PanelHeader";
|
||||
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||
import { Card } from "@app/ui/Card";
|
||||
import { ChipFlow } from "@app/ui/ChipFlow";
|
||||
import { StatusBadge } from "@app/ui/StatusBadge";
|
||||
import { EmptyState } from "@app/ui/EmptyState";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { ListRow } from "@app/ui/ListRow";
|
||||
import type {
|
||||
PolicyActivityItem,
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyRowStatus,
|
||||
PolicyStats,
|
||||
} from "@app/types/policies";
|
||||
import type { AutomationOperation } from "@app/types/automation";
|
||||
|
||||
interface PolicyDetailPanelProps {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
/** Derived display status. */
|
||||
status: PolicyRowStatus;
|
||||
/**
|
||||
* The policy's real configured steps (from its backing automation). When
|
||||
* present these drive the Enforces flow; otherwise the preset's decorative
|
||||
* `rules` are shown (e.g. before configuration).
|
||||
*/
|
||||
steps?: AutomationOperation[];
|
||||
/** Activity feed derived from the user's files; empty until files exist. */
|
||||
activity?: PolicyActivityItem[];
|
||||
/** Summary stats derived from the user's files. */
|
||||
stats?: PolicyStats;
|
||||
canConfigure: boolean;
|
||||
/** Default (built-in) policies aren't deletable, so the Delete action hides. */
|
||||
canDelete: boolean;
|
||||
onBack: () => void;
|
||||
onEditSettings: () => void;
|
||||
onTogglePause: () => void;
|
||||
onDelete: () => void;
|
||||
/** Re-run a failed activity item's policy on its file. */
|
||||
onRetry?: (item: PolicyActivityItem) => void;
|
||||
}
|
||||
|
||||
/** "addWatermark" → "Add Watermark" — a light humanisation of op ids for display. */
|
||||
function humanizeOperation(op: string): string {
|
||||
return op
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A failed run's error in the activity feed. Backend errors can be long (or
|
||||
* multi-line stack traces) and would otherwise blow up the row, so anything
|
||||
* lengthy is clamped and collapsed by default with a Show more/less toggle.
|
||||
* Short messages (e.g. "Enforcement failed") render plainly with no toggle.
|
||||
*/
|
||||
function ActivityError({
|
||||
message,
|
||||
t,
|
||||
}: {
|
||||
message: string;
|
||||
t: (key: string, defaultValue: string) => string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const needsToggle = message.length > 80 || message.includes("\n");
|
||||
if (!needsToggle) return <>{message}</>;
|
||||
return (
|
||||
<span className="pol-activity-error">
|
||||
<span
|
||||
className={`pol-activity-error__text${expanded ? "" : " pol-activity-error__text--clamped"}`}
|
||||
>
|
||||
{message}
|
||||
</span>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="pol-activity-error__toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded
|
||||
? t("policies.detail.showLess", "Show less")
|
||||
: t("policies.detail.showMore", "Show more")}
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Narrative view for a configured policy (Enforces / Activity / Stats). */
|
||||
export function PolicyDetailPanel({
|
||||
category,
|
||||
config,
|
||||
status,
|
||||
steps,
|
||||
activity,
|
||||
stats,
|
||||
canConfigure,
|
||||
canDelete,
|
||||
onBack,
|
||||
onEditSettings,
|
||||
onTogglePause,
|
||||
onDelete,
|
||||
onRetry,
|
||||
}: PolicyDetailPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const isPaused = status === "paused";
|
||||
const [activityOpen, setActivityOpen] = useState(true);
|
||||
// Real configured steps drive the flow; fall back to the preset's rule labels.
|
||||
const enforceItems =
|
||||
steps && steps.length > 0
|
||||
? steps.map((s) => humanizeOperation(s.operation))
|
||||
: config.rules;
|
||||
// Activity + stats are derived from the user's real files; until they load (or
|
||||
// if none exist) show an honest empty feed / zeroed stats.
|
||||
const activityItems = activity ?? [];
|
||||
const statValues = stats ?? {
|
||||
enforced: 0,
|
||||
dataProcessed: "0 B",
|
||||
activeFor: "—",
|
||||
};
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
accent={ROW_ACCENT[category.id]}
|
||||
title={t(`policies.catalog.${category.id}`, category.label)}
|
||||
onClose={onBack}
|
||||
closeLabel={t("policies.detail.close", "Close")}
|
||||
actions={
|
||||
<StatusBadge
|
||||
tone={isPaused ? "warning" : "success"}
|
||||
showDot
|
||||
pulse={!isPaused}
|
||||
>
|
||||
{isPaused
|
||||
? t("policies.detail.statusPaused", "Paused")
|
||||
: t("policies.detail.statusActive", "Active")}
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="pol-scroll">
|
||||
{/* Enforces */}
|
||||
<div>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.detail.enforces", "Enforces")}
|
||||
</p>
|
||||
<Card padding="default">
|
||||
<div className="pol-rule-flow">
|
||||
<ChipFlow items={enforceItems} separator="arrow" />
|
||||
</div>
|
||||
<div className="pol-meta-row">
|
||||
<span className="pol-meta-item">
|
||||
<PublicIcon sx={{ fontSize: "0.8rem" }} />
|
||||
{config.scopeLabel}
|
||||
</span>
|
||||
<span className="pol-meta-item">
|
||||
<ScheduleIcon sx={{ fontSize: "0.8rem" }} />
|
||||
{t("policies.detail.onEveryUpload", "On every upload")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-note">
|
||||
<HistoryIcon sx={{ fontSize: "0.8rem" }} />
|
||||
{t(
|
||||
"policies.detail.originalsNote",
|
||||
"Originals stay untouched • Enforced version saved alongside",
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div>
|
||||
<Button
|
||||
variant="quiet"
|
||||
fullWidth
|
||||
justify="between"
|
||||
className="pol-section-label pol-section-toggle"
|
||||
onClick={() => setActivityOpen((o) => !o)}
|
||||
aria-expanded={activityOpen}
|
||||
rightSection={
|
||||
<KeyboardArrowDownIcon
|
||||
className={`pol-section-chevron${activityOpen ? " is-open" : ""}`}
|
||||
fontSize="small"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{t("policies.detail.recentActivity", "Recent Activity")}
|
||||
</span>
|
||||
</Button>
|
||||
{activityOpen &&
|
||||
(activityItems.length > 0 ? (
|
||||
<Card padding="none">
|
||||
<div className="pol-activity-list">
|
||||
{activityItems.map((item, i) => (
|
||||
<ListRow
|
||||
key={item.runId ?? `${item.doc}-${item.time}`}
|
||||
divider={i > 0}
|
||||
leadingTone={
|
||||
item.status === "flagged"
|
||||
? "warning"
|
||||
: item.status === "processing"
|
||||
? "info"
|
||||
: "success"
|
||||
}
|
||||
leading={
|
||||
item.status === "flagged" ? (
|
||||
<WarningAmberIcon sx={{ fontSize: "0.85rem" }} />
|
||||
) : item.status === "processing" ? (
|
||||
<AutorenewIcon
|
||||
className="pol-spin"
|
||||
sx={{ fontSize: "0.85rem" }}
|
||||
/>
|
||||
) : (
|
||||
<CheckCircleIcon sx={{ fontSize: "0.85rem" }} />
|
||||
)
|
||||
}
|
||||
title={item.doc}
|
||||
description={
|
||||
item.status === "flagged" ? (
|
||||
<ActivityError message={item.action} t={t} />
|
||||
) : (
|
||||
item.action
|
||||
)
|
||||
}
|
||||
meta={item.time}
|
||||
trailing={
|
||||
item.status === "flagged" && onRetry ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={() => onRetry(item)}
|
||||
>
|
||||
{t("policies.detail.retry", "Retry")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card padding="default">
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={<DescriptionIcon sx={{ fontSize: "1.5rem" }} />}
|
||||
title={t(
|
||||
"policies.detail.noActivityTitle",
|
||||
"No activity yet",
|
||||
)}
|
||||
description={t(
|
||||
"policies.detail.noActivityDescription",
|
||||
"Documents will appear here once this policy runs.",
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats — one grouped card with divided columns, intentionally
|
||||
unlabelled for a quiet summary footer. */}
|
||||
<Card padding="none">
|
||||
<div className="pol-stats">
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">
|
||||
{statValues.enforced.toLocaleString()}
|
||||
</span>
|
||||
<span className="pol-stat-label">
|
||||
{t("policies.detail.statDocsEnforced", "Docs enforced")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">{statValues.dataProcessed}</span>
|
||||
<span className="pol-stat-label">
|
||||
{t("policies.detail.statDataProcessed", "Data processed")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-stat">
|
||||
<span className="pol-stat-value">{statValues.activeFor}</span>
|
||||
<span className="pol-stat-label">
|
||||
{t("policies.detail.statActive", "Active")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!canConfigure && (
|
||||
<Banner
|
||||
tone="neutral"
|
||||
icon={<LockIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={t(
|
||||
"policies.detail.managedByOrg",
|
||||
"Managed by your organization. Contact a team leader to change this policy.",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canConfigure && (
|
||||
<div className={`pol-footer${canDelete ? "" : " pol-footer-end"}`}>
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
leftSection={<DeleteOutlineIcon sx={{ fontSize: "0.9rem" }} />}
|
||||
onClick={onDelete}
|
||||
style={{ marginRight: "auto" }}
|
||||
>
|
||||
{t("delete", "Delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={onTogglePause}>
|
||||
{isPaused
|
||||
? t("policies.detail.resume", "Resume")
|
||||
: t("policies.detail.pause", "Pause")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={onEditSettings}>
|
||||
{t("policies.detail.editSettings", "Edit Settings")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToggleSwitch } from "@app/ui/ToggleSwitch";
|
||||
import { Select } from "@app/ui/Select";
|
||||
import { Input } from "@app/ui/Input";
|
||||
import { Chip } from "@app/ui/Chip";
|
||||
import { SettingsRow } from "@app/ui/SettingsRow";
|
||||
import type { PolicyField } from "@app/types/policies";
|
||||
|
||||
interface PolicyFieldRowProps {
|
||||
field: PolicyField;
|
||||
/** Effective current value (override or definition default). */
|
||||
value: boolean | string | string[];
|
||||
onChange: (value: boolean | string | string[]) => void;
|
||||
/** First row in a group omits the top divider. */
|
||||
first?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one policy setting: toggle, select, multi-select chips, or text.
|
||||
* Controlled — the parent owns the value. Uses SUI controls (ToggleSwitch /
|
||||
* Select / Input / Chip) so it matches the rest of the policy surface.
|
||||
*/
|
||||
export function PolicyFieldRow({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
first,
|
||||
}: PolicyFieldRowProps) {
|
||||
const { t } = useTranslation();
|
||||
// Field labels and option labels come from the policy catalog data, so they're
|
||||
// wrapped at the render site with data-keyed ids (English stays the fallback).
|
||||
const fieldLabel = t(`policies.field.${field.key}`, field.label);
|
||||
|
||||
if (field.type === "chips") {
|
||||
const selected = Array.isArray(value) ? value : [];
|
||||
const toggle = (opt: string) =>
|
||||
onChange(
|
||||
selected.includes(opt)
|
||||
? selected.filter((o) => o !== opt)
|
||||
: [...selected, opt],
|
||||
);
|
||||
return (
|
||||
<div className="pol-field" data-first={first || undefined}>
|
||||
<div className="pol-field-chips-head">
|
||||
<span className="pol-field-label">{fieldLabel}</span>
|
||||
<span className="pol-field-count">
|
||||
{t("policies.fields.selectedCount", "{{count}} selected", {
|
||||
count: selected.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pol-field-chips">
|
||||
{(field.options ?? []).map((opt) => (
|
||||
<Chip key={opt} size="sm" onClick={() => toggle(opt)}>
|
||||
{t(`policies.fieldOption.${field.key}.${opt}`, opt)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const control =
|
||||
field.type === "toggle" ? (
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={Boolean(value)}
|
||||
onChange={(checked) => onChange(checked)}
|
||||
aria-label={fieldLabel}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<Select
|
||||
inputSize="sm"
|
||||
options={(field.options ?? []).map((o) => ({
|
||||
value: o,
|
||||
label: t(`policies.fieldOption.${field.key}.${o}`, o),
|
||||
}))}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(value) => onChange(value ?? "")}
|
||||
aria-label={fieldLabel}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={typeof value === "string" ? value : ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={fieldLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pol-field" data-first={first || undefined}>
|
||||
<SettingsRow label={fieldLabel} control={control} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,737 +0,0 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
|
||||
import { PanelHeader } from "@app/ui/PanelHeader";
|
||||
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||
import { Card } from "@app/ui/Card";
|
||||
import { Button } from "@app/ui/Button";
|
||||
import { Input } from "@app/ui/Input";
|
||||
import { Select } from "@app/ui/Select";
|
||||
import { SettingsRow } from "@app/ui/SettingsRow";
|
||||
import { Checkbox } from "@app/ui/Checkbox";
|
||||
import { Banner } from "@app/ui/Banner";
|
||||
import { EmptyState } from "@app/ui/EmptyState";
|
||||
import { StepIndicator } from "@app/ui/StepIndicator";
|
||||
import type {
|
||||
PolicyCategory,
|
||||
PolicyConfigDef,
|
||||
PolicyConfigResult,
|
||||
PolicySource,
|
||||
PolicyState,
|
||||
PolicyWizardResult,
|
||||
} from "@app/types/policies";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
AutomationOperation,
|
||||
} from "@app/types/automation";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { WatchedFolder } from "@app/types/watchedFolders";
|
||||
import { buildPipelineDefinition } from "@app/services/policyPipeline";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { PolicyFieldRow } from "@app/components/policies/PolicyFieldRow";
|
||||
import { resolveFieldValues } from "@app/components/policies/policyValues";
|
||||
import {
|
||||
PolicyWorkflowStep,
|
||||
AutomationMode,
|
||||
} from "@app/components/policies/PolicyWorkflowStep";
|
||||
import { PolicyToolConfigStep } from "@app/components/policies/PolicyToolConfigStep";
|
||||
import { getPolicyToolChain } from "@app/components/policies/policyToolChains";
|
||||
import { ClassificationLabelsSection } from "@app/components/policies/ClassificationLabelsSection";
|
||||
|
||||
// Sources are always "editor" for this release, so the Sources step is dropped
|
||||
// from the flow (its panel code is kept below for when other sources return).
|
||||
const SOURCES_IN_FLOW = false;
|
||||
const TOTAL_STEPS = SOURCES_IN_FLOW ? 3 : 2;
|
||||
|
||||
interface PolicySetupWizardProps {
|
||||
category: PolicyCategory;
|
||||
config: PolicyConfigDef;
|
||||
initial: PolicyState;
|
||||
/** Sources a policy can run over (catalog-supplied). */
|
||||
sources: PolicySource[];
|
||||
/** Document types scope can be narrowed to (catalog-supplied). */
|
||||
docTypes: string[];
|
||||
canConfigure: boolean;
|
||||
/** Whether the Classification (ingestion) policy is active — gates doc-type narrowing. */
|
||||
classificationEnabled: boolean;
|
||||
/** "create" seeds the workflow from the preset; "edit" loads the backing automation. */
|
||||
mode?: "create" | "edit";
|
||||
/** The backing automation to edit (edit mode). */
|
||||
existingAutomation?: AutomationConfig;
|
||||
/** The backing folder, to pre-fill output + retry settings (edit mode). */
|
||||
initialFolder?: WatchedFolder;
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Fires on submit with the saved workflow + collected settings. May be async;
|
||||
* if the returned promise rejects, the wizard re-enables submit and surfaces
|
||||
* the failure rather than hanging on a permanently-disabled button.
|
||||
*/
|
||||
onComplete: (result: PolicyWizardResult) => void | Promise<void>;
|
||||
/**
|
||||
* For preset (tool-chain) policies whose Workflow step is the locked tool
|
||||
* config: fires instead of `onComplete`, carrying the configured tools as
|
||||
* operations + mapped pipeline steps. When absent the wizard uses the
|
||||
* add/remove builder + `onComplete`.
|
||||
*/
|
||||
onCommitConfig?: (result: PolicyConfigResult) => void | Promise<void>;
|
||||
onSetupClassification: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared policy wizard, used for both setup and edit. Two steps: Workflow
|
||||
* (the tool pipeline, reusing the Watch Folders builder) → Settings (the policy
|
||||
* fields + output/retry config). The workflow builder is kept mounted across
|
||||
* steps so the final action can trigger its save.
|
||||
*/
|
||||
export function PolicySetupWizard({
|
||||
category,
|
||||
config,
|
||||
initial,
|
||||
sources: sourceDefs,
|
||||
docTypes,
|
||||
canConfigure,
|
||||
classificationEnabled,
|
||||
mode = "create",
|
||||
existingAutomation,
|
||||
initialFolder,
|
||||
onCancel,
|
||||
onComplete,
|
||||
onCommitConfig,
|
||||
onSetupClassification,
|
||||
}: PolicySetupWizardProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = mode === "edit";
|
||||
// Preset (tool-chain) policies render the locked tool config as their Workflow
|
||||
// step instead of the add/remove builder.
|
||||
const toolChain = getPolicyToolChain(category.id);
|
||||
// A single-tool chain has nothing to toggle/configure, so its config UI is
|
||||
// hidden (kept mounted so the submit trigger still emits that one tool).
|
||||
const singleToolChain = toolChain != null && toolChain.length === 1;
|
||||
const { user } = useAuth();
|
||||
const [step, setStep] = useState(1);
|
||||
const [fieldValues, setFieldValues] = useState(() =>
|
||||
resolveFieldValues(config, initial),
|
||||
);
|
||||
const [sources, setSources] = useState<string[]>(
|
||||
initial.sources.length ? initial.sources : ["editor"],
|
||||
);
|
||||
const [scopeNarrow, setScopeNarrow] = useState(initial.scopeTypes.length > 0);
|
||||
const [scopeTypes, setScopeTypes] = useState<string[]>(initial.scopeTypes);
|
||||
// Reviewer isn't shown in the flow; the field is still saved on the policy,
|
||||
// defaulted to the signed-in user.
|
||||
const reviewerEmail = initial.reviewerEmail || user?.email || "";
|
||||
// Output + retry settings — the real, working folder settings (the engine
|
||||
// applies them). Pre-filled from the backing folder in edit mode.
|
||||
const [outputMode, setOutputMode] = useState<"new_file" | "new_version">(
|
||||
initialFolder?.outputMode ?? "new_version",
|
||||
);
|
||||
const [outputName, setOutputName] = useState(initialFolder?.outputName ?? "");
|
||||
const [outputNamePosition, setOutputNamePosition] = useState<
|
||||
"prefix" | "suffix" | "auto-number"
|
||||
>(initialFolder?.outputNamePosition ?? "prefix");
|
||||
const [maxRetries, setMaxRetries] = useState(initialFolder?.maxRetries ?? 3);
|
||||
const [retryDelayMinutes, setRetryDelayMinutes] = useState(
|
||||
initialFolder?.retryDelayMinutes ?? 5,
|
||||
);
|
||||
// The editor event this policy runs on: input on upload, or output on export.
|
||||
const [runOn, setRunOn] = useState<"upload" | "export">(
|
||||
initial.runOn ?? "upload",
|
||||
);
|
||||
const workflowSave = useRef<(() => void) | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
// Seed the workflow builder: the backing automation in edit, else a synthetic
|
||||
// config carrying the category preset's operations (created on save).
|
||||
const seedAutomation = useMemo<AutomationConfig>(
|
||||
() =>
|
||||
existingAutomation ?? {
|
||||
id: "",
|
||||
name: `${category.label} Policy`,
|
||||
description: `${category.label} policy workflow`,
|
||||
icon: "WorkIcon",
|
||||
operations: config.defaultOperations,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
[existingAutomation, category.label, config.defaultOperations],
|
||||
);
|
||||
|
||||
if (!canConfigure) {
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
accent={ROW_ACCENT[category.id]}
|
||||
title={
|
||||
isEdit
|
||||
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
}
|
||||
onClose={onCancel}
|
||||
closeLabel={t("policies.wizard.close", "Close")}
|
||||
/>
|
||||
<div className="pol-scroll">
|
||||
<EmptyState
|
||||
title={t(
|
||||
"policies.wizard.lockedTitle",
|
||||
"Managed by your organization",
|
||||
)}
|
||||
description={t(
|
||||
"policies.wizard.lockedDescription",
|
||||
"Contact a team leader to change this policy.",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const back = () =>
|
||||
step > 1 ? setStep((s) => Math.max(1, s - 1)) : onCancel();
|
||||
|
||||
const toggleSource = (id: string) =>
|
||||
setSources((prev) =>
|
||||
prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id],
|
||||
);
|
||||
|
||||
// Once the builder persists the workflow, map its operations to backend
|
||||
// endpoint paths (the registry only lives in the Workflow step) and hand the
|
||||
// automation + built steps + settings to the host (which closes the wizard on
|
||||
// success). If the host's async save rejects, recover so the submit button
|
||||
// doesn't stay disabled forever.
|
||||
const handleWorkflowSaved = (
|
||||
automation: AutomationConfig,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
) => {
|
||||
const { definition, unresolved } = buildPipelineDefinition(
|
||||
automation,
|
||||
toolRegistry,
|
||||
);
|
||||
Promise.resolve(
|
||||
onComplete({
|
||||
automation,
|
||||
fieldValues,
|
||||
sources,
|
||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||
reviewerEmail,
|
||||
folder: {
|
||||
runOn,
|
||||
outputMode,
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
},
|
||||
pipelineSteps: definition.steps,
|
||||
unresolvedOps: unresolved,
|
||||
}),
|
||||
).catch(() => {
|
||||
setSubmitting(false);
|
||||
setSaveError(
|
||||
t(
|
||||
"policies.wizard.saveError",
|
||||
"Couldn't save the policy. Please try again.",
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Tool-chain policies: the locked config step emits its enabled tools as
|
||||
// operations + mapped steps; hand them to the host's commit path.
|
||||
const handleToolConfigSaved = (
|
||||
operations: AutomationOperation[],
|
||||
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
|
||||
unresolvedOps: string[],
|
||||
) => {
|
||||
Promise.resolve(
|
||||
onCommitConfig?.({
|
||||
operations,
|
||||
pipelineSteps,
|
||||
unresolvedOps,
|
||||
fieldValues,
|
||||
sources,
|
||||
scopeTypes: scopeNarrow ? scopeTypes : [],
|
||||
reviewerEmail,
|
||||
folder: {
|
||||
runOn,
|
||||
outputMode,
|
||||
outputName: outputName.trim(),
|
||||
outputNamePosition,
|
||||
maxRetries,
|
||||
retryDelayMinutes,
|
||||
},
|
||||
}),
|
||||
).catch(() => {
|
||||
setSubmitting(false);
|
||||
setSaveError(
|
||||
t(
|
||||
"policies.wizard.saveError",
|
||||
"Couldn't save the policy. Please try again.",
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Final submit: guard against double-submit (the step stays mounted, so a
|
||||
// second click would persist twice), then trigger the step's save.
|
||||
const submit = () => {
|
||||
if (submitting) return;
|
||||
setSaveError(null);
|
||||
setSubmitting(true);
|
||||
workflowSave.current?.();
|
||||
};
|
||||
|
||||
// The builder couldn't save (e.g. no configured tools) — surface it and send
|
||||
// the user back to the Workflow step to fix it.
|
||||
const handleSaveFailed = () => {
|
||||
setSubmitting(false);
|
||||
setSaveError(
|
||||
t(
|
||||
"policies.wizard.noToolsError",
|
||||
"Add at least one configured tool to the workflow first.",
|
||||
),
|
||||
);
|
||||
setStep(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pol-detail">
|
||||
<PanelHeader
|
||||
icon={category.icon}
|
||||
accent={ROW_ACCENT[category.id]}
|
||||
title={
|
||||
isEdit
|
||||
? t("policies.wizard.editTitle", "Edit {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
: t("policies.wizard.setupTitle", "Set up {{label}} Policy", {
|
||||
label: t(`policies.catalog.${category.id}`, category.label),
|
||||
})
|
||||
}
|
||||
onClose={onCancel}
|
||||
closeLabel={t("cancel", "Cancel")}
|
||||
/>
|
||||
|
||||
<div className="pol-steps">
|
||||
<span className="pol-step-label">
|
||||
{t("policies.wizard.stepOf", "Step {{step}} of {{total}}", {
|
||||
step,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
</span>
|
||||
<StepIndicator total={TOTAL_STEPS} current={step} />
|
||||
</div>
|
||||
|
||||
<div className="pol-scroll">
|
||||
{saveError && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
description={saveError}
|
||||
/>
|
||||
)}
|
||||
{/* Step 1 — Workflow. Kept mounted (hidden on other steps) so the final
|
||||
submit can trigger its save. Preset (tool-chain) policies show the
|
||||
locked, per-tool config; the rest show the add/remove builder. */}
|
||||
<div style={{ display: step === 1 ? undefined : "none" }}>
|
||||
{toolChain ? (
|
||||
<>
|
||||
{/* Single-tool chains have nothing to configure — hide the prompt
|
||||
and the toggle, but keep the step mounted (display:none) so the
|
||||
final submit still emits that one tool. */}
|
||||
{!singleToolChain && (
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.wizard.toolChainDesc",
|
||||
"Configure the tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: singleToolChain ? "none" : undefined }}>
|
||||
<PolicyToolConfigStep
|
||||
chainIds={toolChain}
|
||||
initialOperations={
|
||||
existingAutomation?.operations ?? config.defaultOperations
|
||||
}
|
||||
presetOperations={config.defaultOperations}
|
||||
categoryLabel={category.label}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleToolConfigSaved}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.wizard.builderDesc",
|
||||
"Build the sequence of tools this policy runs on each document.",
|
||||
)}
|
||||
</p>
|
||||
<PolicyWorkflowStep
|
||||
automation={seedAutomation}
|
||||
mode={isEdit ? AutomationMode.EDIT : AutomationMode.SUGGESTED}
|
||||
saveTriggerRef={workflowSave}
|
||||
onComplete={handleWorkflowSaved}
|
||||
onSaveFailed={handleSaveFailed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* The Classification policy owns the editable label sets (team-shared
|
||||
+ personal) the classifier picks from. Kept on the first step
|
||||
alongside the tool so it's not buried. */}
|
||||
{category.id === "classification" && (
|
||||
<ClassificationLabelsSection canConfigure={canConfigure} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === 2 && (
|
||||
<>
|
||||
<p className="pol-desc">{category.desc}</p>
|
||||
{config.fields.length > 0 && (
|
||||
<Card padding="none">
|
||||
{config.fields.map((f, i) => (
|
||||
<PolicyFieldRow
|
||||
key={f.key}
|
||||
field={f}
|
||||
value={fieldValues[f.key]}
|
||||
first={i === 0}
|
||||
onChange={(v) =>
|
||||
setFieldValues((prev) => ({ ...prev, [f.key]: v }))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Real, working output + retry settings (applied by the engine). */}
|
||||
<p className="pol-section-label">
|
||||
{t("policies.wizard.outputRetriesLabel", "Output & retries")}
|
||||
</p>
|
||||
<Card padding="none">
|
||||
{/* The editor event the policy runs on: input on upload, or
|
||||
output on export (enforced before the file is exported). */}
|
||||
<div className="pol-subhead">
|
||||
{t("policies.wizard.runOnSubhead", "Run on")}
|
||||
</div>
|
||||
<div className="pol-field" data-first>
|
||||
<SettingsRow
|
||||
label={t("policies.wizard.runOnLabel", "Run on")}
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={runOn}
|
||||
onChange={(value) =>
|
||||
setRunOn((value ?? "upload") as "upload" | "export")
|
||||
}
|
||||
aria-label={t("policies.wizard.runOnLabel", "Run on")}
|
||||
options={[
|
||||
{
|
||||
value: "upload",
|
||||
label: t("policies.wizard.runOnUpload", "Upload"),
|
||||
},
|
||||
{
|
||||
value: "export",
|
||||
label: t("policies.wizard.runOnExport", "Export"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-subhead">
|
||||
{t("policies.wizard.outputSubhead", "Output")}
|
||||
</div>
|
||||
<div className="pol-field" data-first>
|
||||
<SettingsRow
|
||||
label={t("policies.wizard.outputAsLabel", "Output as")}
|
||||
control={
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputMode}
|
||||
onChange={(value) => {
|
||||
const mode = (value ?? "new_file") as
|
||||
| "new_file"
|
||||
| "new_version";
|
||||
setOutputMode(mode);
|
||||
// Auto-number only applies to new files; a new version
|
||||
// replaces the file in place, so fall back to suffix.
|
||||
if (
|
||||
mode === "new_version" &&
|
||||
outputNamePosition === "auto-number"
|
||||
) {
|
||||
setOutputNamePosition("suffix");
|
||||
}
|
||||
}}
|
||||
aria-label={t(
|
||||
"policies.wizard.outputModeAria",
|
||||
"Output mode",
|
||||
)}
|
||||
options={[
|
||||
{
|
||||
value: "new_file",
|
||||
label: t("policies.wizard.outputNewFile", "New file"),
|
||||
},
|
||||
{
|
||||
value: "new_version",
|
||||
label: t(
|
||||
"policies.wizard.outputNewVersion",
|
||||
"New version",
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Output filename: position + custom text together as one row. */}
|
||||
<div className="pol-subhead">
|
||||
{t("policies.wizard.outputFilenameSubhead", "Output filename")}
|
||||
</div>
|
||||
<div className="pol-field" data-first>
|
||||
<div className="pol-name-row">
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value={outputNamePosition}
|
||||
onChange={(value) =>
|
||||
setOutputNamePosition(
|
||||
(value ?? "suffix") as
|
||||
| "prefix"
|
||||
| "suffix"
|
||||
| "auto-number",
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"policies.wizard.filenamePositionAria",
|
||||
"Filename position",
|
||||
)}
|
||||
options={[
|
||||
{
|
||||
value: "prefix",
|
||||
label: t("policies.wizard.filenamePrefix", "Prefix"),
|
||||
},
|
||||
{
|
||||
value: "suffix",
|
||||
label: t("policies.wizard.filenameSuffix", "Suffix"),
|
||||
},
|
||||
// Auto-number only makes sense for separate new files.
|
||||
...(outputMode === "new_file"
|
||||
? [
|
||||
{
|
||||
value: "auto-number",
|
||||
label: t(
|
||||
"policies.wizard.filenameAutoNumber",
|
||||
"Auto-number",
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{/* Auto-number names the file itself, so there's no custom
|
||||
text to add — only show the input for prefix/suffix. */}
|
||||
{outputNamePosition !== "auto-number" && (
|
||||
<Input
|
||||
inputSize="sm"
|
||||
value={outputName}
|
||||
onChange={(e) => setOutputName(e.target.value)}
|
||||
placeholder={t(
|
||||
"policies.wizard.filenameTextPlaceholder",
|
||||
"Text to add (optional)",
|
||||
)}
|
||||
aria-label={t(
|
||||
"policies.wizard.filenameTextAria",
|
||||
"Filename text",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label={t("policies.wizard.maxRetriesLabel", "Max retries")}
|
||||
control={
|
||||
<Input
|
||||
type="number"
|
||||
inputSize="sm"
|
||||
value={String(maxRetries)}
|
||||
onChange={(e) =>
|
||||
setMaxRetries(Math.max(0, Number(e.target.value) || 0))
|
||||
}
|
||||
aria-label={t(
|
||||
"policies.wizard.maxRetriesLabel",
|
||||
"Max retries",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="pol-field">
|
||||
<SettingsRow
|
||||
label={t(
|
||||
"policies.wizard.retryDelayLabel",
|
||||
"Retry delay (min)",
|
||||
)}
|
||||
control={
|
||||
<Input
|
||||
type="number"
|
||||
inputSize="sm"
|
||||
value={String(retryDelayMinutes)}
|
||||
onChange={(e) =>
|
||||
setRetryDelayMinutes(
|
||||
Math.max(0, Number(e.target.value) || 0),
|
||||
)
|
||||
}
|
||||
aria-label={t(
|
||||
"policies.wizard.retryDelayAria",
|
||||
"Retry delay minutes",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Sources step — kept in code, out of the flow for this release
|
||||
(SOURCES_IN_FLOW), since sources are always "editor" for now. */}
|
||||
{SOURCES_IN_FLOW && step === 3 && (
|
||||
<>
|
||||
<p className="pol-desc">
|
||||
{t(
|
||||
"policies.wizard.sourcesDesc",
|
||||
"Choose where this policy runs and which document types it applies to.",
|
||||
)}
|
||||
</p>
|
||||
<p className="pol-section-label">
|
||||
{t("policies.wizard.sourcesLabel", "Sources")}
|
||||
</p>
|
||||
<Card padding="none">
|
||||
{sourceDefs.map((src, i) => (
|
||||
<div
|
||||
key={src.id}
|
||||
className="pol-source"
|
||||
data-first={i === 0 || undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={sources.includes(src.id)}
|
||||
onChange={() => toggleSource(src.id)}
|
||||
leadingIcon={src.icon}
|
||||
label={src.label}
|
||||
description={src.desc}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
|
||||
<p className="pol-section-label">
|
||||
{t("policies.wizard.docTypesLabel", "Document types")}
|
||||
</p>
|
||||
{!classificationEnabled ? (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={<InfoOutlinedIcon sx={{ fontSize: "1rem" }} />}
|
||||
title={t(
|
||||
"policies.wizard.allDocTypesTitle",
|
||||
"All document types",
|
||||
)}
|
||||
description={t(
|
||||
"policies.wizard.allDocTypesDescription",
|
||||
"Enable the Classification policy to filter by document type.",
|
||||
)}
|
||||
action={
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
onClick={onSetupClassification}
|
||||
>
|
||||
{t(
|
||||
"policies.wizard.setupClassification",
|
||||
"Set up Classification",
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Card padding="none">
|
||||
<div className="pol-doctypes-head">
|
||||
<span className="pol-field-label">
|
||||
{scopeTypes.length === 0
|
||||
? t(
|
||||
"policies.wizard.allDocTypesTitle",
|
||||
"All document types",
|
||||
)
|
||||
: t(
|
||||
"policies.wizard.typesSelected",
|
||||
"{{count}} types selected",
|
||||
{ count: scopeTypes.length },
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="pol-link"
|
||||
onClick={() => setScopeNarrow((v) => !v)}
|
||||
>
|
||||
{scopeNarrow
|
||||
? t("policies.wizard.clear", "Clear")
|
||||
: t("policies.wizard.edit", "Edit")}
|
||||
</Button>
|
||||
</div>
|
||||
{scopeNarrow && (
|
||||
<div className="pol-doctypes">
|
||||
{docTypes.map((dt) => (
|
||||
<Checkbox
|
||||
key={dt}
|
||||
checked={scopeTypes.includes(dt)}
|
||||
onChange={() =>
|
||||
setScopeTypes((prev) =>
|
||||
prev.includes(dt)
|
||||
? prev.filter((d) => d !== dt)
|
||||
: [...prev, dt],
|
||||
)
|
||||
}
|
||||
label={t(`policies.docType.${dt}`, dt)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pol-footer">
|
||||
<Button variant="tertiary" size="sm" onClick={back}>
|
||||
{step > 1 ? t("policies.wizard.back", "Back") : t("cancel", "Cancel")}
|
||||
</Button>
|
||||
{step < TOTAL_STEPS ? (
|
||||
<Button
|
||||
size="sm"
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={() => setStep((s) => Math.min(TOTAL_STEPS, s + 1))}
|
||||
>
|
||||
{t("policies.wizard.continue", "Continue")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
style={{ marginLeft: "auto" }}
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
>
|
||||
{isEdit
|
||||
? t("policies.wizard.saveChanges", "Save Changes")
|
||||
: t("policies.wizard.enablePolicy", "Enable Policy")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader } from "@mantine/core";
|
||||
import { ToggleSwitch } from "@app/ui/ToggleSwitch";
|
||||
import { ActionIcon } from "@app/ui/ActionIcon";
|
||||
import { Card } from "@app/ui/Card";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
|
||||
import { PolicyRedactConfig } from "@app/components/policies/PolicyRedactConfig";
|
||||
import { PolicyWatermarkConfig } from "@app/components/policies/PolicyWatermarkConfig";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
|
||||
/**
|
||||
* Plain-language, non-technical descriptions shown by each tool's info button.
|
||||
* Stored as [i18n key, English default] pairs so they can be resolved with `t`
|
||||
* at render (the map lives at module scope, outside the component).
|
||||
*/
|
||||
const TOOL_PLAIN_INFO: Record<string, readonly [key: string, en: string]> = {
|
||||
redact: [
|
||||
"policies.toolConfig.info.redact",
|
||||
"Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document.",
|
||||
],
|
||||
sanitize: [
|
||||
"policies.toolConfig.info.sanitize",
|
||||
"Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it.",
|
||||
],
|
||||
watermark: [
|
||||
"policies.toolConfig.info.watermark",
|
||||
"Stamps a visible mark (e.g. “Confidential”) across every page.",
|
||||
],
|
||||
};
|
||||
|
||||
/** One tool in a policy's fixed chain: whether it runs + its configured params. */
|
||||
export interface PolicyToolState {
|
||||
/** Frontend tool-registry id (also the registry key + the thing we map to an endpoint). */
|
||||
operation: string;
|
||||
/** Whether this tool runs as part of the policy (the per-tool on/off). */
|
||||
enabled: boolean;
|
||||
/** Tool-specific parameters (the shape its endpoint accepts). */
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface PolicyToolConfigProps {
|
||||
/** The policy's fixed tool chain — locked (no add/remove), only configurable. */
|
||||
tools: PolicyToolState[];
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
onChange: (tools: PolicyToolState[]) => void;
|
||||
/** Read-only when the policy is managed / the user can't configure. */
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locked, configure-only tool panel for a policy. The chain is fixed (you can't
|
||||
* add or remove tools); each tool is a section that renders its OWN settings form
|
||||
* from the tool registry (`automationSettings`) — the same forms the automation
|
||||
* builder uses — so the config is generated from the tools in the workflow, not
|
||||
* hardcoded per policy. The parameters produced here are exactly what the backend
|
||||
* engine POSTs to each tool's endpoint.
|
||||
*/
|
||||
export function PolicyToolConfig({
|
||||
tools,
|
||||
toolRegistry,
|
||||
onChange,
|
||||
editable = true,
|
||||
}: PolicyToolConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const patchTool = (index: number, patch: Partial<PolicyToolState>) =>
|
||||
onChange(tools.map((t, i) => (i === index ? { ...t, ...patch } : t)));
|
||||
|
||||
return (
|
||||
<div className="pol-tool-config">
|
||||
{tools.map((tool, index) => {
|
||||
const entry = toolRegistry[tool.operation as ToolId];
|
||||
const Settings = entry?.automationSettings ?? null;
|
||||
const toolName = entry?.name ?? tool.operation;
|
||||
const plainInfo = TOOL_PLAIN_INFO[tool.operation];
|
||||
return (
|
||||
<Card key={tool.operation} padding="none">
|
||||
<div className="pol-tool-head">
|
||||
<span className="pol-tool-icon">{entry?.icon}</span>
|
||||
<span className="pol-tool-name">{toolName}</span>
|
||||
{plainInfo && (
|
||||
<AppTooltip
|
||||
content={t(plainInfo[0], plainInfo[1])}
|
||||
sidebarTooltip
|
||||
pinOnClick
|
||||
>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="tertiary"
|
||||
className="pol-info-btn"
|
||||
aria-label={t(
|
||||
"policies.toolConfig.infoAriaLabel",
|
||||
"What does {{tool}} do?",
|
||||
{ tool: toolName },
|
||||
)}
|
||||
>
|
||||
<LocalIcon
|
||||
icon="info-outline-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
style={{ color: "var(--icon-files-color)" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</AppTooltip>
|
||||
)}
|
||||
<ToggleSwitch
|
||||
size="sm"
|
||||
checked={tool.enabled}
|
||||
disabled={!editable}
|
||||
onChange={(checked) => patchTool(index, { enabled: checked })}
|
||||
aria-label={t(
|
||||
"policies.toolConfig.enableAriaLabel",
|
||||
"Enable {{tool}}",
|
||||
{
|
||||
tool: toolName,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{tool.enabled &&
|
||||
(tool.operation === "redact" ? (
|
||||
<div className="pol-tool-body">
|
||||
<PolicyRedactConfig
|
||||
parameters={tool.parameters}
|
||||
onChange={(parameters) => patchTool(index, { parameters })}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
) : tool.operation === "sanitize" ? (
|
||||
// Sanitize is config-less: it only removes JavaScript (params
|
||||
// are fixed in the policy preset), so no settings are shown.
|
||||
<></>
|
||||
) : tool.operation === "watermark" ? (
|
||||
// Watermark settings with flatten hidden + forced on (see
|
||||
// PolicyWatermarkConfig).
|
||||
<div className="pol-tool-body">
|
||||
<PolicyWatermarkConfig
|
||||
parameters={tool.parameters}
|
||||
onChange={(parameters) => patchTool(index, { parameters })}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
) : Settings ? (
|
||||
<div className="pol-tool-body">
|
||||
<Suspense fallback={<Loader size="sm" />}>
|
||||
<Settings
|
||||
parameters={tool.parameters}
|
||||
onParameterChange={(key: string, value: unknown) =>
|
||||
patchTool(index, {
|
||||
parameters: { ...tool.parameters, [key]: value },
|
||||
})
|
||||
}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useState, useEffect, type MutableRefObject } from "react";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { buildPipelineDefinition } from "@app/services/policyPipeline";
|
||||
import {
|
||||
PolicyToolConfig,
|
||||
type PolicyToolState,
|
||||
} from "@app/components/policies/PolicyToolConfig";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import type { AutomationOperation } from "@app/types/automation";
|
||||
|
||||
/**
|
||||
* Seed a tool's parameters: start from the tool's own registry defaults, overlay
|
||||
* the preset's defaults (e.g. the PII patterns), then apply only the saved
|
||||
* values the user actually changed from the tool default. This means a policy
|
||||
* saved while a param was at its default (an empty redact list) still inherits
|
||||
* the preset value, while genuine user edits are preserved.
|
||||
*/
|
||||
function seedToolParameters(
|
||||
registryDefaults: Record<string, unknown>,
|
||||
presetParams: Record<string, unknown>,
|
||||
savedParams: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> = {
|
||||
...registryDefaults,
|
||||
...presetParams,
|
||||
};
|
||||
const eq = (a: unknown, b: unknown) =>
|
||||
JSON.stringify(a) === JSON.stringify(b);
|
||||
for (const [key, value] of Object.entries(savedParams)) {
|
||||
if (!eq(value, registryDefaults[key])) merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
interface PolicyToolConfigStepProps {
|
||||
/** The fixed, configurable tool chain (locked set) for this policy. */
|
||||
chainIds: string[];
|
||||
/** Operations to seed enabled/params from (saved ops, or preset defaults). */
|
||||
initialOperations: AutomationOperation[];
|
||||
/**
|
||||
* The preset's default operations. Their params seed any tool whose saved
|
||||
* value is still at the tool's own default — so e.g. a policy saved with the
|
||||
* PII list at its default inherits the preset patterns rather than running empty.
|
||||
*/
|
||||
presetOperations: AutomationOperation[];
|
||||
/** Used to name the built pipeline definition. */
|
||||
categoryLabel: string;
|
||||
/** The wizard triggers this on its final submit (mirrors PolicyWorkflowStep). */
|
||||
saveTriggerRef: MutableRefObject<(() => void) | null>;
|
||||
/** Emits the enabled tools as operations + the endpoint-mapped backend steps. */
|
||||
onComplete: (
|
||||
operations: AutomationOperation[],
|
||||
pipelineSteps: { operation: string; parameters: Record<string, unknown> }[],
|
||||
unresolvedOps: string[],
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The wizard's Workflow step for preset (tool-chain) policies: the locked,
|
||||
* per-tool config ({@link PolicyToolConfig}) instead of the add/remove builder.
|
||||
* Isolates the ToolWorkflow dependency (so it's mockable in the rail tests) and
|
||||
* wires the wizard's submit trigger to emit the configured tools as operations
|
||||
* + the endpoint-mapped pipeline steps.
|
||||
*/
|
||||
export function PolicyToolConfigStep({
|
||||
chainIds,
|
||||
initialOperations,
|
||||
presetOperations,
|
||||
categoryLabel,
|
||||
saveTriggerRef,
|
||||
onComplete,
|
||||
}: PolicyToolConfigStepProps) {
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
|
||||
const [tools, setTools] = useState<PolicyToolState[]>(() =>
|
||||
chainIds.map((op) => {
|
||||
const saved = initialOperations.find((o) => o.operation === op);
|
||||
const preset = presetOperations.find((o) => o.operation === op);
|
||||
const defaults = (toolRegistry[op as ToolId]?.operationConfig
|
||||
?.defaultParameters ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
operation: op,
|
||||
enabled: Boolean(saved),
|
||||
parameters: seedToolParameters(
|
||||
defaults,
|
||||
(preset?.parameters ?? {}) as Record<string, unknown>,
|
||||
(saved?.parameters ?? {}) as Record<string, unknown>,
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// Re-wire the submit trigger whenever the tools change so it emits the latest.
|
||||
useEffect(() => {
|
||||
saveTriggerRef.current = () => {
|
||||
const operations: AutomationOperation[] = tools
|
||||
.filter((t) => t.enabled)
|
||||
.map((t) => ({ operation: t.operation, parameters: t.parameters }));
|
||||
const { definition, unresolved } = buildPipelineDefinition(
|
||||
{ name: `${categoryLabel} Policy`, operations },
|
||||
toolRegistry,
|
||||
);
|
||||
onComplete(operations, definition.steps, unresolved);
|
||||
};
|
||||
}, [tools, toolRegistry, categoryLabel, saveTriggerRef, onComplete]);
|
||||
|
||||
return (
|
||||
<PolicyToolConfig
|
||||
tools={tools}
|
||||
toolRegistry={toolRegistry}
|
||||
onChange={setTools}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import AutomationCreation from "@app/components/tools/automate/AutomationCreation";
|
||||
import { AutomationMode } from "@app/types/automation";
|
||||
import type { AutomationConfig } from "@app/types/automation";
|
||||
import type { ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
|
||||
interface PolicyWorkflowStepProps {
|
||||
/**
|
||||
* The automation to seed/edit. For setup this is a synthetic config carrying
|
||||
* the category preset's operations; for edit it's the policy's backing
|
||||
* automation.
|
||||
*/
|
||||
automation: AutomationConfig;
|
||||
/** SUGGESTED seeds-then-creates (setup); EDIT updates in place (settings). */
|
||||
mode: AutomationMode;
|
||||
/** The host (wizard) triggers the builder's save imperatively from its footer. */
|
||||
saveTriggerRef: MutableRefObject<(() => void) | null>;
|
||||
/**
|
||||
* Called with the saved automation once the builder persists it, plus the tool
|
||||
* registry (which lives here) so the wizard can map operations to backend
|
||||
* endpoint paths without depending on the ToolWorkflow context itself.
|
||||
*/
|
||||
onComplete: (
|
||||
automation: AutomationConfig,
|
||||
toolRegistry: Partial<ToolRegistry>,
|
||||
) => void;
|
||||
/** Called when save is triggered but the workflow isn't in a saveable state. */
|
||||
onSaveFailed?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy wizard's "Workflow" step: the Watch Folders automation builder
|
||||
* ({@link AutomationCreation}) reused to define a policy's tool pipeline. Kept
|
||||
* as its own component so the heavy builder + its ToolWorkflow dependency are
|
||||
* isolated (and mockable in the rail tests).
|
||||
*/
|
||||
export function PolicyWorkflowStep({
|
||||
automation,
|
||||
mode,
|
||||
saveTriggerRef,
|
||||
onComplete,
|
||||
onSaveFailed,
|
||||
}: PolicyWorkflowStepProps) {
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
return (
|
||||
<AutomationCreation
|
||||
mode={mode}
|
||||
existingAutomation={automation}
|
||||
toolRegistry={toolRegistry}
|
||||
hideMetadata
|
||||
nameOverride={automation.name}
|
||||
saveTriggerRef={saveTriggerRef}
|
||||
onBack={() => {}}
|
||||
onComplete={(saved) => onComplete(saved, toolRegistry)}
|
||||
onSaveFailed={onSaveFailed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { AutomationMode };
|
||||
@@ -1,84 +1,35 @@
|
||||
# Policies (frontend)
|
||||
# Policies (editor)
|
||||
|
||||
Automation-backed document-enforcement policies — conceptually like Watch
|
||||
Folders but backend-driven, with non-folder triggers (editor save/export,
|
||||
device sweeps, cloud connectors). **This is the frontend only**: per-policy
|
||||
state is persisted locally (localStorage) and activity + stats are derived from
|
||||
your real uploaded files; server persistence + real enforcement land in a
|
||||
follow-up. It ships behind the `POLICIES_ENABLED` feature flag (proprietary
|
||||
build = on while in development, core build = off).
|
||||
Automation-backed document-enforcement policies. The editor side is
|
||||
**enforcement only**: policies are configured in the admin portal
|
||||
(`src/portal/views/Policies.tsx`); the editor runs enabled policies on
|
||||
uploaded files, blocks the file's exit points while a run is in flight, and
|
||||
badges files a policy has produced. It ships behind the `POLICIES_ENABLED`
|
||||
feature flag (SaaS build = on; proprietary and core builds = off; desktop
|
||||
additionally requires an active SaaS connection).
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | Role |
|
||||
|------|------|
|
||||
| `types/policies.ts` | Type model (category, fields, state). |
|
||||
| `data/policyDefinitions.tsx` | Static preset definitions for the catalog: 5 categories (with the `providesClassification` data flag), per-category config fields, sources, doc types, and each category's default tool pipeline. Read it through `policyCatalog`, not directly. |
|
||||
| `services/policyCatalog.ts` | **The definitions seam.** `loadPolicyCatalog()` returns categories/configs/sources/doc-types. Components reach definitions only through here (via `usePolicyCatalog`) — swap this one function for a backend fetch to go live without touching a component. |
|
||||
| `hooks/usePolicyCatalog.ts` | Hook over the catalog seam (memoised; where loading/error state lands when it becomes async). |
|
||||
| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy **state** + change events. Swap this layer for the real API. |
|
||||
| `hooks/usePolicies.ts` | State + lifecycle actions + permission flag. |
|
||||
| `services/policyLiveData.ts` | Derives the detail view's activity feed + stats from the user's real uploaded files. |
|
||||
| `components/policies/PoliciesSidebar.tsx` | The three right-rail slots: list section, detail takeover, collapsed-rail icons (+ `usePoliciesEnabled` / `usePolicyDetailActive`). Shadows the core stub. |
|
||||
| `components/policies/policySelectionStore.ts` | Shared selected-policy / detail-view store the three slots sync through. |
|
||||
| `components/policies/PolicySetupWizard.tsx` | 3-step setup (operations → sources/types → reviewer/confirm). |
|
||||
| `components/policies/PolicyDetailPanel.tsx` | Configured "narrative" view (Enforces / Activity / Stats). |
|
||||
| `components/policies/PolicySettingsForm.tsx` | Edit-settings sub-view. |
|
||||
| `components/policies/PolicyFieldRow.tsx` | toggle / select / chips / text field renderer (SUI `SettingsRow` + `ToggleSwitch`/`Select`/`Input`/`Chip`). |
|
||||
| `data/policyDefinitions.tsx` | Static preset definitions for the catalog. Read through `services/policyCatalog.ts` (`loadPolicyCatalog()`), not directly. |
|
||||
| `services/policyStorage.ts` | Local persistence (localStorage) of per-policy state + change events. |
|
||||
| `hooks/usePolicies.ts` | Policy state + permission flag, consumed by the auto-run controller. |
|
||||
| `hooks/usePolicyFileBadges.ts` | Per-file badge map (which policies produced/are enforcing a file) — drives the shared `PolicyBadges` row and the exit-point blocking. |
|
||||
| `components/policies/usePoliciesEnabled.ts` | The single build/connection gate for mounting the auto-run controller. Core stub = false; desktop shadow adds the SaaS-connection check. |
|
||||
| `components/policies/PolicyAutoRunController.tsx` | Headless: enforces enabled policies on every uploaded file. Mounted by `RightSidebar`. |
|
||||
| `components/policies/usePolicyAutoRun.ts` | The auto-run engine: dispatch, polling, retry, output import, server reconcile. |
|
||||
| `components/policies/policyRunStore.ts` | `useSyncExternalStore` store of run records (status, progress, outputs), persisted to localStorage. |
|
||||
| `components/policies/enforcementQueue.ts` | Export-time enforcement queue used by `services/policyExport.ts`. |
|
||||
| `components/policies/policyStatus.ts` | Category → accent-colour mapping shared by badges and export toasts. |
|
||||
|
||||
The core build gets a no-op stub at `core/components/policies/PoliciesSidebar.tsx`;
|
||||
`RightSidebar` (core) consumes the seam, so the section appears only in
|
||||
proprietary builds.
|
||||
|
||||
## Design system (SUI + Mantine)
|
||||
|
||||
The surface is composed almost entirely from the shared SUI design system
|
||||
(`@app/ui`), mixed with Mantine only where SUI has no equivalent.
|
||||
SUI components used here: `PanelHeader` (+ leading `IconBadge`), `Card`,
|
||||
`Button`, `Chip`, `ChipFlow`, `StatusBadge`, `Banner`, `EmptyState`,
|
||||
`MetricCard`, `Input`, `Select`, `ToggleSwitch`, `Checkbox`, `FormField`,
|
||||
`NavItem` (status `accent`), `ListRow`, `DataRow`, `SectionHeader`,
|
||||
`StepIndicator`. Several of those (`IconBadge`, `ListRow`, `DataRow`,
|
||||
`SectionHeader`, `StepIndicator`, `ChipFlow`, `SettingsRow`, plus the `NavItem`
|
||||
accent / `PanelHeader` icon slot / `Checkbox` leadingIcon / `MetricCard size`)
|
||||
were **built up in SUI** as part of this work — each has a Storybook story.
|
||||
|
||||
Bootstrapping: the editor loads `@app/tokens/tokens.css` globally via
|
||||
`ThemeProvider`, which also mirrors the Mantine colour scheme onto
|
||||
`<html data-theme>` (SUI's dark palette keys on `data-theme`).
|
||||
|
||||
The bespoke `.pol-*` CSS in `Policies.css` is now only thin layout scaffolding
|
||||
(detail/scroll/footer wrappers, the collapsed rail, row insets that match SUI
|
||||
`ListRow`); spacing snaps to the SUI `--space-*` scale and colour to the SUI
|
||||
token set.
|
||||
|
||||
**Status-colour convention (locked):** blue = accent/identity (NavItem accent
|
||||
bar, rail icon, detail Card accent — the prototype's blue); green `success`
|
||||
StatusBadge = the "Active" pill/dot everywhere (list + detail + rail dot);
|
||||
amber = paused. Configured rows render as raised cards (surface + border).
|
||||
|
||||
## Faithful to the prototype
|
||||
|
||||
5 categories (Ingestion, Security, Compliance, Routing, Retention), their full
|
||||
field sets, the 3-step wizard (incl. the doc-type step gated behind the
|
||||
Classification/ingestion policy), the configured narrative view (Enforces /
|
||||
recent-activity feed / three-up stats), settings, the permission model
|
||||
(owner/admin/member + solo), and the
|
||||
docked right-sidebar placement: a collapsible **Policies** list above Tools, a
|
||||
detail view that takes over the rail when a policy is open, and a collapsed-rail
|
||||
of policy icons with active/paused status dots.
|
||||
|
||||
## Deviations / follow-ups
|
||||
|
||||
- **Billing upgrade flows.** The prototype's free → pay-as-you-go → enterprise
|
||||
upgrade/commit/bespoke modals live in the Settings billing tab — a
|
||||
billing-integration surface (Stripe/org state that doesn't exist yet),
|
||||
deferred. The in-rail surface shows only the spend-limit warning chip, as in
|
||||
the prototype's policy section.
|
||||
- **Backend.** All persistence, enforcement, activity, and stats are mock. To
|
||||
go live, replace `services/policyStorage.ts` and feed real activity/stats.
|
||||
Enforcement UI lives with the surfaces it gates: `PolicyEnforcementOverlay`
|
||||
(proprietary viewer), `PolicyEnforcingOverlay` (thumbnails + viewer overlay
|
||||
body), and the shared `PolicyBadges` row (`core/components/shared/`).
|
||||
|
||||
## Tests
|
||||
|
||||
`services/policyStorage.test.ts` (seed/update/reset/heal/events) and
|
||||
`data/policyDefinitions.test.ts` (permission matrix + definition integrity).
|
||||
`policyRunStore.test.ts`, `usePolicyAutoRun.test.ts` (+ `.retry` / `.import`
|
||||
variants), `hooks/usePolicyFileBadges.test.ts`,
|
||||
`services/policyStorage.test.ts`, and `data/policyDefinitions.test.ts`.
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Tiny external store for the currently-selected policy and its detail sub-view.
|
||||
*
|
||||
* The Policies surface is split across two slots in the right tool sidebar — the
|
||||
* list section (above Tools) and the detail takeover (which replaces Tools when a
|
||||
* policy is open) — plus the collapsed-rail icons. They live in different parts of
|
||||
* {@code RightSidebar}'s tree, so selection can't be component-local useState.
|
||||
* This module-level store (read via {@code useSyncExternalStore}) lets all three
|
||||
* stay in sync without threading a context through the core sidebar.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { PolicyDetailView } from "@app/types/policies";
|
||||
|
||||
interface PolicySelection {
|
||||
selectedId: string | null;
|
||||
detailView: PolicyDetailView;
|
||||
/** The policy-settings page (execution order) takes over the rail. Independent
|
||||
* of {@link selectedId} — it's a section-level view, not tied to one policy. */
|
||||
settingsOpen: boolean;
|
||||
}
|
||||
|
||||
let state: PolicySelection = {
|
||||
selectedId: null,
|
||||
detailView: "detail",
|
||||
settingsOpen: false,
|
||||
};
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): PolicySelection {
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Deterministic initial snapshot for SSR/hydration (never the mutable store). */
|
||||
const SERVER_SNAPSHOT: PolicySelection = {
|
||||
selectedId: null,
|
||||
detailView: "detail",
|
||||
settingsOpen: false,
|
||||
};
|
||||
function getServerSnapshot(): PolicySelection {
|
||||
return SERVER_SNAPSHOT;
|
||||
}
|
||||
|
||||
/** Open a policy's detail (resets the sub-view to the narrative). */
|
||||
export function selectPolicy(id: string | null) {
|
||||
state = { selectedId: id, detailView: "detail", settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Switch the open policy between its narrative and edit-settings sub-views. */
|
||||
export function setPolicyDetailView(view: PolicyDetailView) {
|
||||
if (state.detailView === view) return;
|
||||
state = { ...state, detailView: view };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Open the policy-settings page (execution order). Clears any open policy. */
|
||||
export function openPolicySettings() {
|
||||
state = { selectedId: null, detailView: "detail", settingsOpen: true };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Close the policy-settings page and return to the list. */
|
||||
export function closePolicySettings() {
|
||||
if (!state.settingsOpen) return;
|
||||
state = { ...state, settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Close the open policy and return to the list. */
|
||||
export function closePolicy() {
|
||||
selectPolicy(null);
|
||||
}
|
||||
|
||||
/** Reset to the initial state — used by tests to isolate the module store. */
|
||||
export function resetPolicySelection() {
|
||||
state = { selectedId: null, detailView: "detail", settingsOpen: false };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function usePolicySelection(): PolicySelection {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@@ -1,21 +1,4 @@
|
||||
import type { IconBadgeAccent } from "@app/ui/IconBadge";
|
||||
import type { PolicyRowStatus, PolicyState } from "@app/types/policies";
|
||||
|
||||
/** Derive a single row/detail status from a policy's persisted state. */
|
||||
export function deriveRowStatus(
|
||||
state: PolicyState | undefined,
|
||||
): PolicyRowStatus {
|
||||
if (!state?.configured) return "setup";
|
||||
if (state.status === "paused") return "paused";
|
||||
return "active";
|
||||
}
|
||||
|
||||
/** Human label for each row status. */
|
||||
export const STATUS_LABEL: Record<PolicyRowStatus, string> = {
|
||||
active: "Active",
|
||||
paused: "Paused",
|
||||
setup: "Set up",
|
||||
};
|
||||
|
||||
/** Per-category icon accent — neutral (no tint background) across all categories. */
|
||||
export const ROW_ACCENT: Record<string, IconBadgeAccent> = {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { PolicyConfigDef, PolicyState } from "@app/types/policies";
|
||||
|
||||
/**
|
||||
* Resolve each field's effective value for a policy: the saved override from
|
||||
* state, falling back to the definition's default.
|
||||
*/
|
||||
export function resolveFieldValues(
|
||||
config: PolicyConfigDef,
|
||||
state: PolicyState,
|
||||
): Record<string, boolean | string | string[]> {
|
||||
const out: Record<string, boolean | string | string[]> = {};
|
||||
for (const f of config.fields) {
|
||||
out[f.key] = state.fieldValues[f.key] ?? f.value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
|
||||
/**
|
||||
* Whether policy enforcement is active for this build. Gates mounting the
|
||||
* headless PolicyAutoRunController. Shadows the core stub; the desktop build
|
||||
* shadows this again to additionally require an active SaaS connection.
|
||||
*/
|
||||
export function usePoliciesEnabled(): boolean {
|
||||
return POLICIES_ENABLED;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user