Compare commits

...
Author SHA1 Message Date
Anthony Stirling 2df88881c1 Run the multi-node regression suite nightly in build-enterprise 2026-07-14 07:44:27 +01:00
Anthony Stirling 4fde0fde89 Add multi-node cluster regression suite (compose stack + behave e2e) 2026-07-14 07:16:39 +01:00
2b118556f3 Merge hotfix/v2.14.2 into main (#7023)
Merges the `hotfix/v2.14.2` branch into `main`.
on the hotfix branch:

### What this actually changes on `main`
- **Version bump 2.14.1 → 2.14.2** `build.gradle`, `tauri.conf.json`,
both AUR `PKGBUILD`s, and the two `serverExperienceSimulations.ts`
test-config files.
- **Fix Postgres user settings for some users** removes `@Lob` from
`User.java that broke settings for some Postgres users.
- **Release workflow: stop msiexec hang in Windows signature verify**

---------

Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: LFdev <146497073+LFd3v@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-07-13 23:45:19 +01:00
Anthony Stirling fbaff56d1c Merge hotfix/v2.14.2 into main (v2.14.2 bump, Postgres user settings fix, msiexec release fix) 2026-07-13 20:23:14 +01:00
Anthony StirlingandGitHub a1b15e0570 Portal: dark disabled buttons and role column width (#7004)
# Description of Changes

Fixes disabled buttons rendering as plain grey in dark mode, and widens
the Users role column so "Organisation Owner" no longer clips.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<!-- paste before / after screenshots here -->

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 15:00:01 +00:00
James BruntonandGitHub 76549288a9 Redesign S3 connections to use connection resolver (#6965)
# Description of Changes
Redesign S3 connections based on feedback from #6948. Also redesigns the
UI for Sources to make them more like the Pipelines page which improves
UX quite a bit. There's still plenty more UI/UX work for Sources and S3
but moving in the right direction.
2026-07-13 14:44:41 +00:00
Anthony StirlingandGitHub b5d0c4a5ed Portal Home: SVG quick-action icons (#6998)
# Description of Changes

Replaces the ASCII quick-action glyphs on the Home hero with crisp
stroke SVG icons.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after
<img width="2880" height="2726" alt="after-home-dark"
src="https://github.com/user-attachments/assets/ab4c3611-25a6-4b2c-a993-99ce2f7b7558"
/>
<img width="2880" height="2726" alt="after-home-light"
src="https://github.com/user-attachments/assets/b0b342b9-4436-4791-b0a6-92837c3ec355"
/>
<img width="2880" height="2726" alt="before-home-dark"
src="https://github.com/user-attachments/assets/93d73393-5a81-4f34-a483-90671a6ae79e"
/>
<img width="2880" height="2726" alt="before-home-light"
src="https://github.com/user-attachments/assets/119389cc-78ef-4a43-9f9a-d551b03c9733"
/>


---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 13:26:07 +00:00
Anthony StirlingandGitHub 8bfcf6eb7e Portal: theme-aware code blocks and hero-navy token (#7003)
# Description of Changes

Makes the code-snippet boxes theme-aware (a light palette in light mode)
and moves the hero navy into a design token without changing the colour
itself.

Part of a portal (processor) UI-consistency pass, split into small
focused PRs.

## Before / after

<img width="2136" height="272" alt="after-codeblock-light"
src="https://github.com/user-attachments/assets/ebdd4a7d-2d1a-429a-971b-0b04e93854fe"
/>
<img width="1800" height="740" alt="before-codeblock-light"
src="https://github.com/user-attachments/assets/6819231a-10d5-4730-9b7d-3c36dc1c8170"
/>

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-13 13:26:00 +00:00
James BruntonandGitHub b019f9b570 Fix missing and broken translations in Processor (#7016)
# Description of Changes
<img width="385" height="92" alt="image"
src="https://github.com/user-attachments/assets/7f4b1921-72e8-4c18-a4de-4b9736a5a2f1"
/>

Started from trying to fix this, but became a larger piece of work to
find missing/broken translations in the Processor and fix as many as I
could.
2026-07-13 12:55:36 +00:00
Anthony Stirling 38ccea074c Version bump 2026-07-08 10:50:37 +01:00
Anthony Stirling a7307ff393 Fix Postgres user settings for some users 2026-07-08 10:50:36 +01:00
Anthony Stirling 72729e99c1 fix(release): stop msiexec hang in Windows signature verify; don't force latest or regen release notes 2026-07-07 23:35:12 +01:00
Anthony Stirling 5fba2720f0 Fix cert sign not showing under certain instances (#6908) 2026-07-07 22:45:56 +01:00
01a1ef8c44 Fix missing app icon on Linux/Wayland (#6875)
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-07-07 22:15:01 +01:00
LudyandAnthony Stirling 8535c7e9ac feat(ui): add dedicated third-party license sections to settings (#6820) 2026-07-07 22:15:01 +01:00
ConnorYohandAnthony Stirling cca3f42623 Set App version to v2.14.1 (#6891)
Upped version in build.gradle then ran build so version falls through
2026-07-07 12:15:01 +01:00
James BruntonandAnthony Stirling 1b7ffcdbac Fix tooltip positioning on Add Page Numbers (#6885)
# Description of Changes
## Before

<img width="483" height="227" alt="image"
src="https://github.com/user-attachments/assets/4bf86eec-a9cc-4f63-84f0-4eb2bd535bab"
/>

## After

<img width="732" height="235" alt="image"
src="https://github.com/user-attachments/assets/101d2ea4-36e8-4e8f-990a-d72b33fa0ac2"
/>
2026-07-07 12:09:51 +01:00
LudyandAnthony Stirling 67a0ca6110 fix(frontend): respect analytics config before initializing PostHog (#6812)
# Description of Changes

Please provide a summary of the changes, including:

- What was changed
- Moved PostHog startup out of `index.tsx` and into a config-aware
initializer inside `AppProviders`.
- Added a dedicated `usePosthogTracking` hook that only initializes
PostHog when `enableAnalytics` is explicitly `true` and `enablePosthog`
is not disabled.
- Kept cookie-consent handling in the same flow so consent is applied
only after PostHog is actually initialized.
- Removed the unconditional `PostHogProvider` and `posthog.init(...)`
bootstrap from the app entrypoint.
- Added targeted frontend tests covering analytics-disabled and
analytics-enabled startup behavior.

- Why the change was made
- The previous frontend bootstrap initialized PostHog before app config
was loaded, so disabling analytics in the UI or via environment settings
did not prevent PostHog network activity.
- This change makes analytics behavior follow the server-provided config
instead of always connecting on page load.

Closes #6358

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-07 12:09:51 +01:00
Anthony Stirling 8e4b2e2fc6 Disable update check and notification in SaaS mode (#6863)
# Description of Changes

In SaaS mode the self-hosted "Update Available" notification could still
appear and the update-check code (external call to
`supabase.stirling.com/functions/v1/updates`) still ran, even though the
cloud owns app versioning. The web `UpdateStartupPopup` was already
SaaS-gated via a null override, but two other paths were not:

- **Desktop app in SaaS connection mode** - `useDesktopUpdatePopup()`
ran its startup check and rendered the `UpdateModal` regardless of
connection mode, so a self-hosted update popup appeared while connected
to SaaS.
- **Settings → General** - the core `GeneralSection` fired
`checkForUpdate()` on mount unconditionally, even when the update
section was hidden (as SaaS does), so the external call still ran.

**What changed**

- `useDesktopUpdatePopup.ts` - the startup timer now bails out
immediately when `connectionModeService.getCurrentMode() === "saas"`. No
mode lookup, no external fetch, no modal.
- `core/GeneralSection.tsx` - the mount `checkForUpdate()` now returns
early when `hideUpdateSection` is set, so hiding the section (web SaaS,
managed-disabled desktop) also stops the external call.
- `desktop/GeneralSection.tsx` - passes `hideUpdateSection` when
`useSaaSMode()` is true, which (via the above) suppresses the settings
check in desktop-SaaS too.

**Why** - in SaaS the update check should never be called and no update
notification should be shown; the cloud handles versioning.

---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-07-07 12:09:51 +01:00
Anthony Stirling 3c93457021 Fix rearrange-pages DUPLICATE producing shared page nodes (pypdf cyclic-references CI break) (#6851) 2026-07-07 12:09:51 +01:00
108 changed files with 4833 additions and 1661 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
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')
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Stirling PDF Inc <contact@stirlingpdf.com>
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')
+67
View File
@@ -308,3 +308,70 @@ jobs:
name: playwright-report-enterprise-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
# Multi-node regression: builds + seeds the clustered stack (testing/compose/docker-compose-multinode.yml)
# and runs behave features/multinode. Licence-gated, so it runs after the Playwright job (not in parallel).
multinode-e2e:
needs: [pick, playwright-e2e-enterprise]
# Nightly cron + manual dispatch only (heavy build), fork-gated for the licence secret.
if: >-
always() && needs.pick.outputs.is_fork != 'true'
&& (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
runs-on: ${{ needs.pick.outputs.is_fork == 'true' && 'ubuntu-latest' || format('depot-ubuntu-24.04-{0}', inputs.depot_cores || '8') }}
timeout-minutes: 60
env:
PREMIUM_KEY: ${{ secrets.PREMIUM_KEY_ENTERPRISE }}
PREMIUM_ENABLED: "true"
SYSTEM_ENABLEANALYTICS: "false"
DEPOT_TOKEN: ${{ secrets.DEPOT_TOKEN }}
MN_COMPOSE: docker-compose-multinode.yml
steps:
- name: Harden Runner
uses: step-security/harden-runner@ab7a9404c0f3da075243ca237b5fac12c98deaa5 # v2.19.3
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: ./testing/cucumber/requirements.txt
- name: Install behave test deps
run: |
pip install --require-hashes --only-binary=:all: -r ./testing/cucumber/requirements.txt
- name: Build the multi-node image
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" build
- name: Bring up the cluster and wait for both nodes healthy
working-directory: testing/compose
run: |
docker compose -f "$MN_COMPOSE" up -d
for i in $(seq 1 90); do
h1=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null || echo starting)
h2=$(docker inspect -f '{{.State.Health.Status}}' multinode-stirling-2 2>/dev/null || echo starting)
if [ "$h1" = healthy ] && [ "$h2" = healthy ]; then echo "both nodes healthy"; exit 0; fi
sleep 5
done
echo "::error::nodes did not become healthy"
docker compose -f "$MN_COMPOSE" logs --tail=200 stirling-1 stirling-2
exit 1
- name: Seed the cluster (teams, users, S3 connection, policy)
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" --profile seed run --rm seed
- name: Run multi-node regression (implemented guarantees)
working-directory: testing/cucumber
# -e overrides behave.ini's exclusion of features/multinode; ~@known_gap skips any tracked-gap scenarios.
run: python -m behave features/multinode -e "features/enterprise" --tags="~@known_gap ~@destructive" --no-capture -f plain
- name: Run multi-node failover (destructive)
working-directory: testing/cucumber
run: python -m behave features/multinode -e "features/enterprise" --tags="@destructive ~@known_gap" --no-capture -f plain
- name: Dump node logs on failure
if: failure()
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" logs --tail=400 stirling-1 stirling-2
- name: Tear down
if: always()
working-directory: testing/compose
run: docker compose -f "$MN_COMPOSE" --profile seed down -v --remove-orphans
+23 -6
View File
@@ -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.
@@ -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<IntegrationConfigValidator> validators;
private final List<IntegrationConfigUsageCheck> 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<String, Object> 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<String, Object> 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<String> 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<String, Object> 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()
@@ -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<String> usagesOf(long configId);
}
@@ -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<String, Object> config);
}
@@ -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
@@ -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) {
@@ -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<ResolvedInput> 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".
@@ -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<ResultFile> deliver(
OutputDelivery delivery, List<Resource> outputs, OutputSpec spec) throws IOException {
S3Config config = S3Config.from(spec.options());
S3Config config = connectionResolver.resolve(spec.options());
S3Client client = connectionPool.clientFor(config);
List<ResultFile> results = new ArrayList<>();
@@ -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.
*
* <p>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<String> 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<String, IntegrationConfig> 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<String, Object> options) {
return options.get("accessKeyId") != null;
}
/** Reuses an existing connection with identical coordinates+credentials, else creates one. */
private IntegrationConfig connectionFor(
Map<String, Object> options, Long teamId, Map<String, IntegrationConfig> 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<String, Object> 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<String, Object> referencing(
IntegrationConfig connection, Map<String, Object> legacy, boolean keepMode) {
Map<String, Object> 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<String, IntegrationConfig> indexExistingConnections() {
Map<String, IntegrationConfig> byKey = new LinkedHashMap<>();
for (IntegrationConfig connection : connections.findAll()) {
if (connection.getIntegrationType() != IntegrationType.S3) {
continue;
}
try {
Map<String, Object> 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<String, Object> 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<String, Object> options, Map<String, IntegrationConfig> 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<String, Object> 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());
}
}
@@ -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<String> usagesOf(long configId) {
List<String> 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<String, Object> options, long configId) {
try {
Long reference = S3ConnectionResolver.connectionId(options);
return reference != null && reference == configId;
} catch (IllegalArgumentException unparseable) {
return false;
}
}
}
@@ -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,
@@ -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.
*
* <p>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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> connectionConfig(IntegrationConfig connection) {
String json = connection.getConfig();
if (json == null || json.isBlank()) {
return Map.of();
}
try {
return OBJECT_MAPPER.readValue(
json, new TypeReference<LinkedHashMap<String, Object>>() {});
} catch (Exception e) {
throw new IllegalArgumentException(
"s3 connection '" + connection.getName() + "' has unreadable config", e);
}
}
private static void copyPerUseOption(
Map<String, Object> options, Map<String, Object> merged, String key) {
Object value = options.get(key);
if (value != null && !value.toString().isBlank()) {
merged.put(key, value);
}
}
}
@@ -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<String, Object> 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);
}
}
}
@@ -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
@@ -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() {
@@ -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
@@ -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");
@@ -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)
@@ -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();
}
@@ -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
@@ -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);
}
@@ -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<String, Object> 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<String, Object> 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);
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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<String, Object> 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);
}
}
@@ -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));
}
}
+1 -1
View File
@@ -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"
@@ -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.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -7341,10 +7374,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"
@@ -7415,6 +7444,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"
@@ -7986,34 +8016,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"
@@ -8029,10 +8054,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"
@@ -8080,6 +8110,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"
@@ -8109,21 +8143,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"
@@ -10139,6 +10166,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"
@@ -10209,7 +10249,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]
+1 -1
View File
@@ -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",
@@ -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,
+28 -3
View File
@@ -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;
+9 -6
View File
@@ -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;
}
+2 -1
View File
@@ -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;
+42 -31
View File
@@ -19,6 +19,12 @@ export interface TableProps<T> {
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<T>({
rows,
rowKey,
onRowClick,
isRowInteractive,
empty,
className,
}: TableProps<T>) {
@@ -66,38 +73,42 @@ export function Table<T>({
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={rowKey(row)}
className={
interactive
? "sui-table__row sui-table__row--interactive"
: "sui-table__row"
}
onClick={onRowClick ? () => 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 (
<tr
key={rowKey(row)}
className={
rowInteractive
? "sui-table__row sui-table__row--interactive"
: "sui-table__row"
}
onClick={rowInteractive ? () => 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) => (
<td
key={c.key}
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
>
{c.render(row)}
</td>
))}
</tr>
))
: undefined
}
>
{columns.map((c) => (
<td
key={c.key}
className={`sui-table__td sui-table__td--${c.align ?? "left"}`}
>
{c.render(row)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
@@ -5,6 +5,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";
@@ -36,6 +37,14 @@ export function ViewRouter() {
element={<PipelineBuilder />}
/>
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
<Route
path={`${rel(VIEW_PATHS.sources)}/new`}
element={<SourceBuilder />}
/>
<Route
path={`${rel(VIEW_PATHS.sources)}/:id`}
element={<SourceBuilder />}
/>
<Route
path={rel(VIEW_PATHS["agent-builder"])}
element={<AgentBuilder />}
+5
View File
@@ -99,6 +99,11 @@ export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
draft: "neutral",
};
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
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
@@ -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<string, unknown>;
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<string, unknown>;
}
export async function fetchIntegrations(): Promise<IntegrationConfig[]> {
return apiClient.local.json<IntegrationConfig[]>("/api/v1/integrations");
}
/** The S3 connections the caller may use, for source/output pickers. */
export async function fetchS3Connections(): Promise<IntegrationConfig[]> {
return (await fetchIntegrations()).filter(
(integration) => integration.integrationType === "S3",
);
}
export async function createIntegration(
body: IntegrationConfigRequest,
): Promise<IntegrationConfig> {
return apiClient.local.json<IntegrationConfig>("/api/v1/integrations", {
method: "POST",
body,
});
}
export async function updateIntegration(
id: number,
body: IntegrationConfigRequest,
): Promise<IntegrationConfig> {
return apiClient.local.json<IntegrationConfig>(
`/api/v1/integrations/${encodeURIComponent(id)}`,
{ method: "PUT", body },
);
}
export async function deleteIntegration(id: number): Promise<void> {
await apiClient.local.json<void>(
`/api/v1/integrations/${encodeURIComponent(id)}`,
{ method: "DELETE" },
);
}
+17 -2
View File
@@ -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,
+14 -9
View File
@@ -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". */
@@ -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);
@@ -26,7 +26,7 @@ export function ProcessingStatusStrip() {
style={{ background: TIER_INFO[tier].dotColor }}
aria-hidden
/>
{TIER_INFO[tier].label}
{t(TIER_INFO[tier].labelKey)}
</span>
<span className="portal-statusstrip__sep" aria-hidden>
·
@@ -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);
@@ -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({
</div>
<div className="portal-agents__builder-meta">
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
{agent.status}
{t(AGENT_STATUS_LABEL[agent.status])}
</StatusBadge>
<code className="portal-agents__builder-version">
{agent.version}
@@ -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({
</span>
<span className="portal-agents__selector-meta">
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
{a.status}
{t(AGENT_STATUS_LABEL[a.status])}
</StatusBadge>
<code className="portal-agents__selector-version">{a.version}</code>
</span>
@@ -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}
</code>
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
{v.status}
{t(AGENT_STATUS_LABEL[v.status])}
</StatusBadge>
{isCurrent && (
<StatusBadge tone="info" size="sm" showDot={false}>
@@ -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({
/>
<StatTile
label={t("portal.catalogue.detail.stats.billedOn")}
value={component.pricing.unit}
value={t(BILLING_UNIT_LABEL[component.pricing.unit])}
/>
<StatTile
label={t("portal.catalogue.detail.stats.freeQuota")}
@@ -220,7 +222,7 @@ export function ComponentDetailModal({
</div>
<p className="portal-components__pricing-note">
{t("portal.catalogue.detail.pricing.note", {
unit: component.pricing.unit,
unit: t(BILLING_UNIT_LABEL[component.pricing.unit]),
})}
</p>
</div>
@@ -157,7 +157,7 @@ export function ReviewQueue({ documents, loading }: ReviewQueueProps) {
<Button
variant="secondary"
onClick={() =>
navigate(`${toPortalPath(VIEW_PATHS.sources)}?new`)
navigate(`${toPortalPath(VIEW_PATHS.sources)}/new`)
}
>
{t("portal.documents.queue.empty.connectSource")}
@@ -23,7 +23,7 @@ export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
rightSection={
<span className="portal-infra__key-head-right">
<StatusBadge tone={KEY_TONE[apiKey.status]} size="sm">
{KEY_LABEL[apiKey.status]}
{t(KEY_LABEL[apiKey.status])}
</StatusBadge>
<span
className={"portal-infra__chevron" + (open ? " is-open" : "")}
@@ -75,7 +75,7 @@ export function DocumentLedger({
className="portal-proc__stage-label"
data-current={cur || undefined}
>
{group.label}
{t(group.label)}
</span>
{blurb && (
<span className="portal-proc__stage-hint">
@@ -1,16 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
const meta: Meta<typeof ConnectWizard> = {
title: "Portal/Sources/ConnectWizard",
component: ConnectWizard,
parameters: { layout: "fullscreen" },
args: { open: true, onClose: () => {}, onCreated: () => {} },
};
export default meta;
type Story = StoryObj<typeof ConnectWizard>;
/** Opens on the type-picker step; Continue/Back walk through the three steps. */
export const Open: Story = {};
export const Closed: Story = { args: { open: false } };
@@ -1,192 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
import { ConnectWizard } from "@portal/components/sources/ConnectWizard";
function renderWithMantine(ui: React.ReactElement) {
return render(<MantineProvider>{ui}</MantineProvider>);
}
// Deterministic i18n: keys come back verbatim so the test never waits on the
// async TOML backend.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn() },
}),
}));
// Mock the service layer so no real fetch is issued.
const createSource = vi.fn();
vi.mock("@portal/api/sources", () => ({
createSource: (...args: unknown[]) => createSource(...args),
}));
/** Step through the folder-source flow: choose type -> configure -> review. */
function stepToReview() {
// Step 0: folder is the default-selected type. Continue.
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
// Step 1: fill the required name + directory fields.
const inputs = screen.getAllByRole("textbox");
// First textbox is the name field, second is the folder's directory field.
fireEvent.change(inputs[0], { target: { value: "Claims intake" } });
fireEvent.change(inputs[1], { target: { value: "/data/incoming" } });
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
}
describe("ConnectWizard", () => {
beforeEach(() => {
createSource.mockReset();
});
it("creates a folder source with the configured options", async () => {
createSource.mockResolvedValue({ id: "src-1" });
const onCreated = vi.fn();
const onClose = vi.fn();
renderWithMantine(
<ConnectWizard open onClose={onClose} onCreated={onCreated} />,
);
stepToReview();
// Step 2: submit via the final "connect source" action.
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
await waitFor(() => {
expect(createSource).toHaveBeenCalledTimes(1);
});
expect(createSource).toHaveBeenCalledWith({
name: "Claims intake",
type: "folder",
options: {
directory: "/data/incoming",
mode: "consume",
recursive: "false",
identity: "stat",
},
enabled: true,
});
await waitFor(() => {
expect(onCreated).toHaveBeenCalledTimes(1);
});
});
it("creates an s3 source with a masked secret in review", async () => {
createSource.mockResolvedValue({ id: "src-2" });
renderWithMantine(
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
);
// Step 0: pick the S3 type card.
fireEvent.click(screen.getByText("portal.sources.types.s3.label"));
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
// Step 1: name, bucket, credentials. The secret renders as a password
// input, so it is not part of the textbox roles.
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
fireEvent.change(inputs[0], { target: { value: "Claims bucket" } });
fireEvent.change(inputs[1], { target: { value: "claims-inbox" } });
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
const secret = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
fireEvent.change(secret, { target: { value: "shh-secret" } });
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
// Step 2: the secret is masked in review, never echoed.
expect(screen.queryByText("shh-secret")).not.toBeInTheDocument();
expect(screen.getByText("********")).toBeInTheDocument();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
await waitFor(() => {
expect(createSource).toHaveBeenCalledTimes(1);
});
expect(createSource).toHaveBeenCalledWith({
name: "Claims bucket",
type: "s3",
options: {
bucket: "claims-inbox",
region: "us-east-1",
prefix: "",
accessKeyId: "AKIAEXAMPLE",
secretAccessKey: "shh-secret",
endpoint: "",
mode: "consume",
},
enabled: true,
});
});
it("edits an existing source: prefilled, skips type, submits with its id", async () => {
createSource.mockResolvedValue({ id: "s1" });
const onCreated = vi.fn();
renderWithMantine(
<ConnectWizard
open
source={{
id: "s1",
name: "James",
type: "folder",
options: { directory: "'/data/in'", mode: "consume" },
enabled: true,
}}
onClose={vi.fn()}
onCreated={onCreated}
/>,
);
// Edit opens on the configure step with name + directory prefilled.
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
expect(inputs[0].value).toBe("James");
expect(inputs[1].value).toBe("'/data/in'");
// Fix the stray quotes, continue to review, save.
fireEvent.change(inputs[1], { target: { value: "/data/in" } });
fireEvent.click(screen.getByText("portal.sources.wizard.continue"));
fireEvent.click(screen.getByText("portal.sources.wizard.save"));
await waitFor(() => {
expect(createSource).toHaveBeenCalledTimes(1);
});
// Options absent from the stored source are submitted at their defaults.
expect(createSource).toHaveBeenCalledWith({
id: "s1",
name: "James",
type: "folder",
options: {
directory: "/data/in",
mode: "consume",
recursive: "false",
identity: "stat",
},
enabled: true,
});
await waitFor(() => {
expect(onCreated).toHaveBeenCalledTimes(1);
});
});
it("renders the inline error message when create fails", async () => {
createSource.mockRejectedValue(
new HttpError(400, "Bad Request", {
detail: "Directory is not readable",
}),
);
renderWithMantine(
<ConnectWizard open onClose={vi.fn()} onCreated={vi.fn()} />,
);
stepToReview();
fireEvent.click(screen.getByText("portal.sources.actions.connectSource"));
expect(
await screen.findByText("Directory is not readable"),
).toBeInTheDocument();
});
});
@@ -1,298 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Banner,
Button,
FormField,
Input,
Modal,
Select,
StatTile,
} from "@app/ui";
import { errorMessage } from "@portal/api/http";
import { createSource, type Source } from "@portal/api/sources";
import { creatableSourceTypes } from "@portal/components/sources/creatableSourceTypes";
import {
defaultOptions,
sourceTypeMeta,
type CreatableSourceType,
} from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
const OFFERED_TYPES = creatableSourceTypes();
const DEFAULT_TYPE = OFFERED_TYPES[0];
/** Wizard steps. Editing skips type selection (the type is fixed once created). */
type StepId = "type" | "configure" | "review";
const CREATE_STEPS: StepId[] = ["type", "configure", "review"];
const EDIT_STEPS: StepId[] = ["configure", "review"];
interface ConnectWizardProps {
open: boolean;
onClose: () => void;
/** Called after a source is created or updated so the page can refetch. */
onCreated: () => void;
/** When set, the wizard edits this existing source instead of creating one. */
source?: Source;
}
/** The creatable-type metadata for a source's stored type, falling back to the first offered. */
function typeFor(type: string | undefined): CreatableSourceType {
return OFFERED_TYPES.find((t) => t.type === type) ?? DEFAULT_TYPE;
}
/** Source options coerced to strings for the form, defaulted from the type's fields. */
function optionsFor(
type: CreatableSourceType,
options: Record<string, unknown> | undefined,
): Record<string, string> {
const out = defaultOptions(type);
for (const [key, value] of Object.entries(options ?? {})) {
out[key] = value == null ? "" : String(value);
}
return out;
}
/**
* Guided flow for connecting a source (choose type -> configure -> review) or
* editing an existing one (configure -> review, type fixed). On submit a blank
* id creates and a set id updates, matching the backend's POST contract.
*/
export function ConnectWizard({
open,
onClose,
onCreated,
source,
}: ConnectWizardProps) {
const { t } = useTranslation();
const isEdit = source !== undefined;
const steps = isEdit ? EDIT_STEPS : CREATE_STEPS;
const [stepIndex, setStepIndex] = useState(0);
const [type, setType] = useState<CreatableSourceType>(() =>
typeFor(source?.type),
);
const [name, setName] = useState(source?.name ?? "");
const [options, setOptions] = useState<Record<string, string>>(() =>
optionsFor(typeFor(source?.type), source?.options),
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Re-seed the form whenever the wizard opens (or its target source changes) so
// editing prefills the current config and a reopened create starts clean.
useEffect(() => {
if (!open) return;
const ct = typeFor(source?.type);
setStepIndex(0);
setType(ct);
setName(source?.name ?? "");
setOptions(optionsFor(ct, source?.options));
setSubmitting(false);
setError(null);
}, [open, source]);
const stepId = steps[stepIndex];
const isLast = stepIndex === steps.length - 1;
function chooseType(next: CreatableSourceType) {
setType(next);
setOptions(defaultOptions(next));
}
const requiredFilled = type.fields.every(
(f) => !f.required || (options[f.key] ?? "").trim() !== "",
);
const canContinue =
stepId === "configure" ? name.trim() !== "" && requiredFilled : true;
async function advance() {
if (!isLast) {
setStepIndex((i) => i + 1);
return;
}
setSubmitting(true);
setError(null);
try {
const fields = {
name: name.trim(),
type: type.type,
options,
enabled: source?.enabled ?? true,
};
await createSource(isEdit ? { ...fields, id: source.id } : fields);
onCreated();
onClose();
} catch (e) {
setError(errorMessage(e));
} finally {
setSubmitting(false);
}
}
const stepLabels: Record<StepId, string> = {
type: t("portal.sources.wizard.steps.chooseType"),
configure: t("portal.sources.wizard.steps.configure"),
review: t("portal.sources.wizard.steps.review"),
};
return (
<Modal
open={open}
onClose={onClose}
width="lg"
title={
isEdit
? t("portal.sources.wizard.editTitle")
: t("portal.sources.wizard.title")
}
subtitle={t("portal.sources.wizard.subtitle", {
current: stepIndex + 1,
total: steps.length,
label: stepLabels[stepId],
})}
footer={
<div className="portal-sources__wizard-footer">
<Button
variant="tertiary"
size="sm"
disabled={submitting}
onClick={() =>
stepIndex === 0 ? onClose() : setStepIndex((i) => i - 1)
}
>
{stepIndex === 0
? t("portal.sources.wizard.cancel")
: t("portal.sources.wizard.back")}
</Button>
<Button
size="sm"
onClick={advance}
loading={submitting}
disabled={!canContinue}
rightSection={!isLast ? <span aria-hidden>→</span> : undefined}
>
{!isLast
? t("portal.sources.wizard.continue")
: isEdit
? t("portal.sources.wizard.save")
: t("portal.sources.actions.connectSource")}
</Button>
</div>
}
>
<ol className="portal-sources__steps" aria-hidden>
{steps.map((id, i) => (
<li
key={id}
className={
"portal-sources__step" +
(i === stepIndex ? " is-active" : i < stepIndex ? " is-done" : "")
}
>
<span className="portal-sources__step-mark">
{i < stepIndex ? "✓" : i + 1}
</span>
{stepLabels[id]}
</li>
))}
</ol>
{stepId === "type" && (
<div className="portal-sources__type-grid">
{OFFERED_TYPES.map((ct) => (
<Button
key={ct.type}
variant="tertiary"
className={
"portal-sources__type-card" +
(type.type === ct.type ? " is-selected" : "")
}
onClick={() => chooseType(ct)}
>
<span className="portal-sources__type-icon" aria-hidden>
{sourceTypeMeta(ct.type).icon}
</span>
<span className="portal-sources__type-name">
{t(ct.labelKey)}
</span>
</Button>
))}
</div>
)}
{stepId === "configure" && (
<div className="portal-sources__wizard-body">
<FormField label={t("portal.sources.wizard.name")} required>
<Input
value={name}
placeholder={t("portal.sources.wizard.namePlaceholder")}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
{type.fields.map((field) => (
<FormField
key={field.key}
label={t(field.labelKey)}
helperText={
field.helperTextKey ? t(field.helperTextKey) : undefined
}
required={field.required}
>
{field.control === "select" ? (
<Select
value={options[field.key] ?? ""}
options={(field.options ?? []).map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(value) =>
setOptions((o) => ({ ...o, [field.key]: value ?? "" }))
}
/>
) : (
<Input
type={field.control === "password" ? "password" : undefined}
value={options[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
}
onChange={(e) =>
setOptions((o) => ({ ...o, [field.key]: e.target.value }))
}
/>
)}
</FormField>
))}
</div>
)}
{stepId === "review" && (
<div className="portal-sources__wizard-body">
<div className="portal-sources__stat-grid">
<StatTile
label={t("portal.sources.wizard.name")}
value={name || "—"}
/>
<StatTile
label={t("portal.sources.wizard.type")}
value={t(type.labelKey)}
/>
{type.fields.map((field) => (
<StatTile
key={field.key}
label={t(field.labelKey)}
value={
field.control === "password" && options[field.key]
? "********"
: options[field.key] || "—"
}
/>
))}
</div>
{error && <Banner tone="danger" description={error} />}
</div>
)}
</Modal>
);
}
@@ -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<typeof baseRender>[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(<ConnectionsTab />);
expect(
await screen.findByText("portal.connections.empty.title"),
).toBeInTheDocument();
});
it("lists connections and deletes one", async () => {
fetchS3Connections.mockResolvedValueOnce([CONNECTION]);
fetchS3Connections.mockResolvedValueOnce([]);
render(<ConnectionsTab />);
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(<ConnectionsTab />);
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();
});
});
@@ -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<IntegrationConfig[] | null>(
null,
);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<IntegrationConfig | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<TableColumn<IntegrationConfig>[]>(
() => [
{
key: "name",
header: t("portal.connections.table.name"),
render: (c) => <strong>{c.name}</strong>,
},
{
key: "bucket",
header: t("portal.connections.table.bucket"),
render: (c) => (
<span className="portal-sources__connections-bucket">
{String(c.config?.bucket ?? "")}
</span>
),
},
{
key: "region",
header: t("portal.connections.table.region"),
render: (c) => String(c.config?.region ?? ""),
},
{
key: "actions",
header: "",
align: "right",
render: (c) =>
c.canManage ? (
<span className="portal-sources__connections-actions">
<Button
variant="tertiary"
size="sm"
disabled={busy}
onClick={() => openEdit(c)}
>
{t("portal.connections.edit")}
</Button>
<Button
variant="tertiary"
size="sm"
accent="danger"
disabled={busy}
onClick={() => void remove(c)}
>
{t("portal.connections.delete")}
</Button>
</span>
) : 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 (
<section className="portal-sources__connections">
<div className="portal-sources__connections-head">
<p className="portal-sources__connections-sub">
{t("portal.connections.subtitle")}
</p>
<Button
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.connections.actions.new")}
</Button>
</div>
{error && <Banner tone="danger" description={error} />}
{isLoading && (
<div className="portal-sources__table-skeleton" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{isEmpty && (
<EmptyState
icon={<SourcesIcon size={28} />}
title={t("portal.connections.empty.title")}
description={t("portal.connections.empty.description")}
actions={
<Button
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.connections.actions.new")}
</Button>
}
/>
)}
{connections !== null && connections.length > 0 && (
<Table<IntegrationConfig>
className="portal-sources__connections-table"
columns={columns}
rows={connections}
rowKey={(c) => String(c.id)}
/>
)}
<S3ConnectionModal
open={modalOpen}
connection={editing}
onClose={() => setModalOpen(false)}
onSaved={() => void refresh()}
/>
</section>
);
}
@@ -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<string, unknown> {
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 (
<div className="portal-sources__connection-form">
<FormField label={t("portal.connections.s3.fields.name")} required>
<Input
value={values.name}
placeholder={t("portal.connections.s3.fields.namePlaceholder")}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.bucket.label")}
required
>
<Input
value={values.bucket}
placeholder="my-company-inbox"
onChange={(e) => set("bucket", e.target.value)}
/>
</FormField>
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
<Input
value={values.region}
placeholder="us-east-1"
onChange={(e) => set("region", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
required
>
<Input
value={values.accessKeyId}
onChange={(e) => set("accessKeyId", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
required
>
<Input
type="password"
value={values.secretAccessKey}
onChange={(e) => set("secretAccessKey", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.endpoint.label")}
helperText={t("portal.sources.types.s3.fields.endpoint.helperText")}
>
<Input
value={values.endpoint}
placeholder="https://s3.example.com"
onChange={(e) => set("endpoint", e.target.value)}
/>
</FormField>
</div>
);
}
@@ -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<typeof baseRender>[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(<S3ConnectionModal open onClose={onClose} onSaved={onSaved} />);
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(
<S3ConnectionModal
open
connection={connection}
onClose={vi.fn()}
onSaved={vi.fn()}
/>,
);
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(<S3ConnectionModal open onClose={vi.fn()} onSaved={vi.fn()} />);
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();
});
});
@@ -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<S3ConnectionFormValues>(EMPTY_S3_CONNECTION);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
open={open}
onClose={onClose}
title={t(
isEdit
? "portal.connections.editTitle"
: "portal.connections.createTitle",
)}
footer={
<div className="portal-sources__connection-create-actions">
<Button
variant="tertiary"
size="sm"
disabled={saving}
onClick={onClose}
>
{t("portal.connections.picker.cancel")}
</Button>
<Button
size="sm"
loading={saving}
disabled={!s3ConnectionFormValid(form)}
onClick={() => void save()}
>
{t("portal.connections.picker.save")}
</Button>
</div>
}
>
<S3ConnectionForm values={form} onChange={setForm} />
{error && <Banner tone="danger" description={error} />}
</Modal>
);
}
@@ -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<typeof baseRender>[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(<S3ConnectionPicker value="" onChange={onChange} />);
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"));
});
});
@@ -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<IntegrationConfig[] | null>(
null,
);
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let mounted = true;
fetchS3Connections()
.then((list) => {
if (mounted) setConnections(list);
})
.catch((e) => {
if (mounted) setError(errorMessage(e));
});
return () => {
mounted = false;
};
}, []);
return (
<div className="portal-sources__connection-picker">
<Select
value={value || null}
placeholder={t("portal.connections.picker.placeholder")}
options={(connections ?? []).map((connection) => ({
value: String(connection.id),
label: connection.name,
}))}
onChange={(selected) => onChange(selected ?? "")}
/>
<Button variant="tertiary" size="sm" onClick={() => setModalOpen(true)}>
{t("portal.connections.picker.createNew")}
</Button>
{error && <Banner tone="danger" description={error} />}
<S3ConnectionModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSaved={(created) => {
setConnections((list) => [...(list ?? []), created]);
onChange(String(created.id));
}}
/>
</div>
);
}
@@ -1,63 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { SourceView } from "@portal/api/sources";
import { SourceDetailCard } from "@portal/components/sources/SourceDetailCard";
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
const SAMPLE_SERIES = sampleDailySeries(330);
const IN_USE: SourceView = {
id: "src-claims",
name: "Claims intake",
type: "folder",
status: "active",
referenceCount: 2,
referencingPolicies: [
{ id: "a", name: "Security Policy" },
{ id: "b", name: "Redaction Policy" },
],
config: [
{ label: "Directory", value: "/data/claims-intake" },
{ label: "Mode", value: "consume" },
],
docsTotal: 45230,
docs24h: 312,
docs30d: 9870,
};
const ORPHANED: SourceView = {
id: "src-archive",
name: "Archive reprocess",
type: "folder",
status: "unused",
referenceCount: 0,
referencingPolicies: [],
config: [{ label: "Directory", value: "/data/archive" }],
docsTotal: 45230,
docs24h: 312,
docs30d: 9870,
};
const meta: Meta<typeof SourceDetailCard> = {
title: "Portal/Sources/SourceDetailCard",
component: SourceDetailCard,
parameters: { layout: "padded" },
args: {
docSeries: SAMPLE_SERIES,
onClose: () => {},
onEdit: () => {},
onTogglePause: () => {},
onDelete: () => {},
},
decorators: [
(S) => (
<div style={{ maxWidth: "56rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SourceDetailCard>;
export const InUse: Story = { args: { source: IN_USE } };
export const Orphaned: Story = { args: { source: ORPHANED } };
@@ -1,100 +0,0 @@
import { useTranslation } from "react-i18next";
import { ActionIcon, Button } from "@app/ui";
import type { SourceView } from "@portal/api/sources";
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
import {
EDITOR_SOURCE_TYPE,
sourceTypeMeta,
} from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
import CloseIcon from "@mui/icons-material/Close";
interface SourceDetailCardProps {
source: SourceView;
docSeries: number[];
onClose: () => void;
onEdit: (source: SourceView) => void;
onTogglePause: (source: SourceView) => void;
onDelete: (source: SourceView) => void;
/** Disables the actions while a mutation is in flight. */
busy?: boolean;
}
/** Expanded detail for the selected source row, with edit/pause/delete actions. */
export function SourceDetailCard({
source,
docSeries,
onClose,
onEdit,
onTogglePause,
onDelete,
busy = false,
}: SourceDetailCardProps) {
const { t } = useTranslation();
const meta = sourceTypeMeta(source.type);
const paused = source.status === "disabled";
// The editor is a built-in source: it has no instance name and can't be edited/paused/deleted.
const isEditor = source.type === EDITOR_SOURCE_TYPE;
return (
<section className="portal-sources__expanded">
<header className="portal-sources__expanded-head">
<span
className={`portal-sources__type-dot portal-sources__type-dot--${meta.accent}`}
aria-hidden
>
{meta.icon}
</span>
<div>
<h2 className="portal-sources__expanded-title">
{isEditor ? t(meta.labelKey) : source.name}
</h2>
<span className="portal-sources__expanded-sub">
{t("portal.sources.detail.subtitle", {
type: t(meta.labelKey),
status: t(`portal.sources.status.${source.status}`),
})}
</span>
</div>
<ActionIcon
variant="tertiary"
className="portal-sources__expanded-close"
onClick={onClose}
aria-label={t("portal.sources.detail.closeAriaLabel")}
>
<CloseIcon />
</ActionIcon>
</header>
<SourceDetailPanel source={source} docSeries={docSeries} />
{!isEditor && (
<div className="portal-sources__detail-actions">
<Button
variant="secondary"
disabled={busy}
onClick={() => onEdit(source)}
>
{t("portal.sources.detail.edit")}
</Button>
<Button
variant="secondary"
disabled={busy}
onClick={() => onTogglePause(source)}
>
{paused
? t("portal.sources.detail.resume")
: t("portal.sources.detail.pause")}
</Button>
<Button
accent="danger"
variant="secondary"
disabled={busy}
onClick={() => onDelete(source)}
>
{t("portal.sources.detail.delete")}
</Button>
</div>
)}
</section>
);
}
@@ -1,59 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type { SourceView } from "@portal/api/sources";
import { SourceDetailPanel } from "@portal/components/sources/SourceDetailPanel";
import { sampleDailySeries } from "@portal/mocks/sampleDailySeries";
const SAMPLE_SERIES = sampleDailySeries(330);
const IN_USE: SourceView = {
id: "src-claims",
name: "Claims intake",
type: "folder",
status: "active",
referenceCount: 2,
referencingPolicies: [
{ id: "a", name: "Security Policy" },
{ id: "b", name: "Redaction Policy" },
],
config: [
{ label: "Directory", value: "/data/claims-intake" },
{ label: "Mode", value: "consume" },
],
docsTotal: 45230,
docs24h: 312,
docs30d: 9870,
};
const ORPHANED: SourceView = {
id: "src-archive",
name: "Archive reprocess",
type: "folder",
status: "unused",
referenceCount: 0,
referencingPolicies: [],
config: [{ label: "Directory", value: "/data/archive" }],
docsTotal: 1180,
docs24h: 0,
docs30d: 0,
};
const meta: Meta<typeof SourceDetailPanel> = {
title: "Portal/Sources/SourceDetailPanel",
component: SourceDetailPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "48rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof SourceDetailPanel>;
export const InUse: Story = {
args: { source: IN_USE, docSeries: SAMPLE_SERIES },
};
/** A source no policy references is called out as safe to delete. */
export const Orphaned: Story = { args: { source: ORPHANED, docSeries: [] } };
@@ -1,91 +0,0 @@
import { useTranslation } from "react-i18next";
import { Chip, StatTile } from "@app/ui";
import type { SourceView } from "@portal/api/sources";
import { Sparkline } from "@portal/components/sources/Sparkline";
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
import "@portal/views/Sources.css";
interface SourceDetailPanelProps {
source: SourceView;
/** The 30-day daily series for the sparkline, fetched per source when expanded. */
docSeries: number[];
}
/**
* Expanded detail for a source row: its config (key/value), the documents it has
* fed into runs, and which policies reference it (a 0-reference source is called
* out as safe to delete).
*/
export function SourceDetailPanel({
source,
docSeries,
}: SourceDetailPanelProps) {
const { t } = useTranslation();
const isEditor = source.type === EDITOR_SOURCE_TYPE;
return (
<div className="portal-sources__detail">
{isEditor && (
<p className="portal-sources__muted">
{t("portal.sources.types.editor.description")}
</p>
)}
{source.config.length > 0 && (
<div className="portal-sources__stat-grid">
{source.config.map((row) => (
<StatTile key={row.label} label={row.label} value={row.value} />
))}
</div>
)}
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("portal.sources.detail.usedBy")}
</span>
{source.referencingPolicies.length === 0 ? (
<p className="portal-sources__muted">
{t(
isEditor
? "portal.sources.types.editor.noPolicies"
: "portal.sources.detail.notReferenced",
)}
</p>
) : (
<div className="portal-sources__chips">
{source.referencingPolicies.map((policy) => (
<Chip key={policy.id} size="sm">
{policy.name}
</Chip>
))}
</div>
)}
</div>
<div className="portal-sources__detail-section">
<span className="portal-sources__detail-heading">
{t("portal.sources.detail.documents")}
</span>
<div className="portal-sources__stat-grid">
<StatTile
label={t("portal.sources.detail.docsTotal")}
value={source.docsTotal.toLocaleString()}
/>
<StatTile
label={t("portal.sources.detail.docs24h")}
value={source.docs24h.toLocaleString()}
/>
<StatTile
label={t("portal.sources.detail.docs30d")}
value={source.docs30d.toLocaleString()}
/>
</div>
{docSeries.length > 0 && (
<Sparkline
data={docSeries}
ariaLabel={t("portal.sources.detail.docsTrend")}
/>
)}
</div>
</div>
);
}
@@ -51,14 +51,9 @@ const meta: Meta<typeof SourcesTable> = {
title: "Portal/Sources/SourcesTable",
component: SourcesTable,
parameters: { layout: "padded" },
args: { sources: SOURCES, expandedId: null, onRowClick: () => {} },
args: { sources: SOURCES, onRowClick: () => {} },
};
export default meta;
type Story = StoryObj<typeof SourcesTable>;
export const Default: Story = {};
/** A row with an open detail panel rotates its caret. */
export const RowExpanded: Story = {
args: { expandedId: SOURCES[0].id },
};
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import ChevronRightRoundedIcon from "@mui/icons-material/ChevronRightRounded";
import {
Chip,
StatusBadge,
@@ -22,16 +23,11 @@ const STATUS_TONE: Record<SourceStatus, StatusTone> = {
interface SourcesTableProps {
sources: SourceView[];
/** Id of the row whose detail panel is open, drives the caret state. */
expandedId: string | null;
/** Opens a source's own page. Not called for the virtual editor row. */
onRowClick: (source: SourceView) => void;
}
export function SourcesTable({
sources,
expandedId,
onRowClick,
}: SourcesTableProps) {
export function SourcesTable({ sources, onRowClick }: SourcesTableProps) {
const { t } = useTranslation();
const columns = useMemo<TableColumn<SourceView>[]>(
() => [
@@ -76,6 +72,18 @@ export function SourcesTable({
</StatusBadge>
),
},
{
key: "docs",
header: t("portal.sources.table.documents"),
align: "right",
render: (s) => (
<span
className={s.docsTotal === 0 ? "portal-sources__muted" : undefined}
>
{s.docsTotal.toLocaleString()}
</span>
),
},
{
key: "referenceCount",
header: t("portal.sources.table.usedBy"),
@@ -91,23 +99,20 @@ export function SourcesTable({
),
},
{
key: "expand",
key: "open",
header: "",
align: "right",
width: "2.5rem",
render: (s) => (
<span
className={
"portal-sources__caret" + (expandedId === s.id ? " is-open" : "")
}
aria-hidden
>
▸
</span>
),
// The editor source has no page to open, so it shows no chevron.
render: (s) =>
s.type === EDITOR_SOURCE_TYPE ? null : (
<span className="portal-sources__caret" aria-hidden>
<ChevronRightRoundedIcon style={{ fontSize: "1.25rem" }} />
</span>
),
},
],
[expandedId, t],
[t],
);
return (
@@ -117,6 +122,8 @@ export function SourcesTable({
rows={sources}
rowKey={(s) => s.id}
onRowClick={onRowClick}
// The virtual editor row has no page to open, so it's inert - not a fake button.
isRowInteractive={(s) => s.type !== EDITOR_SOURCE_TYPE}
/>
);
}
@@ -1,48 +0,0 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { Sparkline } from "@portal/components/sources/Sparkline";
function pointsOf(container: HTMLElement): string[] {
const poly = container.querySelector("polyline");
return (poly?.getAttribute("points") ?? "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
describe("Sparkline", () => {
it("draws one point per value", () => {
const { container } = render(<Sparkline data={[1, 5, 2, 8, 3]} />);
expect(pointsOf(container)).toHaveLength(5);
});
it("renders nothing for an empty series", () => {
const { container } = render(<Sparkline data={[]} />);
expect(container.querySelector("svg")).toBeNull();
});
it("renders a flat finite line for an all-zero series (no divide-by-zero)", () => {
const { container } = render(<Sparkline data={[0, 0, 0, 0]} />);
const ys = pointsOf(container).map((p) => Number(p.split(",")[1]));
expect(ys).toHaveLength(4);
expect(ys.every(Number.isFinite)).toBe(true);
// All equal: a zero series is a single horizontal line, not NaN-laden.
expect(new Set(ys).size).toBe(1);
});
it("puts the peak value at the top of the band", () => {
const { container } = render(<Sparkline data={[0, 10]} height={36} />);
const ys = pointsOf(container).map((p) => Number(p.split(",")[1]));
// y grows downward in SVG, so the larger value (10) sits at the smaller y.
expect(ys[1]).toBeLessThan(ys[0]);
});
it("exposes its aria-label", () => {
const { container } = render(
<Sparkline data={[1, 2]} ariaLabel="Docs trend" />,
);
expect(container.querySelector("svg")?.getAttribute("aria-label")).toBe(
"Docs trend",
);
});
});
@@ -1,53 +0,0 @@
import "@portal/views/Sources.css";
interface SparklineProps {
/** Series values, oldest first. */
data: number[];
/** Drawing height in px; the width fills the container. */
height?: number;
ariaLabel?: string;
}
/**
* A tiny dependency-free trend line: normalises {@code data} to its own peak and draws a single
* polyline. The viewBox is fixed while the rendered width fills the container (the stroke stays
* crisp via non-scaling-stroke), so it adapts to any column without distorting the line weight.
*/
export function Sparkline({ data, height = 36, ariaLabel }: SparklineProps) {
if (data.length === 0) {
return null;
}
const width = 240;
const pad = 3;
const max = Math.max(...data, 1);
const stepX = data.length > 1 ? width / (data.length - 1) : 0;
const points = data
.map((value, i) => {
const x = i * stepX;
const y = pad + (1 - value / max) * (height - 2 * pad);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg
className="portal-sparkline"
width="100%"
height={height}
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
role="img"
aria-label={ariaLabel}
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
@@ -54,7 +54,7 @@ export function sourceTypeMeta(type: string): SourceTypeMeta {
export interface SourceFieldDef {
key: string;
labelKey: string;
control: "text" | "password" | "select";
control: "text" | "password" | "select" | "s3Connection";
required?: boolean;
placeholderKey?: string;
helperTextKey?: string;
@@ -148,18 +148,11 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
descriptionKey: "portal.sources.types.s3.description",
fields: [
{
key: "bucket",
labelKey: "portal.sources.types.s3.fields.bucket.label",
control: "text",
key: "connectionId",
labelKey: "portal.sources.types.s3.fields.connection.label",
control: "s3Connection",
required: true,
placeholderKey: "portal.sources.types.s3.fields.bucket.placeholder",
},
{
key: "region",
labelKey: "portal.sources.types.s3.fields.region.label",
control: "text",
defaultValue: "us-east-1",
placeholderKey: "portal.sources.types.s3.fields.region.placeholder",
helperTextKey: "portal.sources.types.s3.fields.connection.helperText",
},
{
key: "prefix",
@@ -168,25 +161,6 @@ export const CREATABLE_SOURCE_TYPES: CreatableSourceType[] = [
placeholderKey: "portal.sources.types.s3.fields.prefix.placeholder",
helperTextKey: "portal.sources.types.s3.fields.prefix.helperText",
},
{
key: "accessKeyId",
labelKey: "portal.sources.types.s3.fields.accessKeyId.label",
control: "text",
required: true,
},
{
key: "secretAccessKey",
labelKey: "portal.sources.types.s3.fields.secretAccessKey.label",
control: "password",
required: true,
},
{
key: "endpoint",
labelKey: "portal.sources.types.s3.fields.endpoint.label",
control: "text",
placeholderKey: "portal.sources.types.s3.fields.endpoint.placeholder",
helperTextKey: "portal.sources.types.s3.fields.endpoint.helperText",
},
{
key: "mode",
labelKey: "portal.sources.types.s3.fields.mode.label",
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { Avatar, Button } from "@app/ui";
import type { PendingInvitation } from "@portal/api/users";
import "@portal/views/Users.css";
@@ -11,13 +12,15 @@ interface PendingInvitationsProps {
/** Human "Expires in 3 days" from an ISO expiry; empty when absent, unparseable,
* or already past (the adapter filters expired invites, so no "expired" state). */
function expiryLabel(iso: string | undefined, expiresWord: string): string {
function expiryLabel(iso: string | undefined, t: TFunction): string {
if (!iso) return "";
const ts = Date.parse(iso);
if (!Number.isFinite(ts) || ts <= Date.now()) return "";
const days = Math.round((ts - Date.now()) / 86400000);
if (days === 0) return `${expiresWord} today`;
return `${expiresWord} in ${days === 1 ? "1 day" : `${days} days`}`;
if (days === 0) return t("users.invites.expiresToday", "Expires today");
return t("users.invites.expiresInDays", "Expires in {{count}} days", {
count: days,
});
}
/**
@@ -30,7 +33,6 @@ export function PendingInvitations({
onCancel,
}: PendingInvitationsProps) {
const { t } = useTranslation();
const expiresWord = t("users.invites.expires", "Expires");
return (
<section className="portal-users__group">
<header className="portal-users__group-head">
@@ -50,7 +52,7 @@ export function PendingInvitations({
</span>
</header>
{invitations.map((inv) => {
const expires = expiryLabel(inv.expiresAt, expiresWord);
const expires = expiryLabel(inv.expiresAt, t);
return (
<div className="portal-users__row" key={inv.id}>
<div className="portal-users__row-main">
@@ -10,16 +10,20 @@ import { usePlanTier } from "@portal/contexts/usePlanTier";
export type Tier = "free" | "pro" | "enterprise";
export interface TierInfo {
label: string;
/** i18n key for the plan label; resolve with `t()` at the call site. */
labelKey: string;
dotColor: string;
}
export const TIER_INFO: Record<Tier, TierInfo> = {
// Matches SaaS branding (editor/cloud Payg + PaygFree): the always-free
// manual-tools tier is "Editor plan"; the metered tier is "Processor plan".
free: { label: "Editor plan", dotColor: "var(--color-text-4)" },
pro: { label: "Processor plan", dotColor: "var(--color-blue)" },
enterprise: { label: "Enterprise plan", dotColor: "var(--color-purple)" },
free: { labelKey: "portal.tier.free", dotColor: "var(--color-text-4)" },
pro: { labelKey: "portal.tier.pro", dotColor: "var(--color-blue)" },
enterprise: {
labelKey: "portal.tier.enterprise",
dotColor: "var(--color-purple)",
},
};
interface TierContextValue {
@@ -51,7 +51,7 @@ const ENTERPRISE_DEAL: Deal = {
const ENTERPRISE_LEDGER: LedgerGroup[] = [
{
stage: "trial",
label: "Trial",
label: "portal.procurement.journeySteps.trial.label",
docs: [
{
id: "doc-trial-quickstart",
@@ -71,7 +71,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "quote",
label: "Quote",
label: "portal.procurement.journeySteps.quote.label",
docs: [
{
id: "doc-quote-formal",
@@ -84,7 +84,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "security",
label: "Agreement",
label: "portal.procurement.journeySteps.agreement.label",
docs: [
{
id: "doc-agreement-enterprise",
@@ -97,7 +97,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "procurement",
label: "Payment",
label: "portal.procurement.journeySteps.payment.label",
docs: [
{
id: "doc-pay-online",
@@ -124,7 +124,7 @@ const ENTERPRISE_LEDGER: LedgerGroup[] = [
},
{
stage: "active",
label: "Implementation",
label: "portal.procurement.journeySteps.implementation.label",
docs: [
{
id: "doc-active-playbook",
+10 -3
View File
@@ -93,8 +93,11 @@
width: 1.75rem;
height: 1.75rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 600;
flex-shrink: 0;
}
.portal-home__quick-icon svg {
width: 1.05rem;
height: 1.05rem;
}
.portal-home__quick-text {
@@ -116,9 +119,13 @@
}
.portal-home__quick-arrow {
font-size: 0.875rem;
display: inline-flex;
color: var(--color-text-5);
}
.portal-home__quick-arrow svg {
width: 1rem;
height: 1rem;
}
.portal-home__quick-row:hover .portal-home__quick-arrow {
color: var(--color-blue);
+36 -6
View File
@@ -13,31 +13,52 @@ import "@portal/views/Home.css";
/* Quick actions card */
/* ──────────────────────────────────────────────────────────────────────── */
/** A stroke icon used inside the quick-action badge (replaces the old text
glyphs ⌃ ⇢ ⚙, which rendered off-style vs the portal's icon set). */
function QuickIcon({ d }: { d: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d={d} />
</svg>
);
}
/** Rows for the Quick Actions list. Each `view` navigates the portal. */
const QUICK_ACTIONS: Array<{
key: string;
glyph: string;
iconD: string;
bg: string;
fg: string;
view: ViewId;
}> = [
{
key: "buildPipeline",
glyph: "⌃",
iconD:
"M6 4a2 2 0 100 4 2 2 0 000-4zM18 16a2 2 0 100 4 2 2 0 000-4zM6 8v6a4 4 0 004 4h4",
bg: "var(--color-purple-light)",
fg: "var(--color-purple)",
view: "pipelines",
},
{
key: "connectSource",
glyph: "⇢",
iconD:
"M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1M14 11a5 5 0 00-7 0l-3 3a5 5 0 007 7l1-1",
bg: "var(--color-green-light)",
fg: "var(--color-green-dark)",
view: "sources",
},
{
key: "issueApiKey",
glyph: "⚙",
iconD:
"M2.6 17.4A2 2 0 002 18.8V21a1 1 0 001 1h3a1 1 0 001-1v-1a1 1 0 011-1h1a1 1 0 001-1v-1a1 1 0 011-1h.2a2 2 0 001.4-.6l.8-.8a6.5 6.5 0 10-4-4z M16.5 7.5 h.01",
bg: "var(--color-amber-light)",
fg: "var(--color-amber-dark)",
view: "infrastructure",
@@ -75,12 +96,21 @@ function QuickActions() {
style={{ background: action.bg, color: action.fg }}
aria-hidden
>
{action.glyph}
<QuickIcon d={action.iconD} />
</span>
}
rightSection={
<span className="portal-home__quick-arrow" aria-hidden>
→
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 6l6 6-6 6" />
</svg>
</span>
}
>
@@ -392,27 +392,3 @@
min-width: 1.5rem;
min-height: 1.5rem;
}
.portal-builder__s3-output {
display: flex;
align-items: center;
gap: 0.625rem;
}
.portal-builder__s3-summary {
font-size: 0.8125rem;
color: var(--color-text-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-builder__s3-summary.is-unset {
color: var(--color-text-4);
}
.portal-builder__s3-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@@ -50,6 +50,13 @@ vi.mock("@portal/api/policies", () => ({
clearProcessedHistory: (id: string) => clearProcessedHistory(id),
}));
const fetchS3Connections = vi.fn();
const createIntegration = vi.fn();
vi.mock("@portal/api/integrations", () => ({
fetchS3Connections: () => fetchS3Connections(),
createIntegration: (...args: unknown[]) => createIntegration(...args),
}));
// One editable tool, Compress, so the picker and step settings have something to render.
vi.mock("@app/contexts/ToolRegistryContext", () => {
const compress = {
@@ -149,6 +156,9 @@ describe("PipelineBuilder", () => {
fetchRun.mockResolvedValue({ status: "COMPLETED" });
clearProcessedHistory.mockReset();
clearProcessedHistory.mockResolvedValue(undefined);
fetchS3Connections.mockReset();
fetchS3Connections.mockResolvedValue([]);
createIntegration.mockReset();
});
it("builds a new pipeline: name it, add a tool, and save", async () => {
@@ -177,7 +187,8 @@ describe("PipelineBuilder", () => {
expect(await screen.findByText("pipelines list")).toBeInTheDocument();
});
it("saves an s3 output with its connection options", async () => {
it("saves an s3 output referencing an inline-created connection", async () => {
createIntegration.mockResolvedValue({ id: 12, name: "Claims bucket" });
renderBuilder("/processor/pipelines/new");
fireEvent.change(await screen.findByRole("textbox"), {
@@ -185,32 +196,55 @@ describe("PipelineBuilder", () => {
});
fireEvent.click(screen.getByLabelText("portal.pipelines.output.s3"));
// With s3 selected but no bucket, saving is blocked and the summary reads
// unconfigured; the connection fields live behind the Configure modal.
// With s3 selected but no connection chosen, saving is blocked. The
// connection picker + prefix are inline (no modal), like the folder output.
expect(
screen.getByText("portal.pipelines.composer.create").closest("button"),
).toBeDisabled();
expect(
screen.getByText("portal.pipelines.composer.s3NotConfigured"),
).toBeInTheDocument();
fireEvent.click(screen.getByText("portal.pipelines.composer.s3Configure"));
// Textboxes: name, then the modal's bucket, region, prefix, access key id,
// endpoint; the secret renders as a password input outside the textbox role.
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
fireEvent.change(inputs[1], { target: { value: "claims-processed" } });
fireEvent.change(inputs[3], { target: { value: "processed/" } });
fireEvent.change(inputs[4], { target: { value: "AKIAEXAMPLE" } });
const secret = document.querySelector(
'input[type="password"]',
) as HTMLInputElement;
fireEvent.change(secret, { target: { value: "shh-secret" } });
fireEvent.click(screen.getByText("portal.pipelines.composer.s3Done"));
// No connections exist: create one inline from the picker. Target fields by
// label, not position - the picker's Mantine Select also carries an input
// role and would shift index-based queries.
fireEvent.click(
await screen.findByText("portal.connections.picker.createNew"),
);
fireEvent.change(
screen.getByLabelText(/portal\.connections\.s3\.fields\.name/),
{ target: { value: "Claims bucket" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.bucket\.label/,
),
{ target: { value: "claims-processed" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.accessKeyId\.label/,
),
{ target: { value: "AKIAEXAMPLE" } },
);
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.secretAccessKey\.label/,
),
{ target: { value: "shh-secret" } },
);
fireEvent.click(screen.getByText("portal.connections.picker.save"));
await waitFor(() => expect(createIntegration).toHaveBeenCalledTimes(1));
// The connection modal closes once saved and the connection is selected.
await waitFor(() =>
expect(
screen.queryByText("portal.connections.picker.save"),
).not.toBeInTheDocument(),
);
// The summary now shows the configured destination.
expect(
screen.getByText("s3://claims-processed/processed/"),
).toBeInTheDocument();
fireEvent.change(
screen.getByLabelText(
/portal\.sources\.types\.s3\.fields\.prefix\.label/,
),
{ target: { value: "processed/" } },
);
fireEvent.click(screen.getByText("portal.pipelines.composer.create"));
await waitFor(() => expect(savePipeline).toHaveBeenCalledTimes(1));
@@ -219,12 +253,8 @@ describe("PipelineBuilder", () => {
output: {
type: "s3",
options: {
bucket: "claims-processed",
region: "us-east-1",
connectionId: "12",
prefix: "processed/",
endpoint: "",
accessKeyId: "AKIAEXAMPLE",
secretAccessKey: "shh-secret",
},
},
}),
@@ -50,6 +50,7 @@ import {
} from "@portal/api/pipelines";
import { clearProcessedHistory } from "@portal/api/policies";
import { availableOutputModes } from "@portal/components/pipelines/outputModes";
import { S3ConnectionPicker } from "@portal/components/sources/S3ConnectionPicker";
import { fetchSources, type SourceView } from "@portal/api/sources";
import { EDITOR_SOURCE_TYPE } from "@portal/components/sources/sourceTypes";
import { useAsync } from "@portal/hooks/useAsync";
@@ -64,23 +65,15 @@ type OutputMode = PipelineOutputMode;
/** New pipelines (and specs of unoffered types) start on the first offered destination. */
const DEFAULT_OUTPUT_MODE = availableOutputModes()[0];
/** The s3 output's connection fields, mirrored from the OutputSpec options. */
/** The s3 output's options: a stored connection reference plus the per-use prefix. */
interface S3OutputOptions {
bucket: string;
region: string;
connectionId: string;
prefix: string;
endpoint: string;
accessKeyId: string;
secretAccessKey: string;
}
const EMPTY_S3_OUTPUT: S3OutputOptions = {
bucket: "",
region: "us-east-1",
connectionId: "",
prefix: "",
endpoint: "",
accessKeyId: "",
secretAccessKey: "",
};
type ScheduleUnit = "MINUTES" | "HOURS" | "DAYS";
@@ -133,18 +126,12 @@ function parseOutput(output: OutputSpec | undefined): {
};
}
if (output?.type === "s3") {
const option = (key: keyof S3OutputOptions, fallback = "") =>
String(output.options?.[key] ?? fallback);
return {
mode: "s3",
directory: "",
s3: {
bucket: option("bucket"),
region: option("region", "us-east-1"),
prefix: option("prefix"),
endpoint: option("endpoint"),
accessKeyId: option("accessKeyId"),
secretAccessKey: option("secretAccessKey"),
connectionId: String(output.options?.connectionId ?? ""),
prefix: String(output.options?.prefix ?? ""),
},
};
}
@@ -203,7 +190,6 @@ export function PipelineBuilder() {
const [outputMode, setOutputMode] = useState<OutputMode>(DEFAULT_OUTPUT_MODE);
const [outputDirectory, setOutputDirectory] = useState("");
const [outputS3, setOutputS3] = useState<S3OutputOptions>(EMPTY_S3_OUTPUT);
const [s3ConfigOpen, setS3ConfigOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [seeded, setSeeded] = useState(false);
@@ -277,10 +263,6 @@ export function PipelineBuilder() {
if (selected && !triggerAvailable(selected)) setTriggerType(MANUAL);
}, [triggerType, triggers, triggerAvailable]);
function setS3Field(key: keyof S3OutputOptions, value: string) {
setOutputS3((current) => ({ ...current, [key]: value }));
}
function toggleSource(sourceId: string, checked: boolean) {
setSourceIds((ids) =>
checked
@@ -356,10 +338,7 @@ export function PipelineBuilder() {
const scheduleCountValid =
triggerType !== "schedule" || Number(scheduleCount) > 0;
const s3OutputValid =
outputMode !== "s3" ||
(outputS3.bucket.trim() !== "" &&
outputS3.accessKeyId.trim() !== "" &&
outputS3.secretAccessKey.trim() !== "");
outputMode !== "s3" || outputS3.connectionId.trim() !== "";
const outputValid =
(outputMode !== "folder" || outputDirectory.trim() !== "") && s3OutputValid;
const canSave =
@@ -398,7 +377,7 @@ export function PipelineBuilder() {
}
const listPath = toPortalPath(VIEW_PATHS.pipelines);
const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}?new=1`;
const sourcesPath = `${toPortalPath(VIEW_PATHS.sources)}/new`;
function close() {
navigate(listPath);
@@ -774,25 +753,31 @@ export function PipelineBuilder() {
</FormField>
)}
{outputMode === "s3" && (
<div className="portal-builder__s3-output">
<span
className={
"portal-builder__s3-summary" +
(outputS3.bucket ? "" : " is-unset")
}
<>
<FormField
label={t("portal.sources.types.s3.fields.connection.label")}
required
>
{outputS3.bucket
? `s3://${outputS3.bucket}/${outputS3.prefix}`
: t("portal.pipelines.composer.s3NotConfigured")}
</span>
<Button
variant="secondary"
size="sm"
onClick={() => setS3ConfigOpen(true)}
<S3ConnectionPicker
value={outputS3.connectionId}
onChange={(connectionId) =>
setOutputS3((s) => ({ ...s, connectionId }))
}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.prefix.label")}
helperText={t("portal.pipelines.composer.s3PrefixHelp")}
>
{t("portal.pipelines.composer.s3Configure")}
</Button>
</div>
<Input
value={outputS3.prefix}
placeholder="processed/"
onChange={(e) =>
setOutputS3((s) => ({ ...s, prefix: e.target.value }))
}
/>
</FormField>
</>
)}
</div>
</div>
@@ -1006,78 +991,6 @@ export function PipelineBuilder() {
>
<p>{t("portal.pipelines.builder.unsavedBody")}</p>
</Modal>
<Modal
open={s3ConfigOpen}
onClose={() => setS3ConfigOpen(false)}
title={t("portal.pipelines.composer.s3ModalTitle")}
footer={
<div className="portal-pipelines__composer-footer">
<Button size="sm" onClick={() => setS3ConfigOpen(false)}>
{t("portal.pipelines.composer.s3Done")}
</Button>
</div>
}
>
<div className="portal-builder__s3-fields">
<FormField
label={t("portal.sources.types.s3.fields.bucket.label")}
required
>
<Input
value={outputS3.bucket}
placeholder="my-company-inbox"
onChange={(e) => setS3Field("bucket", e.target.value)}
/>
</FormField>
<FormField label={t("portal.sources.types.s3.fields.region.label")}>
<Input
value={outputS3.region}
placeholder="us-east-1"
onChange={(e) => setS3Field("region", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.prefix.label")}
helperText={t("portal.pipelines.composer.s3PrefixHelp")}
>
<Input
value={outputS3.prefix}
placeholder="processed/"
onChange={(e) => setS3Field("prefix", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.accessKeyId.label")}
required
>
<Input
value={outputS3.accessKeyId}
onChange={(e) => setS3Field("accessKeyId", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.secretAccessKey.label")}
required
>
<Input
type="password"
value={outputS3.secretAccessKey}
onChange={(e) => setS3Field("secretAccessKey", e.target.value)}
/>
</FormField>
<FormField
label={t("portal.sources.types.s3.fields.endpoint.label")}
helperText={t("portal.sources.types.s3.fields.endpoint.helperText")}
>
<Input
value={outputS3.endpoint}
placeholder="https://s3.example.com"
onChange={(e) => setS3Field("endpoint", e.target.value)}
/>
</FormField>
</div>
</Modal>
</div>
);
}
@@ -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}`);
@@ -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)),
);
}
@@ -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;
}
@@ -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<typeof baseRender>[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(
<MemoryRouter initialEntries={[initial]}>
<Routes>
<Route path="/processor/sources" element={<div>sources list</div>} />
<Route path="/processor/sources/new" element={<SourceBuilder />} />
<Route path="/processor/sources/:id" element={<SourceBuilder />} />
</Routes>
</MemoryRouter>,
);
}
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();
});
});
@@ -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<string, unknown> | undefined,
): Record<string, string> {
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<Source | null>(
async () => (id ? await fetchSource(id) : null),
[id],
);
const [type, setType] = useState<CreatableSourceType>(OFFERED_TYPES[0]);
const [name, setName] = useState("");
const [options, setOptions] = useState<Record<string, string>>(() =>
defaultOptions(OFFERED_TYPES[0]),
);
const [enabled, setEnabled] = useState(true);
const [seeded, setSeeded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="portal-source-builder">
<Banner tone="danger" description={errorMessage(sourceState.error)} />
<Button variant="tertiary" onClick={() => navigate(listPath)}>
{t("portal.sources.builder.back")}
</Button>
</div>
);
}
if (isEdit && !seeded) {
return (
<div className="portal-source-builder__loading">
<Spinner />
</div>
);
}
return (
<div className="portal-source-builder">
<header className="portal-source-builder__head">
<div className="portal-source-builder__head-main">
<Button
variant="quiet"
size="sm"
onClick={() => navigate(listPath)}
leftSection={
<ArrowBackRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.sources.builder.back")}
</Button>
<h1 className="portal-source-builder__title">
{isEdit
? name || t("portal.sources.builder.editTitle")
: t("portal.sources.builder.createTitle")}
</h1>
</div>
<div className="portal-source-builder__head-actions">
<Checkbox
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
label={t("portal.sources.builder.enabled")}
/>
{isEdit && (
<Button
variant="secondary"
size="sm"
accent="danger"
onClick={() => setPendingDelete(true)}
leftSection={
<DeleteOutlineRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.sources.builder.delete")}
</Button>
)}
<Button
variant="tertiary"
size="sm"
onClick={() => navigate(listPath)}
>
{t("portal.sources.builder.cancel")}
</Button>
<Button size="sm" disabled={!canSave} onClick={() => void save()}>
{isEdit
? t("portal.sources.builder.save")
: t("portal.sources.builder.create")}
</Button>
</div>
</header>
<div className="portal-source-builder__body">
<FormField label={t("portal.sources.wizard.name")} required>
<Input
value={name}
placeholder={t("portal.sources.wizard.namePlaceholder")}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
{!isEdit && OFFERED_TYPES.length > 1 && (
<FormField label={t("portal.sources.wizard.type")}>
<div className="portal-source-builder__type-grid">
{OFFERED_TYPES.map((ct) => (
<Button
key={ct.type}
variant="tertiary"
className={
"portal-source-builder__type-card" +
(type.type === ct.type ? " is-selected" : "")
}
onClick={() => chooseType(ct)}
>
<span
className="portal-source-builder__type-icon"
aria-hidden
>
{sourceTypeMeta(ct.type).icon}
</span>
<span className="portal-source-builder__type-name">
{t(ct.labelKey)}
</span>
</Button>
))}
</div>
</FormField>
)}
{type.fields.map((field) => (
<FormField
key={field.key}
label={t(field.labelKey)}
helperText={
field.helperTextKey ? t(field.helperTextKey) : undefined
}
required={field.required}
>
{field.control === "s3Connection" ? (
<S3ConnectionPicker
value={options[field.key] ?? ""}
onChange={(connectionId) => setOption(field.key, connectionId)}
/>
) : field.control === "select" ? (
<Select
value={options[field.key] ?? ""}
options={(field.options ?? []).map((o) => ({
value: o.value,
label: t(o.labelKey),
}))}
onChange={(value) => setOption(field.key, value ?? "")}
/>
) : (
<Input
type={field.control === "password" ? "password" : undefined}
value={options[field.key] ?? ""}
placeholder={
field.placeholderKey ? t(field.placeholderKey) : undefined
}
onChange={(e) => setOption(field.key, e.target.value)}
/>
)}
</FormField>
))}
{error && <Banner tone="danger" description={error} />}
</div>
<Modal
open={pendingDelete}
onClose={() => !deleting && setPendingDelete(false)}
width="sm"
title={t("portal.sources.delete.title")}
footer={
<div className="portal-source-builder__delete-actions">
<Button
variant="tertiary"
size="sm"
disabled={deleting}
onClick={() => setPendingDelete(false)}
>
{t("portal.sources.delete.cancel")}
</Button>
<Button
size="sm"
accent="danger"
loading={deleting}
onClick={() => void confirmDelete()}
>
{t("portal.sources.delete.confirm")}
</Button>
</div>
}
>
<p>{t("portal.sources.delete.body", { name })}</p>
</Modal>
</div>
);
}
@@ -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;
}
+72 -135
View File
@@ -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<typeof baseRender>[0],
options?: Parameters<typeof baseRender>[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<typeof baseRender>[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(
<MemoryRouter>
<Sources />
<MemoryRouter initialEntries={[initial]}>
<Routes>
<Route path="/processor/sources" element={<Sources />} />
<Route
path="/processor/sources/new"
element={<div>source builder: new</div>}
/>
<Route
path="/processor/sources/:id"
element={<div>source builder: edit</div>}
/>
</Routes>
</MemoryRouter>,
);
}
@@ -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();
+75 -198
View File
@@ -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<SourcesResponse>(() => fetchSources(), [version]);
const activeTab: SourcesTab =
searchParams.get("tab") === "connections" ? "connections" : "sources";
const state = useAsync<SourcesResponse>(() => fetchSources(), []);
const { data, loading } = state;
const { isLoading } = useSectionFlags(state);
const refetch = useCallback(() => setVersion((v) => v + 1), []);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const [editingSource, setEditingSource] = useState<Source | null>(null);
const [mutating, setMutating] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<SourceView | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(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() {
<h1 className="portal-sources__title">{t("portal.sources.title")}</h1>
<p className="portal-sources__sub">{t("portal.sources.subtitle")}</p>
</div>
<div className="portal-sources__actions">
<AgentBuilderAction />
<Button onClick={openCreate} leftSection={<span aria-hidden>+</span>}>
{t("portal.sources.actions.connectSource")}
</Button>
</div>
</header>
{pageError && <Banner tone="danger" description={pageError} />}
{!showEmpty && <KpiStrip data={data} loading={loading} />}
{isLoading && (
<div className="portal-sources__table-skeleton" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{showEmpty && (
<EmptyState
icon={<SourcesIcon size={28} />}
title={t("portal.sources.empty.title")}
description={t("portal.sources.empty.description")}
actions={
{activeTab === "sources" && (
<div className="portal-sources__actions">
<AgentBuilderAction />
<Button
onClick={openCreate}
leftSection={<span aria-hidden>+</span>}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}
>
{t("portal.sources.actions.connectSource")}
</Button>
}
/>
)}
</div>
)}
</header>
{!isLoading && sources.length > 0 && (
<SourcesTable
sources={sources}
expandedId={expandedId}
onRowClick={(s) =>
setExpandedId((cur) => (cur === s.id ? null : s.id))
}
/>
)}
{expanded && (
<SourceDetailCard
source={expanded}
docSeries={docSeries}
onClose={() => setExpandedId(null)}
onEdit={openEdit}
onTogglePause={togglePause}
onDelete={requestDelete}
busy={mutating}
/>
)}
<ConnectWizard
open={wizardOpen}
source={editingSource ?? undefined}
onClose={() => setWizardOpen(false)}
onCreated={refetch}
<Tabs<SourcesTab>
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") },
]}
/>
<Modal
open={pendingDelete !== null}
onClose={() => !deleting && setPendingDelete(null)}
width="sm"
title={t("portal.sources.delete.title")}
footer={
<div className="portal-sources__wizard-footer">
<Button
variant="tertiary"
size="sm"
disabled={deleting}
onClick={() => setPendingDelete(null)}
>
{t("portal.sources.delete.cancel")}
</Button>
<Button
size="sm"
accent="danger"
loading={deleting}
onClick={confirmDelete}
>
{t("portal.sources.delete.confirm")}
</Button>
</div>
}
>
<p>
{t("portal.sources.delete.body", { name: pendingDelete?.name ?? "" })}
</p>
{deleteError && <Banner tone="danger" description={deleteError} />}
</Modal>
{activeTab === "connections" ? (
<ConnectionsTab />
) : (
<>
{!showEmpty && <KpiStrip data={data} loading={loading} />}
{isLoading && (
<div className="portal-sources__table-skeleton" aria-hidden>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} height="3rem" />
))}
</div>
)}
{showEmpty && (
<EmptyState
icon={<SourcesIcon size={28} />}
title={t("portal.sources.empty.title")}
description={t("portal.sources.empty.description")}
actions={
<Button
onClick={openCreate}
leftSection={
<AddRoundedIcon style={{ fontSize: "1.125rem" }} />
}
>
{t("portal.sources.actions.connectSource")}
</Button>
}
/>
)}
{!isLoading && sources.length > 0 && (
<SourcesTable sources={sources} onRowClick={openSource} />
)}
</>
)}
</div>
);
}
+1 -1
View File
@@ -396,7 +396,7 @@
white-space: nowrap;
}
.portal-users__row-role {
width: 148px;
width: 12.5rem;
flex-shrink: 0;
}
@@ -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,
@@ -0,0 +1,225 @@
# Multi-node Stirling-PDF processor test stack: shared Postgres/MinIO/Valkey behind an nginx LB fronting N app nodes, with enterprise features unlocked via a local test licence key.
# Bring up: ./start-multinode-test.sh Validate: ./validate-multinode-test.sh Access: http://localhost:8080 (admin / stirling)
x-stirling-node: &stirling-node
build:
context: ../..
dockerfile: docker/embedded/Dockerfile
image: stirling-pdf-multinode:local
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
minio-init:
condition: service_completed_successfully
deploy:
resources:
limits:
memory: 4G
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/api/v1/info/status | grep -q UP"]
interval: 10s
timeout: 10s
retries: 30
start_period: 90s
environment: &stirling-env
# --- Licensing: unlock pro/enterprise (custom DB + cluster mode) ---
# Cluster mode is licence-gated - set PREMIUM_KEY (locally: export PREMIUM_KEY=...; CI: the PREMIUM_KEY_ENTERPRISE secret); the default below is a non-functional placeholder, never a real key.
PREMIUM_ENABLED: "true"
PREMIUM_KEY: "${PREMIUM_KEY:-00000000-0000-0000-0000-000000000000}"
DISABLE_ADDITIONAL_FEATURES: "false"
# --- Login + teams ---
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_INITIALLOGIN_USERNAME: "admin"
SECURITY_INITIALLOGIN_PASSWORD: "stirling"
# Global API key: lets the seed/validate scripts call admin APIs without a login flow.
SECURITY_CUSTOMGLOBALAPIKEY: "multinode-test-key"
# --- Shared Postgres (the single DB every node coordinates through) ---
# enableCustomDatabase is an enterprise feature, unlocked by PREMIUM_* above.
SYSTEM_DATASOURCE_ENABLECUSTOMDATABASE: "true"
SYSTEM_DATASOURCE_TYPE: "postgresql"
SYSTEM_DATASOURCE_HOSTNAME: "postgres"
SYSTEM_DATASOURCE_PORT: "5432"
SYSTEM_DATASOURCE_NAME: "stirling"
SYSTEM_DATASOURCE_USERNAME: "stirling"
SYSTEM_DATASOURCE_PASSWORD: "stirling"
# User.settings has no @Lob annotation - with one, Postgres treats it as a large object and every login 500s ("Large Objects may not be used in auto-commit mode"); see multinode/README.md.
# --- Shared object storage (persistent user uploads + share/store feature) ---
STORAGE_ENABLED: "true"
STORAGE_PROVIDER: "s3"
STORAGE_S3_ENDPOINT: "http://minio:9000"
STORAGE_S3_BUCKET: "stirling-storage"
STORAGE_S3_REGION: "us-east-1"
STORAGE_S3_ACCESSKEY: "minioadmin"
STORAGE_S3_SECRETKEY: "minioadmin"
STORAGE_S3_PATHSTYLEACCESS: "true"
STORAGE_S3_ALLOWPRIVATEENDPOINTS: "true"
# --- Cluster mode: Valkey backplane + shared S3 job-artifact store ---
# artifactStore=s3 is REQUIRED for multi-node (transient job artifacts must be shared).
CLUSTER_ENABLED: "true"
CLUSTER_BACKPLANE: "valkey"
CLUSTER_ARTIFACTSTORE: "s3"
CLUSTER_VALKEY_URL: "redis://valkey:6379"
# SPRING_DATA_REDIS_REPOSITORIES_ENABLED is not needed: DataRedisRepositoriesAutoConfiguration is excluded (see application.properties, multinode/README.md).
# --- Shared credential-encryption key (REQUIRED in cluster mode) ---
# AES-256 key that encrypts stored integration/S3 secrets - must match on every node or secrets encrypted on one can't decrypt on another; boot fails if unset with cluster.enabled=true (test-only value; JWT keys persist separately in the shared DB).
STIRLING_CREDENTIAL_ENCRYPTION_KEY: "dMobekyUEnEV7WHBah2FkbboP4Coqifd3JRXB00LiIY="
# --- Policy / processor subsystem (the thing under test) ---
POLICIES_ENABLED: "true"
# Let policy S3 sources/webhook-staging connections point at the in-cluster MinIO.
POLICIES_ALLOWPRIVATES3ENDPOINTS: "true"
# --- Misc features on ---
METRICS_ENABLED: "true"
SYSTEM_DEFAULTLOCALE: "en-US"
SYSTEM_MAXFILESIZE: "100"
UI_APPNAME: "Stirling-PDF Multi-Node"
UI_APPNAMENAVBAR: "Stirling Multi-Node"
networks:
- stirling-multinode
services:
# ---- Shared Postgres: the single DB all nodes coordinate through ----------
postgres:
image: postgres:17-alpine
container_name: multinode-postgres
environment:
POSTGRES_USER: stirling
POSTGRES_PASSWORD: stirling
POSTGRES_DB: stirling
ports:
- "5434:5432" # host access for inspection (psql -h localhost -p 5434 -U stirling)
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U stirling -d stirling"]
interval: 3s
timeout: 5s
retries: 30
networks:
- stirling-multinode
# ---- Valkey: cluster backplane (shared job state / rate limiting) ---------
valkey:
image: valkey/valkey:8-alpine
container_name: multinode-valkey
command: ["valkey-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD-SHELL", "valkey-cli ping | grep -q PONG"]
interval: 3s
timeout: 5s
retries: 30
networks:
- stirling-multinode
# ---- MinIO: shared S3 (platform storage + policy connections + webhook staging) ----
minio:
image: minio/minio:latest
container_name: multinode-minio
command: ["server", "/data", "--console-address", ":9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000" # S3 API
- "9001:9001" # web console (minioadmin / minioadmin)
volumes:
- minio-data:/data
healthcheck:
test: ["CMD-SHELL", "mc ready local || exit 1"]
interval: 5s
timeout: 5s
retries: 30
networks:
- stirling-multinode
# ---- One-shot: create the buckets the stack needs -------------------------
minio-init:
image: minio/mc:latest
container_name: multinode-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin &&
mc mb --ignore-existing local/stirling-storage &&
mc mb --ignore-existing local/policy-data &&
echo 'buckets ready: stirling-storage, policy-data'
"
networks:
- stirling-multinode
# ---- App node 1 -----------------------------------------------------------
stirling-1:
<<: *stirling-node
container_name: multinode-stirling-1
environment:
<<: *stirling-env
SYSTEM_NODEID: "node-1"
# ---- App node 2 -----------------------------------------------------------
# No ordering vs node-1 needed - each node mints its own signing key at boot and publishes the public half to the shared DB, so both verify each other's tokens.
stirling-2:
<<: *stirling-node
container_name: multinode-stirling-2
environment:
<<: *stirling-env
SYSTEM_NODEID: "node-2"
# ---- One-shot seeder: teams, ~40 users, S3 connection, policies -----------
# Profile-gated so plain `docker compose up` skips it; the start script runs it once, using postgres:alpine for psql with curl+jq added for the HTTP calls.
seed:
image: postgres:17-alpine
container_name: multinode-seed
profiles: ["seed"]
depends_on:
stirling-1:
condition: service_healthy
stirling-2:
condition: service_healthy
environment:
BASE_URL: "http://nginx:8080"
PGHOST: "postgres"
PGUSER: "stirling"
PGPASSWORD: "stirling"
PGDATABASE: "stirling"
USER_COUNT: "40"
volumes:
- ./multinode/seed.sh:/seed.sh:ro
entrypoint: ["/bin/sh", "-c", "apk add --no-cache curl jq >/dev/null && sh /seed.sh"]
networks:
- stirling-multinode
# ---- nginx load balancer: single entrypoint round-robining the nodes ------
nginx:
image: nginx:1.27-alpine
container_name: multinode-nginx
depends_on:
stirling-1:
condition: service_healthy
stirling-2:
condition: service_healthy
ports:
- "8080:8080"
volumes:
- ./multinode/nginx.conf:/etc/nginx/nginx.conf:ro
networks:
- stirling-multinode
networks:
stirling-multinode:
driver: bridge
volumes:
postgres-data:
minio-data:
+49
View File
@@ -0,0 +1,49 @@
worker_processes 1;
events { worker_connections 1024; }
http {
# Round-robin across the app nodes. Add a node here to scale out.
# max_fails=1 marks a node down after a single failure so a dead node drains fast.
upstream stirling_nodes {
server stirling-1:8080 max_fails=1 fail_timeout=10s;
server stirling-2:8080 max_fails=1 fail_timeout=10s;
}
# Large uploads (SYSTEM_MAXFILESIZE=100MB) plus headroom.
client_max_body_size 200m;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 8080;
location / {
proxy_pass http://stirling_nodes;
# Graceful failover: retries the other node only on connection-level failures (unreachable/timeout), never on 5xx, so a POST a node already started is never re-sent.
proxy_next_upstream error timeout;
proxy_next_upstream_tries 2;
proxy_connect_timeout 3s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket / SSE upgrade support (policy run streaming).
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Stream server-sent events straight through, don't buffer.
proxy_buffering off;
proxy_read_timeout 3600s;
# Surface which app node served the request, so the validate script can prove the LB is spreading load.
add_header X-Served-By $upstream_addr always;
}
}
}
+140
View File
@@ -0,0 +1,140 @@
#!/bin/sh
# Seeds a running multi-node stack: 4 teams, ~40 users, an S3 connection, a scheduled S3 policy, and a webhook source if the build supports it.
# Auth uses the Bearer JWT from the login response body (not a cookie) since the global API key can't create teams.
# Idempotent-ish: re-running skips existing teams/users; each step is best-effort and logs failures without aborting.
set -u
BASE_URL="${BASE_URL:-http://localhost:8080}"
ADMIN_USER="${ADMIN_USER:-admin}"
ADMIN_PASS="${ADMIN_PASS:-stirling}"
USER_COUNT="${USER_COUNT:-40}"
USER_PASS="${USER_PASS:-Password123!}"
PGHOST="${PGHOST:-postgres}"
PGUSER="${PGUSER:-stirling}"
PGPASSWORD="${PGPASSWORD:-stirling}"
PGDATABASE="${PGDATABASE:-stirling}"
export PGPASSWORD
TEAMS="Engineering Finance Legal Operations"
log() { echo "[seed] $*"; }
psqlq() { psql -h "$PGHOST" -U "$PGUSER" -d "$PGDATABASE" -tAc "$1" 2>/dev/null | tr -d '[:space:]'; }
# --- wait for the load balancer to serve a healthy app -----------------------
log "waiting for $BASE_URL ..."
i=0
until curl -fsS "$BASE_URL/api/v1/info/status" 2>/dev/null | grep -q UP; do
i=$((i+1)); [ "$i" -gt 120 ] && { log "timed out waiting for API"; exit 1; }
sleep 3
done
log "API is up"
# --- admin login -> Bearer token ---------------------------------------------
code=$(curl -sS -o /tmp/login.json -w '%{http_code}' \
-X POST "$BASE_URL/api/v1/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}")
log "admin login: HTTP $code"
[ "$code" = "200" ] || { log "login failed: $(cat /tmp/login.json)"; exit 1; }
TOKEN=$(jq -r '.session.access_token' </tmp/login.json)
[ -n "$TOKEN" ] && [ "$TOKEN" != "null" ] || { log "no access_token in login response"; exit 1; }
auth() { curl -sS -H "Authorization: Bearer $TOKEN" "$@"; }
# --- teams -------------------------------------------------------------------
for t in $TEAMS; do
code=$(auth -o /dev/null -w '%{http_code}' -X POST "$BASE_URL/api/v1/team/create" \
--data-urlencode "name=$t")
log "team '$t': HTTP $code"
done
# Resolve team ids from the DB (no admin list endpoint self-hosted).
seed_team_ids=""
for t in $TEAMS; do
id=$(psqlq "select team_id from teams where name='$t' limit 1")
[ -n "$id" ] && seed_team_ids="$seed_team_ids $id"
done
set -- $seed_team_ids
team_count=$#
log "seedable team ids:$seed_team_ids (count=$team_count)"
# --- users: spread across teams, first two are admins ------------------------
created=0; failed=0
n=1
while [ "$n" -le "$USER_COUNT" ]; do
uname=$(printf "user%02d@stirling.test" "$n")
role="ROLE_USER"; [ "$n" -le 2 ] && role="ROLE_ADMIN"
team_id=""
if [ "$team_count" -gt 0 ]; then
idx=$(( (n % team_count) + 1 )); team_id=$(eval echo "\${$idx}")
fi
code=$(auth -o /tmp/user.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/user/admin/saveUser" \
--data-urlencode "username=$uname" \
--data-urlencode "password=$USER_PASS" \
--data-urlencode "role=$role" \
${team_id:+--data-urlencode "teamId=$team_id"} \
--data-urlencode "authType=WEB" \
--data-urlencode "forceChange=false")
case "$code" in
200|201) created=$((created+1));;
409) log "user $uname already exists";;
*) failed=$((failed+1)); [ "$failed" -le 3 ] && log "user $uname failed HTTP $code: $(cat /tmp/user.json)";;
esac
n=$((n+1))
done
log "users created: $created (failed: $failed, requested: $USER_COUNT)"
# --- S3 connection -> the in-cluster MinIO 'policy-data' bucket ---------------
conn_body=$(cat <<JSON
{"integrationType":"S3","name":"MinIO policy bucket","scope":"SERVER","enabled":true,"locked":false,"defaultAccess":"ORG_ALL",
"config":{"bucket":"policy-data","region":"us-east-1","endpoint":"http://minio:9000","accessKeyId":"minioadmin","secretAccessKey":"minioadmin","pathStyleAccess":true}}
JSON
)
conn_id=$(auth -X POST "$BASE_URL/api/v1/integrations" -H 'Content-Type: application/json' -d "$conn_body" \
| jq -r '.id // empty' 2>/dev/null)
log "S3 connection id: ${conn_id:-<none>}"
# --- a scheduled S3 -> compress -> S3 policy ---------------------------------
if [ -n "${conn_id:-}" ]; then
src_body=$(cat <<JSON
{"name":"Incoming S3","type":"s3","enabled":true,
"options":{"connectionId":$conn_id,"prefix":"incoming/","mode":"consume"}}
JSON
)
src_id=$(auth -X POST "$BASE_URL/api/v1/sources" -H 'Content-Type: application/json' -d "$src_body" \
| jq -r '.id // empty' 2>/dev/null)
log "S3 source id: ${src_id:-<none>}"
if [ -n "${src_id:-}" ]; then
pol_body=$(cat <<JSON
{"name":"Compress incoming PDFs","enabled":true,
"trigger":{"type":"schedule","options":{"schedule":{"type":"every","count":5,"unit":"MINUTES"}}},
"sourceIds":["$src_id"],
"steps":[{"operation":"/api/v1/misc/compress-pdf","parameters":{}}],
"output":{"type":"s3","options":{"connectionId":$conn_id,"prefix":"processed/"}}}
JSON
)
code=$(auth -o /tmp/pol.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/policies" \
-H 'Content-Type: application/json' -d "$pol_body")
log "policy create: HTTP $code $( [ "$code" != 200 ] && head -c 160 /tmp/pol.json )"
fi
# --- webhook source + policy (only if this build has the webhook type) -----
wh_body=$(cat <<JSON
{"name":"Partner webhook","type":"webhook","enabled":true,
"options":{"connectionId":$conn_id,"mode":"consume"}}
JSON
)
wh=$(auth -o /tmp/wh.json -w '%{http_code}' -X POST "$BASE_URL/api/v1/sources" \
-H 'Content-Type: application/json' -d "$wh_body")
if [ "$wh" = "200" ] || [ "$wh" = "201" ]; then
wh_url=$(jq -r '.options.webhookId // empty' </tmp/wh.json 2>/dev/null)
log "webhook source created (deliver to /api/v1/webhooks/$wh_url)"
else
log "webhook source not created (HTTP $wh) - expected on builds without the webhook branch"
fi
fi
log "seed complete."
log " login: $ADMIN_USER / $ADMIN_PASS at $BASE_URL"
log " users: user01..$(printf '%02d' "$USER_COUNT")@stirling.test / $USER_PASS"
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Runs the multi-node regression suite (behave features/multinode) against the clustered stack: brings it up if needed, runs non-destructive scenarios then @destructive failover ones, and restores any killed node.
# Usage: ./run-multinode-regression.sh [--no-failover] [--no-seed]
# @known_gap scenarios are expected to fail - they mark work not yet done, so a non-zero exit is fine while those are open.
set -uo pipefail
cd "$(dirname "$0")"
COMPOSE="docker compose -f docker-compose-multinode.yml"
CUKE_DIR="../cucumber"
RUN_FAILOVER=1
SEED=1
for arg in "$@"; do
case "$arg" in
--no-failover) RUN_FAILOVER=0 ;;
--no-seed) SEED=0 ;;
esac
done
echo "==> Ensuring the multi-node stack is up..."
if ! docker inspect -f '{{.State.Health.Status}}' multinode-stirling-1 2>/dev/null | grep -q healthy; then
./start-multinode-test.sh $([ "$SEED" = 0 ] && echo --no-seed) || exit 1
elif [ "$SEED" = 1 ]; then
echo " stack already up; seeding (idempotent)..."
$COMPOSE --profile seed run --rm seed >/dev/null 2>&1 || echo " (seed reported issues, continuing)"
fi
echo "==> Checking Python + behave..."
PY="${PYTHON:-python}"
command -v "$PY" >/dev/null || PY=python3
if ! "$PY" -c "import behave" 2>/dev/null; then
echo " installing test deps..."
"$PY" -m pip install -q -r "$CUKE_DIR/requirements.txt" || {
echo " could not install behave; install $CUKE_DIR/requirements.txt manually"; exit 1; }
fi
REPORT_DIR="$(pwd)/multinode/regression-report"
mkdir -p "$REPORT_DIR"
run_behave() { # $1=tags $2=label
echo "==> behave features/multinode --tags='$1' ($2)"
# behave.ini excludes features/multinode by default; -e here overrides that while still excluding the licence-gated enterprise suite.
( cd "$CUKE_DIR" && "$PY" -m behave features/multinode -e "features/enterprise" \
--tags="$1" --no-capture --format plain --format html --outfile "$REPORT_DIR/$2.html" )
return $?
}
rc=0
run_behave "~@destructive" "core" || rc=1
if [ "$RUN_FAILOVER" = 1 ]; then
run_behave "@destructive" "failover" || rc=1
echo "==> Restoring any killed nodes..."
$COMPOSE up -d >/dev/null 2>&1
for n in multinode-stirling-1 multinode-stirling-2; do
for i in $(seq 1 24); do
[ "$(docker inspect -f '{{.State.Health.Status}}' "$n" 2>/dev/null)" = "healthy" ] && break
sleep 5
done
done
fi
echo
echo "============================================================"
echo " Regression run complete. Reports: $REPORT_DIR"
echo " Exit $rc (non-zero = at least one scenario failed;"
echo " @known_gap scenarios are expected to fail - see the report)."
echo " Stack left running: http://localhost:8080 (admin / stirling)"
echo "============================================================"
exit $rc
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Brings up the multi-node stack (Postgres/Valkey/MinIO/2 app nodes/nginx LB), seeds teams/users/an S3 connection/policies, then leaves it running for manual testing.
# Usage: ./start-multinode-test.sh [--no-seed | --down]
set -euo pipefail
cd "$(dirname "$0")"
COMPOSE="docker compose -f docker-compose-multinode.yml"
if [ "${1:-}" = "--down" ]; then
echo "Tearing down multi-node stack + volumes..."
$COMPOSE --profile seed down -v --remove-orphans
exit 0
fi
SEED=1
[ "${1:-}" = "--no-seed" ] && SEED=0
# Cluster mode is licence-gated. Without a valid key the nodes fail the cluster licence gate at boot.
if [ -z "${PREMIUM_KEY:-}" ]; then
echo "WARNING: PREMIUM_KEY is not set - cluster mode needs a valid enterprise/pro licence key."
echo " Run: export PREMIUM_KEY=<your test licence key> before starting."
fi
echo "==> Building the Stirling image (first run compiles the app; be patient)..."
$COMPOSE build
echo "==> Starting Postgres + Valkey + MinIO + 2 app nodes + nginx..."
$COMPOSE up -d
echo "==> Waiting for both app nodes to report healthy..."
for node in multinode-stirling-1 multinode-stirling-2; do
for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' "$node" 2>/dev/null || echo "starting")
[ "$status" = "healthy" ] && { echo " $node: healthy"; break; }
[ "$i" = "60" ] && { echo " $node did not become healthy; see: $COMPOSE logs $node"; exit 1; }
sleep 5
done
done
if [ "$SEED" = "1" ]; then
echo "==> Seeding teams / users / S3 connection / policies..."
$COMPOSE --profile seed run --rm seed || echo " (seed reported issues; check output above)"
fi
cat <<EOF
============================================================================
Multi-node Stirling is UP.
App (via load balancer): http://localhost:8080 (admin / stirling)
MinIO console: http://localhost:9001 (minioadmin / minioadmin)
Postgres: localhost:5434 (stirling / stirling, db 'stirling')
Seeded users: user01..user40@stirling.test / Password123!
Global API key: multinode-test-key (header: X-API-KEY)
Try it:
./validate-multinode-test.sh # multi-node smoke tests (optional)
$COMPOSE logs -f stirling-1 # tail a node
./start-multinode-test.sh --down # stop + wipe
Nodes are reachable directly for cross-node checks:
docker compose -f docker-compose-multinode.yml exec stirling-1 curl -s localhost:8080/api/v1/info/status
============================================================================
EOF
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Multi-node smoke tests against a running stack (start-multinode-test.sh): load-balancer spread, cross-node JWT validation (signing keys persist in the shared DB), and processor state visible from every node.
# Auth: extracts the Bearer JWT from the login body via sed (no jq needed host-side) and hits nodes directly with docker exec.
# Non-destructive - safe to re-run against the stack at http://localhost:8080.
set -uo pipefail
cd "$(dirname "$0")"
LB="http://localhost:8080"
ADMIN_USER="admin"; ADMIN_PASS="stirling"
NODES="multinode-stirling-1 multinode-stirling-2"
# An authed, admin-visible endpoint that returns 200 with a valid token, 401 without.
PROBE="/api/v1/sources"
pass=0; fail=0
ok() { echo " PASS - $*"; pass=$((pass+1)); }
bad() { echo " FAIL - $*"; fail=$((fail+1)); }
login() { # -> prints the bearer token
curl -s -X POST "$LB/api/v1/auth/login" -H 'Content-Type: application/json' \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASS\"}" \
| sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p'
}
echo "== 1. Load balancer spreads requests across nodes =="
served=$(for i in $(seq 1 12); do
curl -s -D - -o /dev/null "$LB/api/v1/info/status" | tr -d '\r' | awk -F': ' '/^X-Served-By/{print $2}'
done | sort -u)
distinct=$(printf '%s\n' "$served" | grep -c .)
echo " upstreams seen: $(printf '%s' "$served" | paste -sd, -)"
[ "$distinct" -ge 2 ] && ok "LB round-robined across $distinct nodes" \
|| bad "only $distinct node(s) served (expected >=2; is X-Served-By enabled?)"
echo "== 2. A JWT from the LB is accepted by BOTH nodes directly (shared signing key) =="
jwt=$(login)
if [ -z "$jwt" ]; then
bad "admin login via LB failed - cannot test cross-node JWT"
else
ok "logged in via LB, got a JWT (${#jwt} chars)"
for n in $NODES; do
hc=$(docker exec "$n" curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $jwt" "http://localhost:8080$PROBE" 2>/dev/null)
[ "$hc" = "200" ] && ok "$n accepted the foreign-minted JWT (HTTP 200)" \
|| bad "$n rejected the JWT (HTTP $hc) - keys not shared across nodes"
done
fi
echo "== 3. Processor state is shared: each node sees the same sources =="
count_of() { # $1=node -> number of sources that node reports
docker exec "$1" curl -s -H "Authorization: Bearer $jwt" "http://localhost:8080$PROBE" 2>/dev/null \
| grep -o '"id"' | grep -c .
}
a=$(count_of multinode-stirling-1); b=$(count_of multinode-stirling-2)
echo " stirling-1 sources: $a stirling-2 sources: $b"
if [ "$a" -gt 0 ] && [ "$a" = "$b" ]; then
ok "both nodes report the same $a sources (shared DB)"
else
bad "source counts differ or zero across nodes ($a vs $b)"
fi
echo "== 4. Seeded org is in the shared DB =="
users=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from users" 2>/dev/null | tr -d '[:space:]')
teams=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from teams" 2>/dev/null | tr -d '[:space:]')
conns=$(docker exec multinode-postgres psql -U stirling -d stirling -tAc "select count(*) from integration_configs" 2>/dev/null | tr -d '[:space:]')
echo " users=$users teams=$teams integration_configs=$conns"
[ "${users:-0}" -ge 40 ] && ok "$users users present" || bad "only ${users:-0} users (did the seed run?)"
[ "${conns:-0}" -ge 1 ] && ok "$conns S3/integration connection(s) present" || bad "no integration connections"
echo "== 5. Cross-node encrypted-secret read (shared credential key) =="
# The seed's S3 secret was encrypted by whichever node handled it; fetching it via the LB (either node) and getting a masked, non-error view proves the credential key is shared, not per-node.
lc=$(curl -s -o /tmp/mn_conns.json -w '%{http_code}' -H "Authorization: Bearer $jwt" "$LB/api/v1/integrations")
if [ "$lc" = "200" ] && grep -q '"integrationType"' /tmp/mn_conns.json; then
ok "integration list decrypts through the LB (HTTP 200) - credential key is shared"
else
bad "integration list failed (HTTP $lc) - credential key may not be shared across nodes"
fi
echo
echo "============================================================"
echo " Multi-node validation: $pass passed, $fail failed."
echo " Stack left running: $LB (admin / stirling)"
echo "============================================================"
[ "$fail" -eq 0 ]
+3 -7
View File
@@ -1,9 +1,5 @@
[behave]
# Enterprise and premium-licensed features live in features/enterprise/.
# They are excluded from the default CI run because the test environment
# does not have a commercial licence. To run them explicitly:
#
# python -m behave features/enterprise
#
exclude_re = features/enterprise
# features/enterprise (needs a commercial licence) and features/multinode (needs the clustered stack in testing/compose/docker-compose-multinode.yml) are excluded by default.
# Run either directly, e.g. `python -m behave features/enterprise`.
exclude_re = features/(enterprise|multinode)
tags = ~@manual
@@ -0,0 +1,18 @@
@multinode @health
Feature: Multi-node cluster health
Every node must come up healthy and join the shared Valkey backplane - the precondition for the rest of the suite (stack: testing/compose/docker-compose-multinode.yml).
@smoke
Scenario: Both application nodes are healthy
Given the multi-node stack is running
@smoke
Scenario: Both nodes joined the Valkey backplane in cluster mode
Given the multi-node stack is running
And both nodes are cluster members using the Valkey backplane
Scenario: The load balancer answers the health endpoint
Given the multi-node stack is running
When I request "/api/v1/info/status" 4 times through the load balancer
Then every load-balanced response should be 200
@@ -0,0 +1,12 @@
@multinode @connections
Feature: S3 connections across nodes
Integration connections are team-scoped, secret-encrypted rows in the shared DB - a connection created via the LB must resolve (secret masked) from every node and deletion must remove it cluster-wide.
Scenario: A connection created via the LB resolves everywhere, then deletes cluster-wide
Given the multi-node stack is running
And I am logged in as admin
When I create an S3 connection named "regr_conn_alpha" via the load balancer
Then the connection "regr_conn_alpha" should resolve from every node with its secret masked
When I delete the connection via the load balancer
Then the connection should be gone from every node
@@ -0,0 +1,14 @@
@multinode @auth
Feature: Cross-node authentication
JWTs are signed RS256 and verified by key id, so the signing keys (stored in the shared DB, private half encrypted) must be shared for a token minted on one node to validate on another.
Scenario: A token minted through the LB validates on every node directly
Given the multi-node stack is running
And I am logged in as admin
Then the current token should be accepted by every node
Scenario: Signing keys are persisted in the shared database, encrypted
Given the multi-node stack is running
Then the signing keys should be stored in the shared database
And every stored private key should be encrypted at rest
@@ -0,0 +1,21 @@
@multinode @failover @destructive
Feature: Node failover and recovery
Losing a node must not take the service down: the LB drains it and the survivor keeps serving, then the node rejoins healthy on restart. These scenarios kill/restart containers, so they are @destructive - pass --tags=failover to run them.
@destructive
Scenario: The load balancer keeps serving when a node dies
Given the multi-node stack is running
When I kill node "2"
Then the load balancer should still serve requests
When I restart node "2"
Then node "2" should become healthy again within 120s
@destructive
Scenario: A recovered node validates tokens minted while it was down
Given the multi-node stack is running
And I am logged in as admin
When I kill node "2"
And I restart node "2"
Then node "2" should become healthy again within 120s
And the current token should be accepted by every node

Some files were not shown because too many files have changed in this diff Show More