Compare commits

...
Author SHA1 Message Date
Anthony Stirling ccfdfcbc24 Merge branch 'main' into processor-docs-automation 2026-07-14 10:14:22 +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
Frooodle 91f89cbbb8 Merge branch 'main' into processor-docs-automation 2026-07-13 11:17:09 +01:00
Anthony StirlingandGitHub 3631dfdf93 Merge branch 'main' into processor-docs-automation 2026-07-12 10:18:13 +01:00
Anthony Stirling 1b992fe1a3 Portal docs: de-duplicate heading anchors so the TOC highlights one section 2026-07-12 10:14:28 +01:00
Anthony Stirling 5d5b8ed39d Portal docs: add On this page TOC, edge-to-edge nav, centred content 2026-07-12 00:00:14 +01:00
Anthony Stirling c31f47216d Portal docs: load images eagerly so they render in the scroll pane 2026-07-11 22:40:10 +01:00
Anthony Stirling fc04542552 Portal: auto-synced Documentation tab with full-text search 2026-07-11 22:12:19 +01: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
112 changed files with 6950 additions and 1828 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')
+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.
+92
View File
@@ -0,0 +1,92 @@
name: Sync Portal Docs
# Regenerates the portal Developer Docs manifest from the Stirling docs repo and
# opens a PR when it changes. Runs weekly, on manual dispatch, or when the docs
# repo fires a `docs-updated` repository_dispatch.
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
inputs:
ref:
description: "Docs repo ref (branch or tag) to sync from"
required: false
default: "main"
repository_dispatch:
types: [docs-updated]
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync:
name: Sync docs manifest
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
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
with:
fetch-depth: 0
persist-credentials: false
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
env:
NPM_CONFIG_IGNORE_SCRIPTS: "true"
run: npm ci --ignore-scripts --audit=false --fund=false
- name: Regenerate docs manifest
working-directory: frontend
env:
DOCS_REF: ${{ github.event.inputs.ref || github.event.client_payload.ref || 'main' }}
GITHUB_TOKEN: ${{ steps.setup-bot.outputs.token }}
run: npm run docs:sync
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: "Sync portal docs from docs repo"
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync-portal-docs
base: main
title: "Sync portal docs from docs repo"
body: |
Auto-generated by ${{ steps.setup-bot.outputs.app-slug }}[bot].
Regenerates `frontend/editor/src/portal/generated/docsManifest.json`
from the Stirling docs repo via `npm run docs:sync`.
labels: documentation,github-actions,frontend
add-paths: frontend/editor/src/portal/generated/docsManifest.json
delete-branch: true
sign-commits: true
@@ -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"
+2
View File
@@ -12,6 +12,8 @@ editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/src/core/data/ogImageMap.json
# Auto-generated portal docs manifest (scripts/sync-portal-docs.mts); regenerated verbatim.
editor/src/portal/generated/docsManifest.json
editor/public/pdfjs*/
editor/public/js/thirdParty/
editor/public/css/cookieconsent.css
@@ -6603,6 +6603,10 @@ 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.docs]
browse = "Browse docs"
viewSource = "View source on GitHub"
[portal.docs.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -6688,11 +6692,19 @@ title = "Official SDKs"
beta = "Beta"
deprecated = "Deprecated"
[portal.docs.search]
empty = "No matching docs"
placeholder = "Search docs"
results = "{{count}} results"
[portal.docs.skills]
eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
title = "Agent skills"
[portal.docs.toc]
title = "On this page"
[portal.docs.webhooks]
codeCaption = "document.processed"
eyebrow = "API REFERENCE"
@@ -7246,7 +7258,7 @@ storage = "Storage"
[portal.nav]
agent-builder = "Agent Builder"
components = "Components"
docs = "Developer Docs"
docs = "Documentation"
documents = "Documents"
editor = "Editor"
home = "Home"
@@ -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,39 @@ title = "No components available"
description = "GA components are available on Pay-as-you-go; a few Beta components are enterprise-only. Locked cards show an upgrade nudge."
title = "Some components need a paid plan"
[portal.connections]
createTitle = "New S3 connection"
delete = "Delete"
edit = "Edit"
editTitle = "Edit S3 connection"
subtitle = "Reusable S3 credentials that sources and pipeline outputs connect to."
[portal.connections.actions]
new = "New connection"
[portal.connections.empty]
description = "Add an S3 connection to reuse the same bucket and credentials across sources and pipeline outputs."
title = "No connections yet"
[portal.connections.picker]
cancel = "Cancel"
createNew = "New connection..."
placeholder = "Select a connection"
save = "Save connection"
[portal.connections.s3.fields]
name = "Connection name"
namePlaceholder = "e.g. Claims bucket"
[portal.connections.table]
bucket = "Bucket"
name = "Name"
region = "Region"
[portal.docs]
browse = "Browse docs"
viewSource = "View source on GitHub"
[portal.docs.authentication]
codeCaption = "every request"
eyebrow = "GETTING STARTED"
@@ -6615,11 +6652,19 @@ title = "Official SDKs"
beta = "Beta"
deprecated = "Deprecated"
[portal.docs.search]
empty = "No matching docs"
placeholder = "Search docs"
results = "{{count}} results"
[portal.docs.skills]
eyebrow = "SKILLS"
lead = "Bundled, named capabilities your agent invokes as a single tool. Each skill is a deterministic op chain with evals attached."
title = "Agent skills"
[portal.docs.toc]
title = "On this page"
[portal.docs.webhooks]
codeCaption = "document.processed"
eyebrow = "API REFERENCE"
@@ -7284,7 +7329,7 @@ storage = "Storage"
[portal.nav]
agent-builder = "Agent Builder"
components = "Components"
docs = "Developer Docs"
docs = "Documentation"
documents = "Documents"
editor = "Editor"
home = "Home"
@@ -7341,10 +7386,6 @@ operations_one = "Operation ({{count}})"
operations_other = "Operations ({{count}})"
output = "Output"
removeStep = "Remove operation"
s3Configure = "Configure"
s3Done = "Done"
s3ModalTitle = "Amazon S3 output"
s3NotConfigured = "Not configured"
s3PrefixHelp = "Outputs are uploaded under this key prefix."
save = "Save changes"
scheduleEvery = "Run every"
@@ -7415,6 +7456,7 @@ manual = "Manual"
schedule = "Scheduled"
[portal.policies]
defaultName = "{{category}} Policy"
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
title = "Policies"
@@ -7986,34 +8028,29 @@ primaryNav = "Primary navigation"
switchApp = "Switch app"
[portal.sources]
subtitle = "Reusable input connections that feed documents into Stirling. Configure a connection once, then reference it from any number of policies. Click a row for its config and which policies use it."
subtitle = "Reusable input connections that feed documents into Stirling. Configure a source once, then reference it from any number of pipelines."
title = "Sources"
[portal.sources.actions]
agentBuilder = "Agent Builder"
connectSource = "Connect source"
[portal.sources.builder]
back = "Back to sources"
cancel = "Cancel"
create = "Create source"
createTitle = "Connect a source"
delete = "Delete"
editTitle = "Edit source"
enabled = "Enabled"
save = "Save changes"
[portal.sources.delete]
body = "Delete \"{{name}}\"? This can't be undone. Policies that reference it would need to be updated."
cancel = "Cancel"
confirm = "Delete"
title = "Delete source?"
[portal.sources.detail]
closeAriaLabel = "Close detail"
delete = "Delete source"
docs24h = "Last 24h"
docs30d = "Last 30 days"
docsTotal = "Total seen"
docsTrend = "Documents over the last 30 days"
documents = "Documents"
edit = "Edit"
notReferenced = "Not referenced by any policy, so it's safe to delete."
pause = "Pause"
resume = "Resume"
subtitle = "{{type}} · {{status}}"
usedBy = "Used by"
[portal.sources.empty]
description = "Connect a folder (and, soon, cloud storage) so your policies have somewhere to pull documents from."
title = "No sources connected yet"
@@ -8029,10 +8066,15 @@ disabled = "Disabled"
unused = "Unused"
[portal.sources.table]
documents = "Documents"
source = "Source"
status = "Status"
usedBy = "Policies"
[portal.sources.tabs]
connections = "Connections"
sources = "Sources"
[portal.sources.types.editor]
description = "Documents your team has processed in the editor, across policy and AI runs."
label = "Editor"
@@ -8080,6 +8122,10 @@ label = "Access key ID"
label = "Bucket"
placeholder = "my-company-inbox"
[portal.sources.types.s3.fields.connection]
helperText = "The stored connection holding the bucket and credentials. Reused by every source and pipeline output that references it."
label = "Connection"
[portal.sources.types.s3.fields.endpoint]
helperText = "Leave blank for Amazon S3. Set to use an S3-compatible service such as MinIO."
label = "Custom endpoint"
@@ -8109,21 +8155,14 @@ label = "Secret access key"
label = "Source"
[portal.sources.wizard]
back = "Back"
cancel = "Cancel"
continue = "Continue"
editTitle = "Edit source"
name = "Name"
namePlaceholder = "e.g. Claims intake"
save = "Save changes"
subtitle = "Step {{current}} of {{total}} · {{label}}"
title = "Connect a source"
type = "Type"
[portal.sources.wizard.steps]
chooseType = "Choose type"
configure = "Configure"
review = "Review & connect"
[portal.tier]
enterprise = "Enterprise plan"
free = "Editor plan"
pro = "Processor plan"
[portal.usage]
managePayment = "Manage Payment"
@@ -10139,6 +10178,19 @@ resetPw = "Reset password"
suspend = "Suspend"
unlock = "Unlock account"
[users.activity]
daysAgo = "{{count}}d ago"
hoursAgo = "{{count}}h ago"
justNow = "Just now"
minutesAgo = "{{count}}m ago"
monthsAgo_one = "{{count}} month ago"
monthsAgo_other = "{{count}} months ago"
never = "Never"
weeksAgo_one = "{{count}} week ago"
weeksAgo_other = "{{count}} weeks ago"
yearsAgo_one = "{{count}} year ago"
yearsAgo_other = "{{count}} years ago"
[users.cap]
addProcessor = "+ Processor"
approver = "Approves policy"
@@ -10209,7 +10261,9 @@ by = "Invited by {{who}}"
cancel = "Cancel"
count = "{{count}} pending"
desc = "Invited people who haven't joined yet. They hold a seat until they accept."
expires = "Expires"
expiresInDays_one = "Expires in {{count}} day"
expiresInDays_other = "Expires in {{count}} days"
expiresToday = "Expires today"
title = "Pending invitations"
[users.loadError]
@@ -0,0 +1,159 @@
/**
* Sync the portal Developer Docs from the Stirling docs repo.
*
* Fetches the docs repo tarball, extracts `docs/**` in-process (no external tar
* binary, no per-file GitHub rate limits), shapes it with the pure transforms in
* src/portal/docs/manifest/transform.ts, and writes the committed manifest that
* the portal docs view renders. Re-run with `npm run docs:sync`.
*
* Env: DOCS_REPO, DOCS_REF, DOCS_ROOT override the defaults below.
*/
import { gunzipSync } from "node:zlib";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
// tsx/node16 can't resolve the @portal alias here, so import by relative .ts path.
// eslint-disable-next-line no-restricted-imports
import {
buildManifest,
type CategoryMap,
type RawDoc,
} from "../src/portal/docs/manifest/transform.ts";
const REPO = process.env.DOCS_REPO ?? "Stirling-Tools/Stirling-Tools.github.io";
const REF = process.env.DOCS_REF ?? "main";
const ROOT = process.env.DOCS_ROOT ?? "docs";
const SITE = "https://docs.stirlingpdf.com";
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(HERE, "../src/portal/generated/docsManifest.json");
/* ── Minimal tar reader (ustar + pax/GNU long names) ─────────────────────── */
interface TarEntry {
name: string;
type: string;
data: Buffer;
}
function readTar(buf: Buffer): TarEntry[] {
const entries: TarEntry[] = [];
let offset = 0;
let longName: string | null = null;
let paxPath: string | null = null;
const str = (start: number, len: number) => {
const slice = buf.subarray(start, start + len);
const end = slice.indexOf(0);
return slice.toString("utf8", 0, end === -1 ? len : end);
};
while (offset + 512 <= buf.length) {
const header = buf.subarray(offset, offset + 512);
// Two consecutive zero blocks mark the end of the archive.
if (header.every((b) => b === 0)) break;
const name = str(offset, 100);
const prefix = str(offset + 345, 155);
const sizeStr = str(offset + 124, 12).trim();
const size = parseInt(sizeStr, 8) || 0;
const type = String.fromCharCode(header[156]);
const dataStart = offset + 512;
const data = buf.subarray(dataStart, dataStart + size);
let fullName = prefix ? `${prefix}/${name}` : name;
if (longName) {
fullName = longName;
longName = null;
}
if (paxPath) {
fullName = paxPath;
paxPath = null;
}
if (type === "L") {
// GNU long name: the payload is the real name of the next entry.
longName = data.toString("utf8").replace(/\0+$/, "");
} else if (type === "x") {
// pax extended header: pull a `path=` record for the next entry.
const record = /(?:^|\n)\d+ path=([^\n]+)\n/.exec(data.toString("utf8"));
if (record) paxPath = record[1];
} else if (type === "0" || type === "\0" || type === "") {
entries.push({ name: fullName, type, data: Buffer.from(data) });
}
offset = dataStart + Math.ceil(size / 512) * 512;
}
return entries;
}
/* ── Fetch + shape ───────────────────────────────────────────────────────── */
async function main(): Promise<void> {
const url = `https://api.github.com/repos/${REPO}/tarball/${REF}`;
console.log(`Fetching ${REPO}@${REF} …`);
const res = await fetch(url, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "stirling-portal-docs-sync",
...(process.env.GITHUB_TOKEN
? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` }
: {}),
},
});
if (!res.ok) {
throw new Error(
`GitHub tarball fetch failed: ${res.status} ${res.statusText}`,
);
}
const gz = Buffer.from(await res.arrayBuffer());
const entries = readTar(gunzipSync(gz));
// Strip the "<repo>-<sha>/" wrapper dir and keep only files under docs root.
const prefix = `${ROOT}/`;
const rawDocs: RawDoc[] = [];
const categories: CategoryMap = {};
for (const entry of entries) {
const rel = entry.name.replace(/^[^/]+\//, "");
if (!rel.startsWith(prefix)) continue;
const inner = rel.slice(prefix.length);
if (!inner) continue;
if (inner.endsWith("/_category_.json")) {
const dir = inner.slice(0, -"/_category_.json".length);
try {
categories[dir] = JSON.parse(entry.data.toString("utf8"));
} catch {
console.warn(` skipping unparseable _category_.json in ${dir}`);
}
} else if (/\.mdx?$/i.test(inner)) {
rawDocs.push({ relPath: inner, content: entry.data.toString("utf8") });
}
}
if (rawDocs.length === 0) {
throw new Error(`No markdown found under ${ROOT}/ — wrong repo/ref/root?`);
}
const manifest = buildManifest(rawDocs, categories, {
repo: REPO,
ref: REF,
root: ROOT,
siteBaseUrl: SITE,
});
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(manifest, null, 2) + "\n", "utf8");
const items = manifest.nav.reduce((n, s) => n + s.items.length, 0);
console.log(
`Wrote ${manifest.nav.length} sections, ${items} docs → ${OUT.replace(/.*[/\\]frontend[/\\]/, "frontend/")}`,
);
for (const s of manifest.nav) {
console.log(` ${s.icon} ${s.label} (${s.items.length})`);
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.message : err);
process.exitCode = 1;
});
+3 -1
View File
@@ -4,7 +4,9 @@
"module": "node16",
"moduleResolution": "node16",
"types": ["node"],
"noEmit": true
"noEmit": true,
// sync-portal-docs.mts imports the shared transform by its .ts path (run via tsx).
"allowImportingTsExtensions": true
},
"include": ["./**/*.ts", "./**/*.mts"]
}
+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>
+25 -2
View File
@@ -1,3 +1,4 @@
import { lazy, Suspense } from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import { Home } from "@portal/views/Home";
import { Users } from "@portal/views/Users";
@@ -5,16 +6,23 @@ 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";
import { EditorAdmin } from "@portal/views/EditorAdmin";
import { Infrastructure } from "@portal/views/Infrastructure";
import { PortalBillingGate } from "@portal/components/billing/PortalBillingGate";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
import { Procurement } from "@portal/views/Procurement";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
// Lazy so the generated docs manifest (bundled JSON) lands in its own chunk.
const DeveloperDocs = lazy(() =>
import("@portal/views/DeveloperDocs").then((m) => ({
default: m.DeveloperDocs,
})),
);
// The portal mounts as a route-set under /processor/* in the editor app, so these
// child routes are relative to that base: strip the leading slash from the
// logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath
@@ -36,6 +44,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 />}
@@ -50,7 +66,14 @@ export function ViewRouter() {
/>
<Route path={rel(VIEW_PATHS.usage)} element={<PortalBillingGate />} />
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
<Route
path={rel(VIEW_PATHS.docs)}
element={
<Suspense fallback={null}>
<DeveloperDocs />
</Suspense>
}
/>
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
<Route
path="account-link"
+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>
@@ -1,8 +1,45 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Skeleton, StatusBadge } from "@app/ui";
import { useTranslation } from "react-i18next";
import type { DocsNavSection } from "@portal/api/docs";
/** Left-hand documentation nav tree; each leaf selects an in-page section. */
/**
* Left-hand documentation nav: a hierarchical accordion. Section ids encode their
* path ("functionality/security" is a child of "functionality"), so sub-sections
* nest under their parent. The root "Overview" section is static (always open, no
* toggle); every other section collapses, and only the branch leading to the
* active doc opens by default. (Search lives in DocsSearch above this.)
*/
// Matches the generator's ROOT_SECTION_ID: the intro section is never collapsible.
const STATIC_SECTION_ID = "overview";
interface NavNode {
section: DocsNavSection;
children: NavNode[];
}
/** Split "a/b/c" → "a/b"; null for a top-level id. */
function parentId(id: string): string | null {
const i = id.lastIndexOf("/");
return i === -1 ? null : id.slice(0, i);
}
/** Build the section tree from the flat, pre-sorted section list. */
function buildTree(sections: DocsNavSection[]): NavNode[] {
const byId = new Map<string, NavNode>(
sections.map((s) => [s.id, { section: s, children: [] }]),
);
const roots: NavNode[] = [];
for (const node of byId.values()) {
const pid = parentId(node.section.id);
const parent = pid ? byId.get(pid) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
export function DocsNav({
sections,
active,
@@ -13,50 +50,125 @@ export function DocsNav({
onSelect: (id: string) => void;
}) {
const { t } = useTranslation();
// Per-section manual open/close, overriding the "active branch only" default.
const [toggled, setToggled] = useState<Record<string, boolean>>({});
const activeRef = useRef<HTMLButtonElement>(null);
const activeSectionId = useMemo(
() => sections.find((s) => s.items.some((i) => i.id === active))?.id,
[sections, active],
);
const tree = useMemo(() => buildTree(sections), [sections]);
// Keep the active item in view when navigating (e.g. via a cross-link).
useEffect(() => {
activeRef.current?.scrollIntoView?.({ block: "nearest" });
}, [active]);
const isOpen = (id: string): boolean => {
if (id === STATIC_SECTION_ID) return true;
// Default-open the branch containing the active doc (self or ancestor).
const onActivePath =
!!activeSectionId &&
(activeSectionId === id || activeSectionId.startsWith(id + "/"));
return toggled[id] ?? onActivePath;
};
const renderNode = (node: NavNode) => {
const { section, children } = node;
const isStatic = section.id === STATIC_SECTION_ID;
const open = isOpen(section.id);
return (
<div key={section.id} className="portal-docs__nav-group">
{isStatic ? (
<div className="portal-docs__nav-head">{section.label}</div>
) : (
<Button
variant="tertiary"
fullWidth
justify="between"
className="portal-docs__nav-head portal-docs__nav-head--button"
aria-expanded={open}
onClick={() =>
setToggled((prev) => ({ ...prev, [section.id]: !open }))
}
rightSection={
<span className="portal-docs__nav-count">
{section.items.length}
</span>
}
>
<span className="portal-docs__nav-head-main">
<span
className={
"portal-docs__nav-chevron" + (open ? " is-open" : "")
}
aria-hidden
>
▸
</span>
<span className="portal-docs__nav-headlabel">
{section.label}
</span>
</span>
</Button>
)}
{open && (
<>
{section.items.length > 0 && (
<ul className="portal-docs__nav-list">
{section.items.map((item) => {
const isActive = item.id === active;
return (
<li key={item.id}>
<Button
ref={isActive ? activeRef : undefined}
variant="tertiary"
justify="start"
fullWidth
className={
"portal-docs__nav-link" +
(isActive ? " is-active" : "")
}
aria-current={isActive ? "page" : undefined}
onClick={() => onSelect(item.id)}
>
<span className="portal-docs__nav-label">
{item.label}
</span>
{item.badge && (
<StatusBadge
tone={item.badge === "New" ? "success" : "info"}
size="sm"
>
{item.badge}
</StatusBadge>
)}
</Button>
</li>
);
})}
</ul>
)}
{children.length > 0 && (
<div className="portal-docs__nav-children">
{children.map(renderNode)}
</div>
)}
</>
)}
</div>
);
};
return (
<nav
className="portal-docs__nav"
aria-label={t("portal.docs.nav.ariaLabel")}
>
{sections.map((section) => (
<div key={section.id} className="portal-docs__nav-group">
<div className="portal-docs__nav-head">
<span className="portal-docs__nav-icon" aria-hidden>
{section.icon}
</span>
{section.label}
</div>
<ul className="portal-docs__nav-list">
{section.items.map((item) => {
const isActive = item.id === active;
return (
<li key={item.id}>
<Button
variant="tertiary"
justify="start"
fullWidth
className={
"portal-docs__nav-link" + (isActive ? " is-active" : "")
}
aria-current={isActive ? "page" : undefined}
onClick={() => onSelect(item.id)}
>
<span className="portal-docs__nav-label">{item.label}</span>
{item.badge && (
<StatusBadge
tone={item.badge === "New" ? "success" : "info"}
size="sm"
>
{item.badge}
</StatusBadge>
)}
</Button>
</li>
);
})}
</ul>
</div>
))}
{tree.map(renderNode)}
</nav>
);
}
@@ -64,14 +176,16 @@ export function DocsNav({
export function DocsNavSkeleton() {
return (
<nav className="portal-docs__nav" aria-hidden>
{Array.from({ length: 4 }).map((_, gi) => (
{Array.from({ length: 5 }).map((_, gi) => (
<div key={gi} className="portal-docs__nav-group">
<Skeleton width="7rem" height="0.75rem" />
<div className="portal-docs__nav-list">
{Array.from({ length: 3 }).map((_, li) => (
<Skeleton key={li} width="80%" height="0.875rem" />
))}
</div>
{gi === 0 && (
<div className="portal-docs__nav-list">
{Array.from({ length: 4 }).map((_, li) => (
<Skeleton key={li} width="80%" height="0.875rem" />
))}
</div>
)}
</div>
))}
</nav>
@@ -0,0 +1,137 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import type { SearchResult, Segment } from "@portal/docs/search";
/** Render highlighted segments, wrapping matched runs in <mark>. */
function Highlighted({ segments }: { segments: Segment[] }) {
return (
<>
{segments.map((s, i) =>
s.hit ? (
<mark key={i} className="portal-docs__hl">
{s.text}
</mark>
) : (
<span key={i}>{s.text}</span>
),
)}
</>
);
}
/**
* Docs search box + results. While a query is active it shows a ranked list of
* matching docs — each with its section, a highlighted title, and a content
* snippet — that navigates on click (or Enter). Arrow keys move the selection.
*/
export function DocsSearch({
query,
onQueryChange,
results,
onSelect,
}: {
query: string;
onQueryChange: (q: string) => void;
results: SearchResult[];
onSelect: (docId: string) => void;
}) {
const { t } = useTranslation();
// -1 = nothing pre-selected; arrow keys drive this, the mouse uses CSS :hover.
const [activeIndex, setActiveIndex] = useState(-1);
const listRef = useRef<HTMLUListElement>(null);
const hasQuery = query.trim().length > 0;
useEffect(() => setActiveIndex(-1), [query]);
useEffect(() => {
listRef.current
?.querySelector<HTMLElement>('[data-active="true"]')
?.scrollIntoView?.({ block: "nearest" });
}, [activeIndex]);
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
onQueryChange("");
return;
}
if (!results.length) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const hit = results[activeIndex >= 0 ? activeIndex : 0];
if (hit) onSelect(hit.id);
}
};
return (
<div className="portal-docs__search">
<div className="portal-docs__search-box">
<span className="portal-docs__search-icon" aria-hidden>
⌕
</span>
<input
type="search"
className="portal-docs__search-input"
placeholder={t("portal.docs.search.placeholder")}
value={query}
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={onKeyDown}
aria-label={t("portal.docs.search.placeholder")}
/>
</div>
{hasQuery && (
<div className="portal-docs__results">
{results.length === 0 ? (
<p className="portal-docs__nav-empty">
{t("portal.docs.search.empty")}
</p>
) : (
<>
<div className="portal-docs__results-count">
{t("portal.docs.search.results", { count: results.length })}
</div>
<ul ref={listRef} className="portal-docs__results-list">
{results.map((r, i) => (
<li key={r.id}>
<Button
variant="tertiary"
fullWidth
justify="start"
className={
"portal-docs__result" +
(i === activeIndex ? " is-active" : "")
}
data-active={i === activeIndex}
onClick={() => onSelect(r.id)}
>
<span className="portal-docs__result-body">
<span className="portal-docs__result-head">
<span className="portal-docs__result-title">
<Highlighted segments={r.titleSegments} />
</span>
<span className="portal-docs__result-section">
{r.sectionLabel}
</span>
</span>
<span className="portal-docs__result-snippet">
<Highlighted segments={r.snippet} />
</span>
</span>
</Button>
</li>
))}
</ul>
</>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { useEffect, useState, type RefObject } from "react";
import { useTranslation } from "react-i18next";
import type { Heading } from "@portal/docs/headings";
/**
* "On this page" table of contents. Lists the current doc's H2/H3 headings,
* scrolls the reading pane to a heading on click, and highlights the section
* currently in view (scroll-spy against the pane's scroll container).
*/
export function DocsToc({
headings,
scrollRef,
}: {
headings: Heading[];
scrollRef: RefObject<HTMLElement | null>;
}) {
const { t } = useTranslation();
const [active, setActive] = useState<string>(headings[0]?.slug ?? "");
useEffect(() => {
const root = scrollRef.current;
if (!root || headings.length === 0) return;
setActive(headings[0].slug);
const visible = new Set<string>();
const observer = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) visible.add(e.target.id);
else visible.delete(e.target.id);
}
// The topmost heading currently within the active zone wins.
const current = headings.find((h) => visible.has(h.slug));
if (current) setActive(current.slug);
},
// Active zone = the top ~30% of the reading pane.
{ root, rootMargin: "0px 0px -70% 0px", threshold: 0 },
);
const els = headings
.map((h) => root.querySelector(`[id="${h.slug}"]`))
.filter((el): el is Element => el !== null);
els.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [headings, scrollRef]);
const onSelect = (slug: string) => {
scrollRef.current
?.querySelector(`[id="${slug}"]`)
?.scrollIntoView({ block: "start", behavior: "smooth" });
setActive(slug);
};
return (
<nav className="portal-docs__toc" aria-label={t("portal.docs.toc.title")}>
<div className="portal-docs__toc-title">{t("portal.docs.toc.title")}</div>
<ul className="portal-docs__toc-list">
{headings.map((h) => (
<li key={h.slug}>
<a
href={`#${h.slug}`}
className={
"portal-docs__toc-link" +
(h.level === 3 ? " is-sub" : "") +
(active === h.slug ? " is-active" : "")
}
aria-current={active === h.slug ? "location" : undefined}
onClick={(e) => {
e.preventDefault();
onSelect(h.slug);
}}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,131 @@
import { isValidElement, useState, type ReactNode } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
} from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@app/ui";
import { makeSlugger } from "@portal/docs/headings";
/** Flatten a heading's React children to plain text for its anchor id. */
function childText(node: ReactNode): string {
if (typeof node === "string" || typeof node === "number") return String(node);
if (Array.isArray(node)) return node.map(childText).join("");
if (isValidElement(node)) {
return childText((node.props as { children?: ReactNode }).children);
}
return "";
}
// Keep our internal `doc:` scheme; sanitize every other URL as react-markdown
// would by default (it strips unknown protocols, which would kill doc: links).
function urlTransform(url: string): string {
return url.startsWith("doc:") ? url : defaultUrlTransform(url);
}
/**
* Renders a doc's normalised markdown. Internal cross-doc links carry the
* `doc:` scheme (see the sync transform) and are intercepted here so they
* navigate within the portal instead of leaving the app.
*/
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<Button
variant="tertiary"
size="sm"
className="portal-docs__md-copy"
onClick={() =>
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
}
>
{copied ? "✓ Copied" : "Copy"}
</Button>
);
}
function buildComponents(
onNavigate: (docId: string) => void,
slug: (text: string) => string,
): Components {
return {
h2: ({ children }) => <h2 id={slug(childText(children))}>{children}</h2>,
h3: ({ children }) => <h3 id={slug(childText(children))}>{children}</h3>,
a: ({ href, children }) => {
if (href?.startsWith("doc:")) {
const id = href.slice(4);
return (
<a
href={`#${id}`}
onClick={(e) => {
e.preventDefault();
onNavigate(id);
}}
>
{children}
</a>
);
}
const external = /^https?:/i.test(href ?? "");
return (
<a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noopener noreferrer" : undefined}
>
{children}
</a>
);
},
// Eager, not lazy: lazy-loading inside the docs' own scroll container isn't
// reliably triggered, and docs pages have only a handful of images.
img: ({ node: _node, ...props }) => (
<img {...props} className="portal-docs__md-img" />
),
pre: ({ children }) => {
const code = isValidElement(children)
? String(
(children.props as { children?: unknown }).children ?? "",
).replace(/\n$/, "")
: String(children ?? "");
return (
<div className="portal-docs__md-pre">
<pre>{children}</pre>
<CopyButton text={code} />
</div>
);
},
table: ({ children }) => (
<div className="portal-docs__md-tablewrap">
<table>{children}</table>
</div>
),
};
}
export function MarkdownDoc({
markdown,
onNavigate,
}: {
markdown: string;
onNavigate: (docId: string) => void;
}) {
// A fresh de-duping slugger per render; react-markdown invokes h2/h3 in
// document order, so ids line up with the TOC's extractHeadings slugs.
const slug = makeSlugger();
return (
<div className="portal-docs__md">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
urlTransform={urlTransform}
components={buildComponents(onNavigate, slug)}
>
{markdown}
</ReactMarkdown>
</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">
@@ -20,10 +20,6 @@ export interface NavEntry {
externalUrl?: string;
}
// Developer docs has no built-in portal page yet, so the tab opens the hosted docs
// site in a new tab rather than routing to an empty page.
const DEVELOPER_DOCS_URL = "https://docs.stirlingpdf.com/";
// Sidebar nav groups. This is a flavor seam: the SaaS build shadows this file to
// drop sections not yet shipped there (see src/portal-saas/components/sidebarGroups).
export const GROUP_PRIMARY: NavEntry[] = [{ id: "home", icon: <HomeIcon /> }];
@@ -40,5 +36,5 @@ export const GROUP_OPERATIONAL: NavEntry[] = [
export const GROUP_PLATFORM: NavEntry[] = [
{ id: "infrastructure", icon: <InfrastructureIcon /> },
{ id: "usage", icon: <UsageIcon /> },
{ id: "docs", icon: <DocsIcon />, externalUrl: DEVELOPER_DOCS_URL },
{ id: "docs", icon: <DocsIcon /> },
];
@@ -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 {
@@ -30,7 +30,7 @@ export const VIEW_LABELS: Record<ViewId, string> = {
components: "Components",
infrastructure: "Infrastructure",
usage: "Usage & Billing",
docs: "Developer Docs",
docs: "Documentation",
procurement: "Procurement",
settings: "Settings",
};
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { extractHeadings, slugify } from "@portal/docs/headings";
describe("slugify", () => {
it("lowercases, hyphenates, and trims punctuation", () => {
expect(slugify("How it Works!")).toBe("how-it-works");
expect(slugify(" Trailing & spaces ")).toBe("trailing-spaces");
});
});
describe("extractHeadings", () => {
it("extracts H2/H3 only, with slugs matching the rendered ids", () => {
const md = [
"# Page Title",
"## How it Works",
"text",
"### Sub Section",
"#### Too Deep",
"## Operations",
].join("\n");
expect(extractHeadings(md)).toEqual([
{ level: 2, text: "How it Works", slug: "how-it-works" },
{ level: 3, text: "Sub Section", slug: "sub-section" },
{ level: 2, text: "Operations", slug: "operations" },
]);
});
it("de-duplicates repeated heading text into unique slugs", () => {
const md = ["## What Changed", "### What Changed", "## What Changed"].join(
"\n",
);
expect(extractHeadings(md).map((h) => h.slug)).toEqual([
"what-changed",
"what-changed-1",
"what-changed-2",
]);
});
it("ignores headings inside fenced code and strips inline marks", () => {
const md = ["```", "## not a heading", "```", "## `Code` and *em*"].join(
"\n",
);
expect(extractHeadings(md)).toEqual([
{ level: 2, text: "Code and em", slug: "code-and-em" },
]);
});
});
@@ -0,0 +1,48 @@
/**
* Heading extraction for the "On this page" table of contents. The same
* `slugify` is used here and in MarkdownDoc's heading renderer, so the TOC links
* and the rendered heading ids always match.
*/
export interface Heading {
/** 2 or 3 (H2/H3). */
level: number;
text: string;
slug: string;
}
/** "How it Works!" → "how-it-works" (the base id, before de-duplication). */
export function slugify(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* A stateful slugger that de-duplicates: repeated heading text gets `-1`, `-2`, …
* The TOC extraction and MarkdownDoc's heading renderer each make one and feed it
* headings in document order, so their slugs (and thus link ↔ id) always match.
*/
export function makeSlugger(): (text: string) => string {
const seen = new Map<string, number>();
return (text: string) => {
const base = slugify(text) || "section";
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return n === 0 ? base : `${base}-${n}`;
};
}
/** Extract H2/H3 headings from a doc body, skipping fenced code blocks. */
export function extractHeadings(markdown: string): Heading[] {
const noCode = markdown.replace(/^(```|~~~)[\s\S]*?^\1[ \t]*$/gm, "");
const slug = makeSlugger();
const headings: Heading[] = [];
for (const m of noCode.matchAll(/^ {0,3}(#{2,3})[ \t]+(.+?)[ \t]*#*$/gm)) {
const text = m[2].replace(/[`*_]/g, "").trim();
if (text) headings.push({ level: m[1].length, text, slug: slug(text) });
}
return headings;
}
@@ -0,0 +1,38 @@
/**
* Runtime accessors over the generated docs manifest. This is the only module
* that imports the (large) JSON, so lazy-loading the docs view keeps it in its
* own chunk. Regenerate the JSON with `npm run docs:sync`.
*/
// Imported as a raw string (not a JSON module) so tsc doesn't infer a ~half-MB
// literal type; parsed once here into the typed manifest.
import manifestRaw from "@portal/generated/docsManifest.json?raw";
import type {
DocEntry,
DocsManifest,
DocsNavSection,
} from "@portal/docs/manifest/transform";
const manifest = JSON.parse(manifestRaw) as DocsManifest;
/** Provenance of the current manifest (repo + ref it was generated from). */
export const docsSource = manifest.source;
/** The auto-sorted nav tree (sections → items). */
export function loadDocsNav(): DocsNavSection[] {
return manifest.nav;
}
/** A single doc by id, or undefined if it isn't in the manifest. */
export function loadDoc(id: string): DocEntry | undefined {
return manifest.docs[id];
}
/** Every doc, for building the search index. */
export function allDocs(): DocEntry[] {
return Object.values(manifest.docs);
}
/** The first doc id (first item of the first section) — the default landing. */
export function firstDocId(): string | undefined {
return manifest.nav[0]?.items[0]?.id;
}
@@ -0,0 +1,208 @@
import { describe, expect, it } from "vitest";
import {
buildManifest,
convertAdmonitions,
demoteHeadings,
docIdForPath,
humanize,
parseFrontmatter,
resolveRelative,
rewriteReferences,
sectionIcon,
stripJsxTags,
stripMdxImports,
stripRedundantH1,
type CategoryMap,
type RawDoc,
} from "@portal/docs/manifest/transform";
const OPTS = {
repo: "Owner/Repo",
ref: "main",
root: "docs",
siteBaseUrl: "https://docs.example.com",
};
describe("parseFrontmatter", () => {
it("splits scalar YAML frontmatter from the body", () => {
const { data, body } = parseFrontmatter(
"---\ntitle: OCR\nsidebar_position: 7\n---\n# Heading\ntext",
);
expect(data.title).toBe("OCR");
expect(data.sidebar_position).toBe(7);
expect(body).toBe("# Heading\ntext");
});
it("returns the whole content as body when there is no frontmatter", () => {
const { data, body } = parseFrontmatter("# Just a doc\nbody");
expect(data).toEqual({});
expect(body).toBe("# Just a doc\nbody");
});
it("normalises CRLF line endings", () => {
const { data } = parseFrontmatter("---\r\nid: x\r\n---\r\nbody");
expect(data.id).toBe("x");
});
});
describe("id + label helpers", () => {
it("slugifies nested paths", () => {
expect(docIdForPath("Configuration/OCR.md")).toBe("configuration/ocr");
expect(docIdForPath("Getting Started.md")).toBe("getting-started");
});
it("humanises file/dir names", () => {
expect(humanize("Getting-Started.md")).toBe("Getting Started");
});
it("picks a section icon from the label", () => {
expect(sectionIcon("Configuration")).toBe("⚙");
expect(sectionIcon("Totally Unknown")).toBe("◇");
});
});
describe("MDX normalisation", () => {
it("converts admonitions to titled blockquotes", () => {
const out = convertAdmonitions(":::tip Upgrading?\nread this\n:::");
expect(out).toContain("> **💡 Tip: Upgrading?**");
expect(out).toContain("> read this");
});
it("strips import/export statements", () => {
const out = stripMdxImports("import Tabs from '@theme/Tabs';\n# Keep");
expect(out).toBe("# Keep");
});
it("removes JSX component tags but keeps inner content", () => {
expect(
stripJsxTags("<Tabs>\n<TabItem value='a'>keep</TabItem>\n</Tabs>"),
).toContain("keep");
expect(stripJsxTags("<TabItem>x</TabItem>")).not.toMatch(/<TabItem/);
});
it("demotes body H1 to H2 but leaves code comments alone", () => {
const md = "# Title\n\n```bash\n# a shell comment\n```";
const out = demoteHeadings(md);
expect(out).toContain("## Title");
expect(out).toContain("# a shell comment");
});
it("strips a leading H1 that duplicates the page title", () => {
expect(stripRedundantH1("# OCR\nbody", "OCR")).toBe("body");
expect(stripRedundantH1("# Other\nbody", "OCR")).toBe("# Other\nbody");
});
});
describe("resolveRelative", () => {
it("collapses ./ and ../ against a base dir", () => {
expect(resolveRelative("Configuration", "./OCR.md")).toBe(
"Configuration/OCR.md",
);
expect(
resolveRelative("Configuration", "../Functionality/Compare.md"),
).toBe("Functionality/Compare.md");
});
});
describe("rewriteReferences", () => {
const ctx = {
dir: "Configuration",
// Keys are lowercased file paths (spaces preserved), as buildManifest builds them.
pathToId: new Map([
[
"configuration/system and security",
"configuration/system-and-security",
],
]),
rawBase: "https://raw.example.com/Owner/Repo/main",
siteBaseUrl: "https://docs.example.com",
};
it("rewrites resolvable internal links to the doc: scheme (decoded + case-insensitive)", () => {
const out = rewriteReferences(
"see [sec](./System%20and%20Security.md)",
ctx,
"docs",
);
expect(out).toBe("see [sec](doc:configuration/system-and-security)");
});
it("falls back to the live docs site for unresolved internal links", () => {
const out = rewriteReferences("[x](./Missing.md)", ctx, "docs");
expect(out).toBe("[x](https://docs.example.com/Configuration/Missing)");
});
it("leaves absolute and anchor links untouched", () => {
const md = "[a](https://x.com) and [b](#top)";
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
});
it("rewrites relative images to absolute raw URLs", () => {
expect(rewriteReferences("![a](./img/x.png)", ctx, "docs")).toBe(
"![a](https://raw.example.com/Owner/Repo/main/docs/Configuration/img/x.png)",
);
expect(rewriteReferences("![a](/img/y.png)", ctx, "docs")).toBe(
"![a](https://raw.example.com/Owner/Repo/main/static/img/y.png)",
);
});
it("does not rewrite inside fenced code blocks", () => {
const md = "```\n[x](./y.md)\n```";
expect(rewriteReferences(md, ctx, "docs")).toBe(md);
});
});
describe("buildManifest", () => {
const rawDocs: RawDoc[] = [
{
relPath: "Getting Started.md",
content: "---\nsidebar_position: 0\n---\nintro",
},
{
relPath: "Configuration/OCR.md",
content: "---\ntitle: OCR\nsidebar_position: 7\n---\n# OCR\nbody",
},
{
relPath: "Configuration/DATABASE.md",
content: "---\nsidebar_position: 1\n---\n# Database\nsee [ocr](./OCR.md)",
},
];
const categories: CategoryMap = {
Configuration: { label: "Configuration", position: 5 },
};
it("auto-sorts sections (root Overview first, then by category position)", () => {
const m = buildManifest(rawDocs, categories, OPTS);
expect(m.nav.map((s) => s.id)).toEqual(["overview", "configuration"]);
expect(m.nav[0].label).toBe("Overview");
expect(m.nav[1].label).toBe("Configuration");
});
it("orders nav items by sidebar_position", () => {
const m = buildManifest(rawDocs, categories, OPTS);
const config = m.nav.find((s) => s.id === "configuration")!;
expect(config.items.map((i) => i.id)).toEqual([
"configuration/database",
"configuration/ocr",
]);
});
it("derives titles from frontmatter, heading, then filename", () => {
const m = buildManifest(rawDocs, categories, OPTS);
expect(m.docs["configuration/ocr"].title).toBe("OCR");
expect(m.docs["getting-started"].title).toBe("Getting Started");
});
it("resolves cross-doc links and records source/edit urls", () => {
const m = buildManifest(rawDocs, categories, OPTS);
expect(m.docs["configuration/database"].markdown).toContain(
"[ocr](doc:configuration/ocr)",
);
expect(m.docs["configuration/ocr"].sourcePath).toBe(
"docs/Configuration/OCR.md",
);
expect(m.docs["configuration/ocr"].editUrl).toBe(
"https://github.com/Owner/Repo/blob/main/docs/Configuration/OCR.md",
);
});
});
@@ -0,0 +1,481 @@
/**
* Pure transforms that turn the Docusaurus docs repo into the portal docs
* manifest. No I/O and no external deps so `tsx` (the sync CLI) and vitest can
* both use it. The sync CLI does the fetching; this module does the shaping.
*
* The auto-sort rules:
* - every directory that directly holds markdown becomes a nav section,
* labelled + ordered by its `_category_.json` (root files → "Overview"),
* - each `.md`/`.mdx` file becomes a nav item, ordered by frontmatter
* `sidebar_position` then title,
* - Docusaurus MDX is normalised to plain GitHub-flavoured markdown that
* react-markdown can render (admonitions, JSX, relative links, images).
*/
/* ──────────────────────────────────────────────────────────────────────── */
/* Manifest shape (mirrored structurally by @portal/api/docs) */
/* ──────────────────────────────────────────────────────────────────────── */
export interface DocsNavItem {
id: string;
label: string;
badge?: string;
}
export interface DocsNavSection {
id: string;
label: string;
icon: string;
items: DocsNavItem[];
}
export interface DocEntry {
id: string;
title: string;
description?: string;
section: string;
markdown: string;
sourcePath: string;
editUrl: string;
}
export interface DocsManifest {
source: { repo: string; ref: string; root: string };
nav: DocsNavSection[];
docs: Record<string, DocEntry>;
}
/** One markdown file read from the repo, before shaping. */
export interface RawDoc {
/** Posix path relative to the docs root, e.g. "Configuration/OCR.md". */
relPath: string;
content: string;
}
/** `_category_.json` contents, keyed by posix dir path relative to docs root. */
export type CategoryMap = Record<string, { label?: string; position?: number }>;
export interface BuildOptions {
repo: string;
ref: string;
/** Docs root within the repo, e.g. "docs". */
root: string;
/** Live docs site base, used as the fallback for unresolved internal links. */
siteBaseUrl: string;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Small helpers */
/* ──────────────────────────────────────────────────────────────────────── */
const SECTION_ICONS: Array<[RegExp, string]> = [
[/overview|getting started|start/i, "▶"],
[/config/i, "⚙"],
[/function|tool|feature/i, "▤"],
[/install|deploy/i, "⤓"],
[/migrat|upgrade/i, "⇄"],
[/security|sign|auth/i, "🛡"],
[/convert/i, "⇋"],
[/page/i, "▦"],
[/api|develop/i, "{ }"],
];
/** A single-glyph icon for a section, chosen from its label. */
export function sectionIcon(label: string): string {
for (const [re, glyph] of SECTION_ICONS) if (re.test(label)) return glyph;
return "◇";
}
/** "Getting-Started_Guide" → "Getting Started Guide". */
export function humanize(name: string): string {
return name
.replace(/\.mdx?$/i, "")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Lowercase, hyphenated, url-safe id for a path segment. */
export function slugifySegment(name: string): string {
return name
.replace(/\.mdx?$/i, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/** Docs-root-relative posix path → stable doc id, e.g. "configuration/ocr". */
export function docIdForPath(relPath: string): string {
return relPath.split("/").map(slugifySegment).filter(Boolean).join("/");
}
/** Posix dirname ("" for a root-level file). */
export function dirOf(relPath: string): string {
const i = relPath.lastIndexOf("/");
return i === -1 ? "" : relPath.slice(0, i);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Frontmatter */
/* ──────────────────────────────────────────────────────────────────────── */
export interface Frontmatter {
data: Record<string, string | number>;
body: string;
}
/** Split leading `--- ... ---` YAML frontmatter (scalar keys only) from body. */
export function parseFrontmatter(content: string): Frontmatter {
const normalised = content.replace(/\r\n/g, "\n");
if (!normalised.startsWith("---\n")) return { data: {}, body: normalised };
const end = normalised.indexOf("\n---", 4);
if (end === -1) return { data: {}, body: normalised };
const raw = normalised.slice(4, end);
const rest = normalised.slice(end + 4).replace(/^\n/, "");
const data: Record<string, string | number> = {};
for (const line of raw.split("\n")) {
const m = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
if (!m) continue;
let value: string | number = m[2].trim().replace(/^["']|["']$/g, "");
if (/^-?\d+(\.\d+)?$/.test(value)) value = Number(value);
data[m[1]] = value;
}
return { data, body: rest };
}
/** First `# H1` heading text in a body, if any. */
export function firstHeading(body: string): string | undefined {
const m = /^#\s+(.+?)\s*$/m.exec(stripCodeFences(body));
return m ? m[1].trim() : undefined;
}
/** Blank out fenced code blocks so heading/link scans ignore their contents. */
function stripCodeFences(md: string): string {
return md.replace(/^(```|~~~)[\s\S]*?^\1\s*$/gm, "");
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Body normalisation (MDX → plain markdown) */
/* ──────────────────────────────────────────────────────────────────────── */
/** Run `fn` over the non-fenced-code spans of `md`, leaving code blocks intact. */
function mapOutsideCode(md: string, fn: (text: string) => string): string {
const parts = md.split(/(^(?:```|~~~)[\s\S]*?^(?:```|~~~)\s*$)/gm);
return parts.map((part, i) => (i % 2 === 0 ? fn(part) : part)).join("");
}
const ADMONITION_META: Record<string, { icon: string; label: string }> = {
tip: { icon: "💡", label: "Tip" },
note: { icon: "📝", label: "Note" },
info: { icon: "ℹ️", label: "Info" },
warning: { icon: "⚠️", label: "Warning" },
caution: { icon: "⚠️", label: "Caution" },
danger: { icon: "🚫", label: "Danger" },
};
/** `:::tip Title\n…\n:::` → a blockquote with a bold titled first line. */
export function convertAdmonitions(md: string): string {
const re = /^:::(\w+)[ \t]*(.*)\n([\s\S]*?)^:::[ \t]*$/gm;
return md.replace(re, (_all, type: string, title: string, body: string) => {
const meta = ADMONITION_META[type.toLowerCase()] ?? {
icon: "•",
label: humanize(type),
};
const heading = title.trim()
? `${meta.label}: ${title.trim()}`
: meta.label;
const quoted = body
.replace(/\s+$/, "")
.split("\n")
.map((l) => (l ? `> ${l}` : ">"))
.join("\n");
return `> **${meta.icon} ${heading}**\n>\n${quoted}\n`;
});
}
/** Drop MDX `import`/`export` statement lines (outside code). */
export function stripMdxImports(md: string): string {
return md
.split("\n")
.filter((l) => !/^\s*(import|export)\s.+from\s.+;?\s*$/.test(l))
.filter((l) => !/^\s*import\s+['"][^'"]+['"];?\s*$/.test(l))
.join("\n");
}
/** Remove JSX component tags (Capitalised), keeping any inner content. */
export function stripJsxTags(md: string): string {
return md.replace(/<\/?[A-Z][A-Za-z0-9.]*(?:\s[^>]*?)?\/?>/g, "");
}
/** Resolve a relative posix path against a base dir, collapsing `.`/`..`. */
export function resolveRelative(baseDir: string, target: string): string {
const stack = baseDir ? baseDir.split("/") : [];
for (const seg of target.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") stack.pop();
else stack.push(seg);
}
return stack.join("/");
}
interface LinkContext {
dir: string;
pathToId: Map<string, string>;
rawBase: string;
siteBaseUrl: string;
}
/** Split "path#anchor" → [path, "#anchor" | ""]. */
function splitAnchor(target: string): [string, string] {
const i = target.indexOf("#");
return i === -1 ? [target, ""] : [target.slice(0, i), target.slice(i)];
}
/** Percent-decode a link path, tolerating malformed escapes. */
function decodePath(p: string): string {
try {
return decodeURIComponent(p);
} catch {
return p;
}
}
/**
* Look up the doc id for a relative link target. Keys in pathToId are lowercased
* so links work whether they use the filename or a slug, in any case, and with
* percent-encoded spaces (the repo links to "System%20and%20Security").
*/
function resolveDocId(ctx: LinkContext, rawPath: string): string | undefined {
const resolved = resolveRelative(ctx.dir, decodePath(rawPath)).toLowerCase();
const noExt = resolved.replace(/\.mdx?$/i, "");
const candidates = [
resolved,
noExt,
`${noExt.replace(/\/$/, "")}/index`,
noExt.replace(/\/index$/i, ""),
];
for (const c of candidates) {
const id = ctx.pathToId.get(c);
if (id) return id;
}
return undefined;
}
/** Rewrite a single markdown link target to a portal-usable href. */
function rewriteLinkTarget(ctx: LinkContext, target: string): string {
const trimmed = target.trim();
if (/^(https?:|mailto:|tel:|#|doc:)/i.test(trimmed)) return trimmed;
const [path, anchor] = splitAnchor(trimmed);
if (!path) return trimmed;
const id = resolveDocId(ctx, path);
if (id) return `doc:${id}`;
// Unresolved internal link → fall back to the live docs site (encode spaces).
const slug = resolveRelative(ctx.dir, decodePath(path)).replace(
/\.mdx?$/i,
"",
);
const encoded = slug.split("/").map(encodeURIComponent).join("/");
return `${ctx.siteBaseUrl}/${encoded}${anchor}`;
}
/** Rewrite a relative image src to an absolute raw-content URL. */
function rewriteImageSrc(ctx: LinkContext, src: string, root: string): string {
const trimmed = src.trim();
if (/^(https?:|data:)/i.test(trimmed)) return trimmed;
if (trimmed.startsWith("/")) return `${ctx.rawBase}/static${trimmed}`;
const resolved = resolveRelative(`${root}/${ctx.dir}`, trimmed);
return `${ctx.rawBase}/${resolved}`;
}
/** Rewrite markdown links + images (outside code) to portal/absolute targets. */
export function rewriteReferences(
md: string,
ctx: LinkContext,
root: string,
): string {
return mapOutsideCode(md, (text) => {
// Images first so their `!` prefix isn't eaten by the link pattern.
let out = text.replace(
/!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)/g,
(_m, alt: string, src: string, tail: string) =>
`![${alt}](${rewriteImageSrc(ctx, src, root)}${tail})`,
);
out = out.replace(
/(^|[^!])\[([^\]]+)\]\(([^)\s]+)([^)]*)\)/g,
(_m, pre: string, label: string, href: string, tail: string) =>
`${pre}[${label}](${rewriteLinkTarget(ctx, href)}${tail})`,
);
return out;
});
}
/** Drop a leading `# H1` whose text equals the page title (avoids a dup head). */
export function stripRedundantH1(md: string, title: string): string {
const m = /^\s*#\s+(.+?)\s*(\n|$)/.exec(md);
if (m && m[1].trim().toLowerCase() === title.trim().toLowerCase()) {
return md.slice(m[0].length).replace(/^\n+/, "");
}
return md;
}
/** Demote body `# H1` headings to `## H2` so the page title is the sole H1. */
export function demoteHeadings(md: string): string {
return mapOutsideCode(md, (text) => text.replace(/^# (?=\S)/gm, "## "));
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Section ordering */
/* ──────────────────────────────────────────────────────────────────────── */
const ROOT_SECTION_ID = "overview";
/** Composite order key: `_category_.json.position` down the dir path. */
function sectionOrderKey(dir: string, categories: CategoryMap): number[] {
if (dir === "") return [-1];
const key: number[] = [];
const segs = dir.split("/");
for (let i = 0; i < segs.length; i++) {
const sub = segs.slice(0, i + 1).join("/");
key.push(categories[sub]?.position ?? 999);
}
return key;
}
function compareKeys(a: number[], b: number[]): number {
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i++) {
const d = (a[i] ?? 0) - (b[i] ?? 0);
if (d !== 0) return d;
}
return 0;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Build */
/* ──────────────────────────────────────────────────────────────────────── */
interface ShapedDoc extends DocEntry {
navLabel: string;
sidebarPosition: number;
orderKey: number[];
sectionLabel: string;
}
/** Turn raw docs + category metadata into the full portal docs manifest. */
export function buildManifest(
rawDocs: RawDoc[],
categories: CategoryMap,
opts: BuildOptions,
): DocsManifest {
const rawBase = `https://raw.githubusercontent.com/${opts.repo}/${opts.ref}`;
const editBase = `https://github.com/${opts.repo}/blob/${opts.ref}`;
// First pass: assign stable ids so links between docs can resolve. Keys are
// lowercased (case-insensitive link matching); both with and without ext.
const pathToId = new Map<string, string>();
for (const doc of rawDocs) {
const id = docIdForPath(doc.relPath);
const lower = doc.relPath.toLowerCase();
pathToId.set(lower, id);
pathToId.set(lower.replace(/\.mdx?$/i, ""), id);
}
const shaped: ShapedDoc[] = rawDocs.map((doc) => {
const dir = dirOf(doc.relPath);
const { data, body } = parseFrontmatter(doc.content);
const title =
(typeof data.title === "string" && data.title) ||
firstHeading(body) ||
humanize(doc.relPath.split("/").pop() ?? doc.relPath);
const navLabel =
(typeof data.sidebar_label === "string" && data.sidebar_label) || title;
const description =
typeof data.description === "string" ? data.description : undefined;
const ctx: LinkContext = {
dir,
pathToId,
rawBase,
siteBaseUrl: opts.siteBaseUrl.replace(/\/$/, ""),
};
let markdown = body;
markdown = stripMdxImports(markdown);
markdown = convertAdmonitions(markdown);
markdown = stripJsxTags(markdown);
markdown = rewriteReferences(markdown, ctx, opts.root);
markdown = stripRedundantH1(markdown, title);
markdown = demoteHeadings(markdown).trim();
const sectionId = dir === "" ? ROOT_SECTION_ID : docIdForPath(dir);
const sectionLabel =
dir === ""
? "Overview"
: (categories[dir]?.label ?? humanize(dir.split("/").pop() ?? dir));
return {
id: docIdForPath(doc.relPath),
title,
navLabel,
description,
section: sectionId,
markdown,
sourcePath: `${opts.root}/${doc.relPath}`,
editUrl: `${editBase}/${opts.root}/${doc.relPath}`,
sidebarPosition:
typeof data.sidebar_position === "number" ? data.sidebar_position : 999,
orderKey: sectionOrderKey(dir, categories),
sectionLabel,
};
});
// Group into sections and order everything deterministically.
const bySection = new Map<string, ShapedDoc[]>();
for (const doc of shaped) {
const list = bySection.get(doc.section) ?? [];
list.push(doc);
bySection.set(doc.section, list);
}
const nav: DocsNavSection[] = [...bySection.entries()]
.map(([id, docs]) => {
const first = docs[0];
const items = [...docs]
.sort(
(a, b) =>
a.sidebarPosition - b.sidebarPosition ||
a.navLabel.localeCompare(b.navLabel),
)
.map<DocsNavItem>((d) => ({ id: d.id, label: d.navLabel }));
return {
id,
label: first.sectionLabel,
icon: sectionIcon(first.sectionLabel),
items,
_key: first.orderKey,
};
})
.sort(
(a, b) => compareKeys(a._key, b._key) || a.label.localeCompare(b.label),
)
.map(({ _key, ...section }) => section);
const docs: Record<string, DocEntry> = {};
for (const d of shaped) {
docs[d.id] = {
id: d.id,
title: d.title,
description: d.description,
section: d.section,
markdown: d.markdown,
sourcePath: d.sourcePath,
editUrl: d.editUrl,
};
}
return {
source: { repo: opts.repo, ref: opts.ref, root: opts.root },
nav,
docs,
};
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import {
buildSnippet,
highlight,
searchDocs,
toPlainText,
type SearchDoc,
} from "@portal/docs/search";
const DOCS: SearchDoc[] = [
{
id: "ocr",
title: "OCR Guide",
sectionLabel: "Configuration",
text: "Stirling PDF uses Tesseract for its text recognition and language packs.",
},
{
id: "docker",
title: "Docker Install",
sectionLabel: "Installation",
text: "Run Stirling with docker compose up to start the container.",
},
{
id: "ranky",
title: "Something else",
sectionLabel: "Misc",
text: "docker docker docker appears many times in the body here.",
},
];
describe("toPlainText", () => {
it("strips headings, links, inline code, and code fences", () => {
const md =
"# Title\n\nSee [the guide](doc:x) and run `npm i`.\n\n```bash\nnpm run build\n```";
const out = toPlainText(md);
expect(out).toContain("Title");
expect(out).toContain("the guide");
expect(out).toContain("npm i");
expect(out).toContain("npm run build"); // code text kept, fences dropped
expect(out).not.toContain("#");
expect(out).not.toContain("```");
expect(out).not.toContain("](");
});
});
describe("highlight", () => {
it("splits text into hit/non-hit segments", () => {
expect(highlight("Hello world", ["world"])).toEqual([
{ text: "Hello ", hit: false },
{ text: "world", hit: true },
]);
});
it("is safe against regex metacharacters in terms", () => {
expect(() => highlight("a (b) c", ["("])).not.toThrow();
});
});
describe("buildSnippet", () => {
it("windows around the first match and marks it", () => {
const text =
"lorem ipsum ".repeat(20) + "the TARGET keyword " + "dolor ".repeat(20);
const segs = buildSnippet(text, ["target"]);
expect(segs.some((s) => s.hit && /target/i.test(s.text))).toBe(true);
// Windowed, so it should be far shorter than the full text.
expect(segs.map((s) => s.text).join("").length).toBeLessThan(text.length);
});
});
describe("searchDocs", () => {
it("returns nothing for an empty query", () => {
expect(searchDocs(DOCS, " ")).toEqual([]);
});
it("matches body content, not just titles (with a snippet)", () => {
const res = searchDocs(DOCS, "tesseract");
expect(res.map((r) => r.id)).toEqual(["ocr"]);
expect(res[0].snippet.some((s) => s.hit && /tesseract/i.test(s.text))).toBe(
true,
);
});
it("ranks title matches above body matches", () => {
const res = searchDocs(DOCS, "docker");
// "Docker Install" (title hit) outranks "Something else" (body-only hits).
expect(res[0].id).toBe("docker");
expect(res.map((r) => r.id)).toContain("ranky");
});
it("requires every term to match (AND)", () => {
expect(searchDocs(DOCS, "docker compose").map((r) => r.id)).toEqual([
"docker",
]);
expect(searchDocs(DOCS, "docker tesseract")).toEqual([]);
});
it("does not throw on regex-special-character queries", () => {
expect(() => searchDocs(DOCS, "a(b")).not.toThrow();
});
});
+153
View File
@@ -0,0 +1,153 @@
/**
* Full-text search over the docs manifest. Pure + dependency-free so it's unit
* testable. Indexes each doc's title + plaintext body; ranks title matches above
* body matches; returns highlighted title + a content snippet per hit.
*/
export interface SearchDoc {
id: string;
title: string;
sectionLabel: string;
/** Plaintext body (markdown stripped), original case. */
text: string;
}
/** A run of result text, flagged when it matches a query term (for <mark>). */
export interface Segment {
text: string;
hit: boolean;
}
export interface SearchResult {
id: string;
title: string;
sectionLabel: string;
titleSegments: Segment[];
snippet: Segment[];
score: number;
}
/** Strip markdown/MDX down to readable plaintext for indexing + snippets. */
export function toPlainText(md: string): string {
return md
.replace(/^(```|~~~).*$/gm, " ") // fence delimiters (keep the code text)
.replace(/`([^`]+)`/g, "$1") // inline code
.replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // links → their text
.replace(/^\s{0,3}>\s?/gm, "") // blockquotes
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // headings
.replace(/^\s*[-*+]\s+/gm, "") // list bullets
.replace(/[*_~]/g, "") // emphasis marks
.replace(/\|/g, " ") // table pipes
.replace(/\s+/g, " ") // collapse whitespace
.trim();
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function countOccurrences(haystack: string, needle: string): number {
let count = 0;
let i = haystack.indexOf(needle);
while (i !== -1) {
count++;
i = haystack.indexOf(needle, i + needle.length);
}
return count;
}
/** Split `text` into segments, flagging any run that matches a query term. */
export function highlight(text: string, terms: string[]): Segment[] {
const cleaned = terms.map(escapeRegExp).filter(Boolean);
if (!cleaned.length) return [{ text, hit: false }];
const re = new RegExp(`(${cleaned.join("|")})`, "gi");
const segments: Segment[] = [];
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
if (m.index > last) {
segments.push({ text: text.slice(last, m.index), hit: false });
}
segments.push({ text: m[0], hit: true });
last = m.index + m[0].length;
if (m.index === re.lastIndex) re.lastIndex++; // guard against zero-width
}
if (last < text.length) segments.push({ text: text.slice(last), hit: false });
return segments.length ? segments : [{ text, hit: false }];
}
/** Build a ~context-window snippet around the earliest term match. */
export function buildSnippet(
text: string,
terms: string[],
radius = 90,
): Segment[] {
const lower = text.toLowerCase();
let pos = -1;
for (const term of terms) {
const i = lower.indexOf(term);
if (i !== -1 && (pos === -1 || i < pos)) pos = i;
}
if (pos === -1) {
const head = text.slice(0, radius * 2);
return highlight(head + (text.length > head.length ? "…" : ""), terms);
}
let start = Math.max(0, pos - radius);
let end = Math.min(text.length, pos + radius);
// Snap to word boundaries so we don't slice mid-word.
if (start > 0) {
const space = text.indexOf(" ", start);
if (space !== -1 && space < pos) start = space + 1;
}
if (end < text.length) {
const space = text.lastIndexOf(" ", end);
if (space > pos) end = space;
}
let snippet = text.slice(start, end).trim();
if (start > 0) snippet = "…" + snippet;
if (end < text.length) snippet = snippet + "…";
return highlight(snippet, terms);
}
/**
* Rank docs against a query. A doc matches when every term appears in its title
* or body; title hits score highest.
*/
export function searchDocs(
docs: SearchDoc[],
query: string,
limit = 40,
): SearchResult[] {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!terms.length) return [];
const results: SearchResult[] = [];
for (const doc of docs) {
const titleLower = doc.title.toLowerCase();
const textLower = doc.text.toLowerCase();
const matchesAll = terms.every(
(term) => titleLower.includes(term) || textLower.includes(term),
);
if (!matchesAll) continue;
let score = 0;
for (const term of terms) {
if (titleLower.includes(term)) score += 10;
if (titleLower.startsWith(term)) score += 5;
score += Math.min(countOccurrences(textLower, term), 5);
}
results.push({
id: doc.id,
title: doc.title,
sectionLabel: doc.sectionLabel,
titleSegments: highlight(doc.title, terms),
snippet: buildSnippet(doc.text, terms),
score,
});
}
results.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
return results.slice(0, limit);
}
File diff suppressed because one or more lines are too long
@@ -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",
@@ -1,73 +1,254 @@
/* Full-height docs surface: the sidebar and reading pane each scroll on their
own inside the portal shell's scroll container, so a 70+ item nav no longer
drags the whole page around. */
.portal-docs {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
gap: 2rem;
padding: 1.5rem;
max-width: 84rem;
margin: 0 auto;
align-items: start;
grid-template-columns: 16rem minmax(0, 1fr);
height: 100%;
min-height: 0;
overflow: hidden;
}
@media (max-width: 60rem) {
.portal-docs {
grid-template-columns: 1fr;
gap: 1.25rem;
/* With an "On this page" TOC, add a right-hand column. */
.portal-docs--with-toc {
grid-template-columns: 16rem minmax(0, 1fr) 15rem;
}
/* Not enough room for three columns → drop the TOC. */
@media (max-width: 72rem) {
.portal-docs--with-toc {
grid-template-columns: 16rem minmax(0, 1fr);
}
.portal-docs__toc-col {
display: none;
}
}
/* ── Left nav ──────────────────────────────────────────────────────────── */
.portal-docs--empty {
display: block;
padding: 3rem 1.5rem;
}
/* ── Left nav (own scroll column) ──────────────────────────────────────── */
.portal-docs__sidebar {
position: sticky;
top: 1.5rem;
align-self: start;
}
@media (max-width: 60rem) {
.portal-docs__sidebar {
position: static;
}
min-height: 0;
height: 100%;
overflow-y: auto;
border-right: 1px solid var(--color-border-light);
padding: 1.25rem 0;
}
.portal-docs__nav {
display: flex;
flex-direction: column;
gap: 1.25rem;
gap: 0.25rem;
}
/* Search */
.portal-docs__search {
margin-bottom: 0.75rem;
padding: 0 0.75rem;
}
.portal-docs__search-box {
position: relative;
}
.portal-docs__search-icon {
position: absolute;
left: 0.625rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.9375rem;
color: var(--color-text-4);
pointer-events: none;
}
.portal-docs__search-input {
width: 100%;
padding: 0.4rem 0.6rem 0.4rem 1.9rem;
font-size: 0.8125rem;
color: var(--color-text-1);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
outline: none;
}
.portal-docs__search-input:focus {
border-color: var(--color-blue);
}
.portal-docs__nav-empty {
font-size: 0.8125rem;
color: var(--color-text-4);
padding: 0.5rem 0.75rem;
margin: 0;
}
/* ── Search results ────────────────────────────────────────────────────── */
.portal-docs__results {
margin-top: 0.5rem;
}
.portal-docs__results-count {
font-size: 0.6875rem;
font-weight: 500;
color: var(--color-text-4);
padding: 0 0.75rem 0.5rem;
}
.portal-docs__results-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
/* Hairline divider between results for clear, calm separation. */
.portal-docs__results-list li + li {
border-top: 1px solid var(--color-border-light);
}
.portal-docs__result {
height: auto;
padding: 0.5rem 0.75rem;
border-radius: 0;
}
.portal-docs__result:hover,
.portal-docs__result.is-active {
background: var(--color-bg-hover);
}
.portal-docs__result-body {
display: flex;
flex-direction: column;
gap: 0.125rem;
width: 100%;
min-width: 0;
text-align: left;
white-space: normal;
}
/* Title + section share one line; the title truncates before the section. */
.portal-docs__result-head {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
}
.portal-docs__result-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.3;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.portal-docs__result-section {
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--color-text-4);
}
.portal-docs__result-snippet {
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-text-4);
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Subtle match emphasis — coloured text, not a filled block. */
.portal-docs__hl {
color: var(--color-blue);
font-weight: 600;
background: none;
}
.portal-docs__nav-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: 0.0625rem;
}
/* Section label: shared typography for the static Overview label and the
collapsible accordion headers below. */
.portal-docs__nav-head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem 0.2rem;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-4);
padding: 0 0.5rem;
}
.portal-docs__nav-icon {
display: inline-flex;
/* Collapsible section header (Overview stays a static label). */
.portal-docs__nav-head--button:hover {
background: var(--color-bg-hover);
color: var(--color-text-2);
}
.portal-docs__nav-head-main {
display: flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
font-size: 0.6875rem;
border-radius: var(--radius-sm);
gap: 0.4rem;
min-width: 0;
}
.portal-docs__nav-headlabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-docs__nav-chevron {
font-size: 0.625rem;
color: var(--color-text-4);
transition: transform var(--motion-fast);
}
.portal-docs__nav-chevron.is-open {
transform: rotate(90deg);
}
.portal-docs__nav-count {
font-size: 0.625rem;
font-weight: 600;
color: var(--color-text-4);
background: var(--color-bg-subtle);
color: var(--color-text-3);
border-radius: var(--radius-pill);
padding: 0.05rem 0.4rem;
}
/* Space consecutive sections apart (not before the first). */
.portal-docs__nav-group + .portal-docs__nav-group {
margin-top: 0.4rem;
}
/* Nested sub-sections: indented under their parent with a guide line. */
.portal-docs__nav-children {
margin: 0.125rem 0 0.25rem 0.85rem;
padding-left: 0.4rem;
border-left: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-docs__nav-list {
list-style: none;
margin: 0;
margin: 0 0 0.375rem;
padding: 0;
display: flex;
flex-direction: column;
@@ -80,10 +261,10 @@
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.5rem;
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
border-radius: var(--radius-md);
border-radius: 0;
font-size: 0.8125rem;
color: var(--color-text-3);
cursor: pointer;
@@ -110,10 +291,129 @@
white-space: nowrap;
}
/* ── Content shell ─────────────────────────────────────────────────────── */
/* Mobile-only nav toggle (a drawer button); hidden on desktop. */
.portal-docs__nav-toggle {
display: none;
}
/* ── Reading pane (own scroll column) ──────────────────────────────────── */
.portal-docs__content {
min-width: 0;
min-height: 0;
height: 100%;
overflow-y: auto;
padding: 1.5rem 2rem 4rem;
}
.portal-docs__content-inner {
max-width: 46rem;
margin: 0 auto;
}
/* ── On this page (right rail) ─────────────────────────────────────────── */
.portal-docs__toc-col {
min-height: 0;
height: 100%;
overflow-y: auto;
border-left: 1px solid var(--color-border-light);
padding: 1.5rem 0.75rem;
}
.portal-docs__toc {
position: sticky;
top: 0;
}
.portal-docs__toc-title {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-4);
padding: 0 0.5rem 0.5rem;
}
.portal-docs__toc-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.portal-docs__toc-link {
display: block;
padding: 0.25rem 0.5rem;
border-left: 2px solid transparent;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--color-text-3);
text-decoration: none;
transition: color var(--motion-fast);
}
.portal-docs__toc-link.is-sub {
padding-left: 1.25rem;
font-size: 0.75rem;
}
.portal-docs__toc-link:hover {
color: var(--color-text-1);
}
.portal-docs__toc-link.is-active {
color: var(--color-blue);
border-left-color: var(--color-blue);
font-weight: 600;
}
/* Scroll-to-heading lands just below the pane top, not flush against it. */
.portal-docs__md h2,
.portal-docs__md h3 {
scroll-margin-top: 1rem;
}
/* ── Small screens: nav collapses into a drawer above the content ──────── */
@media (max-width: 60rem) {
.portal-docs {
grid-template-columns: 1fr;
grid-template-rows: auto auto minmax(0, 1fr);
}
.portal-docs__nav-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 1rem 0;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
cursor: pointer;
}
.portal-docs__sidebar {
display: none;
height: auto;
max-height: 60vh;
border-right: none;
border-bottom: 1px solid var(--color-border-light);
}
.portal-docs__sidebar.is-open {
display: block;
}
.portal-docs__content {
padding: 1.25rem 1.25rem 3rem;
}
}
.portal-docs__section {
@@ -523,3 +823,160 @@
font-size: 0.75rem;
color: var(--color-text-4);
}
/* ── Markdown content ──────────────────────────────────────────────────── */
.portal-docs__md {
font-size: 0.9375rem;
line-height: 1.65;
color: var(--color-text-2);
}
.portal-docs__md h1,
.portal-docs__md h2,
.portal-docs__md h3,
.portal-docs__md h4 {
color: var(--color-text-1);
font-weight: 650;
line-height: 1.25;
margin: 1.75rem 0 0.75rem;
}
.portal-docs__md h2 {
font-size: 1.3125rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--color-border-light);
}
.portal-docs__md h3 {
font-size: 1.0625rem;
}
.portal-docs__md h4 {
font-size: 0.9375rem;
}
.portal-docs__md > :first-child {
margin-top: 0;
}
.portal-docs__md p,
.portal-docs__md ul,
.portal-docs__md ol {
margin: 0 0 0.875rem;
}
.portal-docs__md li {
margin: 0.25rem 0;
}
.portal-docs__md a {
color: var(--color-blue);
text-decoration: none;
}
.portal-docs__md a:hover {
text-decoration: underline;
}
.portal-docs__md code {
font-family: var(--font-mono, monospace);
font-size: 0.85em;
background: var(--color-bg-subtle);
padding: 0.1em 0.35em;
border-radius: var(--radius-sm);
}
.portal-docs__md blockquote {
margin: 0 0 0.875rem;
padding: 0.125rem 0.875rem;
border-left: 3px solid var(--color-blue);
background: var(--color-bg-subtle);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
color: var(--color-text-3);
}
.portal-docs__md blockquote p {
margin: 0.5rem 0;
}
.portal-docs__md-img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
}
.portal-docs__md hr {
border: none;
border-top: 1px solid var(--color-border-light);
margin: 1.5rem 0;
}
.portal-docs__md-pre {
position: relative;
margin: 0 0 0.875rem;
}
.portal-docs__md-pre pre {
margin: 0;
padding: 0.875rem 4rem 0.875rem 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: 0.8125rem;
line-height: 1.5;
}
.portal-docs__md-pre pre code {
background: none;
padding: 0;
font-size: inherit;
}
.portal-docs__md-copy {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
.portal-docs__md-tablewrap {
overflow-x: auto;
margin: 0 0 0.875rem;
}
.portal-docs__md-tablewrap table {
border-collapse: collapse;
width: 100%;
font-size: 0.8125rem;
}
.portal-docs__md-tablewrap th,
.portal-docs__md-tablewrap td {
border: 1px solid var(--color-border-light);
padding: 0.4rem 0.625rem;
text-align: left;
}
.portal-docs__md-tablewrap th {
background: var(--color-bg-subtle);
font-weight: 600;
}
.portal-docs__source {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-light);
font-size: 0.8125rem;
}
.portal-docs__source a {
color: var(--color-text-3);
text-decoration: none;
}
.portal-docs__source a:hover {
color: var(--color-blue);
text-decoration: underline;
}
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
// Renders the real docs manifest (generated by scripts/sync-portal-docs.mts), so
// this story is a live view of the auto-built Documentation browser. The view
// fills its container's height (independent sidebar/content scroll), so the
// decorator gives it a viewport-height frame.
const meta: Meta<typeof DeveloperDocs> = {
title: "Portal/DeveloperDocs/View",
component: DeveloperDocs,
parameters: { layout: "fullscreen" },
decorators: [
(Story) => (
<div style={{ height: "100vh" }}>
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof DeveloperDocs>;
export const Default: Story = {};
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
// Deterministic labels; the view + DocsNav + MarkdownDoc all use useTranslation.
vi.mock("react-i18next", () => ({
useTranslation: () => ({
// 2nd arg may be a default string or i18next interpolation options ({count}).
t: (key: string, opts?: unknown) => (typeof opts === "string" ? opts : key),
i18n: { changeLanguage: vi.fn() },
}),
}));
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
const renderDocs = (ui: ReactElement) =>
render(
<MemoryRouter initialEntries={["/processor/docs"]}>
<MantineProvider>{ui}</MantineProvider>
</MemoryRouter>,
);
describe("DeveloperDocs — markdown browser over the generated manifest", () => {
it("keeps Overview static (open, no toggle) and other sections collapsed", () => {
renderDocs(<DeveloperDocs />);
expect(screen.getByRole("searchbox")).toBeInTheDocument();
// Overview is static: its items show, and it has no toggle button.
expect(
screen.getByRole("button", { name: "Production Deployment Guide" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /^Overview/i }),
).not.toBeInTheDocument();
// Other sections collapse, so their items are hidden until expanded.
expect(
screen.queryByRole("button", { name: "Kubernetes Guide" }),
).not.toBeInTheDocument();
expect(
screen.getByText(/locally hosted web application/i),
).toBeInTheDocument();
});
it("expands a collapsible section when its header is clicked", () => {
renderDocs(<DeveloperDocs />);
fireEvent.click(screen.getByRole("button", { name: /Installation/i }));
expect(
screen.getByRole("button", { name: "Kubernetes Guide" }),
).toBeInTheDocument();
});
it("searches doc content (not just titles), shows a snippet, and navigates", async () => {
renderDocs(<DeveloperDocs />);
// "Tesseract" appears in the OCR doc body but in no doc title — a result
// whose snippet contains it proves full-text (content) search.
fireEvent.change(screen.getByRole("searchbox"), {
target: { value: "Tesseract" },
});
const hits = await screen.findAllByRole("button", { name: /Tesseract/i });
expect(hits.length).toBeGreaterThan(0);
fireEvent.click(hits[0]);
await waitFor(() =>
expect(
screen.queryByText(/locally hosted web application/i),
).not.toBeInTheDocument(),
);
});
it("follows an internal doc: link inside the rendered markdown", async () => {
renderDocs(<DeveloperDocs />);
// The Getting Started body links to the Migration guide via the doc: scheme.
fireEvent.click(screen.getByRole("link", { name: /Migration Guide/i }));
await waitFor(() =>
expect(
screen.queryByText(/locally hosted web application/i),
).not.toBeInTheDocument(),
);
});
});
@@ -1,97 +1,140 @@
import { useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EmptyState } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { useLocation, useNavigate } from "react-router-dom";
import { Button, EmptyState } from "@app/ui";
import { DocsNav } from "@portal/components/docs/DocsNav";
import { DocsSearch } from "@portal/components/docs/DocsSearch";
import { DocsSection } from "@portal/components/docs/DocsSection";
import { DocsToc } from "@portal/components/docs/DocsToc";
import { MarkdownDoc } from "@portal/components/docs/MarkdownDoc";
import { extractHeadings } from "@portal/docs/headings";
import {
fetchDocsContent,
fetchDocsNav,
type DocsContent,
type DocsNavSection,
} from "@portal/api/docs";
import { DocsNav, DocsNavSkeleton } from "@portal/components/docs/DocsNav";
import { GettingStartedSection } from "@portal/components/docs/GettingStartedSection";
import { AuthenticationSection } from "@portal/components/docs/AuthenticationSection";
import { RateLimitsSection } from "@portal/components/docs/RateLimitsSection";
import { EndpointReferenceSection } from "@portal/components/docs/EndpointReferenceSection";
import { ErrorsSection } from "@portal/components/docs/ErrorsSection";
import { WebhooksSection } from "@portal/components/docs/WebhooksSection";
import { SdksSection } from "@portal/components/docs/SdksSection";
import { ComponentsSection } from "@portal/components/docs/ComponentsSection";
import { PlaybooksSection } from "@portal/components/docs/PlaybooksSection";
import { SkillsSection } from "@portal/components/docs/SkillsSection";
allDocs,
firstDocId,
loadDoc,
loadDocsNav,
} from "@portal/docs/manifest/registry";
import { searchDocs, toPlainText, type SearchDoc } from "@portal/docs/search";
import "@portal/views/DeveloperDocs.css";
/** Renders the content pane for the active nav leaf against fetched content. */
function DocsContentPane({
active,
content,
}: {
active: string;
content: DocsContent;
}) {
switch (active) {
case "authentication":
return <AuthenticationSection />;
case "rate-limits":
return <RateLimitsSection rateLimit={content.rateLimit} />;
case "endpoints":
return <EndpointReferenceSection />;
case "errors":
return <ErrorsSection errors={content.errors} />;
case "webhooks":
return <WebhooksSection />;
case "sdk-overview":
return <SdksSection sdks={content.sdks} />;
case "component-library":
return <ComponentsSection components={content.components} />;
case "recipes":
return <PlaybooksSection playbooks={content.playbooks} />;
case "skill-catalog":
return <SkillsSection skills={content.skills} />;
default:
return (
<GettingStartedSection
samples={content.quickstartSamples}
response={content.quickstartResponse}
/>
);
}
}
/**
* Developer Docs — a markdown browser over the docs manifest generated from the
* Stirling docs repo (see scripts/sync-portal-docs.mts). The nav is auto-sorted
* from the repo's folders + frontmatter; content is the repo markdown, and the
* search box does full-text search across every doc.
*/
export function DeveloperDocs() {
const { t } = useTranslation();
const { tier } = useTier();
const [active, setActive] = useState("quickstart");
const { hash } = useLocation();
const navigate = useNavigate();
const contentRef = useRef<HTMLElement>(null);
const [navOpen, setNavOpen] = useState(false);
const [query, setQuery] = useState("");
const navState = useAsync<DocsNavSection[]>(() => fetchDocsNav(), []);
const { data: nav } = navState;
const { isLoading, isEmpty } = useSectionFlags(navState);
const nav = useMemo(() => loadDocsNav(), []);
const fallback = useMemo(() => firstDocId(), []);
const { data: content } = useAsync<DocsContent>(
() => fetchDocsContent(tier),
[tier],
// Full-text index over every doc's plaintext body (built once).
const index = useMemo<SearchDoc[]>(() => {
const labels = new Map(nav.map((s) => [s.id, s.label]));
return allDocs().map((d) => ({
id: d.id,
title: d.title,
sectionLabel: labels.get(d.section) ?? "",
text: toPlainText(d.markdown),
}));
}, [nav]);
const results = useMemo(() => searchDocs(index, query), [index, query]);
const searching = query.trim().length > 0;
// Deep-link support: the active doc id lives in the URL hash.
const hashId = decodeURIComponent(hash.replace(/^#/, ""));
const activeId = hashId && loadDoc(hashId) ? hashId : fallback;
const doc = activeId ? loadDoc(activeId) : undefined;
const section = useMemo(
() => nav.find((s) => s.items.some((i) => i.id === activeId)),
[nav, activeId],
);
// "On this page" headings for the current doc.
const headings = useMemo(
() => (doc ? extractHeadings(doc.markdown) : []),
[doc],
);
// Navigating closes the mobile drawer, clears the search, and resets the pane.
const onSelect = useCallback(
(id: string) => {
navigate({ hash: id });
setNavOpen(false);
setQuery("");
},
[navigate],
);
useEffect(() => {
contentRef.current?.scrollTo?.({ top: 0 });
}, [activeId]);
if (nav.length === 0 || !doc) {
return (
<div className="portal-docs portal-docs--empty">
<EmptyState
title={t("portal.docs.nav.empty.title")}
description={t("portal.docs.nav.empty.description")}
/>
</div>
);
}
const hasToc = headings.length > 0;
return (
<div className="portal-docs">
<aside className="portal-docs__sidebar">
{isLoading && <DocsNavSkeleton />}
{isEmpty && (
<EmptyState
size="compact"
title={t("portal.docs.nav.empty.title")}
description={t("portal.docs.nav.empty.description")}
/>
)}
{nav && nav.length > 0 && (
<DocsNav sections={nav} active={active} onSelect={setActive} />
<div className={"portal-docs" + (hasToc ? " portal-docs--with-toc" : "")}>
<Button
variant="tertiary"
className="portal-docs__nav-toggle"
aria-expanded={navOpen}
onClick={() => setNavOpen((open) => !open)}
leftSection={<span aria-hidden>☰</span>}
>
{t("portal.docs.browse")}
</Button>
<aside className={"portal-docs__sidebar" + (navOpen ? " is-open" : "")}>
<DocsSearch
query={query}
onQueryChange={setQuery}
results={results}
onSelect={onSelect}
/>
{!searching && (
<DocsNav sections={nav} active={activeId ?? ""} onSelect={onSelect} />
)}
</aside>
<main className="portal-docs__content">
{content && <DocsContentPane active={active} content={content} />}
<main className="portal-docs__content" ref={contentRef}>
<div className="portal-docs__content-inner">
<DocsSection
id={doc.id}
eyebrow={section?.label ?? ""}
title={doc.title}
lead={doc.description}
>
<MarkdownDoc markdown={doc.markdown} onNavigate={onSelect} />
<div className="portal-docs__source">
<a href={doc.editUrl} target="_blank" rel="noopener noreferrer">
{t("portal.docs.viewSource")}
</a>
</div>
</DocsSection>
</div>
</main>
{hasToc && (
<aside className="portal-docs__toc-col">
<DocsToc headings={headings} scrollRef={contentRef} />
</aside>
)}
</div>
);
}
+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",
},
},
}),

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