Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33de5cac55 | ||
|
|
a54dae9536 | ||
|
|
65609665a2 | ||
|
|
a51d3781a9 |
@@ -117,3 +117,21 @@ See:
|
||||
- [Governed connector configuration](docs/GOVERNED_CONNECTOR_CONFIGURATION.md)
|
||||
- [MediaWiki and BlueSpice connector](docs/MEDIAWIKI_BLUESPICE_CONNECTOR.md)
|
||||
- [Znuny and OTRS-compatible service-desk connector](docs/ZNUNY_OTRS_CONNECTOR.md)
|
||||
|
||||
## Git-source WebUI package
|
||||
|
||||
The repository root exposes `@govoplan/connectors-webui` for Git-tagged release
|
||||
dependencies. It mirrors the owning `webui/package.json` version, public
|
||||
TypeScript/CSS exports and peer requirements, with entry paths under
|
||||
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
|
||||
development or install scripts. The source archive contains `webui/src`, this
|
||||
README and any repository license file. Run module development checks from `webui/`; Python
|
||||
installation remains governed by `pyproject.toml`.
|
||||
|
||||
Das Repository stellt `@govoplan/connectors-webui` am Wurzelpfad für versionierte
|
||||
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
|
||||
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
|
||||
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
|
||||
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
|
||||
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
|
||||
`pyproject.toml` definiert.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@govoplan/connectors-webui",
|
||||
"version": "0.1.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "webui/src/index.ts",
|
||||
"module": "webui/src/index.ts",
|
||||
"types": "webui/src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./webui/src/index.ts",
|
||||
"import": "./webui/src/index.ts"
|
||||
},
|
||||
"./styles/connectors.css": "./webui/src/styles/connectors.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"webui/src",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-connectors"
|
||||
version = "0.1.23"
|
||||
version = "0.1.26"
|
||||
description = "Governed connector catalogue and tabular source capabilities for GovOPlaN."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -39,7 +39,9 @@ _TRANSLATIONS = {
|
||||
"title": "Gesteuerte tabellarische Quellen",
|
||||
"summary": "Anbieterneutrale Quellenerkennung und begrenzte Lesezugriffe für Dataflow bereitstellen.",
|
||||
"body": (
|
||||
"Connectors verantwortet Quellkonfiguration, Zugriffsprüfung, Schemaerkennung, Fingerabdrücke und begrenzte Lesevorgänge. Dataflow speichert nur undurchsichtige Quellreferenzen und erwartete Fingerabdrücke. Jede Quelle weist ihren Live-, Cache-, Datei- oder statischen Modus, einen strukturierten Zustand und unterstützte Projektion, Filterung, Aggregation, Sortierung und Seitennavigation aus. Unveränderliche JSON- und CSV-Snapshots bleiben verfügbar. Verwaltete CSV- und XLSX-Quellen nutzen optional Files, binden eine exakt autorisierte Version, erzwingen Archiv- und Entpackgrenzen und übernehmen neuere Versionen erst nach ausdrücklicher Aktualisierung. Der PostgreSQL-Adapter nutzt eine aktive gesteuerte Konfiguration und eine eingegrenzte Core-Zugangsdatenhülle, liest nur einfache Schema- und Tabellenkennungen und blockiert bei Konfigurations-, Zugangsdaten- oder Schemadrift bis zur geprüften Aktualisierung. Zugangsdaten, Endpunkte, Speicherschlüssel und interne Dateiinhalte werden nie über die Quelle offengelegt."
|
||||
"Connectors verantwortet Quellkonfiguration, Zugriffsprüfung, Schemaerkennung, Fingerabdrücke und begrenzte Lesevorgänge. Dataflow speichert nur undurchsichtige Quellreferenzen und erwartete Fingerabdrücke. Jede Quelle weist ihren Live-, Cache-, Datei- oder statischen Modus, einen strukturierten Zustand und unterstützte Projektion, Filterung, Aggregation, Sortierung und Seitennavigation aus. Unveränderliche JSON- und CSV-Snapshots bleiben verfügbar. Verwaltete CSV- und XLSX-Quellen nutzen optional Files, binden eine exakt autorisierte Version, erzwingen Archiv- und Entpackgrenzen und übernehmen neuere Versionen erst nach ausdrücklicher Aktualisierung. "
|
||||
"XLSX-Lesevorgänge prüfen die tatsächlichen Koordinaten des ausgewählten Arbeitsblatts vor dem Aufbau des Zellrasters: höchstens 500 Spalten und 10.000 Zeilenpositionen nach der Kopfzeile einschließlich leerer Zwischenräume. Unzuverlässige Dimensionsangaben vergrößern weder das Raster noch verbergen sie Zellen; übergroße oder widersprüchliche Koordinaten führen zu einem Validierungsfehler statt zu still abgeschnittenen Daten. "
|
||||
"Der PostgreSQL-Adapter nutzt eine aktive gesteuerte Konfiguration und eine eingegrenzte Core-Zugangsdatenhülle, liest nur einfache Schema- und Tabellenkennungen und blockiert bei Konfigurations-, Zugangsdaten- oder Schemadrift bis zur geprüften Aktualisierung. Zugangsdaten, Endpunkte, Speicherschlüssel und interne Dateiinhalte werden nie über die Quelle offengelegt."
|
||||
),
|
||||
},
|
||||
"connectors.sanctions-snapshots": {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""German translations for public structured documentation metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'connectors.data-subject-requests': {'consequence_classes': {'exclude_connector_secrets': 'Gibt '
|
||||
'niemals '
|
||||
'Anmeldeinformationen, '
|
||||
'Endpunkte, '
|
||||
'externe '
|
||||
'Zeilen '
|
||||
'oder '
|
||||
'Beweisnutzlasten '
|
||||
'zurück.',
|
||||
'export_operator_attribution': 'Retouren '
|
||||
'minimierte '
|
||||
'Connector-Aktivität '
|
||||
'für '
|
||||
'das '
|
||||
'genaue '
|
||||
'Konto.',
|
||||
'retain_connector_evidence': 'Bewahrt '
|
||||
'die '
|
||||
'Konfiguration '
|
||||
'und '
|
||||
'die '
|
||||
'Rechenschaftspflicht '
|
||||
'für '
|
||||
'externe '
|
||||
'Operationen '
|
||||
'bei.'}},
|
||||
'connectors.governed-configuration': {'outcome': 'Das aktive Konnektorverhalten ist inspizierbar, '
|
||||
'versionengebunden, testbar und überprüfbar, '
|
||||
'bevor ein anbieterspezifisches Schreiben '
|
||||
'erfolgt.',
|
||||
'prerequisites': ['Eine Konnektordefinition wurde '
|
||||
'installiert oder erstellt.',
|
||||
'Anmeldematerial wird außerhalb der '
|
||||
'Connector-URL gespeichert und durch eine '
|
||||
'genehmigte geheime Kennung '
|
||||
'referenziert.'],
|
||||
'verification': 'Laden Sie die Konfiguration neu, prüfen '
|
||||
'Sie geschützte Pfade und effektiven Hash, '
|
||||
'führen Sie eine Simulation mit einem neuen '
|
||||
'idempotency-Schlüssel aus und lösen Sie '
|
||||
'alle ausstehenden Überprüfungsergebnisse.'},
|
||||
'connectors.mediawiki-bluespice': {'outcome': 'Externes Wissen bleibt identitätsstabil, '
|
||||
'verlustsichtbar, ACL-sicher und migrationsfähig.',
|
||||
'prerequisites': ['Eine aktive verwaltete MediaWiki Action '
|
||||
'API-Konfiguration existiert.',
|
||||
'Namespace-Ziele und Fallback-ACLs wurden '
|
||||
'überprüft.',
|
||||
'Die Such- und Wiki-Module sind optional und '
|
||||
'bleiben fähigkeitsgetrennt.'],
|
||||
'verification': 'Entdecken Sie das Profil neu, führen Sie ein '
|
||||
'Delta mit Schlüsseln aus, inspizieren Sie '
|
||||
'Gesundheit und Diagnose, überprüfen Sie einen '
|
||||
'erlaubten und verweigerten Suchprinzipal und '
|
||||
'führen Sie einen Migrations-Dry-Run aus, '
|
||||
'bevor Sie zielseitig arbeiten.'},
|
||||
'connectors.znuny-otrs': {'outcome': 'Externe Tickets bleiben identitätsstabil, verlustsichtbar, '
|
||||
'ACL-sicher, wiederherstellbar und semantisch getrennt von '
|
||||
'GovOPlaN-Domäneneinträgen.',
|
||||
'prerequisites': ['Eine aktiv gesteuerte Znuny/OTRS GenericInterface '
|
||||
'REST-Konfiguration existiert.',
|
||||
'Bereitstellungsdefinierte Routen, '
|
||||
'Warteschlangenpartitionen, Autorität und '
|
||||
'Fallback-ACLs wurden überprüft.',
|
||||
'Tickets, Helpdesk, Cases und Search bleiben '
|
||||
'optionale funktionsgetrennte Verbraucher.'],
|
||||
'verification': 'Entdecke das Profil neu, beende einen Keyed-Full-Run, '
|
||||
'führe ein Keyed-Delta aus, inspiziere die '
|
||||
'Mapping-Diagnose, überprüfe einen erlaubten und '
|
||||
'verweigerten Suchprinzipal und versöhne jedes '
|
||||
'ergebnisunbekannte Update vor dem erneuten Versuch.'}}
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||
from govoplan_connectors.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
@@ -123,7 +126,7 @@ from govoplan_connectors.backend.german_documentation import (
|
||||
|
||||
|
||||
MODULE_ID = "connectors"
|
||||
MODULE_VERSION = "0.1.23"
|
||||
MODULE_VERSION = "0.1.26"
|
||||
TABULAR_SOURCE_INTERFACE_VERSION = "0.1.0"
|
||||
DATASOURCE_ORIGIN_INTERFACE_VERSION = "0.1.0"
|
||||
SANCTIONS_SNAPSHOT_INTERFACE_VERSION = "1.0.0"
|
||||
@@ -1050,6 +1053,10 @@ manifest = ModuleManifest(
|
||||
order=39,
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"help_contexts": [
|
||||
"connectors.action.reject-ambiguous-result",
|
||||
"connectors.action.approve-ambiguous-result",
|
||||
],
|
||||
"fields": [
|
||||
"Direction and technical maturity",
|
||||
"Configured source authority",
|
||||
@@ -1108,7 +1115,9 @@ manifest = ModuleManifest(
|
||||
"Immutable JSON/CSV snapshots remain available. Managed CSV/XLSX "
|
||||
"sources use the optional Files capability, pin an exact authorized "
|
||||
"version, apply archive and expansion limits, and require explicit "
|
||||
"refresh before adopting a newer version. The PostgreSQL adapter uses "
|
||||
"refresh before adopting a newer version. XLSX reads validate actual selected-worksheet coordinates before grid allocation: "
|
||||
"at most 500 columns and 10,000 row positions after the header, including blank gaps. Unreliable declared dimensions "
|
||||
"neither expand the grid nor conceal cells; oversized or inconsistent coordinates fail validation instead of truncating data. The PostgreSQL adapter uses "
|
||||
"an active governed configuration and scoped Core credential envelope, "
|
||||
"reflects simple schema/table identifiers, runs read-only bounded "
|
||||
"projection and pagination, and blocks configuration, credential, or "
|
||||
@@ -1260,6 +1269,11 @@ manifest = ModuleManifest(
|
||||
)
|
||||
|
||||
|
||||
manifest = with_documentation_structured_translations(
|
||||
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||
)
|
||||
|
||||
|
||||
def get_manifest() -> ModuleManifest:
|
||||
return manifest
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from defusedxml import ElementTree as SafeET
|
||||
from defusedxml.common import DefusedXmlException
|
||||
from openpyxl import load_workbook
|
||||
from openpyxl.utils.cell import column_index_from_string
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
@@ -595,7 +598,12 @@ def _parse_xlsx(
|
||||
f"Managed XLSX worksheet {selected_name!r} was not found."
|
||||
)
|
||||
worksheet = workbook[selected_name]
|
||||
iterator = worksheet.iter_rows(values_only=True)
|
||||
maximum_column = _validate_xlsx_worksheet(worksheet)
|
||||
# Declared dimensions are not authoritative: they can inflate sparse
|
||||
# rows/columns or conceal actual cells. Validate the XML coordinates
|
||||
# before openpyxl synthesizes any missing cells, then ignore dimensions.
|
||||
worksheet.reset_dimensions()
|
||||
iterator = worksheet.iter_rows(max_col=maximum_column, values_only=True)
|
||||
try:
|
||||
raw_headers = next(iterator)
|
||||
except StopIteration as exc:
|
||||
@@ -604,10 +612,10 @@ def _parse_xlsx(
|
||||
) from exc
|
||||
headers = _xlsx_headers(raw_headers)
|
||||
rows: list[Mapping[str, object]] = []
|
||||
for values in iterator:
|
||||
if len(rows) >= MAX_FILE_ROWS:
|
||||
for row_number, values in enumerate(iterator, start=1):
|
||||
if row_number > MAX_FILE_ROWS:
|
||||
raise TabularSourceValidationError(
|
||||
f"Managed XLSX worksheets are limited to {MAX_FILE_ROWS:,} data rows."
|
||||
f"Managed XLSX worksheets are limited to {MAX_FILE_ROWS:,} row positions after the header, including blank gaps."
|
||||
)
|
||||
normalized = tuple(values[: len(headers)])
|
||||
if all(value in (None, "") for value in normalized):
|
||||
@@ -625,6 +633,60 @@ def _parse_xlsx(
|
||||
workbook.close()
|
||||
|
||||
|
||||
def _validate_xlsx_worksheet(worksheet: Any) -> int:
|
||||
"""Bound actual coordinates before read-only openpyxl allocates row tuples.
|
||||
|
||||
The pinned openpyxl 3.x read-only source handle is streamed and closed here;
|
||||
no untrusted dimensions or filesystem paths are used to allocate a grid.
|
||||
"""
|
||||
maximum_column = 1
|
||||
row_position = 0
|
||||
rows_seen = 0
|
||||
column_position = 0
|
||||
cells_seen = 0
|
||||
try:
|
||||
with worksheet._get_source() as source:
|
||||
for event, element in SafeET.iterparse(source, events=("start", "end"), forbid_dtd=True):
|
||||
tag = element.tag.rsplit("}", 1)[-1]
|
||||
if event == "end":
|
||||
element.clear()
|
||||
continue
|
||||
if tag == "row":
|
||||
rows_seen += 1
|
||||
raw_row = element.get("r", str(row_position + 1))
|
||||
if len(raw_row) > 7 or not raw_row.isascii() or not raw_row.isdigit():
|
||||
raise TabularSourceValidationError("Managed XLSX content has an invalid row coordinate.")
|
||||
row_position = int(raw_row)
|
||||
if not 1 <= row_position <= MAX_FILE_ROWS + 1 or rows_seen > MAX_FILE_ROWS + 1:
|
||||
raise TabularSourceValidationError(
|
||||
f"Managed XLSX worksheets are limited to {MAX_FILE_ROWS:,} row positions after the header, including blank gaps."
|
||||
)
|
||||
column_position = 0
|
||||
cells_seen = 0
|
||||
elif tag == "c":
|
||||
cells_seen += 1
|
||||
reference = element.get("r")
|
||||
if reference is not None:
|
||||
match = re.fullmatch(r"([A-Za-z]{1,3})([1-9][0-9]{0,6})", reference)
|
||||
if match is None:
|
||||
raise TabularSourceValidationError("Managed XLSX content has an invalid cell coordinate.")
|
||||
column_position = column_index_from_string(match.group(1))
|
||||
if int(match.group(2)) != row_position:
|
||||
raise TabularSourceValidationError("Managed XLSX cell and row coordinates do not agree.")
|
||||
else:
|
||||
column_position += 1
|
||||
if column_position > MAX_FILE_COLUMNS or cells_seen > MAX_FILE_COLUMNS:
|
||||
raise TabularSourceValidationError(
|
||||
f"Managed XLSX worksheets are limited to {MAX_FILE_COLUMNS:,} columns."
|
||||
)
|
||||
maximum_column = max(maximum_column, column_position)
|
||||
return maximum_column
|
||||
except (DefusedXmlException, SafeET.ParseError, ValueError) as exc:
|
||||
if isinstance(exc, TabularSourceValidationError):
|
||||
raise
|
||||
raise TabularSourceValidationError("Managed XLSX worksheet XML could not be safely parsed.") from exc
|
||||
|
||||
|
||||
def _validate_xlsx_archive(payload: bytes) -> None:
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(payload)) as archive:
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
from zipfile import ZipFile
|
||||
|
||||
from openpyxl import Workbook
|
||||
|
||||
from govoplan_core.core.tabular_sources import TabularSourceValidationError
|
||||
from govoplan_connectors.backend import tabular_adapters as adapters
|
||||
|
||||
|
||||
def workbook_bytes(*, dimension: str | None = None, last_cell: str = "A2") -> bytes:
|
||||
workbook = Workbook()
|
||||
workbook.active.append(["name"])
|
||||
workbook.active.append(["Ada"])
|
||||
output = BytesIO()
|
||||
workbook.save(output)
|
||||
workbook.close()
|
||||
result = BytesIO()
|
||||
with ZipFile(BytesIO(output.getvalue())) as original, ZipFile(result, "w") as modified:
|
||||
for entry in original.infolist():
|
||||
content = original.read(entry)
|
||||
if entry.filename == "xl/worksheets/sheet1.xml":
|
||||
xml = content.decode()
|
||||
if dimension is not None:
|
||||
xml = re.sub(r'<dimension ref="[^"]+"\s*/>', f'<dimension ref="{dimension}"/>', xml)
|
||||
xml = xml.replace('r="A2"', f'r="{last_cell}"')
|
||||
xml = xml.replace('<row r="2">', f'<row r="{re.search(r"[0-9]+$", last_cell).group()}">')
|
||||
content = xml.encode()
|
||||
modified.writestr(entry, content)
|
||||
return result.getvalue()
|
||||
|
||||
|
||||
def parse(payload: bytes):
|
||||
return adapters.parse_managed_tabular_content(payload, filename="fixture.xlsx", content_type=None, delimiter=",", sheet_name=None)
|
||||
|
||||
|
||||
class XlsxSafetyBoundsTests(unittest.TestCase):
|
||||
def test_sparse_rows_cannot_bypass_limit_by_not_counting_as_data(self):
|
||||
with patch.object(adapters, "MAX_FILE_ROWS", 3):
|
||||
with self.assertRaisesRegex(TabularSourceValidationError, "row"):
|
||||
parse(workbook_bytes(last_cell="A6"))
|
||||
|
||||
def test_forged_small_dimensions_cannot_hide_far_away_cells(self):
|
||||
with patch.object(adapters, "MAX_FILE_ROWS", 3):
|
||||
with self.assertRaisesRegex(TabularSourceValidationError, "row"):
|
||||
parse(workbook_bytes(dimension="A1:A2", last_cell="A1000000"))
|
||||
|
||||
def test_forged_small_dimensions_cannot_hide_out_of_range_columns(self):
|
||||
with self.assertRaisesRegex(TabularSourceValidationError, "column"):
|
||||
parse(workbook_bytes(dimension="A1:A2", last_cell="XFD2"))
|
||||
|
||||
def test_declared_dimensions_do_not_expand_or_truncate_real_rows(self):
|
||||
for dimension in ("A1:XFD1048576", "A1:A1"):
|
||||
with self.subTest(dimension=dimension):
|
||||
rows, sheet = parse(workbook_bytes(dimension=dimension))
|
||||
self.assertEqual(({"name": "Ada"},), rows)
|
||||
self.assertEqual("Sheet", sheet)
|
||||
|
||||
def test_small_blank_gaps_and_exact_limit_remain_usable(self):
|
||||
with patch.object(adapters, "MAX_FILE_ROWS", 3):
|
||||
rows, _sheet = parse(workbook_bytes(last_cell="A4"))
|
||||
self.assertEqual(({"name": "Ada"},), rows)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/connectors-webui",
|
||||
"version": "0.1.23",
|
||||
"version": "0.1.26",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
||||
@@ -483,7 +483,7 @@ export default function ConnectorGovernancePage({ settings, auth }: Props) {
|
||||
<FormField label="Endpoint URL" hint="Credentials are rejected in URLs.">
|
||||
<input value={draft.endpoint_url} disabled={!canAdmin || busy} placeholder="https://provider.example/api" onChange={(event) => setDraft({ ...draft, endpoint_url: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Credential reference" hint="Reference an approved secret; do not paste a secret.">
|
||||
<FormField label="Credential reference" hint="Reference an approved secret; do not paste a secret." helpContextId="connectors.admin.governed-configurations" helpModuleId="connectors">
|
||||
<input value={draft.credential_ref} disabled={!canAdmin || busy} placeholder="vault://connectors/provider" onChange={(event) => setDraft({ ...draft, credential_ref: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Ambiguous-result policy">
|
||||
@@ -574,7 +574,7 @@ export default function ConnectorGovernancePage({ settings, auth }: Props) {
|
||||
<FormField label="Endpoint URL">
|
||||
<input value={newDraft.endpoint_url} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, endpoint_url: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Credential reference">
|
||||
<FormField label="Credential reference" helpContextId="connectors.admin.governed-configurations" helpModuleId="connectors">
|
||||
<input value={newDraft.credential_ref} disabled={busy} onChange={(event) => setNewDraft({ ...newDraft, credential_ref: event.target.value })} />
|
||||
</FormField>
|
||||
<FormField label="Ambiguous-result policy">
|
||||
@@ -597,8 +597,8 @@ export default function ConnectorGovernancePage({ settings, auth }: Props) {
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setReviewRun(null)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="danger" onClick={() => void decideReview("rejected")} disabled={busy || reviewReason.trim().length < 5}>Reject</Button>
|
||||
<Button variant="primary" onClick={() => void decideReview("approved")} disabled={busy || reviewReason.trim().length < 5}>Approve</Button>
|
||||
<Button variant="danger" helpContextId="connectors.action.reject-ambiguous-result" helpModuleId="connectors" onClick={() => void decideReview("rejected")} disabled={busy || reviewReason.trim().length < 5}>Reject</Button>
|
||||
<Button variant="primary" helpContextId="connectors.action.approve-ambiguous-result" helpModuleId="connectors" onClick={() => void decideReview("approved")} disabled={busy || reviewReason.trim().length < 5}>Approve</Button>
|
||||
</>}
|
||||
>
|
||||
<p>Review {reviewRun?.summary.ambiguous ?? 0} ambiguous effects against the retained input and configuration hashes before deciding.</p>
|
||||
|
||||
@@ -326,7 +326,7 @@ export default function ExternalKnowledgePage({ settings, auth }: Props) {
|
||||
<Button variant="secondary" onClick={() => void sync(false)} disabled={!selected || !canSync || busy || dirty}>Run delta</Button>
|
||||
<Button variant="secondary" onClick={() => void sync(true)} disabled={!selected || !canSync || busy || dirty}>Run full backfill</Button>
|
||||
<Button variant="secondary" onClick={() => setMigrationOpen(true)} disabled={!selected || !canMigrate || busy || dirty}>Preview migration</Button>
|
||||
<Button variant="primary" onClick={() => setPublishOpen(true)} disabled={!selected || !canPublish || busy || dirty}>Publish page</Button>
|
||||
<Button variant="primary" helpContextId="connectors.admin.external-knowledge" helpModuleId="connectors" onClick={() => setPublishOpen(true)} disabled={!selected || !canPublish || busy || dirty}>Publish page</Button>
|
||||
</>}
|
||||
discardAction={{
|
||||
label: "Discard changes",
|
||||
@@ -501,7 +501,7 @@ export default function ExternalKnowledgePage({ settings, auth }: Props) {
|
||||
|
||||
<Dialog open={publishOpen} title="Publish provider page revision" onClose={() => !busy && setPublishOpen(false)} closeDisabled={busy} footer={<>
|
||||
<Button onClick={() => setPublishOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="primary" onClick={() => void publish()} disabled={busy || !externalPageId.trim() || !publishTitle.trim()}>Publish revision</Button>
|
||||
<Button variant="primary" helpContextId="connectors.admin.external-knowledge" helpModuleId="connectors" onClick={() => void publish()} disabled={busy || !externalPageId.trim() || !publishTitle.trim()}>Publish revision</Button>
|
||||
</>}>
|
||||
<p className="muted">Publication is an external effect. Supply the current provider revision where possible; an unknown outcome blocks blind retry.</p>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
|
||||
Reference in New Issue
Block a user