diff --git a/.github/aur/stirling-pdf-desktop/PKGBUILD b/.github/aur/stirling-pdf-desktop/PKGBUILD index 5d92425b19..fb6a99cfca 100644 --- a/.github/aur/stirling-pdf-desktop/PKGBUILD +++ b/.github/aur/stirling-pdf-desktop/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-desktop -pkgver=2.14.1 +pkgver=2.14.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (Tauri desktop app, official Stirling PDF Inc build)" arch=('x86_64') diff --git a/.github/aur/stirling-pdf-server-bin/PKGBUILD b/.github/aur/stirling-pdf-server-bin/PKGBUILD index f5a2bf3c6c..70bcee0423 100644 --- a/.github/aur/stirling-pdf-server-bin/PKGBUILD +++ b/.github/aur/stirling-pdf-server-bin/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Stirling PDF Inc pkgname=stirling-pdf-server-bin -pkgver=2.14.1 +pkgver=2.14.2 pkgrel=1 pkgdesc="Locally hosted, web-based PDF manipulation tool (server JAR, prebuilt)" arch=('any') diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 9e8a65f02c..7df9a49b46 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -510,6 +510,7 @@ jobs: # cargo output unsigned, so checking it produces false negatives. - name: Verify Windows Code Signature if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || (github.event_name == 'workflow_dispatch' && github.event.inputs.sign != 'false') || github.ref == 'refs/heads/V2-master') }} + timeout-minutes: 15 shell: pwsh run: | $allSigned = $true @@ -531,11 +532,26 @@ jobs: # Extract MSI and verify the inner exe (the file that actually gets installed). # This is the critical check - AV flags the installed exe at runtime. + # Use lessmsi, not `msiexec /a`: msiexec serializes on the global + # _MSIExecute mutex and hangs forever on hosted runners when another + # installer is busy. lessmsi reads MSI tables directly - no mutex, no service. $msi = $msiFiles[0].FullName $extractDir = Join-Path $env:RUNNER_TEMP "msi-verify" if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force } - $proc = Start-Process msiexec.exe -ArgumentList '/a', $msi, '/qn', "TARGETDIR=$extractDir" -Wait -PassThru -NoNewWindow - if ($proc.ExitCode -eq 0) { + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + + choco install lessmsi -y --no-progress --limit-output | Out-Null + + # Bound the extraction and kill on hang (defence in depth over timeout-minutes). + $proc = Start-Process lessmsi -ArgumentList 'x', "`"$msi`"", "`"$extractDir\`"" -PassThru -NoNewWindow + if (-not $proc.WaitForExit(120000)) { + try { $proc.Kill() } catch {} + Write-Host "[ERROR] MSI extraction timed out after 120s" + $allSigned = $false + } elseif ($proc.ExitCode -ne 0) { + Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" + $allSigned = $false + } else { $innerExe = Get-ChildItem -Path $extractDir -Filter "stirling-pdf.exe" -Recurse -File | Select-Object -First 1 if ($innerExe) { $sig = Get-AuthenticodeSignature -FilePath $innerExe.FullName @@ -548,9 +564,6 @@ jobs: Write-Host "[ERROR] Could not find stirling-pdf.exe inside MSI" $allSigned = $false } - } else { - Write-Host "[ERROR] Failed to extract MSI for verification (exit code: $($proc.ExitCode))" - $allSigned = $false } if (-not $allSigned) { @@ -800,7 +813,11 @@ jobs: uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 with: tag_name: v${{ needs.determine-matrix.outputs.version }} - generate_release_notes: true + # Don't regenerate/append notes on re-runs, and don't force this into the + # "Latest" slot - leave the release body and latest marker as they are. + generate_release_notes: false + append_body: false + make_latest: false fail_on_unmatched_files: true # Installers + updater payloads + manifest. .sig contents are embedded # in latest.json so the .sig files themselves are not uploaded. diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java index 9e44b35523..a7ef7d4512 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigService.java @@ -43,6 +43,10 @@ public class IntegrationConfigService { private final OwnershipService ownership; private final SecretMasker secretMasker; private final ResourceGrantRepository grantRepository; + // Bean-discovered extension points: features that understand a type contribute its config + // schema and report what still references a config, without this module depending on them. + private final List validators; + private final List usageChecks; // ---- commands ---- @@ -66,13 +70,21 @@ public class IntegrationConfigService { ? DefaultAccessPolicy.EXPLICIT_ONLY : request.defaultAccess()); + // TEAM scope may omit the team id: default to the caller's own team so clients (the + // portal) need not know it. assignOwnership still enforces admin-or-leader of that team. + Long ownerTeamId = request.ownerTeamId(); + if (ownerTeamId == null && scope == OwnerScope.TEAM && currentUser.getTeam() != null) { + ownerTeamId = currentUser.getTeam().getId(); + } ownership.assignOwnership( cfg, scope, - request.ownerTeamId(), + ownerTeamId, currentUser, () -> lockedServerExists(cfg.getIntegrationType())); - cfg.setConfig(writeJson(secretMasker.sanitize(request.config()))); + Map config = secretMasker.sanitize(request.config()); + validateConfig(cfg.getIntegrationType(), config); + cfg.setConfig(writeJson(config)); return repository.save(cfg); } @@ -101,8 +113,10 @@ public class IntegrationConfigService { cfg.setDefaultAccess(request.defaultAccess()); } if (request.config() != null) { - cfg.setConfig( - writeJson(secretMasker.merge(readJson(cfg.getConfig()), request.config()))); + Map merged = + secretMasker.merge(readJson(cfg.getConfig()), request.config()); + validateConfig(cfg.getIntegrationType(), merged); + cfg.setConfig(writeJson(merged)); } return repository.save(cfg); } @@ -113,6 +127,15 @@ public class IntegrationConfigService { if (!ownership.canManage(TYPE, cfg, currentUser)) { throw forbidden("You cannot manage this integration"); } + // Refuse to pull a connection out from under whatever still references it. + List usages = + usageChecks.stream() + .flatMap(check -> check.usagesOf(cfg.getId()).stream()) + .toList(); + if (!usages.isEmpty()) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, "Integration is in use by: " + String.join(", ", usages)); + } // Drop grants sharing this config so they do not dangle as dead rows. grantRepository.deleteByResourceTypeAndResourceId(TYPE, String.valueOf(cfg.getId())); repository.delete(cfg); @@ -188,6 +211,19 @@ public class IntegrationConfigService { // ---- integration-specific glue ---- + /** Runs every registered validator for the type; unknown types save free-form. */ + private void validateConfig(IntegrationType type, Map config) { + for (IntegrationConfigValidator validator : validators) { + if (validator.type() == type) { + try { + validator.validate(config == null ? Map.of() : config); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + } + } + /** A non-admin can't create a personal config of a type an admin has locked at server scope. */ private boolean lockedServerExists(IntegrationType type) { return repository.findByScope(OwnerScope.SERVER).stream() diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java new file mode 100644 index 0000000000..6d703baeda --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigUsageCheck.java @@ -0,0 +1,15 @@ +package stirling.software.proprietary.integration.service; + +import java.util.List; + +/** + * Reports what still references an integration config, so deletion can be refused instead of + * pulling a connection out from under a live consumer. Implementations are beans discovered by + * {@link IntegrationConfigService} (e.g. the policy subsystem reporting sources and pipelines that + * reference a connection). + */ +public interface IntegrationConfigUsageCheck { + + /** Human-readable labels of everything still using the config; empty when unreferenced. */ + List usagesOf(long configId); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java new file mode 100644 index 0000000000..05857d2714 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/integration/service/IntegrationConfigValidator.java @@ -0,0 +1,23 @@ +package stirling.software.proprietary.integration.service; + +import java.util.Map; + +import stirling.software.proprietary.integration.model.IntegrationType; + +/** + * Validates one integration type's config map at save time. Implementations are beans discovered by + * {@link IntegrationConfigService}, so the feature that understands a type (e.g. the policy S3 + * backend) owns its schema without the integration module depending on it. Types with no registered + * validator save free-form. + */ +public interface IntegrationConfigValidator { + + /** The type this validator understands. */ + IntegrationType type(); + + /** + * Validates the config as it will be stored (secrets already sanitized/merged, so values are + * real, never the redaction mask). Throws {@link IllegalArgumentException} on bad config. + */ + void validate(Map config); +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java index e2c89f6a54..95fde9304a 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/controller/PolicyController.java @@ -120,6 +120,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); + validateAdHocOutput(definition); PolicyInputs inputs = toInputs(files); PolicyRunHandle handle = policyRunner.runAdHoc(definition, inputs, PolicyProgressListener.NOOP); @@ -140,6 +141,7 @@ public class PolicyController { throws IOException { stampPolicyAudit(definition); requireRunnable(definition); + validateAdHocOutput(definition); PolicyInputs inputs = toInputs(files); SseEmitter emitter = @@ -530,6 +532,24 @@ public class PolicyController { } } + /** + * Authorization-check an ad-hoc run's output while the caller's principal is present (this + * request thread). The worker thread that later delivers carries no security context, so an S3 + * output's connection-access check would be skipped there; without this gate a caller could + * reference another tenant's connection by id and write to it (confused deputy). Stored + * policies are covered by save-time {@link PolicyValidator#validate} instead. + */ + private void validateAdHocOutput(PipelineDefinition definition) { + if (definition.output() == null) { + return; + } + try { + policyValidator.validateOutput(definition.output()); + } catch (IllegalArgumentException e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + /** * Ad-hoc runs (AI / one-off pipelines) are still editor activity, so their supplied documents * feed the same virtual editor source as stored editor policies, counted against the caller's diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java index c08d2dd857..92c4cf95d9 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/engine/PolicyValidator.java @@ -52,7 +52,19 @@ public class PolicyValidator { InputSpec spec = source.toInputSpec(); inputSourceFor(spec).validate(spec); } - outputSinkFor(policy.output()).validate(policy.output()); + validateOutput(policy.output()); + } + + /** + * Validate an output spec against its sink. Must be called on a request thread (caller's + * principal present) so an S3 output's connection is authorization-checked against the caller - + * ad-hoc runs are never persisted and so never hit {@link #validate(Policy)}, and the worker + * thread that later delivers has no principal, so this is their only access gate. + * + * @throws IllegalArgumentException if the type is unknown or the config is invalid/inaccessible + */ + public void validateOutput(OutputSpec output) { + outputSinkFor(output).validate(output); } private PolicyTrigger triggerFor(TriggerConfig config) { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java index 99e189f326..fbbcfc4549 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/input/S3InputSource.java @@ -18,6 +18,7 @@ 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.S3ConnectionResolver; import stirling.software.proprietary.policy.s3.S3Identities; import software.amazon.awssdk.core.exception.SdkException; @@ -36,15 +37,14 @@ 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. + * Options: "connectionId" references the stored S3 connection (an {@code IntegrationConfig} owning + * bucket, region, endpoint, and credentials - resolved by {@link S3ConnectionResolver}); "prefix" + * (only keys starting with it are read) and "mode" are per-source, where mode 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 @@ -55,6 +55,7 @@ public class S3InputSource implements InputSource { private static final String TYPE = "s3"; private final S3ConnectionPool connectionPool; + private final S3ConnectionResolver connectionResolver; @Override public String type() { @@ -67,12 +68,12 @@ public class S3InputSource implements InputSource { } /** - * Fails fast at save time: bad config shape, a private endpoint without the operator opt-in, or - * a bucket the supplied credentials cannot list. + * Fails fast at save time: an unknown/disabled/unusable connection, bad config shape, a private + * endpoint without the operator opt-in, or a bucket the connection cannot list. */ @Override public void validate(InputSpec spec) { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); try { connectionPool.clientFor(config).listObjectsV2(listRequest(config).maxKeys(1).build()); } catch (SdkException e) { @@ -89,7 +90,7 @@ public class S3InputSource implements InputSource { @Override public List resolve(InputSpec spec, ResolveContext ctx) throws IOException { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(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". diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java index c7d740868a..fb590b3c2e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/output/S3OutputSink.java @@ -27,6 +27,7 @@ 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.S3ConnectionResolver; import stirling.software.proprietary.policy.s3.S3Identities; import software.amazon.awssdk.core.exception.SdkException; @@ -59,6 +60,7 @@ public class S3OutputSink implements PolicyOutputSink { private static final String TYPE = "s3"; private final S3ConnectionPool connectionPool; + private final S3ConnectionResolver connectionResolver; private final ProcessedLedger processedLedger; @Override @@ -72,19 +74,19 @@ public class S3OutputSink implements PolicyOutputSink { } /** - * 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. + * Connection resolution (including the saving user's right to use it) 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())); + connectionPool.clientFor(connectionResolver.resolve(spec.options())); } @Override public List deliver( OutputDelivery delivery, List outputs, OutputSpec spec) throws IOException { - S3Config config = S3Config.from(spec.options()); + S3Config config = connectionResolver.resolve(spec.options()); S3Client client = connectionPool.clientFor(config); List results = new ArrayList<>(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java new file mode 100644 index 0000000000..5aa82a8e66 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigration.java @@ -0,0 +1,212 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.DefaultAccessPolicy; +import stirling.software.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; +import stirling.software.proprietary.security.repository.TeamRepository; + +import tools.jackson.databind.ObjectMapper; + +/** + * One-time, idempotent extraction of legacy embedded S3 credentials into stored connections: + * sources and policy outputs written before connections shipped carry bucket/credentials in their + * own options; this rewrites each to reference a (deduplicated) S3 {@link IntegrationConfig} and + * keeps only per-use options (prefix, mode). MUST be programmatic - the option JSON is encrypted at + * the application layer, so no SQL migration can read it. + * + *

Idempotent by construction: rewritten rows no longer embed credentials, so re-runs find + * nothing to do. Connections are deduplicated against both this run's extractions and existing S3 + * connections; a concurrent multi-node boot can at worst create a redundant connection row, never + * corrupt a source. Ownership follows the owning row: team-scoped when the source/policy has a + * team, server-scoped otherwise (single-operator self-hosted). + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class EmbeddedS3CredentialMigration { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final List CONNECTION_OPTIONS = + List.of("bucket", "region", "endpoint", "accessKeyId", "secretAccessKey"); + // Field separator for the dedup key: a unit-separator control char that cannot appear in a + // bucket/region/endpoint/credential, so distinct field sets can never collide. + private static final char DELIMITER = '\u001f'; + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + private final IntegrationConfigRepository connections; + private final TeamRepository teamRepository; + + @EventListener(ApplicationReadyEvent.class) + @Transactional + public void migrate() { + Map byCredentialKey = indexExistingConnections(); + int migrated = 0; + for (Source source : sourceStore.all()) { + if (!"s3".equals(source.type()) || !embedsCredentials(source.options())) { + continue; + } + IntegrationConfig connection = + connectionFor(source.options(), source.teamId(), byCredentialKey); + sourceStore.save(withOptions(source, referencing(connection, source.options(), true))); + migrated++; + } + for (Policy policy : policyStore.all()) { + OutputSpec output = policy.output(); + if (!"s3".equals(output.type()) || !embedsCredentials(output.options())) { + continue; + } + IntegrationConfig connection = + connectionFor(output.options(), policy.teamId(), byCredentialKey); + policyStore.save( + withOutput( + policy, + new OutputSpec( + output.type(), + referencing(connection, output.options(), false)))); + migrated++; + } + if (migrated > 0) { + log.info("Extracted embedded S3 credentials from {} row(s) into connections", migrated); + } + } + + private static boolean embedsCredentials(Map options) { + return options.get("accessKeyId") != null; + } + + /** Reuses an existing connection with identical coordinates+credentials, else creates one. */ + private IntegrationConfig connectionFor( + Map options, Long teamId, Map byKey) { + String key = credentialKey(options); + IntegrationConfig existing = byKey.get(key); + if (existing != null) { + return existing; + } + IntegrationConfig connection = new IntegrationConfig(); + connection.setIntegrationType(IntegrationType.S3); + connection.setName(connectionName(options, byKey)); + connection.setEnabled(true); + connection.setLocked(false); + connection.setDefaultAccess(DefaultAccessPolicy.EXPLICIT_ONLY); + Team team = teamId == null ? null : teamRepository.findById(teamId).orElse(null); + if (team != null) { + connection.setScope(OwnerScope.TEAM); + connection.setOwnerTeam(team); + } else { + // No team (teamless self-hosted, or a source whose team was since deleted): server + // scope, i.e. admin-owned. An orphaned-team source's non-admin editor would then need + // an admin to re-share the connection - acceptable for the narrow orphaned case. + connection.setScope(OwnerScope.SERVER); + } + Map config = new LinkedHashMap<>(); + for (String option : CONNECTION_OPTIONS) { + Object value = options.get(option); + if (value != null && !value.toString().isBlank()) { + config.put(option, value); + } + } + connection.setConfig(OBJECT_MAPPER.writeValueAsString(config)); + IntegrationConfig saved = connections.save(connection); + byKey.put(key, saved); + return saved; + } + + /** The rewritten options: the connection reference plus per-use settings only. */ + private static Map referencing( + IntegrationConfig connection, Map legacy, boolean keepMode) { + Map options = new LinkedHashMap<>(); + options.put(S3ConnectionResolver.CONNECTION_ID_OPTION, connection.getId()); + Object prefix = legacy.get("prefix"); + if (prefix != null && !prefix.toString().isBlank()) { + options.put("prefix", prefix); + } + Object mode = legacy.get("mode"); + if (keepMode && mode != null && !mode.toString().isBlank()) { + options.put("mode", mode); + } + return options; + } + + private Map indexExistingConnections() { + Map byKey = new LinkedHashMap<>(); + for (IntegrationConfig connection : connections.findAll()) { + if (connection.getIntegrationType() != IntegrationType.S3) { + continue; + } + try { + Map config = + OBJECT_MAPPER.readValue(connection.getConfig(), Map.class); + byKey.putIfAbsent(credentialKey(config), connection); + } catch (Exception e) { + log.debug( + "Skipping unreadable S3 connection {} while indexing: {}", + connection.getId(), + e.getMessage()); + } + } + return byKey; + } + + private static String credentialKey(Map options) { + StringBuilder key = new StringBuilder(); + for (String option : CONNECTION_OPTIONS) { + Object value = options.get(option); + key.append(value == null ? "" : value.toString().trim()).append(DELIMITER); + } + return key.toString(); + } + + private static String connectionName( + Map options, Map byKey) { + String base = "S3: " + options.getOrDefault("bucket", "bucket"); + long sameName = byKey.values().stream().filter(c -> c.getName().startsWith(base)).count(); + return sameName == 0 ? base : base + " (" + (sameName + 1) + ")"; + } + + private static Source withOptions(Source source, Map options) { + return new Source( + source.id(), + source.name(), + source.type(), + options, + source.enabled(), + source.owner(), + source.teamId()); + } + + 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()); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java new file mode 100644 index 0000000000..00d1b28e6c --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheck.java @@ -0,0 +1,56 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.proprietary.integration.service.IntegrationConfigUsageCheck; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.source.SourceStore; +import stirling.software.proprietary.policy.store.PolicyStore; + +/** + * Reports the policy sources and pipeline outputs referencing an S3 connection, so the connection + * cannot be deleted out from under them (mirrors {@code SourceController}'s referenced-source + * delete guard). Scans in memory - fine at admin-dashboard scale, always consistent with the live + * stores. + */ +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class PolicyS3ConnectionUsageCheck implements IntegrationConfigUsageCheck { + + private final SourceStore sourceStore; + private final PolicyStore policyStore; + + @Override + public List usagesOf(long configId) { + List usages = new ArrayList<>(); + for (Source source : sourceStore.all()) { + if (references(source.options(), configId)) { + usages.add("source '" + source.name() + "'"); + } + } + for (Policy policy : policyStore.all()) { + if (references(policy.output().options(), configId)) { + usages.add("pipeline '" + policy.name() + "'"); + } + } + return usages; + } + + private static boolean references(Map options, long configId) { + try { + Long reference = S3ConnectionResolver.connectionId(options); + return reference != null && reference == configId; + } catch (IllegalArgumentException unparseable) { + return false; + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java index 152a6de4cf..c9d3eabd83 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3Config.java @@ -5,10 +5,12 @@ 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. + * The fully resolved connection settings the S3 input source and output sink run with - normally + * produced by {@link S3ConnectionResolver} merging a stored connection (bucket, region, endpoint, + * credentials) with per-use options (prefix, mode), or parsed directly from legacy options that + * still embed credentials. 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, diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java new file mode 100644 index 0000000000..d15839aebe --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3ConnectionResolver.java @@ -0,0 +1,151 @@ +package stirling.software.proprietary.policy.s3; + +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.proprietary.access.model.ResourceType; +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +import tools.jackson.core.type.TypeReference; +import tools.jackson.databind.ObjectMapper; + +/** + * Turns a source's or output's options into a full {@link S3Config} by dereferencing its {@code + * connectionId} to a stored S3 {@link IntegrationConfig} (the connection owns bucket, region, + * endpoint, and credentials; the options own per-use settings such as prefix and mode). Options + * with no {@code connectionId} fall back to legacy embedded credentials, so rows written before + * connections shipped keep working until {@link EmbeddedS3CredentialMigration} rewrites them. + * + *

When an authenticated caller is present (save-time validation), they must be allowed to use + * the connection. Background sweeps and deliveries run with no caller and skip that check: the + * referencing source or policy was access-checked when it was saved. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class S3ConnectionResolver { + + static final String CONNECTION_ID_OPTION = "connectionId"; + private static final String PREFIX_OPTION = "prefix"; + private static final String MODE_OPTION = "mode"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final IntegrationConfigRepository connections; + private final OwnershipService ownership; + private final UserService userService; + + public S3Config resolve(Map options) { + Long connectionId = connectionId(options); + if (connectionId == null) { + // Legacy embedded credentials, pending migration. + return S3Config.from(options); + } + IntegrationConfig connection = + connections + .findById(connectionId) + .filter(cfg -> cfg.getIntegrationType() == IntegrationType.S3) + .filter(this::usableByCurrentUser) + // Existence and access collapse into one error: a caller must not be able + // to tell "no such connection" from "someone else's connection" and + // enumerate ids. The id/name are never echoed. + .orElseThrow( + () -> + new IllegalArgumentException( + "unknown or inaccessible s3 connection")); + if (!connection.isEnabled()) { + throw new IllegalArgumentException("s3 connection is disabled"); + } + Map merged = new LinkedHashMap<>(connectionConfig(connection)); + copyPerUseOption(options, merged, PREFIX_OPTION); + copyPerUseOption(options, merged, MODE_OPTION); + return S3Config.from(merged); + } + + /** The {@code connectionId} option as a long, or null when the options are legacy-embedded. */ + static Long connectionId(Map options) { + Object reference = options.get(CONNECTION_ID_OPTION); + if (reference == null || (reference instanceof String s && s.isBlank())) { + return null; + } + if (reference instanceof Number number) { + return number.longValue(); + } + try { + return Long.valueOf(reference.toString().trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "s3 'connectionId' is not a valid connection reference: " + reference); + } + } + + /** + * Whether the current caller may use this connection. With no principal - a background sweep or + * delivery on a worker thread that carries no {@code SecurityContext} - access is treated as + * already established: stored policies are validated with the caller present at save time, and + * ad-hoc runs are validated on the request thread before dispatch (see {@code + * PolicyValidator#validateOutput}). A missing principal must therefore never be the ONLY thing + * standing between a caller and a connection, or the check becomes a confused deputy. + */ + private boolean usableByCurrentUser(IntegrationConfig connection) { + User user = currentUser(); + return user == null || ownership.canUse(ResourceType.INTEGRATION_CONFIG, connection, user); + } + + // Mirrors ResourceAccessSecurity's principal resolution; null when unauthenticated. + private User currentUser() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated()) { + return null; + } + Object principal = auth.getPrincipal(); + if (principal instanceof User user) { + return user; + } + if (principal instanceof UserDetails userDetails) { + return userService.findByUsername(userDetails.getUsername()).orElse(null); + } + if (principal instanceof String username && !"anonymousUser".equals(username)) { + return userService.findByUsername(username).orElse(null); + } + return null; + } + + private static Map connectionConfig(IntegrationConfig connection) { + String json = connection.getConfig(); + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return OBJECT_MAPPER.readValue( + json, new TypeReference>() {}); + } catch (Exception e) { + throw new IllegalArgumentException( + "s3 connection '" + connection.getName() + "' has unreadable config", e); + } + } + + private static void copyPerUseOption( + Map options, Map merged, String key) { + Object value = options.get(key); + if (value != null && !value.toString().isBlank()) { + merged.put(key, value); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java new file mode 100644 index 0000000000..ee36ba5693 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/policy/s3/S3IntegrationValidator.java @@ -0,0 +1,51 @@ +package stirling.software.proprietary.policy.s3; + +import java.net.URI; +import java.util.Map; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.cluster.s3.S3Clients; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.service.IntegrationConfigValidator; + +/** + * The S3 connection schema, enforced when an S3 {@link IntegrationType} config is saved: bucket and + * credentials required, endpoint an http(s) URL that must not reach private addresses without the + * operator opt-in - the same rules {@link S3ConnectionPool} enforces before signing, moved to save + * time so a bad connection fails in the form rather than in a sweep. + */ +@Component +@RequiredArgsConstructor +@ConditionalOnBooleanProperty(name = "policies.enabled") +public class S3IntegrationValidator implements IntegrationConfigValidator { + + private final ApplicationProperties applicationProperties; + + @Override + public IntegrationType type() { + return IntegrationType.S3; + } + + @Override + public void validate(Map config) { + S3Config parsed = S3Config.from(config); + if (parsed.endpoint() == null) { + return; + } + try { + S3Clients.validateEndpointHost( + URI.create(parsed.endpoint()), + applicationProperties.getPolicies().isAllowPrivateS3Endpoints(), + "S3 connection endpoint", + "set policies.allowPrivateS3Endpoints=true to opt in (e.g. for a local" + + " MinIO)."); + } catch (IllegalStateException e) { + throw new IllegalArgumentException(e.getMessage(), e); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java index 2b2d22cfc7..eeeda1823b 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java @@ -103,7 +103,6 @@ public class User implements UserDetails, Serializable { @ElementCollection @MapKeyColumn(name = "setting_key") - @Lob @Column(name = "setting_value", columnDefinition = "text") @CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id")) @JsonIgnore diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java index 8e0ef200bb..fbfef15721 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/integration/service/IntegrationConfigServiceTest.java @@ -13,9 +13,9 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; @@ -52,7 +52,58 @@ class IntegrationConfigServiceTest { @Mock private stirling.software.proprietary.access.repository.ResourceGrantRepository grantRepository; - @InjectMocks private IntegrationConfigService service; + @Mock private IntegrationConfigValidator validator; + @Mock private IntegrationConfigUsageCheck usageCheck; + + private IntegrationConfigService service; + + @BeforeEach + void setUp() { + service = + new IntegrationConfigService( + repository, + ownership, + secretMasker, + grantRepository, + List.of(validator), + List.of(usageCheck)); + } + + @Test + void createRejectsAConfigItsTypeValidatorRefuses() { + when(secretMasker.sanitize(any())).thenReturn(Map.of()); + when(validator.type()).thenReturn(IntegrationType.API); + org.mockito.Mockito.doThrow(new IllegalArgumentException("api config needs a 'url'")) + .when(validator) + .validate(any()); + + assertThatThrownBy( + () -> + service.create( + request(IntegrationType.API, OwnerScope.USER, null), + user(7))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + } + + @Test + void deleteRefusedWhileAnythingStillReferencesTheConfig() { + IntegrationConfig cfg = config(9L); + when(repository.findById(9L)).thenReturn(Optional.of(cfg)); + when(ownership.canManage(any(), eq(cfg), any())).thenReturn(true); + when(usageCheck.usagesOf(9L)).thenReturn(List.of("source 'Claims intake'")); + + assertThatThrownBy(() -> service.delete(9L, user(7))) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.CONFLICT)); + verify(repository, org.mockito.Mockito.never()).delete(any(IntegrationConfig.class)); + } @Test void createDelegatesOwnershipAndSanitizesConfig() { diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java index c02945fdad..8258622e28 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/controller/PolicyControllerTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -194,6 +195,29 @@ class PolicyControllerTest { assertThat(((ResponseStatusException) e).getStatusCode()) .isEqualTo(HttpStatus.BAD_REQUEST)); } + + @Test + @DisplayName("rejects an ad-hoc output the caller cannot use, on the request thread") + void rejectsUnauthorizedAdHocOutput() { + // The confused-deputy guard: an S3 output referencing a connection the caller may not + // use is validated here (principal present) and refused before any worker dispatch. + PipelineDefinition definition = + new PipelineDefinition( + "pipe", + List.of(new PipelineStep("/api/v1/misc/compress-pdf", null)), + new OutputSpec("s3", Map.of("connectionId", 999))); + doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection")) + .when(policyValidator) + .validateOutput(any()); + + assertThatThrownBy(() -> controller.run(definition, new PolicyRunFiles())) + .isInstanceOf(ResponseStatusException.class) + .satisfies( + e -> + assertThat(((ResponseStatusException) e).getStatusCode()) + .isEqualTo(HttpStatus.BAD_REQUEST)); + verify(policyRunner, never()).runAdHoc(any(), any(), any()); + } } @Nested diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java index 8cdb1b45a3..21a9f3e42e 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/engine/PolicyValidatorTest.java @@ -82,6 +82,28 @@ class PolicyValidatorTest { assertTrue(ex.getMessage().contains("schedule")); } + @Test + void validateOutputDelegatesToTheSink() { + when(outputSink.supports(any())).thenReturn(true); + OutputSpec output = new OutputSpec("s3", Map.of("connectionId", 1)); + + validator.validateOutput(output); + + verify(outputSink).validate(output); + } + + @Test + void validateOutputSurfacesAnInaccessibleConnection() { + when(outputSink.supports(any())).thenReturn(true); + doThrow(new IllegalArgumentException("unknown or inaccessible s3 connection")) + .when(outputSink) + .validate(any()); + + assertThrows( + IllegalArgumentException.class, + () -> validator.validateOutput(new OutputSpec("s3", Map.of("connectionId", 1)))); + } + @Test void rejectsAnUnknownTriggerType() { when(trigger.type()).thenReturn("schedule"); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java index 4e1e1fc305..47de8f6092 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceMinioTest.java @@ -23,6 +23,7 @@ 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 stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -82,7 +83,9 @@ class S3InputSourceMinioTest { // 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)); + source = + new S3InputSource( + new S3ConnectionPool(properties), S3TestConnections.legacyResolver()); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); } @@ -161,7 +164,9 @@ class S3InputSourceMinioTest { @Test void aPrivateEndpointIsRejectedWithoutTheOperatorOptIn() { S3InputSource guarded = - new S3InputSource(new S3ConnectionPool(new ApplicationProperties())); + new S3InputSource( + new S3ConnectionPool(new ApplicationProperties()), + S3TestConnections.legacyResolver()); assertThatThrownBy(() -> guarded.validate(spec(Map.of()))) .isInstanceOf(IllegalArgumentException.class) diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java index 73995248dd..9054b4f304 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/input/S3InputSourceTest.java @@ -29,6 +29,7 @@ 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 stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.core.exception.SdkClientException; @@ -64,7 +65,8 @@ class S3InputSourceTest { void setUp() { source = new S3InputSource( - new S3ConnectionPool(new ApplicationProperties(), config -> s3Client)); + new S3ConnectionPool(new ApplicationProperties(), config -> s3Client), + S3TestConnections.legacyResolver()); ledger = new InProcessProcessedLedger(); ctx = new RecordingContext(); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java index a2a5a41ca0..153e6e1508 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkMinioTest.java @@ -28,6 +28,7 @@ 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 stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -90,8 +91,8 @@ class S3OutputSinkMinioTest { properties.getPolicies().setAllowPrivateS3Endpoints(true); S3ConnectionPool pool = new S3ConnectionPool(properties); ledger = new InProcessProcessedLedger(); - sink = new S3OutputSink(pool, ledger); - source = new S3InputSource(pool); + sink = new S3OutputSink(pool, S3TestConnections.legacyResolver(), ledger); + source = new S3InputSource(pool, S3TestConnections.legacyResolver()); } @Test diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java index 8240bcc4e1..a5a3327c51 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/output/S3OutputSinkTest.java @@ -32,6 +32,7 @@ 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 stirling.software.proprietary.policy.s3.S3TestConnections; import software.amazon.awssdk.awscore.exception.AwsServiceException; import software.amazon.awssdk.core.exception.SdkClientException; @@ -66,6 +67,7 @@ class S3OutputSinkTest { sink = new S3OutputSink( new S3ConnectionPool(new ApplicationProperties(), config -> s3Client), + S3TestConnections.legacyResolver(), ledger); } diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java new file mode 100644 index 0000000000..ff14d8cf81 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/EmbeddedS3CredentialMigrationTest.java @@ -0,0 +1,212 @@ +package stirling.software.proprietary.policy.s3; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.times; +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.atomic.AtomicLong; + +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.proprietary.access.model.OwnerScope; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; +import stirling.software.proprietary.security.repository.TeamRepository; + +/** + * Tests for {@link EmbeddedS3CredentialMigration}: legacy embedded credentials become deduplicated + * team-scoped connections, rewritten rows keep only per-use options, and re-runs are no-ops. + */ +@ExtendWith(MockitoExtension.class) +class EmbeddedS3CredentialMigrationTest { + + @Mock private IntegrationConfigRepository connections; + @Mock private TeamRepository teamRepository; + + private final InProcessSourceStore sourceStore = new InProcessSourceStore(); + private final InProcessPolicyStore policyStore = new InProcessPolicyStore(); + private EmbeddedS3CredentialMigration migration; + + @BeforeEach + void setUp() { + migration = + new EmbeddedS3CredentialMigration( + sourceStore, policyStore, connections, teamRepository); + AtomicLong ids = new AtomicLong(100); + // Lenient: the nothing-to-migrate cases never create a connection. + lenient().when(connections.findAll()).thenReturn(List.of()); + lenient() + .when(connections.save(any())) + .thenAnswer( + invocation -> { + IntegrationConfig saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(ids.incrementAndGet()); + } + return saved; + }); + } + + @Test + void extractsSharedCredentialsIntoOneTeamScopedConnection() { + Team team = new Team(); + team.setId(7L); + when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + Source source = + sourceStore.save( + new Source( + null, + "Claims intake", + "s3", + Map.of( + "bucket", "inbox", + "prefix", "incoming/", + "mode", "snapshot", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"), + true, + "alice", + 7L)); + Policy policy = + policyStore.save( + new Policy( + null, + "Rotate", + "alice", + true, + null, + List.of(source.id()), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + new OutputSpec( + "s3", + Map.of( + "bucket", "inbox", + "prefix", "processed/", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh")), + 7L)); + + migration.migrate(); + + // Same bucket + credentials on both rows: exactly one connection extracted. + verify(connections, times(1)).save(any()); + Map sourceOptions = sourceStore.get(source.id()).orElseThrow().options(); + assertEquals(101L, sourceOptions.get("connectionId")); + assertEquals("incoming/", sourceOptions.get("prefix")); + assertEquals("snapshot", sourceOptions.get("mode")); + assertNull(sourceOptions.get("accessKeyId")); + assertNull(sourceOptions.get("secretAccessKey")); + assertNull(sourceOptions.get("bucket")); + + Map outputOptions = + policyStore.get(policy.id()).orElseThrow().output().options(); + assertEquals(101L, outputOptions.get("connectionId")); + assertEquals("processed/", outputOptions.get("prefix")); + assertNull(outputOptions.get("secretAccessKey")); + } + + @Test + void connectionOwnershipFollowsTheSourceTeam() { + Team team = new Team(); + team.setId(7L); + when(teamRepository.findById(7L)).thenReturn(Optional.of(team)); + sourceStore.save(s3Source("teamed", 7L)); + + migration.migrate(); + + verify(connections) + .save( + org.mockito.ArgumentMatchers.argThat( + connection -> + connection.getScope() == OwnerScope.TEAM + && connection.getOwnerTeam() == team)); + } + + @Test + void teamlessRowsBecomeServerScopedConnections() { + sourceStore.save(s3Source("solo", null)); + + migration.migrate(); + + verify(connections) + .save( + org.mockito.ArgumentMatchers.argThat( + connection -> connection.getScope() == OwnerScope.SERVER)); + } + + @Test + void aSecondRunFindsNothingToDo() { + sourceStore.save(s3Source("once", null)); + + migration.migrate(); + migration.migrate(); + + // One connection from the first run; the rewritten source no longer embeds credentials. + verify(connections, times(1)).save(any()); + } + + @Test + void nonS3AndAlreadyMigratedRowsAreUntouched() { + Source folder = + sourceStore.save( + new Source( + null, + "Folder", + "folder", + Map.of("directory", "/in"), + true, + "alice", + null)); + Source migrated = + sourceStore.save( + new Source( + null, + "Done already", + "s3", + Map.of("connectionId", 55L, "prefix", "in/"), + true, + "alice", + null)); + + migration.migrate(); + + verify(connections, times(0)).save(any()); + assertEquals( + Map.of("directory", "/in"), sourceStore.get(folder.id()).orElseThrow().options()); + assertEquals( + Map.of("connectionId", 55L, "prefix", "in/"), + sourceStore.get(migrated.id()).orElseThrow().options()); + } + + private static Source s3Source(String name, Long teamId) { + return new Source( + null, + name, + "s3", + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"), + true, + "alice", + teamId); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java new file mode 100644 index 0000000000..847a553ba6 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/PolicyS3ConnectionUsageCheckTest.java @@ -0,0 +1,52 @@ +package stirling.software.proprietary.policy.s3; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.proprietary.policy.model.OutputSpec; +import stirling.software.proprietary.policy.model.PipelineStep; +import stirling.software.proprietary.policy.model.Policy; +import stirling.software.proprietary.policy.source.InProcessSourceStore; +import stirling.software.proprietary.policy.source.Source; +import stirling.software.proprietary.policy.store.InProcessPolicyStore; + +/** Tests for {@link PolicyS3ConnectionUsageCheck}'s reference scan across sources and outputs. */ +class PolicyS3ConnectionUsageCheckTest { + + private final InProcessSourceStore sourceStore = new InProcessSourceStore(); + private final InProcessPolicyStore policyStore = new InProcessPolicyStore(); + private final PolicyS3ConnectionUsageCheck check = + new PolicyS3ConnectionUsageCheck(sourceStore, policyStore); + + @Test + void reportsSourcesAndOutputsReferencingTheConnection() { + sourceStore.save( + new Source( + null, + "Claims intake", + "s3", + Map.of("connectionId", 5L, "prefix", "in/"), + true, + "alice", + null)); + policyStore.save( + new Policy( + null, + "Rotate", + "alice", + true, + null, + List.of(), + List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())), + new OutputSpec("s3", Map.of("connectionId", "5")), + null)); + + assertThat(check.usagesOf(5)) + .containsExactlyInAnyOrder("source 'Claims intake'", "pipeline 'Rotate'"); + assertThat(check.usagesOf(6)).isEmpty(); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java new file mode 100644 index 0000000000..6a480842c9 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3ConnectionResolverTest.java @@ -0,0 +1,149 @@ +package stirling.software.proprietary.policy.s3; + +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.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.AfterEach; +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.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.model.IntegrationConfig; +import stirling.software.proprietary.integration.model.IntegrationType; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.model.User; +import stirling.software.proprietary.security.service.UserService; + +/** + * Tests for {@link S3ConnectionResolver}: connection dereferencing with per-use overrides, the + * legacy embedded fallback, and the save-time access check that background sweeps skip. + */ +@ExtendWith(MockitoExtension.class) +class S3ConnectionResolverTest { + + @Mock private IntegrationConfigRepository connections; + @Mock private OwnershipService ownership; + @Mock private UserService userService; + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void resolvesAConnectionAndMergesPerUseOptions() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + S3Config config = + resolver() + .resolve( + Map.of( + "connectionId", 9L, + "prefix", "incoming/", + "mode", "snapshot")); + + assertEquals("inbox", config.bucket()); + assertEquals("AKIAEXAMPLE", config.accessKeyId()); + assertEquals("incoming/", config.prefix()); + assertTrue(config.snapshot()); + } + + @Test + void acceptsAStringConnectionReference() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + assertEquals("inbox", resolver().resolve(Map.of("connectionId", "9")).bucket()); + } + + @Test + void fallsBackToLegacyEmbeddedCredentials() { + S3Config config = + resolver() + .resolve( + Map.of( + "bucket", "legacy", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh")); + + assertEquals("legacy", config.bucket()); + } + + @Test + void rejectsUnknownDisabledOrWrongTypeConnections() { + when(connections.findById(1L)).thenReturn(Optional.empty()); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 1L))); + + when(connections.findById(2L)).thenReturn(Optional.of(s3Connection(2L, false))); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 2L))); + + IntegrationConfig mcp = s3Connection(3L, true); + mcp.setIntegrationType(IntegrationType.MCP); + when(connections.findById(3L)).thenReturn(Optional.of(mcp)); + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 3L))); + } + + @Test + void anAuthenticatedSaverMustBeAllowedToUseTheConnection() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + User saver = new User(); + saver.setUsername("alice"); + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken(saver, null, java.util.List.of())); + when(ownership.canUse(any(), any(IntegrationConfig.class), eq(saver))).thenReturn(false); + + // Denied reads the same as unknown and never echoes the connection name, so ids can't be + // enumerated by probing. + assertThrows( + IllegalArgumentException.class, + () -> resolver().resolve(Map.of("connectionId", 9L))); + try { + resolver().resolve(Map.of("connectionId", 9L)); + } catch (IllegalArgumentException e) { + org.junit.jupiter.api.Assertions.assertFalse( + e.getMessage().contains("Claims bucket"), + "access-denied error must not leak the connection name"); + } + } + + @Test + void backgroundSweepsWithNoUserSkipTheAccessCheck() { + when(connections.findById(9L)).thenReturn(Optional.of(s3Connection(9L, true))); + + // No authentication in the context: resolution succeeds without consulting ownership. + assertEquals("inbox", resolver().resolve(Map.of("connectionId", 9L)).bucket()); + } + + private S3ConnectionResolver resolver() { + return new S3ConnectionResolver(connections, ownership, userService); + } + + private static IntegrationConfig s3Connection(long id, boolean enabled) { + IntegrationConfig connection = new IntegrationConfig(); + connection.setId(id); + connection.setIntegrationType(IntegrationType.S3); + connection.setName("Claims bucket"); + connection.setEnabled(enabled); + connection.setConfig( + "{\"bucket\":\"inbox\",\"accessKeyId\":\"AKIAEXAMPLE\"," + + "\"secretAccessKey\":\"shh\"}"); + return connection; + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java new file mode 100644 index 0000000000..4c07968807 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3IntegrationValidatorTest.java @@ -0,0 +1,71 @@ +package stirling.software.proprietary.policy.s3; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.integration.model.IntegrationType; + +/** + * Tests for {@link S3IntegrationValidator}: the S3 connection schema fails at save time - missing + * credentials, bad endpoints, and private endpoints without the operator opt-in. + */ +class S3IntegrationValidatorTest { + + @Test + void acceptsACompleteConnection() { + assertThatCode( + () -> + validator(false) + .validate( + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"))) + .doesNotThrowAnyException(); + } + + @Test + void rejectsMissingCredentialsOrBucket() { + assertThatThrownBy(() -> validator(false).validate(Map.of("bucket", "inbox"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + validator(false) + .validate( + Map.of( + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void rejectsAPrivateEndpointWithoutTheOperatorOptIn() { + Map config = + Map.of( + "bucket", "inbox", + "accessKeyId", "AKIAEXAMPLE", + "secretAccessKey", "shh", + "endpoint", "http://localhost:9000"); + + assertThatThrownBy(() -> validator(false).validate(config)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allowPrivateS3Endpoints"); + assertThatCode(() -> validator(true).validate(config)).doesNotThrowAnyException(); + } + + @Test + void itOnlyClaimsTheS3Type() { + org.junit.jupiter.api.Assertions.assertEquals(IntegrationType.S3, validator(false).type()); + } + + private static S3IntegrationValidator validator(boolean allowPrivateEndpoints) { + ApplicationProperties properties = new ApplicationProperties(); + properties.getPolicies().setAllowPrivateS3Endpoints(allowPrivateEndpoints); + return new S3IntegrationValidator(properties); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java new file mode 100644 index 0000000000..7649831d2a --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/policy/s3/S3TestConnections.java @@ -0,0 +1,24 @@ +package stirling.software.proprietary.policy.s3; + +import static org.mockito.Mockito.mock; + +import stirling.software.proprietary.access.service.OwnershipService; +import stirling.software.proprietary.integration.repository.IntegrationConfigRepository; +import stirling.software.proprietary.security.service.UserService; + +/** Test fixtures for S3 connection plumbing shared across the policy S3 tests. */ +public final class S3TestConnections { + + private S3TestConnections() {} + + /** + * A resolver for tests whose options embed credentials directly (the legacy pass-through path), + * so its collaborators are never touched. + */ + public static S3ConnectionResolver legacyResolver() { + return new S3ConnectionResolver( + mock(IntegrationConfigRepository.class), + mock(OwnershipService.class), + mock(UserService.class)); + } +} diff --git a/build.gradle b/build.gradle index 5c89fd7af2..073bccc654 100644 --- a/build.gradle +++ b/build.gradle @@ -91,7 +91,7 @@ springBoot { allprojects { group = 'stirling.software' - version = '2.14.1' + version = '2.14.2' configurations.configureEach { exclude group: "org.springframework.boot", module: "spring-boot-starter-tomcat" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 0f3498c4fb..b9f3f71b6b 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -6240,6 +6240,10 @@ muted = "muted" nameLabel = "Name" namePlaceholder = "e.g. Compliance escalation" +[portal.agentBuilder.status] +draft = "Draft" +published = "Published" + [portal.agentBuilder.tabs] evals = "Evals" scenarios = "Scenarios" @@ -6463,7 +6467,7 @@ install = "Install" usage = "Usage" [portal.catalogue.detail.locked] -description = "{{name}} is included from the {{tier}} plan. Upgrade to embed it." +description = "{{name}} is included from the {{plan}}. Upgrade to embed it." title = "Not available on your plan" [portal.catalogue.detail.preview] @@ -6530,6 +6534,35 @@ title = "No components available" description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge." title = "Some components need a paid plan" +[portal.connections] +createTitle = "New S3 connection" +delete = "Delete" +edit = "Edit" +editTitle = "Edit S3 connection" +subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to." + +[portal.connections.actions] +new = "New connection" + +[portal.connections.empty] +description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs." +title = "No connections yet" + +[portal.connections.picker] +cancel = "Cancel" +createNew = "New connection..." +placeholder = "Select a connection" +save = "Save connection" + +[portal.connections.s3.fields] +name = "Connection name" +namePlaceholder = "e.g. Claims bucket" + +[portal.connections.table] +bucket = "Bucket" +name = "Name" +region = "Region" + [portal.docs] browse = "Browse docs" viewSource = "View source on GitHub" @@ -7353,10 +7386,6 @@ 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" @@ -7427,6 +7456,7 @@ manual = "Manual" schedule = "Scheduled" [portal.policies] +defaultName = "{{category}} Policy" subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original." title = "Policies" @@ -7998,34 +8028,29 @@ primaryNav = "Primary navigation" switchApp = "Switch app" [portal.sources] -subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it." +subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines." title = "Sources" [portal.sources.actions] agentBuilder = "Agent Builder" connectSource = "Connect source" +[portal.sources.builder] +back = "Back to sources" +cancel = "Cancel" +create = "Create source" +createTitle = "Connect a source" +delete = "Delete" +editTitle = "Edit source" +enabled = "Enabled" +save = "Save changes" + [portal.sources.delete] body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated." cancel = "Cancel" confirm = "Delete" title = "Delete source?" -[portal.sources.detail] -closeAriaLabel = "Close detail" -delete = "Delete source" -docs24h = "Last 24h" -docs30d = "Last 30 days" -docsTotal = "Total seen" -docsTrend = "Documents over the last 30 days" -documents = "Documents" -edit = "Edit" -notReferenced = "Not referenced by any policy, so it's safe to delete." -pause = "Pause" -resume = "Resume" -subtitle = "{{type}} · {{status}}" -usedBy = "Used by" - [portal.sources.empty] description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from." title = "No sources connected yet" @@ -8041,10 +8066,15 @@ disabled = "Disabled" unused = "Unused" [portal.sources.table] +documents = "Documents" source = "Source" status = "Status" usedBy = "Policies" +[portal.sources.tabs] +connections = "Connections" +sources = "Sources" + [portal.sources.types.editor] description = "Documents your team has processed in the editor, across policy and AI runs." label = "Editor" @@ -8092,6 +8122,10 @@ label = "Access key ID" label = "Bucket" placeholder = "my-company-inbox" +[portal.sources.types.s3.fields.connection] +helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it." +label = "Connection" + [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" @@ -8121,21 +8155,14 @@ label = "Secret access key" label = "Source" [portal.sources.wizard] -back = "Back" -cancel = "Cancel" -continue = "Continue" -editTitle = "Edit source" name = "Name" namePlaceholder = "e.g. Claims intake" -save = "Save changes" -subtitle = "Step {{current}} of {{total}} · {{label}}" -title = "Connect a source" type = "Type" -[portal.sources.wizard.steps] -chooseType = "Choose type" -configure = "Configure" -review = "Review & connect" +[portal.tier] +enterprise = "Enterprise plan" +free = "Editor plan" +pro = "Processor plan" [portal.usage] managePayment = "Manage Payment" @@ -10151,6 +10178,19 @@ resetPw = "Reset password" suspend = "Suspend" unlock = "Unlock account" +[users.activity] +daysAgo = "{{count}}d ago" +hoursAgo = "{{count}}h ago" +justNow = "Just now" +minutesAgo = "{{count}}m ago" +monthsAgo_one = "{{count}} month ago" +monthsAgo_other = "{{count}} months ago" +never = "Never" +weeksAgo_one = "{{count}} week ago" +weeksAgo_other = "{{count}} weeks ago" +yearsAgo_one = "{{count}} year ago" +yearsAgo_other = "{{count}} years ago" + [users.cap] addProcessor = "+ Processor" approver = "Approves policy" @@ -10221,7 +10261,9 @@ by = "Invited by {{who}}" cancel = "Cancel" count = "{{count}} pending" desc = "Invited people who haven't joined yet. They hold a seat until they accept." -expires = "Expires" +expiresInDays_one = "Expires in {{count}} day" +expiresInDays_other = "Expires in {{count}} days" +expiresToday = "Expires today" title = "Pending invitations" [users.loadError] diff --git a/frontend/editor/src-tauri/tauri.conf.json b/frontend/editor/src-tauri/tauri.conf.json index 6cfe33cbe6..dee2cc7023 100644 --- a/frontend/editor/src-tauri/tauri.conf.json +++ b/frontend/editor/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Stirling PDF", "mainBinaryName": "Stirling-PDF", - "version": "2.14.1", + "version": "2.14.2", "identifier": "stirling.pdf.dev", "build": { "frontendDist": "../dist", diff --git a/frontend/editor/src/core/testing/serverExperienceSimulations.ts b/frontend/editor/src/core/testing/serverExperienceSimulations.ts index cd7ed8f2ba..97da95c730 100644 --- a/frontend/editor/src/core/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/core/testing/serverExperienceSimulations.ts @@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.1", + appVersion: "2.14.2", serverCertificateEnabled: false, enableAlphaFunctionality: false, serverPort: 8080, diff --git a/frontend/editor/src/core/tokens/tokens.css b/frontend/editor/src/core/tokens/tokens.css index 247cd22a4e..f667a93851 100644 --- a/frontend/editor/src/core/tokens/tokens.css +++ b/frontend/editor/src/core/tokens/tokens.css @@ -270,8 +270,29 @@ --grad-banner: linear-gradient(135deg, #0f172a 0%, #111827 50%, #1a1535 100%); } -/* Always-dark code palette. Not theme-switched. */ -:root { +/* Code palette — theme-aware: a light GitHub-style box in light mode, the dark + terminal palette in dark mode. (CodeBlock renders plain text, so only the + base colours are load-bearing; the syntax slots are kept for future Shiki. */ +:root, +[data-theme="light"] { + --code-bg: #f6f8fa; + --code-bg-alt: #eef1f4; + --code-bg-header: #eaeef2; + --code-text: #1f2328; + --code-dim: #656d76; + --code-muted: #8c959f; + --code-keyword: #cf222e; + --code-string: #0a3069; + --code-number: #0550ae; + --code-fn: #8250df; + --code-type: #953800; + --code-property: #0550ae; + --code-comment: #6e7781; + --code-border: #d0d7de; + /* Window-chrome traffic-light dots in the code-block header. */ + --code-dot: #d0d7de; +} +[data-theme="dark"] { --code-bg: #0f172a; --code-bg-alt: #1e293b; --code-bg-header: #1a2332; @@ -286,12 +307,16 @@ --code-property: #93c5fd; --code-comment: #475569; --code-border: #1e293b; - /* Window-chrome traffic-light dots in the code-block header. */ --code-dot: #475569; } /* Radii / typography / motion / spacing / z-index — theme-stable */ :root { + /* Home hero strip navy. Theme-stable by design — the hero keeps this deep + navy in both light and dark (it's a branded surface, like the assistant + header), so it's defined once here rather than in the light/dark blocks. */ + --color-hero-navy: #16213e; + --radius-xs: 0.1875rem; --radius-sm: 0.25rem; --radius-md: 0.375rem; diff --git a/frontend/editor/src/core/ui/Button.css b/frontend/editor/src/core/ui/Button.css index e80bf13222..6ec23ca9ea 100644 --- a/frontend/editor/src/core/ui/Button.css +++ b/frontend/editor/src/core/ui/Button.css @@ -61,11 +61,14 @@ background: var(--button-bg, transparent) !important; } +/* Disabled buttons in dark read as a muted surface (not a dimmed accent that + still looks clickable, and not an invisible transparent pill). Covers every + variant so a disabled primary and a disabled secondary look alike. */ +[data-theme="dark"] .sui-btn.mantine-Button-root:disabled:not([data-loading]), [data-theme="dark"] - .sui-btn--primary.mantine-Button-root:disabled:not([data-loading]), -[data-theme="dark"] - .sui-btn--primary.mantine-Button-root[data-disabled]:not([data-loading]) { - background: var(--button-bg); - color: var(--button-color); - opacity: 0.55; + .sui-btn.mantine-Button-root[data-disabled]:not([data-loading]) { + background: var(--color-bg-muted); + color: var(--color-text-5); + border-color: transparent; + opacity: 1; } diff --git a/frontend/editor/src/core/ui/CodeBlock.css b/frontend/editor/src/core/ui/CodeBlock.css index b74a03c67f..76ebea940c 100644 --- a/frontend/editor/src/core/ui/CodeBlock.css +++ b/frontend/editor/src/core/ui/CodeBlock.css @@ -51,7 +51,8 @@ transition: background var(--motion-fast); } .sui-code__copy:hover { - background: rgba(255, 255, 255, 0.05); + /* Subtle tint that reads on both the light and dark code surfaces. */ + background: color-mix(in srgb, var(--code-text) 8%, transparent); } .sui-code__pre { margin: 0; diff --git a/frontend/editor/src/core/ui/Table.tsx b/frontend/editor/src/core/ui/Table.tsx index 0a07315a3d..fcd50e5fd6 100644 --- a/frontend/editor/src/core/ui/Table.tsx +++ b/frontend/editor/src/core/ui/Table.tsx @@ -19,6 +19,12 @@ export interface TableProps { rowKey: (row: T) => string; /** Makes rows interactive (hover + click + keyboard). */ onRowClick?: (row: T) => void; + /** + * Per-row gate for interactivity, checked only when {@link onRowClick} is set. A row for which + * this returns false is inert: no click/keyboard, and not announced as a button. Defaults to + * all rows interactive. + */ + isRowInteractive?: (row: T) => boolean; /** Rendered in place of the body when there are no rows. */ empty?: ReactNode; className?: string; @@ -35,6 +41,7 @@ export function Table({ rows, rowKey, onRowClick, + isRowInteractive, empty, className, }: TableProps) { @@ -66,38 +73,42 @@ export function Table({ ) : ( - rows.map((row) => ( - onRowClick(row) : undefined} - tabIndex={interactive ? 0 : undefined} - role={interactive ? "button" : undefined} - onKeyDown={ - interactive - ? (e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onRowClick?.(row); + rows.map((row) => { + const rowInteractive = + interactive && (isRowInteractive?.(row) ?? true); + return ( + onRowClick?.(row) : undefined} + tabIndex={rowInteractive ? 0 : undefined} + role={rowInteractive ? "button" : undefined} + onKeyDown={ + rowInteractive + ? (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onRowClick?.(row); + } } - } - : undefined - } - > - {columns.map((c) => ( - - {c.render(row)} - - ))} - - )) + : undefined + } + > + {columns.map((c) => ( + + {c.render(row)} + + ))} + + ); + }) )} diff --git a/frontend/editor/src/portal/ViewRouter.tsx b/frontend/editor/src/portal/ViewRouter.tsx index 42ab7da4d0..d1682e8cfd 100644 --- a/frontend/editor/src/portal/ViewRouter.tsx +++ b/frontend/editor/src/portal/ViewRouter.tsx @@ -6,6 +6,7 @@ import { Documents } from "@portal/views/Documents"; import { Pipelines } from "@portal/views/Pipelines"; import { PipelineBuilder } from "@portal/views/PipelineBuilder"; import { Sources } from "@portal/views/Sources"; +import { SourceBuilder } from "@portal/views/SourceBuilder"; import { AgentBuilder } from "@portal/views/AgentBuilder"; import { Policies } from "@portal/views/Policies"; import { Components } from "@portal/views/Components"; @@ -43,6 +44,14 @@ export function ViewRouter() { element={} /> } /> + } + /> + } + /> } diff --git a/frontend/editor/src/portal/api/agents.ts b/frontend/editor/src/portal/api/agents.ts index 61a9e75906..7a0cd49464 100644 --- a/frontend/editor/src/portal/api/agents.ts +++ b/frontend/editor/src/portal/api/agents.ts @@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record = { draft: "neutral", }; +export const AGENT_STATUS_LABEL: Record = { + published: "portal.agentBuilder.status.published", + draft: "portal.agentBuilder.status.draft", +}; + /** * Catalogue of tools an agent can be granted or denied. Surfaced as the chip * palette in restricted mode so the deny list is picked from a known set diff --git a/frontend/editor/src/portal/api/integrations.ts b/frontend/editor/src/portal/api/integrations.ts new file mode 100644 index 0000000000..19f88fea29 --- /dev/null +++ b/frontend/editor/src/portal/api/integrations.ts @@ -0,0 +1,74 @@ +/** + * Integrations service layer: stored connections (S3 today; MCP/API later) that + * policy sources and pipeline outputs reference by id instead of embedding + * credentials. Secrets are write-only - reads return them masked, and sending + * the mask back on update keeps the stored value. + */ +import { apiClient } from "@portal/api/http"; + +export type IntegrationType = "S3" | "MCP" | "API"; +export type OwnerScope = "USER" | "TEAM" | "SERVER"; + +/** Mirrors the backend IntegrationConfigResponse; `config` values are masked. */ +export interface IntegrationConfig { + id: number; + integrationType: IntegrationType; + name: string; + scope: OwnerScope; + ownerUserId: number | null; + ownerTeamId: number | null; + enabled: boolean; + locked: boolean; + defaultAccess: string; + config: Record; + canManage: boolean; + createdAt: string; + updatedAt: string; +} + +/** Create/update body; omitted fields keep their stored values on update. */ +export interface IntegrationConfigRequest { + integrationType?: IntegrationType; + name?: string; + scope?: OwnerScope; + ownerTeamId?: number | null; + enabled?: boolean; + config?: Record; +} + +export async function fetchIntegrations(): Promise { + return apiClient.local.json("/api/v1/integrations"); +} + +/** The S3 connections the caller may use, for source/output pickers. */ +export async function fetchS3Connections(): Promise { + return (await fetchIntegrations()).filter( + (integration) => integration.integrationType === "S3", + ); +} + +export async function createIntegration( + body: IntegrationConfigRequest, +): Promise { + return apiClient.local.json("/api/v1/integrations", { + method: "POST", + body, + }); +} + +export async function updateIntegration( + id: number, + body: IntegrationConfigRequest, +): Promise { + return apiClient.local.json( + `/api/v1/integrations/${encodeURIComponent(id)}`, + { method: "PUT", body }, + ); +} + +export async function deleteIntegration(id: number): Promise { + await apiClient.local.json( + `/api/v1/integrations/${encodeURIComponent(id)}`, + { method: "DELETE" }, + ); +} diff --git a/frontend/editor/src/portal/api/policies.ts b/frontend/editor/src/portal/api/policies.ts index 5c3c33a75a..78f8b07667 100644 --- a/frontend/editor/src/portal/api/policies.ts +++ b/frontend/editor/src/portal/api/policies.ts @@ -9,6 +9,7 @@ * approach the editor uses for its own catalogue view. */ +import type { TFunction } from "i18next"; import { apiClient } from "@portal/api/http"; import { fromWirePolicy, toWirePolicy } from "@app/policies/codec"; import { runsToActivity, runsToStats } from "@app/policies/runs"; @@ -556,17 +557,30 @@ const DEFAULT_RETRY_DELAY = 5; // POST /api/v1/policies endpoint. The real backend ignores unknown fields. type CatalogueWireBody = WirePolicy & { categoryId: string }; +/** + * The persisted policy name derived from its category, e.g. "Security Policy". + * `category.label` is an i18n key, so translate it before building the name; + * otherwise the raw key is persisted and surfaces in the UI (e.g. the Sources + * "Used by" pill). + */ +function policyDisplayName(entry: CatalogueEntry, t: TFunction): string { + return t("portal.policies.defaultName", { + category: t(entry.category.label), + }); +} + /** Build a wire policy from a setup wizard result. */ export function buildWireFromSetup( entry: CatalogueEntry, result: PolicySetupResult, + t: TFunction, enabled = true, ): CatalogueWireBody { return { categoryId: entry.category.id, ...toWirePolicy({ id: entry.policy?.state.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: result.sources, @@ -589,13 +603,14 @@ export function buildWireFromState( entry: CatalogueEntry, policy: DecoratedPolicy, enabled: boolean, + t: TFunction, ): CatalogueWireBody { const s = policy.state; return { categoryId: entry.category.id, ...toWirePolicy({ id: s.backendId ?? "", - name: `${entry.category.label} Policy`, + name: policyDisplayName(entry, t), enabled, categoryId: entry.category.id, sources: s.sources, diff --git a/frontend/editor/src/portal/api/users.ts b/frontend/editor/src/portal/api/users.ts index 3505238927..19a686cf8c 100644 --- a/frontend/editor/src/portal/api/users.ts +++ b/frontend/editor/src/portal/api/users.ts @@ -1,3 +1,7 @@ +// The bare i18next singleton (the same instance @app/i18n initializes at +// startup), imported directly so this data module doesn't pull i18n's +// init side effects into unit tests that mock react-i18next. +import i18n from "i18next"; import { apiClient } from "@portal/api/http"; import type { Tier } from "@portal/contexts/TierContext"; @@ -269,22 +273,23 @@ function roleIdFor(u: AdminUserSummaryDto): RoleId { /** A member's last-seen time as plain language; "Never" when no session is tracked. */ function relativeTime(value: number | string | undefined): string { - if (value === undefined || value === null) return "Never"; + if (value === undefined || value === null) + return i18n.t("users.activity.never"); const ts = typeof value === "string" ? Date.parse(value) : value; - if (!Number.isFinite(ts) || ts <= 0) return "Never"; + if (!Number.isFinite(ts) || ts <= 0) return i18n.t("users.activity.never"); const mins = Math.max(0, Math.round((Date.now() - ts) / 60000)); - if (mins < 1) return "Just now"; - if (mins < 60) return `${mins}m ago`; + if (mins < 1) return i18n.t("users.activity.justNow"); + if (mins < 60) return i18n.t("users.activity.minutesAgo", { count: mins }); const hours = Math.round(mins / 60); - if (hours < 24) return `${hours}h ago`; + if (hours < 24) return i18n.t("users.activity.hoursAgo", { count: hours }); const days = Math.round(hours / 24); - if (days < 7) return `${days}d ago`; + if (days < 7) return i18n.t("users.activity.daysAgo", { count: days }); const weeks = Math.round(days / 7); - if (weeks < 5) return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`; + if (weeks < 5) return i18n.t("users.activity.weeksAgo", { count: weeks }); const months = Math.round(days / 30); - if (months < 12) return months <= 1 ? "1 month ago" : `${months} months ago`; + if (months < 12) return i18n.t("users.activity.monthsAgo", { count: months }); const years = Math.round(days / 365); - return years <= 1 ? "1 year ago" : `${years} years ago`; + return i18n.t("users.activity.yearsAgo", { count: years }); } /** 0 / huge sentinel license values mean "no seat limit". */ diff --git a/frontend/editor/src/portal/components/EditorStatusCard.css b/frontend/editor/src/portal/components/EditorStatusCard.css index 83426bae91..9732ec2fb0 100644 --- a/frontend/editor/src/portal/components/EditorStatusCard.css +++ b/frontend/editor/src/portal/components/EditorStatusCard.css @@ -15,7 +15,7 @@ align-items: center; gap: 1.25rem; padding: 1rem 1.25rem; - background: #16213e; + background: var(--color-hero-navy); } .portal-editor-hero__logo { @@ -123,7 +123,7 @@ .portal-editor-hero__action .portal-editor-hero__cta.sui-btn { background: #ffffff; border-color: #ffffff; - color: #16213e; + color: var(--color-hero-navy); } .portal-editor-hero__action .portal-editor-hero__cta.sui-btn:hover { background: rgba(255, 255, 255, 0.88); diff --git a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx index af198d488f..2f4602b11d 100644 --- a/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx +++ b/frontend/editor/src/portal/components/ProcessingStatusStrip.tsx @@ -26,7 +26,7 @@ export function ProcessingStatusStrip() { style={{ background: TIER_INFO[tier].dotColor }} aria-hidden /> - {TIER_INFO[tier].label} + {t(TIER_INFO[tier].labelKey)} · diff --git a/frontend/editor/src/portal/components/WelcomeBanner.css b/frontend/editor/src/portal/components/WelcomeBanner.css index 5c63462754..57f54846eb 100644 --- a/frontend/editor/src/portal/components/WelcomeBanner.css +++ b/frontend/editor/src/portal/components/WelcomeBanner.css @@ -18,7 +18,7 @@ gap: 1rem; flex-wrap: wrap; padding: 0.875rem 1.25rem; - background: #16213e; + background: var(--color-hero-navy); } .portal-welcome__brand { @@ -90,7 +90,7 @@ .portal-welcome__header .portal-welcome__cta.sui-btn { background: #ffffff; border-color: #ffffff; - color: #16213e; + color: var(--color-hero-navy); } .portal-welcome__header .portal-welcome__cta.sui-btn:hover { background: rgba(255, 255, 255, 0.88); diff --git a/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx b/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx index 20cbcab89c..a411f7f680 100644 --- a/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/AgentBuilderPanel.tsx @@ -1,7 +1,11 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { StatusBadge, Tabs, type TabItem } from "@app/ui"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel"; import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel"; import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel"; @@ -52,7 +56,7 @@ export function AgentBuilderPanel({

- {agent.status} + {t(AGENT_STATUS_LABEL[agent.status])} {agent.version} diff --git a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx index 9891c10d03..bb01bf914b 100644 --- a/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx +++ b/frontend/editor/src/portal/components/agent-builder/AgentSelector.tsx @@ -1,5 +1,9 @@ import { useTranslation } from "react-i18next"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import { Button, StatusBadge } from "@app/ui"; import "@portal/views/AgentBuilder.css"; @@ -40,7 +44,7 @@ export function AgentSelector({ - {a.status} + {t(AGENT_STATUS_LABEL[a.status])} {a.version} diff --git a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx index 20d71dbfbc..3b63fe3045 100644 --- a/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx +++ b/frontend/editor/src/portal/components/agent-builder/VersionsPanel.tsx @@ -1,6 +1,10 @@ import { useTranslation } from "react-i18next"; import { Button, StatusBadge } from "@app/ui"; -import { type Agent, AGENT_STATUS_TONE } from "@portal/api/agents"; +import { + type Agent, + AGENT_STATUS_LABEL, + AGENT_STATUS_TONE, +} from "@portal/api/agents"; import "@portal/views/AgentBuilder.css"; interface VersionsPanelProps { @@ -52,7 +56,7 @@ export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) { {v.version} - {v.status} + {t(AGENT_STATUS_LABEL[v.status])} {isCurrent && ( diff --git a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx index 337cfc6119..0599d80d72 100644 --- a/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx +++ b/frontend/editor/src/portal/components/catalogue/ComponentDetailModal.tsx @@ -12,9 +12,11 @@ import { } from "@app/ui"; import { type SdkComponent, + BILLING_UNIT_LABEL, MATURITY_META, formatPrice, } from "@portal/api/sdkComponents"; +import { TIER_INFO } from "@portal/contexts/TierContext"; import { ComponentPropsTable } from "@portal/components/catalogue/ComponentPropsTable"; import "@portal/views/Components.css"; @@ -113,7 +115,7 @@ export function ComponentDetailModal({ title={t("portal.catalogue.detail.locked.title")} description={t("portal.catalogue.detail.locked.description", { name: component.name, - tier: component.minTier, + plan: t(TIER_INFO[component.minTier].labelKey), })} /> )} @@ -205,7 +207,7 @@ export function ComponentDetailModal({ />

{t("portal.catalogue.detail.pricing.note", { - unit: component.pricing.unit, + unit: t(BILLING_UNIT_LABEL[component.pricing.unit]), })}

diff --git a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx index 240d61f782..2200237cf6 100644 --- a/frontend/editor/src/portal/components/documents/ReviewQueue.tsx +++ b/frontend/editor/src/portal/components/documents/ReviewQueue.tsx @@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) { - - - } - > -
    - {steps.map((id, i) => ( -
  1. - - {i < stepIndex ? "✓" : i + 1} - - {stepLabels[id]} -
  2. - ))} -
- - {stepId === "type" && ( -
- {OFFERED_TYPES.map((ct) => ( - - ))} -
- )} - - {stepId === "configure" && ( -
- - setName(e.target.value)} - /> - - {type.fields.map((field) => ( - - {field.control === "select" ? ( - - setOptions((o) => ({ ...o, [field.key]: e.target.value })) - } - /> - )} - - ))} -
- )} - - {stepId === "review" && ( -
-
- - - {type.fields.map((field) => ( - - ))} -
- {error && } -
- )} - - ); -} diff --git a/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx b/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx new file mode 100644 index 0000000000..938dfa832b --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionsTab.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { HttpError } from "@portal/api/http"; +import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab"; +import type { IntegrationConfig } from "@portal/api/integrations"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchS3Connections = vi.fn(); +const deleteIntegration = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + deleteIntegration: (id: number) => deleteIntegration(id), + createIntegration: vi.fn(), + updateIntegration: vi.fn(), +})); + +const CONNECTION = { + id: 5, + integrationType: "S3", + name: "Claims bucket", + config: { bucket: "inbox", region: "us-east-1" }, + canManage: true, +} as unknown as IntegrationConfig; + +describe("ConnectionsTab", () => { + beforeEach(() => { + fetchS3Connections.mockReset(); + deleteIntegration.mockReset(); + deleteIntegration.mockResolvedValue(undefined); + }); + + it("shows the empty state when there are no connections", async () => { + fetchS3Connections.mockResolvedValue([]); + render(); + expect( + await screen.findByText("portal.connections.empty.title"), + ).toBeInTheDocument(); + }); + + it("lists connections and deletes one", async () => { + fetchS3Connections.mockResolvedValueOnce([CONNECTION]); + fetchS3Connections.mockResolvedValueOnce([]); + render(); + + expect(await screen.findByText("Claims bucket")).toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.connections.delete")); + await waitFor(() => expect(deleteIntegration).toHaveBeenCalledWith(5)); + }); + + it("surfaces the 409 when deleting a connection still in use", async () => { + fetchS3Connections.mockResolvedValue([CONNECTION]); + deleteIntegration.mockRejectedValue( + new HttpError(409, "Conflict", { + detail: "Integration is in use by: source 'Claims intake'", + }), + ); + render(); + + await screen.findByText("Claims bucket"); + fireEvent.click(screen.getByText("portal.connections.delete")); + expect( + await screen.findByText( + "Integration is in use by: source 'Claims intake'", + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx b/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx new file mode 100644 index 0000000000..136f557032 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/ConnectionsTab.tsx @@ -0,0 +1,186 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { + Banner, + Button, + EmptyState, + Skeleton, + Table, + type TableColumn, +} from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + deleteIntegration, + fetchS3Connections, + type IntegrationConfig, +} from "@portal/api/integrations"; +import { SourcesIcon } from "@portal/components/icons"; +import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal"; + +/** + * The Connections tab of the Sources page: stored S3 connections that sources + * and pipeline outputs reference by id. Create/edit go through the shared + * {@link S3ConnectionModal}; deleting one the backend still references returns a + * 409, surfaced inline. + */ +export function ConnectionsTab() { + const { t } = useTranslation(); + const [connections, setConnections] = useState( + null, + ); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setConnections(await fetchS3Connections()); + } catch (e) { + setError(errorMessage(e)); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + function openCreate() { + setEditing(null); + setModalOpen(true); + } + + function openEdit(connection: IntegrationConfig) { + setEditing(connection); + setModalOpen(true); + } + + async function remove(connection: IntegrationConfig) { + if (busy) return; + setBusy(true); + setError(null); + try { + await deleteIntegration(connection.id); + await refresh(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setBusy(false); + } + } + + const columns = useMemo[]>( + () => [ + { + key: "name", + header: t("portal.connections.table.name"), + render: (c) => {c.name}, + }, + { + key: "bucket", + header: t("portal.connections.table.bucket"), + render: (c) => ( + + {String(c.config?.bucket ?? "")} + + ), + }, + { + key: "region", + header: t("portal.connections.table.region"), + render: (c) => String(c.config?.region ?? ""), + }, + { + key: "actions", + header: "", + align: "right", + render: (c) => + c.canManage ? ( + + + + + ) : null, + }, + ], + // remove/openEdit are stable enough for this admin surface; busy gates them. + [t, busy], + ); + + const isLoading = connections === null; + const isEmpty = connections !== null && connections.length === 0; + + return ( +
+
+

+ {t("portal.connections.subtitle")} +

+ +
+ + {error && } + + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + + {isEmpty && ( + } + title={t("portal.connections.empty.title")} + description={t("portal.connections.empty.description")} + actions={ + + } + /> + )} + + {connections !== null && connections.length > 0 && ( + + className="portal-sources__connections-table" + columns={columns} + rows={connections} + rowKey={(c) => String(c.id)} + /> + )} + + setModalOpen(false)} + onSaved={() => void refresh()} + /> +
+ ); +} diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx new file mode 100644 index 0000000000..0158025d83 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionForm.tsx @@ -0,0 +1,116 @@ +import { useTranslation } from "react-i18next"; +import { FormField, Input } from "@app/ui"; + +/** + * The connection-level S3 fields (per-use settings like prefix/mode live on the + * source or output referencing the connection). Secrets are write-only: when + * editing, the backend returns them masked and keeps the stored value if the + * mask is sent back unchanged. + */ +export interface S3ConnectionFormValues { + name: string; + bucket: string; + region: string; + endpoint: string; + accessKeyId: string; + secretAccessKey: string; +} + +export const EMPTY_S3_CONNECTION: S3ConnectionFormValues = { + name: "", + bucket: "", + region: "us-east-1", + endpoint: "", + accessKeyId: "", + secretAccessKey: "", +}; + +export function s3ConnectionRequestConfig( + values: S3ConnectionFormValues, +): Record { + return { + bucket: values.bucket.trim(), + region: values.region.trim(), + endpoint: values.endpoint.trim(), + accessKeyId: values.accessKeyId.trim(), + secretAccessKey: values.secretAccessKey, + }; +} + +export function s3ConnectionFormValid(values: S3ConnectionFormValues): boolean { + return ( + values.name.trim() !== "" && + values.bucket.trim() !== "" && + values.accessKeyId.trim() !== "" && + values.secretAccessKey.trim() !== "" + ); +} + +interface S3ConnectionFormProps { + values: S3ConnectionFormValues; + onChange: (values: S3ConnectionFormValues) => void; +} + +export function S3ConnectionForm({ values, onChange }: S3ConnectionFormProps) { + const { t } = useTranslation(); + const set = (key: keyof S3ConnectionFormValues, value: string) => + onChange({ ...values, [key]: value }); + + return ( +
+ + set("name", e.target.value)} + /> + + + set("bucket", e.target.value)} + /> + + + set("region", e.target.value)} + /> + + + set("accessKeyId", e.target.value)} + /> + + + set("secretAccessKey", e.target.value)} + /> + + + set("endpoint", e.target.value)} + /> + +
+ ); +} diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx new file mode 100644 index 0000000000..e052243fe9 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionModal.test.tsx @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal"; +import type { IntegrationConfig } from "@portal/api/integrations"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const createIntegration = vi.fn(); +const updateIntegration = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + createIntegration: (...a: unknown[]) => createIntegration(...a), + updateIntegration: (...a: unknown[]) => updateIntegration(...a), +})); + +function setField(labelPattern: RegExp, value: string) { + fireEvent.change(screen.getByLabelText(labelPattern), { target: { value } }); +} + +describe("S3ConnectionModal", () => { + beforeEach(() => { + createIntegration.mockReset(); + updateIntegration.mockReset(); + }); + + it("creates a team-scoped connection from the entered fields", async () => { + createIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" }); + const onSaved = vi.fn(); + const onClose = vi.fn(); + render(); + + setField(/portal\.connections\.s3\.fields\.name/, "Claims bucket"); + setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox"); + setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA"); + setField( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + "shh", + ); + fireEvent.click(screen.getByText("portal.connections.picker.save")); + + await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1)); + expect(createIntegration).toHaveBeenCalledWith({ + integrationType: "S3", + name: "Claims bucket", + scope: "TEAM", + config: { + bucket: "inbox", + region: "us-east-1", + endpoint: "", + accessKeyId: "AKIA", + secretAccessKey: "shh", + }, + }); + await waitFor(() => expect(onSaved).toHaveBeenCalledTimes(1)); + expect(onClose).toHaveBeenCalled(); + }); + + it("round-trips a masked secret unchanged on edit (keeps the stored value)", async () => { + updateIntegration.mockResolvedValue({ id: 5, name: "Claims bucket" }); + // The API returns secrets masked; the modal must resend the sentinel verbatim + // so the backend keeps the stored secret rather than overwriting it. + const connection = { + id: 5, + integrationType: "S3", + name: "Claims bucket", + config: { + bucket: "inbox", + region: "us-east-1", + accessKeyId: "AKIA", + secretAccessKey: "********", + }, + canManage: true, + } as unknown as IntegrationConfig; + + render( + , + ); + + const secret = screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + ) as HTMLInputElement; + expect(secret.value).toBe("********"); + // Change only the name; leave the masked secret untouched. + setField(/portal\.connections\.s3\.fields\.name/, "Renamed bucket"); + fireEvent.click(screen.getByText("portal.connections.picker.save")); + + await waitFor(() => expect(updateIntegration).toHaveBeenCalledTimes(1)); + expect(updateIntegration).toHaveBeenCalledWith( + 5, + expect.objectContaining({ + name: "Renamed bucket", + config: expect.objectContaining({ secretAccessKey: "********" }), + }), + ); + }); + + it("keeps save disabled until the required fields are present", () => { + render(); + const save = () => + screen.getByText("portal.connections.picker.save").closest("button"); + + expect(save()).toBeDisabled(); + setField(/portal\.connections\.s3\.fields\.name/, "Only a name"); + expect(save()).toBeDisabled(); + setField(/portal\.sources\.types\.s3\.fields\.bucket\.label/, "inbox"); + setField(/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, "AKIA"); + setField( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + "shh", + ); + expect(save()).not.toBeDisabled(); + }); +}); diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx new file mode 100644 index 0000000000..c4e649df4e --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionModal.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Modal } from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + createIntegration, + updateIntegration, + type IntegrationConfig, +} from "@portal/api/integrations"; +import { + EMPTY_S3_CONNECTION, + S3ConnectionForm, + s3ConnectionFormValid, + s3ConnectionRequestConfig, + type S3ConnectionFormValues, +} from "@portal/components/sources/S3ConnectionForm"; + +/** + * The one place S3 connections are created and edited. Launched from the + * Connections tab, the source builder's connection picker, and the pipeline + * builder output - so connection setup is always a modal, never inline splat. + * Saving validates backend-side (schema, SSRF, credentials); on edit the secret + * arrives masked and round-trips unchanged to keep the stored value. + */ +interface S3ConnectionModalProps { + open: boolean; + /** When set, edit this connection; otherwise create a new one. */ + connection?: IntegrationConfig | null; + onClose: () => void; + /** The saved connection, so callers can select or refresh it. */ + onSaved: (connection: IntegrationConfig) => void; +} + +export function S3ConnectionModal({ + open, + connection, + onClose, + onSaved, +}: S3ConnectionModalProps) { + const { t } = useTranslation(); + const [form, setForm] = useState(EMPTY_S3_CONNECTION); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const isEdit = Boolean(connection); + + // Seed the form each time the modal opens (or its target changes). + useEffect(() => { + if (!open) return; + if (connection) { + const config = connection.config ?? {}; + setForm({ + name: connection.name, + bucket: String(config.bucket ?? ""), + region: String(config.region ?? "us-east-1"), + endpoint: String(config.endpoint ?? ""), + accessKeyId: String(config.accessKeyId ?? ""), + secretAccessKey: String(config.secretAccessKey ?? ""), + }); + } else { + setForm(EMPTY_S3_CONNECTION); + } + setError(null); + }, [open, connection]); + + async function save() { + if (saving || !s3ConnectionFormValid(form)) return; + setSaving(true); + setError(null); + try { + const saved = connection + ? await updateIntegration(connection.id, { + name: form.name.trim(), + config: s3ConnectionRequestConfig(form), + }) + : // TEAM scope suits the team-based portal (the backend defaults the team to the + // caller's own). A teamless single-operator self-hosted deployment would need a + // USER/SERVER scope choice here - follow-up if the portal ships there. + await createIntegration({ + integrationType: "S3", + name: form.name.trim(), + scope: "TEAM", + config: s3ConnectionRequestConfig(form), + }); + onSaved(saved); + onClose(); + } catch (e) { + setError(errorMessage(e)); + } finally { + setSaving(false); + } + } + + return ( + + + + + } + > + + {error && } + + ); +} diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx new file mode 100644 index 0000000000..8800fdfd15 --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.test.tsx @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const fetchS3Connections = vi.fn(); +const createIntegration = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + createIntegration: (...a: unknown[]) => createIntegration(...a), + updateIntegration: vi.fn(), +})); + +describe("S3ConnectionPicker", () => { + beforeEach(() => { + fetchS3Connections.mockReset(); + fetchS3Connections.mockResolvedValue([]); + createIntegration.mockReset(); + }); + + it("creates a connection inline and selects it", async () => { + createIntegration.mockResolvedValue({ id: 7, name: "New bucket" }); + const onChange = vi.fn(); + render(); + + fireEvent.click( + await screen.findByText("portal.connections.picker.createNew"), + ); + fireEvent.change( + screen.getByLabelText(/portal\.connections\.s3\.fields\.name/), + { target: { value: "New bucket" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.bucket\.label/, + ), + { target: { value: "inbox" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.accessKeyId\.label/, + ), + { target: { value: "AKIA" } }, + ); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/, + ), + { target: { value: "shh" } }, + ); + fireEvent.click(screen.getByText("portal.connections.picker.save")); + + await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1)); + // The newly created connection's id is selected in the parent. + await waitFor(() => expect(onChange).toHaveBeenCalledWith("7")); + }); +}); diff --git a/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx new file mode 100644 index 0000000000..58b559e23b --- /dev/null +++ b/frontend/editor/src/portal/components/sources/S3ConnectionPicker.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Banner, Button, Select } from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + fetchS3Connections, + type IntegrationConfig, +} from "@portal/api/integrations"; +import { S3ConnectionModal } from "@portal/components/sources/S3ConnectionModal"; + +/** + * Selects a stored S3 connection by id. Creating a new one opens the shared + * connection modal (saved immediately and validated backend-side), so the + * parent only ever sees a real connection id. + */ +interface S3ConnectionPickerProps { + value: string; + onChange: (connectionId: string) => void; +} + +export function S3ConnectionPicker({ + value, + onChange, +}: S3ConnectionPickerProps) { + const { t } = useTranslation(); + const [connections, setConnections] = useState( + null, + ); + const [modalOpen, setModalOpen] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let mounted = true; + fetchS3Connections() + .then((list) => { + if (mounted) setConnections(list); + }) + .catch((e) => { + if (mounted) setError(errorMessage(e)); + }); + return () => { + mounted = false; + }; + }, []); + + return ( +
+ + setOutputS3((s) => ({ ...s, prefix: e.target.value })) + } + /> + + )}
@@ -1006,78 +991,6 @@ export function PipelineBuilder() { >

{t("portal.pipelines.builder.unsavedBody")}

- - setS3ConfigOpen(false)} - title={t("portal.pipelines.composer.s3ModalTitle")} - footer={ -
- -
- } - > -
- - setS3Field("bucket", e.target.value)} - /> - - - setS3Field("region", e.target.value)} - /> - - - setS3Field("prefix", e.target.value)} - /> - - - setS3Field("accessKeyId", e.target.value)} - /> - - - setS3Field("secretAccessKey", e.target.value)} - /> - - - setS3Field("endpoint", e.target.value)} - /> - -
-
); } diff --git a/frontend/editor/src/portal/views/Pipelines.tsx b/frontend/editor/src/portal/views/Pipelines.tsx index e2345f62a6..23a8da75da 100644 --- a/frontend/editor/src/portal/views/Pipelines.tsx +++ b/frontend/editor/src/portal/views/Pipelines.tsx @@ -30,7 +30,7 @@ export function Pipelines() { const openCreate = () => navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/new`); const connectSource = () => - navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`); + navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); // A row opens that pipeline's own page (view / edit / run / delete live there). const openPipeline = (pipeline: PipelineView) => navigate(`${toPortalPath(VIEW_PATHS.pipelines)}/${pipeline.id}`); diff --git a/frontend/editor/src/portal/views/Policies.tsx b/frontend/editor/src/portal/views/Policies.tsx index 0795cef0f0..8d58ed0e79 100644 --- a/frontend/editor/src/portal/views/Policies.tsx +++ b/frontend/editor/src/portal/views/Policies.tsx @@ -67,7 +67,7 @@ export function Policies() { ) { setPageError(null); try { - await savePolicy(buildWireFromSetup(entry, result)); + await savePolicy(buildWireFromSetup(entry, result, t)); setWizard(null); setDetail(null); refetch(); @@ -97,7 +97,7 @@ export function Policies() { if (!entry || !policy?.state.backendId) return; const enabled = policy.state.status === "paused"; void runLifecycle(() => - savePolicy(buildWireFromState(entry, policy, enabled)), + savePolicy(buildWireFromState(entry, policy, enabled, t)), ); } diff --git a/frontend/editor/src/portal/views/SourceBuilder.css b/frontend/editor/src/portal/views/SourceBuilder.css new file mode 100644 index 0000000000..985332046d --- /dev/null +++ b/frontend/editor/src/portal/views/SourceBuilder.css @@ -0,0 +1,87 @@ +.portal-source-builder { + display: flex; + flex-direction: column; + gap: 1.25rem; + padding: 1.5rem; + max-width: 84rem; + margin: 0 auto; +} + +.portal-source-builder__loading { + display: flex; + justify-content: center; + padding: 4rem 0; +} + +.portal-source-builder__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.portal-source-builder__head-main { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; +} + +.portal-source-builder__title { + font-size: 1.375rem; + font-weight: 600; + color: var(--color-text-1); + margin: 0; +} + +.portal-source-builder__head-actions { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.portal-source-builder__body { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 32rem; +} + +.portal-source-builder__type-grid { + display: flex; + gap: 0.625rem; + flex-wrap: wrap; +} + +.portal-source-builder__type-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + min-width: 6rem; + padding: 0.875rem 1rem; + border: 1px solid var(--color-border-2); + border-radius: 0.5rem; +} + +.portal-source-builder__type-card.is-selected { + border-color: var(--color-accent, var(--color-brand)); + background: var(--color-bg-hover); +} + +.portal-source-builder__type-icon { + font-size: 1.5rem; + line-height: 1; +} + +.portal-source-builder__type-name { + font-size: 0.8125rem; + font-weight: 500; +} + +.portal-source-builder__delete-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} diff --git a/frontend/editor/src/portal/views/SourceBuilder.test.tsx b/frontend/editor/src/portal/views/SourceBuilder.test.tsx new file mode 100644 index 0000000000..1f9b3341f8 --- /dev/null +++ b/frontend/editor/src/portal/views/SourceBuilder.test.tsx @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render as baseRender, + screen, + waitFor, +} from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { SourceBuilder } from "@portal/views/SourceBuilder"; + +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +const createSource = vi.fn(); +const fetchSource = vi.fn(); +const deleteSource = vi.fn(); +vi.mock("@portal/api/sources", () => ({ + createSource: (s: unknown) => createSource(s), + fetchSource: (id: string) => fetchSource(id), + deleteSource: (id: string) => deleteSource(id), +})); + +const fetchS3Connections = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + createIntegration: vi.fn(), +})); + +function renderBuilder(initial: string) { + return render( + + + sources list} /> + } /> + } /> + + , + ); +} + +describe("SourceBuilder", () => { + beforeEach(() => { + createSource.mockReset(); + createSource.mockResolvedValue({ id: "src-1" }); + fetchSource.mockReset(); + deleteSource.mockReset(); + deleteSource.mockResolvedValue(undefined); + fetchS3Connections.mockReset(); + fetchS3Connections.mockResolvedValue([]); + }); + + it("creates a folder source and returns to the list", async () => { + renderBuilder("/processor/sources/new"); + + // Folder is the first offered type; fill name + directory. + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Claims intake" }, + }); + fireEvent.change( + screen.getByLabelText( + /portal\.sources\.types\.folder\.fields\.directory\.label/, + ), + { target: { value: "/data/incoming" } }, + ); + fireEvent.click(screen.getByText("portal.sources.builder.create")); + + await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1)); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Claims intake", + type: "folder", + options: expect.objectContaining({ directory: "/data/incoming" }), + enabled: true, + }), + ); + expect(await screen.findByText("sources list")).toBeInTheDocument(); + }); + + it("gates the s3 type on a chosen connection", async () => { + renderBuilder("/processor/sources/new"); + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Bucket source" }, + }); + // Switch to the S3 type: the connection field appears and Create stays + // disabled until a connection is chosen (connectionId is required). + fireEvent.click(screen.getByText("portal.sources.types.s3.label")); + expect( + await screen.findByText( + "portal.sources.types.s3.fields.connection.label", + ), + ).toBeInTheDocument(); + expect( + screen.getByText("portal.sources.builder.create").closest("button"), + ).toBeDisabled(); + }); + + it("blocks create until required fields are filled", async () => { + renderBuilder("/processor/sources/new"); + // Name given but directory (required) still blank -> Create disabled. + fireEvent.change(screen.getByLabelText(/portal\.sources\.wizard\.name/), { + target: { value: "Nameonly" }, + }); + expect( + screen.getByText("portal.sources.builder.create").closest("button"), + ).toBeDisabled(); + }); + + it("edits an existing source prefilled and saves with its id", async () => { + fetchSource.mockResolvedValue({ + id: "src-9", + name: "Existing", + type: "folder", + options: { directory: "/old", mode: "consume" }, + enabled: true, + }); + renderBuilder("/processor/sources/src-9"); + + const directory = await screen.findByLabelText( + /portal\.sources\.types\.folder\.fields\.directory\.label/, + ); + expect((directory as HTMLInputElement).value).toBe("/old"); + fireEvent.change(directory, { target: { value: "/new" } }); + fireEvent.click(screen.getByText("portal.sources.builder.save")); + + await waitFor(() => expect(createSource).toHaveBeenCalledTimes(1)); + expect(createSource).toHaveBeenCalledWith( + expect.objectContaining({ + id: "src-9", + options: expect.objectContaining({ directory: "/new" }), + }), + ); + }); + + it("deletes an existing source after confirmation", async () => { + fetchSource.mockResolvedValue({ + id: "src-9", + name: "Existing", + type: "folder", + options: { directory: "/old" }, + enabled: true, + }); + renderBuilder("/processor/sources/src-9"); + + fireEvent.click(await screen.findByText("portal.sources.builder.delete")); + fireEvent.click(await screen.findByText("portal.sources.delete.confirm")); + + await waitFor(() => expect(deleteSource).toHaveBeenCalledWith("src-9")); + expect(await screen.findByText("sources list")).toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/portal/views/SourceBuilder.tsx b/frontend/editor/src/portal/views/SourceBuilder.tsx new file mode 100644 index 0000000000..31b8550354 --- /dev/null +++ b/frontend/editor/src/portal/views/SourceBuilder.tsx @@ -0,0 +1,326 @@ +import { useEffect, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; +import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded"; +import { + Banner, + Button, + Checkbox, + FormField, + Input, + Modal, + Select, + Spinner, +} from "@app/ui"; +import { errorMessage } from "@portal/api/http"; +import { + createSource, + deleteSource, + fetchSource, + type Source, +} from "@portal/api/sources"; +import { useAsync } from "@portal/hooks/useAsync"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; +import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes"; +import { + CREATABLE_SOURCE_TYPES, + defaultOptions, + sourceTypeMeta, + type CreatableSourceType, +} from "@portal/components/sources/sourceTypes"; +import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker"; +import "@portal/views/SourceBuilder.css"; + +const OFFERED_TYPES = creatableSourceTypes(); + +/** A source's stored type resolved to its create-form metadata (edit falls back to any type). */ +function typeFor(type: string | undefined): CreatableSourceType { + return ( + CREATABLE_SOURCE_TYPES.find((t) => t.type === type) ?? + OFFERED_TYPES[0] ?? + CREATABLE_SOURCE_TYPES[0] + ); +} + +/** Stored options coerced to form strings, defaulted from the type's fields. */ +function optionsFor( + type: CreatableSourceType, + options: Record | undefined, +): Record { + const out = defaultOptions(type); + for (const [key, value] of Object.entries(options ?? {})) { + out[key] = value == null ? "" : String(value); + } + return out; +} + +/** + * Full-page create/edit for a source, mirroring the pipeline builder: new lands + * on /sources/new (with a type picker), a row opens /sources/:id prefilled. + * Save and delete navigate back to the Sources list. The virtual editor source + * is never routed here (the list row is not a link). + */ +export function SourceBuilder() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { id } = useParams(); + const isEdit = Boolean(id); + const listPath = toPortalPath(VIEW_PATHS.sources); + + const sourceState = useAsync( + async () => (id ? await fetchSource(id) : null), + [id], + ); + + const [type, setType] = useState(OFFERED_TYPES[0]); + const [name, setName] = useState(""); + const [options, setOptions] = useState>(() => + defaultOptions(OFFERED_TYPES[0]), + ); + const [enabled, setEnabled] = useState(true); + const [seeded, setSeeded] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [pendingDelete, setPendingDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + + // Seed once: immediately for a new source, or after the record loads for edit. + useEffect(() => { + if (seeded) return; + if (isEdit && !sourceState.data) return; + const source = sourceState.data ?? undefined; + const resolved = typeFor(source?.type); + setType(resolved); + setName(source?.name ?? ""); + setOptions(optionsFor(resolved, source?.options)); + setEnabled(source?.enabled ?? true); + setSeeded(true); + }, [isEdit, sourceState.data, seeded]); + + function chooseType(next: CreatableSourceType) { + setType(next); + setOptions(defaultOptions(next)); + } + + function setOption(key: string, value: string) { + setOptions((current) => ({ ...current, [key]: value })); + } + + const requiredComplete = type.fields.every( + (field) => !field.required || (options[field.key] ?? "").trim() !== "", + ); + const canSave = name.trim() !== "" && requiredComplete && !submitting; + + async function save() { + if (!canSave) return; + setSubmitting(true); + setError(null); + try { + await createSource({ + id: isEdit ? id : undefined, + name: name.trim(), + type: type.type, + options, + enabled, + }); + navigate(listPath); + } catch (e) { + setError(errorMessage(e)); + setSubmitting(false); + } + } + + async function confirmDelete() { + if (!id || deleting) return; + setDeleting(true); + try { + await deleteSource(id); + navigate(listPath); + } catch (e) { + setError(errorMessage(e)); + setDeleting(false); + setPendingDelete(false); + } + } + + if (isEdit && sourceState.error) { + return ( +
+ + +
+ ); + } + + if (isEdit && !seeded) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ +

+ {isEdit + ? name || t("portal.sources.builder.editTitle") + : t("portal.sources.builder.createTitle")} +

+
+
+ setEnabled(e.target.checked)} + label={t("portal.sources.builder.enabled")} + /> + {isEdit && ( + + )} + + +
+
+ +
+ + setName(e.target.value)} + /> + + + {!isEdit && OFFERED_TYPES.length > 1 && ( + +
+ {OFFERED_TYPES.map((ct) => ( + + ))} +
+
+ )} + + {type.fields.map((field) => ( + + {field.control === "s3Connection" ? ( + setOption(field.key, connectionId)} + /> + ) : field.control === "select" ? ( + setOption(field.key, e.target.value)} + /> + )} + + ))} + + {error && } +
+ + !deleting && setPendingDelete(false)} + width="sm" + title={t("portal.sources.delete.title")} + footer={ +
+ + +
+ } + > +

{t("portal.sources.delete.body", { name })}

+
+
+ ); +} diff --git a/frontend/editor/src/portal/views/Sources.css b/frontend/editor/src/portal/views/Sources.css index d8cc6897af..54d8906be9 100644 --- a/frontend/editor/src/portal/views/Sources.css +++ b/frontend/editor/src/portal/views/Sources.css @@ -438,3 +438,101 @@ gap: 0.5rem; width: 100%; } + +.portal-sources__connection-picker { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-items: flex-start; +} + +.portal-sources__connection-picker .sui-select, +.portal-sources__connection-picker > div:first-child { + align-self: stretch; +} + +.portal-sources__connection-create { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--color-border-2); + border-radius: 0.5rem; + align-self: stretch; +} + +.portal-sources__connection-create-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.portal-sources__connection-form { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.portal-sources__connections { + margin-top: 1.5rem; +} + +.portal-sources__connections-title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-2); + margin: 0 0 0.5rem; +} + +.portal-sources__connections-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.portal-sources__connections-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.375rem 0; + border-bottom: 1px solid var(--color-border-2); +} + +.portal-sources__connections-name { + font-weight: 500; + color: var(--color-text-1); +} + +.portal-sources__connections-bucket { + color: var(--color-text-4); + font-size: 0.8125rem; +} + +.portal-sources__connections-actions { + margin-left: auto; + display: flex; + gap: 0.25rem; +} + +.portal-sources__connections-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.5rem; +} + +.portal-sources__connections-sub { + color: var(--color-text-4); + font-size: 0.875rem; + margin: 0; +} + +.portal-sources__connections-actions { + display: inline-flex; + gap: 0.25rem; + justify-content: flex-end; +} diff --git a/frontend/editor/src/portal/views/Sources.test.tsx b/frontend/editor/src/portal/views/Sources.test.tsx index 5e281d5604..a006cbe4a2 100644 --- a/frontend/editor/src/portal/views/Sources.test.tsx +++ b/frontend/editor/src/portal/views/Sources.test.tsx @@ -3,21 +3,16 @@ import { fireEvent, render as baseRender, screen, - waitFor, } from "@testing-library/react"; import { MantineProvider } from "@mantine/core"; -import { MemoryRouter } from "react-router-dom"; -import { HttpError } from "@portal/api/http"; - -const render = ( - ui: Parameters[0], - options?: Parameters[1], -) => baseRender(ui, { wrapper: MantineProvider, ...options }); +import { MemoryRouter, Route, Routes } from "react-router-dom"; import type { SourcesResponse } from "@portal/api/sources"; import { Sources } from "@portal/views/Sources"; -// Deterministic i18n: keys returned verbatim, so assertions are stable without -// the async TOML backend. +const render = (ui: Parameters[0]) => + baseRender(ui, { wrapper: MantineProvider }); + +// Deterministic i18n: keys returned verbatim. vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -26,48 +21,48 @@ vi.mock("react-i18next", () => ({ })); const fetchSources = vi.fn(); -const fetchSource = vi.fn(); -const fetchSourceDocCounts = vi.fn(); -const createSource = vi.fn(); -const deleteSource = vi.fn(); vi.mock("@portal/api/sources", () => ({ fetchSources: () => fetchSources(), - fetchSource: (id: string) => fetchSource(id), - fetchSourceDocCounts: (id: string) => fetchSourceDocCounts(id), - createSource: (source: unknown) => createSource(source), - deleteSource: (id: string) => deleteSource(id), +})); + +const fetchS3Connections = vi.fn(); +vi.mock("@portal/api/integrations", () => ({ + fetchS3Connections: () => fetchS3Connections(), + deleteIntegration: vi.fn(), +})); + +// The Agent Builder header action is a flavor seam; stub it to keep the test focused. +vi.mock("@portal/components/sources/AgentBuilderAction", () => ({ + AgentBuilderAction: () => null, })); const RESPONSE: SourcesResponse = { kpis: [ - { value: 2, description: "" }, { value: 1, description: "" }, { value: 1, description: "" }, + { value: 0, description: "" }, ], sources: [ { - id: "src-referenced", + id: "editor", + name: "Editor", + type: "editor", + status: "active", + referenceCount: 0, + referencingPolicies: [], + config: [], + docsTotal: 5, + docs24h: 0, + docs30d: 5, + }, + { + id: "src-1", name: "Claims intake", type: "folder", status: "active", referenceCount: 2, - referencingPolicies: [ - { id: "pol-1", name: "Redaction" }, - { id: "pol-2", name: "Classification" }, - ], - config: [{ label: "Directory", value: "/data/incoming" }], - docsTotal: 1240, - docs24h: 18, - docs30d: 540, - }, - { - id: "src-orphan", - name: "Scratch folder", - type: "folder", - status: "unused", - referenceCount: 0, referencingPolicies: [], - config: [{ label: "Directory", value: "/tmp/scratch" }], + config: [{ label: "Directory", value: "/in" }], docsTotal: 1240, docs24h: 18, docs30d: 540, @@ -75,10 +70,20 @@ const RESPONSE: SourcesResponse = { ], }; -function renderView() { +function renderView(initial = "/processor/sources") { return render( - - + + + } /> + source builder: new} + /> + source builder: edit} + /> + , ); } @@ -86,122 +91,54 @@ function renderView() { describe("Sources view", () => { beforeEach(() => { fetchSources.mockReset(); - fetchSource.mockReset(); - fetchSourceDocCounts.mockReset(); - fetchSourceDocCounts.mockResolvedValue([]); - createSource.mockReset(); - deleteSource.mockReset(); - }); - - it("surfaces the inline 409 message when deleting a referenced source", async () => { fetchSources.mockResolvedValue(RESPONSE); - deleteSource.mockRejectedValue( - new HttpError(409, "Conflict", { - detail: "Source is referenced by 2 policies", - }), - ); - - renderView(); - - // Wait for the row to render after the async fetch resolves. - const row = await screen.findByText("Claims intake"); - fireEvent.click(row); - - // Detail card opens with its delete action. - fireEvent.click(await screen.findByText("portal.sources.detail.delete")); - - // Confirm in the dialog. - fireEvent.click(await screen.findByText("portal.sources.delete.confirm")); - - await waitFor(() => { - expect(deleteSource).toHaveBeenCalledWith("src-referenced"); - }); - - expect( - await screen.findByText("Source is referenced by 2 policies"), - ).toBeInTheDocument(); + fetchS3Connections.mockReset(); + fetchS3Connections.mockResolvedValue([]); }); - it("shows the editor as a built-in source with no edit, pause, or delete actions", async () => { - fetchSources.mockResolvedValue({ - kpis: [], - sources: [ - { - id: "editor", - name: "Editor", - type: "editor", - status: "active", - referenceCount: 1, - referencingPolicies: [{ id: "pol-1", name: "Redaction" }], - config: [], - docsTotal: 8230, - docs24h: 42, - docs30d: 1680, - }, - ], - } satisfies SourcesResponse); - + it("opens a source's own page on row click", async () => { renderView(); + fireEvent.click(await screen.findByText("Claims intake")); + expect(await screen.findByText("source builder: edit")).toBeInTheDocument(); + }); - // The editor row is labelled from its type (i18n keys are returned verbatim here). + it("navigates to the create page from the connect button", async () => { + renderView(); + await screen.findByText("Claims intake"); + fireEvent.click(screen.getByText("portal.sources.actions.connectSource")); + expect(await screen.findByText("source builder: new")).toBeInTheDocument(); + }); + + it("does not navigate when the virtual editor row is clicked", async () => { + renderView(); fireEvent.click( await screen.findByText("portal.sources.types.editor.label"), ); - - // Detail opens, but none of the mutate actions are offered for the built-in source. - await screen.findByText("portal.sources.detail.documents"); - expect(screen.queryByText("portal.sources.detail.edit")).toBeNull(); - expect(screen.queryByText("portal.sources.detail.pause")).toBeNull(); - expect(screen.queryByText("portal.sources.detail.delete")).toBeNull(); + // Still on the list: the builder stub never rendered. + expect(screen.queryByText("source builder: edit")).not.toBeInTheDocument(); + expect(screen.getByText("Claims intake")).toBeInTheDocument(); }); - it("pauses a source by re-saving it with enabled flipped off", async () => { - fetchSources.mockResolvedValue(RESPONSE); - fetchSource.mockResolvedValue({ - id: "src-referenced", - name: "Claims intake", - type: "folder", - options: { directory: "/data/incoming", mode: "consume" }, - enabled: true, - }); - createSource.mockResolvedValue({}); - - renderView(); - - fireEvent.click(await screen.findByText("Claims intake")); - fireEvent.click(await screen.findByText("portal.sources.detail.pause")); - - await waitFor(() => { - expect(createSource).toHaveBeenCalledTimes(1); - }); - expect(fetchSource).toHaveBeenCalledWith("src-referenced"); - expect(createSource).toHaveBeenCalledWith( - expect.objectContaining({ id: "src-referenced", enabled: false }), - ); - }); - - it("shows the KPI stat boxes when sources exist", async () => { - fetchSources.mockResolvedValue(RESPONSE); + it("shows the connections surface on the Connections tab", async () => { renderView(); await screen.findByText("Claims intake"); - expect(screen.getByText("portal.sources.kpi.total")).toBeInTheDocument(); + fireEvent.click(screen.getByText("portal.sources.tabs.connections")); + // Empty connections list -> the connections empty state. + expect( + await screen.findByText("portal.connections.empty.title"), + ).toBeInTheDocument(); + expect(fetchS3Connections).toHaveBeenCalled(); }); - it("hides the stat boxes and shows the connect CTA when empty", async () => { + it("hides the KPI strip and shows the empty state when only the editor exists", async () => { fetchSources.mockResolvedValue({ - kpis: [ - { value: 0, description: "" }, - { value: 0, description: "" }, - { value: 0, description: "" }, - ], - sources: [], + kpis: RESPONSE.kpis, + sources: [RESPONSE.sources[0]], }); renderView(); - // The empty-state panel renders. expect( await screen.findByText("portal.sources.empty.title"), ).toBeInTheDocument(); - // The KPI strip is gone: no stat-box labels over an empty page. expect( screen.queryByText("portal.sources.kpi.total"), ).not.toBeInTheDocument(); diff --git a/frontend/editor/src/portal/views/Sources.tsx b/frontend/editor/src/portal/views/Sources.tsx index 60f9d628f9..fb0b14ccf5 100644 --- a/frontend/editor/src/portal/views/Sources.tsx +++ b/frontend/editor/src/portal/views/Sources.tsx @@ -1,137 +1,49 @@ -import { useCallback, useEffect, useState } from "react"; -import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import AddRoundedIcon from "@mui/icons-material/AddRounded"; +import { Button, EmptyState, Skeleton, Tabs } from "@app/ui"; import { useAsync, useSectionFlags } from "@portal/hooks/useAsync"; import { SourcesIcon } from "@portal/components/icons"; -import { errorMessage } from "@portal/api/http"; import { - createSource, - deleteSource, - fetchSource, - fetchSourceDocCounts, fetchSources, - type Source, type SourcesResponse, type SourceView, } from "@portal/api/sources"; +import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext"; import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction"; import { KpiStrip } from "@portal/components/sources/KpiStrip"; import { SourcesTable } from "@portal/components/sources/SourcesTable"; -import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard"; -import { ConnectWizard } from "@portal/components/sources/ConnectWizard"; +import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab"; import "@portal/views/Sources.css"; +type SourcesTab = "sources" | "connections"; + export function Sources() { const { t } = useTranslation(); + const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - // Refetch after every mutation by bumping this counter, so the table reflects - // the in-memory store the handlers maintain (mirrors the Policies view). - const [version, setVersion] = useState(0); - const state = useAsync(() => fetchSources(), [version]); + const activeTab: SourcesTab = + searchParams.get("tab") === "connections" ? "connections" : "sources"; + + const state = useAsync(() => fetchSources(), []); const { data, loading } = state; const { isLoading } = useSectionFlags(state); - const refetch = useCallback(() => setVersion((v) => v + 1), []); - - const [expandedId, setExpandedId] = useState(null); - const [wizardOpen, setWizardOpen] = useState(false); - const [editingSource, setEditingSource] = useState(null); - const [mutating, setMutating] = useState(false); - const [pageError, setPageError] = useState(null); - const [pendingDelete, setPendingDelete] = useState(null); - const [deleting, setDeleting] = useState(false); - const [deleteError, setDeleteError] = useState(null); const sources = data?.sources ?? []; - const expanded = sources.find((s) => s.id === expandedId) ?? null; - // Empty once the fetch settles with no sources (or fails → no data). Gates - // both the KPI strip and the empty panel so no placeholder stat boxes sit - // above an empty page. - const showEmpty = !isLoading && sources.length === 0; + // The editor is a virtual row that's always present, so "empty" means no + // configured sources beyond it. Gates the KPI strip and empty panel. + const configuredCount = sources.filter((s) => s.type !== "editor").length; + const showEmpty = !isLoading && configuredCount === 0; - // The 30-day sparkline series lives off the list endpoint; fetch it for the one - // expanded row only (empty while collapsed, so no request fires). - const docSeriesState = useAsync<{ id: string; series: number[] }>( - () => - expandedId - ? fetchSourceDocCounts(expandedId).then((series) => ({ - id: expandedId, - series, - })) - : Promise.resolve({ id: "", series: [] }), - [expandedId], - ); - const docSeries = - docSeriesState.data?.id === expandedId ? docSeriesState.data.series : []; + const openCreate = () => navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`); + const openSource = (source: SourceView) => + navigate(`${toPortalPath(VIEW_PATHS.sources)}/${source.id}`); - function openCreate() { - setEditingSource(null); - setWizardOpen(true); - } - - // Arriving with ?new (e.g. from the pipeline builder's "connect a source" link) opens the - // create wizard straight away, then strips the flag so a refresh doesn't reopen it. - useEffect(() => { - if (searchParams.get("new") === null) return; - setEditingSource(null); - setWizardOpen(true); + function selectTab(tab: SourcesTab) { const next = new URLSearchParams(searchParams); - next.delete("new"); + if (tab === "sources") next.delete("tab"); + else next.set("tab", tab); setSearchParams(next, { replace: true }); - }, [searchParams, setSearchParams]); - - // Editing needs the raw source (config options), which the overview rows don't - // carry, so fetch it before opening the wizard prefilled. - async function openEdit(source: SourceView) { - if (mutating) return; - setPageError(null); - setMutating(true); - try { - setEditingSource(await fetchSource(source.id)); - setWizardOpen(true); - } catch (e) { - setPageError(errorMessage(e)); - } finally { - setMutating(false); - } - } - - // Pause/resume: re-save the source with enabled flipped (same POST contract as - // edit). Fetch the raw record first so the full config round-trips intact. - async function togglePause(source: SourceView) { - if (mutating) return; - setPageError(null); - setMutating(true); - try { - const raw = await fetchSource(source.id); - await createSource({ ...raw, enabled: !raw.enabled }); - refetch(); - } catch (e) { - setPageError(errorMessage(e)); - } finally { - setMutating(false); - } - } - - function requestDelete(source: SourceView) { - setDeleteError(null); - setPendingDelete(source); - } - - async function confirmDelete() { - if (!pendingDelete || deleting) return; - setDeleting(true); - setDeleteError(null); - try { - await deleteSource(pendingDelete.id); - setPendingDelete(null); - setExpandedId(null); - refetch(); - } catch (e) { - setDeleteError(errorMessage(e)); - } finally { - setDeleting(false); - } } return ( @@ -141,102 +53,67 @@ export function Sources() {

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

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

-
- - -
- - - {pageError && } - - {!showEmpty && } - - {isLoading && ( -
- {Array.from({ length: 5 }).map((_, i) => ( - - ))} -
- )} - - {showEmpty && ( - } - title={t("portal.sources.empty.title")} - description={t("portal.sources.empty.description")} - actions={ + {activeTab === "sources" && ( +
+ - } - /> - )} +
+ )} + - {!isLoading && sources.length > 0 && ( - - setExpandedId((cur) => (cur === s.id ? null : s.id)) - } - /> - )} - - {expanded && ( - setExpandedId(null)} - onEdit={openEdit} - onTogglePause={togglePause} - onDelete={requestDelete} - busy={mutating} - /> - )} - - setWizardOpen(false)} - onCreated={refetch} + + variant="underline" + ariaLabel={t("portal.sources.title")} + activeKey={activeTab} + onChange={selectTab} + items={[ + { key: "sources", label: t("portal.sources.tabs.sources") }, + { key: "connections", label: t("portal.sources.tabs.connections") }, + ]} /> - !deleting && setPendingDelete(null)} - width="sm" - title={t("portal.sources.delete.title")} - footer={ -
- - -
- } - > -

- {t("portal.sources.delete.body", { name: pendingDelete?.name ?? "" })} -

- {deleteError && } -
+ {activeTab === "connections" ? ( + + ) : ( + <> + {!showEmpty && } + + {isLoading && ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ )} + + {showEmpty && ( + } + title={t("portal.sources.empty.title")} + description={t("portal.sources.empty.description")} + actions={ + + } + /> + )} + + {!isLoading && sources.length > 0 && ( + + )} + + )} ); } diff --git a/frontend/editor/src/portal/views/Users.css b/frontend/editor/src/portal/views/Users.css index f351ebeaa3..dfaa9284a6 100644 --- a/frontend/editor/src/portal/views/Users.css +++ b/frontend/editor/src/portal/views/Users.css @@ -396,7 +396,7 @@ white-space: nowrap; } .portal-users__row-role { - width: 148px; + width: 12.5rem; flex-shrink: 0; } diff --git a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts index 1aaabfeb33..d92cf8fdf9 100644 --- a/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts +++ b/frontend/editor/src/proprietary/testing/serverExperienceSimulations.ts @@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = { const BASE_NO_LOGIN_CONFIG: AppConfig = { enableAnalytics: true, - appVersion: "2.14.1", + appVersion: "2.14.2", serverCertificateEnabled: false, enableAlphaFunctionality: false, enableDesktopInstallSlide: true,