From c5c952a117dda04cdde63b6a49d32af0ea805d96 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 21:45:03 +0000 Subject: [PATCH] feat(react-ui): add the host access step, approval and page Show the host operations an add-on asks for in the install dialog, hold an upgrade that asks for more until an administrator approves it, and add a Host access tab with the grants of each add-on, the deny rules and the blanket switch. All strings are in every locale. Assisted-by: Claude:claude-sonnet-5-5 playwright eslint --- .../react-ui/public/locales/de/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/en/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/es/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/fr/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/id/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/it/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/ko/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/pt-BR/addons.json | 147 +++++++++++++++- .../react-ui/public/locales/zh-CN/addons.json | 147 +++++++++++++++- .../src/components/addons/AddonBits.jsx | 12 ++ .../src/components/addons/AddonRow.jsx | 7 +- .../addons/ApproveUpgradeDialog.jsx | 89 ++++++++++ .../src/components/addons/AppsView.jsx | 26 ++- .../src/components/addons/BlanketPanel.jsx | 144 ++++++++++++++++ .../src/components/addons/DenySection.jsx | 125 ++++++++++++++ .../src/components/addons/HostAccessList.jsx | 50 ++++++ .../src/components/addons/HostAccessView.jsx | 160 ++++++++++++++++++ .../src/components/addons/HostDialog.jsx | 55 ++++++ .../src/components/addons/InstallDialog.jsx | 45 ++++- .../src/components/addons/addonState.js | 4 +- .../react-ui/src/components/addons/addons.css | 49 ++++++ .../src/components/addons/useHostGrants.js | 44 +++++ .../src/components/addons/useHostPreview.js | 32 ++++ core/http/react-ui/src/pages/Addons.jsx | 11 +- 24 files changed, 2154 insertions(+), 22 deletions(-) create mode 100644 core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx create mode 100644 core/http/react-ui/src/components/addons/BlanketPanel.jsx create mode 100644 core/http/react-ui/src/components/addons/DenySection.jsx create mode 100644 core/http/react-ui/src/components/addons/HostAccessList.jsx create mode 100644 core/http/react-ui/src/components/addons/HostAccessView.jsx create mode 100644 core/http/react-ui/src/components/addons/HostDialog.jsx create mode 100644 core/http/react-ui/src/components/addons/useHostGrants.js create mode 100644 core/http/react-ui/src/components/addons/useHostPreview.js diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json index 781d2a41e5..e4947d66b3 100644 --- a/core/http/react-ui/public/locales/de/addons.json +++ b/core/http/react-ui/public/locales/de/addons.json @@ -357,7 +357,8 @@ "apps": "Installiert", "browse": "Add-ons finden", "label": "Bereiche der Add-ons", - "libraries": "Quellen" + "libraries": "Quellen", + "access": "Host-Zugriff" }, "title": "Add-ons", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "Die Einstellungen konnten nicht gelesen werden", "saveFailed": "Die Einstellungen konnten nicht gespeichert werden: {{message}}" } + }, + "hostAccess": { + "addonsTitle": "Add-ons", + "approve": { + "action": "Freigeben und aktualisieren", + "body": "Version {{current}} von {{name}} läuft weiter. Gibst du frei, darf die neue Version diese Aktionen ausführen. Neue sind markiert.", + "done": "{{name}} wurde aktualisiert.", + "title": "Host-Zugriff für {{name}} {{version}} freigeben" + }, + "asksNothing": "Dieses Add-on fordert keinen Host-Zugriff an.", + "blanket": { + "alsoRevoke": "Auch die erteilten Freigaben widerrufen", + "alsoRevokeHint": "Add-ons, die Zugriff verlören: {{names}}. Ein Add-on, das nötigen Zugriff verliert, hält an.", + "applyInstalled": "Auch auf bereits installierte Add-ons anwenden", + "applyInstalledHint": "Aus: Nur neue Installationen und Updates.", + "change": "Ändern", + "fixed": "Vom Deployment festgelegt. Du kannst es hier nicht ändern.", + "held": "Add-ons mit Freigaben des Pauschalschalters: {{count}}", + "includeOptional": "Optionale Operationen einschließen", + "includeOptionalHint": "Aus: Nur erforderliche Operationen werden erteilt.", + "intro": "Standardmäßig aus. Wenn an, erhält ein Add-on, das du installierst oder aktualisierst, die Host-Operationen, die sein Manifest verlangt, ohne den Schritt im Installationsdialog. Operationen unter Nie erlauben bleiben blockiert.", + "modeAny": "Jede Bibliothek", + "modeLabel": "Umfang", + "modeOfficial": "Offizielle Bibliothek", + "nowOff": "Die pauschale Freigabe ist aus. Die Freigaben, die sie erteilt hat, bleiben.", + "nowOn": "Die pauschale Freigabe ist an.", + "offBody": "Neue Installationen brauchen wieder deine Freigabe. Die Freigaben, die der Schalter schon erteilt hat, bleiben, als pauschal markiert, damit nichts überraschend anhält.", + "offText": "Aus. Du gibst jedes Add-on bei der Installation frei.", + "offTitle": "Pauschale Freigabe ausschalten", + "onTitle": "Pauschale Freigabe einschalten", + "revokeAll": "Alle pauschalen Freigaben widerrufen", + "revoked": "Pauschale Freigaben widerrufen.", + "risk": { + "any": "Ist dies an, erhält jedes Add-on, das du aus einer beliebigen von dir eingerichteten Bibliothek installierst, die Host-Operationen, die sein Manifest verlangt, ohne dass du sie prüfst. Jeder Eintrag jeder Bibliothek erhält, was er angibt. Ein bösartiger oder kompromittierter Eintrag bekommt diese Operationen also sofort. Operationen, die du unter Nie erlauben blockierst, bleiben blockiert.", + "official": "Ist dies an, erhält jedes Add-on, das du aus der offiziellen Bibliothek installierst, die Host-Operationen, die sein Manifest verlangt, ohne dass du sie prüfst. Ein kompromittierter Eintrag erhält dann genau die Operationen, die er angibt. Operationen, die du unter Nie erlauben blockierst, bleiben blockiert." + }, + "scope": { + "any": "An für Add-ons aus jeder Bibliothek.", + "official": "An für Add-ons aus der offiziellen Bibliothek." + }, + "switch": "Pauschale Freigabe", + "title": "Alles freigeben, was ein Add-on anfordert", + "turnOff": "Ausschalten", + "turnOn": "Einschalten", + "typeLabel": "Zur Bestätigung tippe: {{phrase}}", + "withOptional": "Optionale Operationen sind eingeschlossen." + }, + "cannotInstallBlocked": "Dieses Add-on braucht {{ops}}, was deine Administratorrichtlinie blockiert. Es kann nicht installiert werden.", + "cannotInstallUnsupported": "Dieses Add-on braucht {{ops}}, was dieses LocalAI nicht unterstützt. Es kann nicht installiert werden.", + "chip": "Wartet auf Freigabe", + "confirm": { + "revokeBody": "{{name}} verliert alles, was du oder der Pauschalschalter erlaubt hast. Freigaben des Deployments bleiben. Verliert es einen Zugriff, den es braucht, hält es an.", + "revokeTitle": "Den gesamten Host-Zugriff von {{name}} widerrufen?", + "stopAction": "Ausschalten und anhalten", + "stopBody": "{{name}} läuft nicht ohne {{op}}. Es hält an und bleibt angehalten, bis du es wieder erlaubst.", + "stopTitle": "Zugriff ausschalten, den {{name}} braucht?" + }, + "deny": { + "add": "Regel hinzufügen", + "added": "Regel hinzugefügt.", + "affectsNone": "Kein installiertes Add-on verliert Zugriff.", + "allAddons": "Alle Add-ons", + "checkFailed": "Es konnte nicht geprüft werden, was diese Regel ändert. Nichts wurde gespeichert.", + "confirmAction": "Regel hinzufügen", + "confirmBody": "Gilt für: {{target}}. Wirkt sofort. Laufende Aufrufe werden beendet.", + "confirmTitle": "{{entry}} nie erlauben?", + "entry": "Operation, Familie oder *", + "everything": "Alles", + "fromFile": "Im Deployment festgelegt", + "hint": "Eine Familie endet mit einem Punkt, zum Beispiel egress. Ein genauer Name sieht aus wie egress.check. Ein Stern blockiert alles.", + "intro": "Operationen, die kein Add-on nutzen darf, egal was sie sonst erlaubt. Eine Regel hier gewinnt immer.", + "invalid": "Schreibe eine Operation wie egress.check, eine Familie wie egress. oder *.", + "none": "Noch keine Regel.", + "remove": "Entfernen", + "removeNamed": "Die Regel {{entry}} für {{target}} entfernen", + "removed": "Regel entfernt.", + "target": "Gilt für", + "title": "Nie erlauben", + "willStop": "hält an, es braucht dies" + }, + "emptyBody": "Add-ons, die Host-Zugriff anfordern, erscheinen hier nach der Installation.", + "emptyTitle": "Kein Add-on installiert", + "errors": { + "approval_required": "Dieses Add-on braucht Host-Zugriff. Gib ihn im Installationsdialog im Browser frei.", + "approval_stale": "Das Paket hat sich geändert, seit du es angesehen hast. Prüfe die neue Liste und gib erneut frei.", + "blocked_by_policy": "Die Administratorrichtlinie blockiert eine Operation, die dieses Add-on braucht. Nichts wurde geändert.", + "locked_by_deployment": "Das Deployment hat den Host-Zugriff gesperrt. Du kannst ihn hier nicht ändern.", + "not_human_surface": "Ein Administrator muss diese Änderung in diesem Browser vornehmen. Ein API-Schlüssel oder ein Werkzeug kann das nicht.", + "revision_conflict": "Der Host-Zugriff hat sich anderswo geändert. Die Seite wurde neu gelesen. Prüfe sie und versuche es erneut." + }, + "family": { + "connector": "Konnektoren", + "egress": "Netzwerkprüfungen", + "finetune": "Feintuning", + "quantize": "Quantisierung", + "workspace": "Workspace-Dateien" + }, + "fileEntry": "In der Richtliniendatei oder den Flags des Deployments festgelegt.", + "identityMismatch": "Diese Freigaben wurden für ein anderes Paket mit demselben Namen erteilt. Sie haben keine Wirkung. Entferne sie.", + "intro": "Die Aktionen, die Add-ons auf diesem Server ausführen dürfen, wer jede erlaubt hat und wie du sie zurücknimmst. Ein Add-on hat nur, was du freigibst.", + "keepOwn": "Als eigene behalten", + "keepOwnNamed": "Die pauschalen Freigaben von {{name}} als eigene behalten", + "loadError": "Der Host-Zugriff wurde nicht geladen", + "loading": "Lese, was dieses Add-on anfordert...", + "loadingPage": "Host-Zugriff wird geladen", + "locked": "Das Deployment hat Änderungen am Host-Zugriff abgeschaltet. Du kannst diese Seite lesen, aber nicht ändern.", + "newOptional": "Die neue Version fordert auch {{ops}} an. Diese sind optional und nicht erlaubt.", + "noLongerRequested": "Diese Version fordert es nicht mehr an. Du kannst es ausschalten.", + "notOfficial": "Dieses Add-on stammt aus {{library}}, nicht aus der offiziellen Bibliothek.", + "op": { + "alreadyAllowed": "Bereits erlaubt", + "blocked": "Von der Administratorrichtlinie blockiert", + "byDeployment": "Bereits vom Deployment erlaubt", + "new": "Neu in dieser Version", + "optional": "Optional. Das Add-on läuft auch ohne, mit weniger Funktionen.", + "required": "Erforderlich. Das Add-on läuft ohne nicht.", + "unsupported": "Von diesem LocalAI nicht unterstützt" + }, + "previewFailed": "Der Host-Zugriff, den dieses Add-on anfordert, konnte nicht gelesen werden. Versuche es erneut. Braucht es einen Zugriff, den du nicht freigegeben hast, wird die Installation abgelehnt und nichts ändert sich.", + "removeStale": "Entfernen", + "revokeAll": "Alle widerrufen", + "revokeAllNamed": "Den gesamten Host-Zugriff von {{name}} widerrufen", + "risk": { + "changes_data": "Ändert Daten", + "network": "Netzwerk", + "read": "Liest", + "runs_jobs": "Startet Aufträge" + }, + "source": { + "blanket": "Pauschal", + "deployment": "Deployment", + "you": "Du" + }, + "stepFooter": "Mit der Freigabe darf dieses Add-on diese Aktionen auf diesem Server für die Personen ausführen, die es nutzen. Du kannst sie auf der Seite Host-Zugriff prüfen oder widerrufen.", + "stepIntro": "{{name}} möchte diese Aktionen auf diesem Server ausführen. Die erforderlichen braucht es, damit es funktioniert. Optionale bleiben aus, bis du sie einschaltest.", + "stepTitle": "Host-Zugriff", + "stopped": "Angehalten: Es braucht {{ops}}, was nicht mehr erlaubt ist.", + "tabWaiting": "Wartende Freigaben: {{count}}", + "waiting": { + "body": "Die neue Version fordert Host-Zugriff an, den die aktuelle nicht hat. Version {{current}} läuft weiter, bis du freigibst. Hinzukommende Operationen: {{count}}.", + "review": "Prüfen und freigeben", + "reviewNamed": "Den Host-Zugriff von {{name}} prüfen und freigeben", + "title": "{{name}} {{version}} wartet auf deine Freigabe" + } } } diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index 369a228e88..7811484949 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -357,7 +357,8 @@ "apps": "Installed", "browse": "Find add-ons", "label": "Add-ons sections", - "libraries": "Sources" + "libraries": "Sources", + "access": "Host access" }, "title": "Add-ons", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "The settings could not be read", "saveFailed": "The settings could not be saved: {{message}}" } + }, + "hostAccess": { + "addonsTitle": "Add-ons", + "approve": { + "action": "Approve and upgrade", + "body": "Version {{current}} of {{name}} keeps running. If you approve, the new version can run these actions. New ones are marked.", + "done": "{{name}} was upgraded.", + "title": "Approve host access for {{name}} {{version}}" + }, + "asksNothing": "This add-on asks for no host access.", + "blanket": { + "alsoRevoke": "Also revoke the grants it made", + "alsoRevokeHint": "Add-ons that would lose access: {{names}}. An add-on that loses access it requires stops.", + "applyInstalled": "Also apply to add-ons already installed", + "applyInstalledHint": "Off: only new installs and upgrades.", + "change": "Change", + "fixed": "Set by the deployment. You cannot change it here.", + "held": "Add-ons that hold grants made by the blanket switch: {{count}}", + "includeOptional": "Include optional operations", + "includeOptionalHint": "Off: only required operations are granted.", + "intro": "Off by default. When on, an add-on you install or upgrade gets the host operations its manifest says it needs, without the step in the install dialog. Operations in Never allow stay blocked.", + "modeAny": "Any library", + "modeLabel": "Scope", + "modeOfficial": "Official library", + "nowOff": "Blanket approval is off. The grants it made are kept.", + "nowOn": "Blanket approval is on.", + "offBody": "New installs need your approval again. The grants the switch already made stay, marked as blanket, so nothing stops by surprise.", + "offText": "Off. You approve each add-on when you install it.", + "offTitle": "Turn off blanket approval", + "onTitle": "Turn on blanket approval", + "revokeAll": "Revoke all blanket grants", + "revoked": "Blanket grants revoked.", + "risk": { + "any": "With this on, every add-on you install from any library you configured gets the host operations its manifest says it needs, without you reviewing them. Any entry of any library receives what it declares, so a malicious or compromised entry gets those operations at once. Operations you block in Never allow stay blocked.", + "official": "With this on, every add-on you install from the official library gets the host operations its manifest says it needs, without you reviewing them. A compromised entry then gets exactly the operations it declares. Operations you block in Never allow stay blocked." + }, + "scope": { + "any": "On for add-ons from any library.", + "official": "On for add-ons from the official library." + }, + "switch": "Blanket approval", + "title": "Approve everything an add-on asks for", + "turnOff": "Turn off", + "turnOn": "Turn on", + "typeLabel": "To confirm, type: {{phrase}}", + "withOptional": "Optional operations are included." + }, + "cannotInstallBlocked": "This add-on needs {{ops}}, which your administrator policy blocks. It cannot be installed.", + "cannotInstallUnsupported": "This add-on needs {{ops}}, which this LocalAI does not support. It cannot be installed.", + "chip": "Waiting for approval", + "confirm": { + "revokeBody": "{{name}} loses everything you or the blanket switch allowed. Grants of the deployment stay. If it loses access it requires, it stops.", + "revokeTitle": "Revoke all host access of {{name}}?", + "stopAction": "Turn off and stop", + "stopBody": "{{name}} cannot run without {{op}}. It stops, and it stays stopped until you allow this again.", + "stopTitle": "Turn off access that {{name}} needs?" + }, + "deny": { + "add": "Add rule", + "added": "Rule added.", + "affectsNone": "No installed add-on loses access.", + "allAddons": "All add-ons", + "checkFailed": "Could not check what this rule changes. Nothing was saved.", + "confirmAction": "Add rule", + "confirmBody": "Applies to: {{target}}. It takes effect at once. Calls already running finish.", + "confirmTitle": "Never allow {{entry}}?", + "entry": "Operation, family or *", + "everything": "Everything", + "fromFile": "Set in the deployment", + "hint": "A family ends with a dot, for example egress. An exact name looks like egress.check. A star blocks everything.", + "intro": "Operations that no add-on may use, whatever else allows them. A rule here always wins.", + "invalid": "Write an operation such as egress.check, a family such as egress., or *.", + "none": "No rule yet.", + "remove": "Remove", + "removeNamed": "Remove the rule {{entry}} for {{target}}", + "removed": "Rule removed.", + "target": "Applies to", + "title": "Never allow", + "willStop": "will stop, it needs this" + }, + "emptyBody": "Add-ons that ask for host access appear here after you install them.", + "emptyTitle": "No add-on is installed", + "errors": { + "approval_required": "This add-on needs host access. Approve it in the install dialog in the browser.", + "approval_stale": "The package changed since you looked at it. Review the new list and approve again.", + "blocked_by_policy": "The administrator policy blocks an operation this add-on needs. Nothing was changed.", + "locked_by_deployment": "The deployment locked host access. You cannot change it here.", + "not_human_surface": "An administrator must make this change in this browser. An API key or a tool cannot.", + "revision_conflict": "Host access changed elsewhere. The page was read again. Check it and try again." + }, + "family": { + "connector": "Connectors", + "egress": "Network checks", + "finetune": "Fine-tuning", + "quantize": "Quantization", + "workspace": "Workspace files" + }, + "fileEntry": "Set in the deployment's policy file or flags.", + "identityMismatch": "These grants were made for a different package under the same name. They have no effect. Remove them.", + "intro": "The actions add-ons may run on this server, who allowed each one, and how to take it back. An add-on holds only what you approve.", + "keepOwn": "Keep as my own", + "keepOwnNamed": "Keep the blanket grants of {{name}} as my own", + "loadError": "Host access did not load", + "loading": "Reading what this add-on asks for...", + "loadingPage": "Loading host access", + "locked": "The deployment turned off changes to host access. You can read this page but not change it.", + "newOptional": "The new version also asks for {{ops}}. These are optional and not allowed.", + "noLongerRequested": "This version no longer asks for it. You can turn it off.", + "notOfficial": "This add-on comes from {{library}}, not the official library.", + "op": { + "alreadyAllowed": "Already allowed", + "blocked": "Blocked by the administrator policy", + "byDeployment": "Already allowed by the deployment", + "new": "New in this version", + "optional": "Optional. The add-on runs without it, with fewer features.", + "required": "Required. The add-on cannot run without it.", + "unsupported": "Not supported by this LocalAI" + }, + "previewFailed": "Could not read the host access this add-on asks for. Try again. If it needs access you have not approved, the install is refused and nothing changes.", + "removeStale": "Remove", + "revokeAll": "Revoke all", + "revokeAllNamed": "Revoke all host access of {{name}}", + "risk": { + "changes_data": "Changes data", + "network": "Network", + "read": "Reads", + "runs_jobs": "Runs jobs" + }, + "source": { + "blanket": "Blanket", + "deployment": "Deployment", + "you": "You" + }, + "stepFooter": "Granting lets this add-on run these actions on this server for the people who use it. You can review or revoke it on the Host access page.", + "stepIntro": "{{name}} asks to run these actions on this server. Required ones are needed for it to work. Optional ones stay off until you turn them on.", + "stepTitle": "Host access", + "stopped": "Stopped: it needs {{ops}}, which is no longer allowed.", + "tabWaiting": "Approvals waiting: {{count}}", + "waiting": { + "body": "The new version asks for host access that the current one does not have. Version {{current}} keeps running until you approve. Operations it adds: {{count}}.", + "review": "Review and approve", + "reviewNamed": "Review and approve the host access of {{name}}", + "title": "{{name}} {{version}} waits for your approval" + } } } diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json index 44c29a7bcc..a0c3285107 100644 --- a/core/http/react-ui/public/locales/es/addons.json +++ b/core/http/react-ui/public/locales/es/addons.json @@ -357,7 +357,8 @@ "apps": "Instalados", "browse": "Buscar complementos", "label": "Secciones de complementos", - "libraries": "Fuentes" + "libraries": "Fuentes", + "access": "Acceso al host" }, "title": "Complementos", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "No se pudieron leer los ajustes", "saveFailed": "No se pudieron guardar los ajustes: {{message}}" } + }, + "hostAccess": { + "addonsTitle": "Complementos", + "approve": { + "action": "Aprobar y actualizar", + "body": "La versión {{current}} de {{name}} sigue funcionando. Si apruebas, la nueva versión puede ejecutar estas acciones. Las nuevas están marcadas.", + "done": "{{name}} se actualizó.", + "title": "Aprobar el acceso al host de {{name}} {{version}}" + }, + "asksNothing": "Este complemento no pide acceso al host.", + "blanket": { + "alsoRevoke": "Revocar también los permisos que dio", + "alsoRevokeHint": "Complementos que perderían acceso: {{names}}. Un complemento que pierde un acceso que necesita se detiene.", + "applyInstalled": "Aplicar también a los complementos ya instalados", + "applyInstalledHint": "Desactivado: solo instalaciones y actualizaciones nuevas.", + "change": "Cambiar", + "fixed": "Definido por el despliegue. No puedes cambiarlo aquí.", + "held": "Complementos con permisos dados por el interruptor general: {{count}}", + "includeOptional": "Incluir las operaciones opcionales", + "includeOptionalHint": "Desactivado: solo se conceden las operaciones obligatorias.", + "intro": "Desactivado por defecto. Si se activa, un complemento que instales o actualices recibe las operaciones del host que su manifiesto dice necesitar, sin el paso del cuadro de instalación. Las operaciones de Nunca permitir siguen bloqueadas.", + "modeAny": "Cualquier biblioteca", + "modeLabel": "Alcance", + "modeOfficial": "Biblioteca oficial", + "nowOff": "La aprobación general está desactivada. Los permisos que dio se mantienen.", + "nowOn": "La aprobación general está activada.", + "offBody": "Las nuevas instalaciones vuelven a necesitar tu aprobación. Los permisos que el interruptor ya dio se mantienen, marcados como generales, para que nada se detenga por sorpresa.", + "offText": "Desactivada. Apruebas cada complemento al instalarlo.", + "offTitle": "Desactivar la aprobación general", + "onTitle": "Activar la aprobación general", + "revokeAll": "Revocar todos los permisos generales", + "revoked": "Permisos generales revocados.", + "risk": { + "any": "Con esto activado, cada complemento que instales desde cualquier biblioteca que hayas configurado recibe las operaciones del host que su manifiesto dice necesitar, sin que las revises. Cualquier entrada de cualquier biblioteca recibe lo que declara, así que una entrada maliciosa o comprometida obtiene esas operaciones de inmediato. Las operaciones que bloqueas en Nunca permitir siguen bloqueadas.", + "official": "Con esto activado, cada complemento que instales desde la biblioteca oficial recibe las operaciones del host que su manifiesto dice necesitar, sin que las revises. Una entrada comprometida recibe exactamente las operaciones que declara. Las operaciones que bloqueas en Nunca permitir siguen bloqueadas." + }, + "scope": { + "any": "Activada para complementos de cualquier biblioteca.", + "official": "Activada para complementos de la biblioteca oficial." + }, + "switch": "Aprobación general", + "title": "Aprobar todo lo que pide un complemento", + "turnOff": "Desactivar", + "turnOn": "Activar", + "typeLabel": "Para confirmar, escribe: {{phrase}}", + "withOptional": "Se incluyen las operaciones opcionales." + }, + "cannotInstallBlocked": "Este complemento necesita {{ops}}, que la política del administrador bloquea. No se puede instalar.", + "cannotInstallUnsupported": "Este complemento necesita {{ops}}, que este LocalAI no admite. No se puede instalar.", + "chip": "Esperando aprobación", + "confirm": { + "revokeBody": "{{name}} pierde todo lo que permitiste tú o el interruptor general. Los permisos del despliegue se mantienen. Si pierde un acceso que necesita, se detiene.", + "revokeTitle": "¿Revocar todo el acceso al host de {{name}}?", + "stopAction": "Desactivar y detener", + "stopBody": "{{name}} no funciona sin {{op}}. Se detiene y sigue detenido hasta que lo permitas de nuevo.", + "stopTitle": "¿Desactivar el acceso que necesita {{name}}?" + }, + "deny": { + "add": "Añadir regla", + "added": "Regla añadida.", + "affectsNone": "Ningún complemento instalado pierde acceso.", + "allAddons": "Todos los complementos", + "checkFailed": "No se pudo comprobar qué cambia esta regla. No se guardó nada.", + "confirmAction": "Añadir regla", + "confirmBody": "Se aplica a: {{target}}. Tiene efecto de inmediato. Las llamadas en curso terminan.", + "confirmTitle": "¿Nunca permitir {{entry}}?", + "entry": "Operación, familia o *", + "everything": "Todo", + "fromFile": "Definida en el despliegue", + "hint": "Una familia termina en punto, por ejemplo egress. Un nombre exacto es como egress.check. Un asterisco bloquea todo.", + "intro": "Operaciones que ningún complemento puede usar, sin importar qué más las permita. Una regla aquí siempre gana.", + "invalid": "Escribe una operación como egress.check, una familia como egress. o *.", + "none": "Aún no hay reglas.", + "remove": "Quitar", + "removeNamed": "Quitar la regla {{entry}} para {{target}}", + "removed": "Regla quitada.", + "target": "Se aplica a", + "title": "Nunca permitir", + "willStop": "se detendrá, lo necesita" + }, + "emptyBody": "Los complementos que piden acceso al host aparecen aquí tras instalarlos.", + "emptyTitle": "No hay ningún complemento instalado", + "errors": { + "approval_required": "Este complemento necesita acceso al host. Apruébalo en el cuadro de instalación del navegador.", + "approval_stale": "El paquete cambió desde que lo viste. Revisa la nueva lista y aprueba de nuevo.", + "blocked_by_policy": "La política del administrador bloquea una operación que este complemento necesita. No se cambió nada.", + "locked_by_deployment": "El despliegue bloqueó el acceso al host. No puedes cambiarlo aquí.", + "not_human_surface": "Un administrador debe hacer este cambio en este navegador. Una clave de API o una herramienta no pueden.", + "revision_conflict": "El acceso al host cambió en otro lugar. Se volvió a leer la página. Revísala e inténtalo de nuevo." + }, + "family": { + "connector": "Conectores", + "egress": "Comprobaciones de red", + "finetune": "Ajuste fino", + "quantize": "Cuantización", + "workspace": "Archivos del espacio de trabajo" + }, + "fileEntry": "Definido en el archivo de política o en los parámetros del despliegue.", + "identityMismatch": "Estos permisos se dieron a un paquete distinto con el mismo nombre. No tienen efecto. Quítalos.", + "intro": "Las acciones que los complementos pueden ejecutar en este servidor, quién permitió cada una y cómo retirarla. Un complemento solo tiene lo que apruebas.", + "keepOwn": "Conservar como mío", + "keepOwnNamed": "Conservar como míos los permisos generales de {{name}}", + "loadError": "El acceso al host no se cargó", + "loading": "Leyendo lo que pide este complemento...", + "loadingPage": "Cargando el acceso al host", + "locked": "El despliegue desactivó los cambios en el acceso al host. Puedes leer esta página, pero no cambiarla.", + "newOptional": "La nueva versión también pide {{ops}}. Son opcionales y no están permitidos.", + "noLongerRequested": "Esta versión ya no lo pide. Puedes desactivarlo.", + "notOfficial": "Este complemento viene de {{library}}, no de la biblioteca oficial.", + "op": { + "alreadyAllowed": "Ya permitido", + "blocked": "Bloqueado por la política del administrador", + "byDeployment": "Ya permitido por el despliegue", + "new": "Nuevo en esta versión", + "optional": "Opcional. El complemento funciona sin esto, con menos funciones.", + "required": "Obligatorio. El complemento no funciona sin esto.", + "unsupported": "No admitido por este LocalAI" + }, + "previewFailed": "No se pudo leer el acceso al host que pide este complemento. Inténtalo de nuevo. Si necesita un acceso que no has aprobado, la instalación se rechaza y no cambia nada.", + "removeStale": "Quitar", + "revokeAll": "Revocar todo", + "revokeAllNamed": "Revocar todo el acceso al host de {{name}}", + "risk": { + "changes_data": "Cambia datos", + "network": "Red", + "read": "Lee", + "runs_jobs": "Ejecuta trabajos" + }, + "source": { + "blanket": "General", + "deployment": "Despliegue", + "you": "Tú" + }, + "stepFooter": "Conceder el acceso permite que este complemento ejecute estas acciones en este servidor para quienes lo usan. Puedes revisarlo o revocarlo en la página Acceso al host.", + "stepIntro": "{{name}} pide ejecutar estas acciones en este servidor. Las obligatorias son necesarias para que funcione. Las opcionales quedan desactivadas hasta que las actives.", + "stepTitle": "Acceso al host", + "stopped": "Detenido: necesita {{ops}}, que ya no está permitido.", + "tabWaiting": "Aprobaciones pendientes: {{count}}", + "waiting": { + "body": "La nueva versión pide un acceso al host que la actual no tiene. La versión {{current}} sigue funcionando hasta que apruebes. Operaciones que añade: {{count}}.", + "review": "Revisar y aprobar", + "reviewNamed": "Revisar y aprobar el acceso al host de {{name}}", + "title": "{{name}} {{version}} espera tu aprobación" + } } } diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json index 456ed72168..ec3d629882 100644 --- a/core/http/react-ui/public/locales/fr/addons.json +++ b/core/http/react-ui/public/locales/fr/addons.json @@ -361,7 +361,8 @@ "apps": "Installés", "browse": "Trouver des add-ons", "label": "Sections des modules", - "libraries": "Sources" + "libraries": "Sources", + "access": "Accès à l'hôte" }, "title": "Modules", "trust": { @@ -754,5 +755,149 @@ "loadFailed": "Les paramètres n’ont pas pu être lus", "saveFailed": "Les paramètres n’ont pas pu être enregistrés : {{message}}" } + }, + "hostAccess": { + "addonsTitle": "Modules", + "approve": { + "action": "Approuver et mettre à jour", + "body": "La version {{current}} de {{name}} continue de tourner. Si vous approuvez, la nouvelle version peut exécuter ces actions. Les nouvelles sont signalées.", + "done": "{{name}} a été mis à jour.", + "title": "Approuver l'accès à l'hôte pour {{name}} {{version}}" + }, + "asksNothing": "Ce module ne demande aucun accès à l'hôte.", + "blanket": { + "alsoRevoke": "Révoquer aussi les autorisations données", + "alsoRevokeHint": "Modules qui perdraient l'accès : {{names}}. Un module qui perd un accès nécessaire s'arrête.", + "applyInstalled": "Appliquer aussi aux modules déjà installés", + "applyInstalledHint": "Désactivé : seulement les nouvelles installations et mises à jour.", + "change": "Modifier", + "fixed": "Défini par le déploiement. Vous ne pouvez pas le changer ici.", + "held": "Modules détenant des autorisations de l'interrupteur général : {{count}}", + "includeOptional": "Inclure les opérations facultatives", + "includeOptionalHint": "Désactivé : seules les opérations obligatoires sont accordées.", + "intro": "Désactivé par défaut. Activé, un module que vous installez ou mettez à jour reçoit les opérations d'hôte que son manifeste dit nécessaires, sans l'étape de la fenêtre d'installation. Les opérations de Ne jamais autoriser restent bloquées.", + "modeAny": "Toute bibliothèque", + "modeLabel": "Portée", + "modeOfficial": "Bibliothèque officielle", + "nowOff": "L'approbation générale est désactivée. Les autorisations qu'elle a données restent.", + "nowOn": "L'approbation générale est activée.", + "offBody": "Les nouvelles installations demandent de nouveau votre approbation. Les autorisations déjà données par l'interrupteur restent, marquées comme générales, pour que rien ne s'arrête par surprise.", + "offText": "Désactivée. Vous approuvez chaque module à l'installation.", + "offTitle": "Désactiver l'approbation générale", + "onTitle": "Activer l'approbation générale", + "revokeAll": "Révoquer toutes les autorisations générales", + "revoked": "Autorisations générales révoquées.", + "risk": { + "any": "Activée, chaque module que vous installez depuis n'importe quelle bibliothèque configurée reçoit les opérations d'hôte que son manifeste dit nécessaires, sans que vous les examiniez. Toute entrée de toute bibliothèque reçoit ce qu'elle déclare : une entrée malveillante ou compromise obtient donc ces opérations aussitôt. Les opérations que vous bloquez dans Ne jamais autoriser restent bloquées.", + "official": "Activée, chaque module que vous installez depuis la bibliothèque officielle reçoit les opérations d'hôte que son manifeste dit nécessaires, sans que vous les examiniez. Une entrée compromise reçoit alors exactement les opérations qu'elle déclare. Les opérations que vous bloquez dans Ne jamais autoriser restent bloquées." + }, + "scope": { + "any": "Activée pour les modules de toute bibliothèque.", + "official": "Activée pour les modules de la bibliothèque officielle." + }, + "switch": "Approbation générale", + "title": "Approuver tout ce qu'un module demande", + "turnOff": "Désactiver", + "turnOn": "Activer", + "typeLabel": "Pour confirmer, saisissez : {{phrase}}", + "withOptional": "Les opérations facultatives sont incluses." + }, + "cannotInstallBlocked": "Ce module a besoin de {{ops}}, que la politique de l'administrateur bloque. Il ne peut pas être installé.", + "cannotInstallUnsupported": "Ce module a besoin de {{ops}}, que ce LocalAI ne prend pas en charge. Il ne peut pas être installé.", + "chip": "En attente d'approbation", + "confirm": { + "revokeBody": "{{name}} perd tout ce que vous ou l'interrupteur général avez autorisé. Les autorisations du déploiement restent. S'il perd un accès nécessaire, il s'arrête.", + "revokeTitle": "Révoquer tout l'accès à l'hôte de {{name}} ?", + "stopAction": "Désactiver et arrêter", + "stopBody": "{{name}} ne fonctionne pas sans {{op}}. Il s'arrête et reste arrêté tant que vous ne l'autorisez pas de nouveau.", + "stopTitle": "Désactiver l'accès dont {{name}} a besoin ?" + }, + "deny": { + "add": "Ajouter une règle", + "added": "Règle ajoutée.", + "affectsNone": "Aucun module installé ne perd l'accès.", + "allAddons": "Tous les modules", + "checkFailed": "Impossible de vérifier ce que cette règle change. Rien n'a été enregistré.", + "confirmAction": "Ajouter la règle", + "confirmBody": "S'applique à : {{target}}. Effet immédiat. Les appels en cours se terminent.", + "confirmTitle": "Ne jamais autoriser {{entry}} ?", + "entry": "Opération, famille ou *", + "everything": "Tout", + "fromFile": "Définie dans le déploiement", + "hint": "Une famille se termine par un point, par exemple egress. Un nom exact ressemble à egress.check. Une étoile bloque tout.", + "intro": "Opérations qu'aucun module ne peut utiliser, quoi qu'il les autorise par ailleurs. Une règle ici l'emporte toujours.", + "invalid": "Saisissez une opération comme egress.check, une famille comme egress. ou *.", + "none": "Aucune règle pour l'instant.", + "remove": "Retirer", + "removeNamed": "Retirer la règle {{entry}} pour {{target}}", + "removed": "Règle retirée.", + "target": "S'applique à", + "title": "Ne jamais autoriser", + "willStop": "s'arrêtera, il en a besoin" + }, + "emptyBody": "Les modules qui demandent un accès à l'hôte apparaissent ici après leur installation.", + "emptyTitle": "Aucun module installé", + "errors": { + "approval_required": "Ce module a besoin d'un accès à l'hôte. Approuvez-le dans la fenêtre d'installation du navigateur.", + "approval_stale": "Le paquet a changé depuis que vous l'avez consulté. Examinez la nouvelle liste et approuvez de nouveau.", + "blocked_by_policy": "La politique de l'administrateur bloque une opération dont ce module a besoin. Rien n'a été modifié.", + "locked_by_deployment": "Le déploiement a verrouillé l'accès à l'hôte. Vous ne pouvez pas le changer ici.", + "not_human_surface": "Un administrateur doit faire ce changement dans ce navigateur. Une clé d'API ou un outil ne le peut pas.", + "revision_conflict": "L'accès à l'hôte a changé ailleurs. La page a été relue. Vérifiez et réessayez." + }, + "family": { + "connector": "Connecteurs", + "egress": "Contrôles réseau", + "finetune": "Fine-tuning", + "quantize": "Quantification", + "workspace": "Fichiers de l'espace de travail" + }, + "fileEntry": "Défini dans le fichier de politique ou les options du déploiement.", + "identityMismatch": "Ces autorisations ont été données à un autre paquet du même nom. Elles n'ont aucun effet. Retirez-les.", + "intro": "Les actions que les modules peuvent exécuter sur ce serveur, qui a autorisé chacune et comment la retirer. Un module ne détient que ce que vous approuvez.", + "keepOwn": "Garder comme les miennes", + "keepOwnNamed": "Garder comme les miennes les autorisations générales de {{name}}", + "loadError": "L'accès à l'hôte ne s'est pas chargé", + "loading": "Lecture de ce que demande ce module...", + "loadingPage": "Chargement de l'accès à l'hôte", + "locked": "Le déploiement a désactivé les changements d'accès à l'hôte. Vous pouvez lire cette page, pas la modifier.", + "newOptional": "La nouvelle version demande aussi {{ops}}. Elles sont facultatives et non autorisées.", + "noLongerRequested": "Cette version ne le demande plus. Vous pouvez le désactiver.", + "notOfficial": "Ce module vient de {{library}}, pas de la bibliothèque officielle.", + "op": { + "alreadyAllowed": "Déjà autorisé", + "blocked": "Bloqué par la politique de l'administrateur", + "byDeployment": "Déjà autorisé par le déploiement", + "new": "Nouveau dans cette version", + "optional": "Facultatif. Le module fonctionne sans, avec moins de fonctions.", + "required": "Obligatoire. Le module ne fonctionne pas sans.", + "unsupported": "Non pris en charge par ce LocalAI" + }, + "previewFailed": "Impossible de lire l'accès à l'hôte que demande ce module. Réessayez. S'il exige un accès que vous n'avez pas approuvé, l'installation est refusée et rien ne change.", + "removeStale": "Retirer", + "revokeAll": "Tout révoquer", + "revokeAllNamed": "Révoquer tout l'accès à l'hôte de {{name}}", + "risk": { + "changes_data": "Modifie des données", + "network": "Réseau", + "read": "Lit", + "runs_jobs": "Lance des tâches" + }, + "source": { + "blanket": "Générale", + "deployment": "Déploiement", + "you": "Vous" + }, + "stepFooter": "Accorder l'accès permet à ce module d'exécuter ces actions sur ce serveur pour les personnes qui l'utilisent. Vous pouvez le vérifier ou le révoquer sur la page Accès à l'hôte.", + "stepIntro": "{{name}} demande d'exécuter ces actions sur ce serveur. Les obligatoires sont nécessaires à son fonctionnement. Les facultatives restent désactivées tant que vous ne les activez pas.", + "stepTitle": "Accès à l'hôte", + "stopped": "Arrêté : il a besoin de {{ops}}, qui n'est plus autorisé.", + "tabWaiting": "Approbations en attente : {{count}}", + "waiting": { + "body": "La nouvelle version demande un accès à l'hôte que l'actuelle n'a pas. La version {{current}} continue de tourner jusqu'à votre approbation. Opérations ajoutées : {{count}}.", + "review": "Examiner et approuver", + "reviewNamed": "Examiner et approuver l'accès à l'hôte de {{name}}", + "title": "{{name}} {{version}} attend votre approbation" + } } } diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json index 8976f938f6..9e5fc78b1a 100644 --- a/core/http/react-ui/public/locales/id/addons.json +++ b/core/http/react-ui/public/locales/id/addons.json @@ -357,7 +357,8 @@ "apps": "Terpasang", "browse": "Cari add-on", "label": "Bagian add-on", - "libraries": "Sumber" + "libraries": "Sumber", + "access": "Akses host" }, "title": "Add-on", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "Pengaturan tidak dapat dibaca", "saveFailed": "Pengaturan tidak dapat disimpan: {{message}}" } + }, + "hostAccess": { + "addonsTitle": "Add-on", + "approve": { + "action": "Setujui dan tingkatkan", + "body": "Versi {{current}} dari {{name}} tetap berjalan. Jika Anda setuju, versi baru dapat menjalankan aksi ini. Yang baru ditandai.", + "done": "{{name}} telah ditingkatkan.", + "title": "Setujui akses host untuk {{name}} {{version}}" + }, + "asksNothing": "Add-on ini tidak meminta akses host.", + "blanket": { + "alsoRevoke": "Cabut juga izin yang diberikannya", + "alsoRevokeHint": "Add-on yang akan kehilangan akses: {{names}}. Add-on yang kehilangan akses yang dibutuhkan akan berhenti.", + "applyInstalled": "Terapkan juga ke add-on yang sudah terpasang", + "applyInstalledHint": "Mati: hanya instalasi dan peningkatan baru.", + "change": "Ubah", + "fixed": "Ditetapkan oleh deployment. Anda tidak dapat mengubahnya di sini.", + "held": "Add-on yang memegang izin dari sakelar umum: {{count}}", + "includeOptional": "Sertakan operasi opsional", + "includeOptionalHint": "Mati: hanya operasi wajib yang diberikan.", + "intro": "Mati secara bawaan. Jika menyala, add-on yang Anda pasang atau tingkatkan mendapat operasi host yang menurut manifesnya dibutuhkan, tanpa langkah di dialog instalasi. Operasi di Jangan pernah izinkan tetap diblokir.", + "modeAny": "Pustaka mana pun", + "modeLabel": "Cakupan", + "modeOfficial": "Pustaka resmi", + "nowOff": "Persetujuan umum mati. Izin yang telah diberikannya tetap ada.", + "nowOn": "Persetujuan umum menyala.", + "offBody": "Instalasi baru kembali membutuhkan persetujuan Anda. Izin yang sudah diberikan sakelar tetap ada, ditandai sebagai umum, agar tidak ada yang berhenti tiba-tiba.", + "offText": "Mati. Anda menyetujui tiap add-on saat memasangnya.", + "offTitle": "Matikan persetujuan umum", + "onTitle": "Nyalakan persetujuan umum", + "revokeAll": "Cabut semua izin umum", + "revoked": "Izin umum dicabut.", + "risk": { + "any": "Jika ini menyala, setiap add-on yang Anda pasang dari pustaka mana pun yang Anda atur mendapat operasi host yang menurut manifesnya dibutuhkan, tanpa Anda meninjaunya. Entri mana pun di pustaka mana pun menerima apa yang dideklarasikannya, jadi entri berbahaya atau yang disusupi langsung mendapat operasi itu. Operasi yang Anda blokir di Jangan pernah izinkan tetap diblokir.", + "official": "Jika ini menyala, setiap add-on yang Anda pasang dari pustaka resmi mendapat operasi host yang menurut manifesnya dibutuhkan, tanpa Anda meninjaunya. Entri yang disusupi lalu mendapat persis operasi yang dideklarasikannya. Operasi yang Anda blokir di Jangan pernah izinkan tetap diblokir." + }, + "scope": { + "any": "Menyala untuk add-on dari pustaka mana pun.", + "official": "Menyala untuk add-on dari pustaka resmi." + }, + "switch": "Persetujuan umum", + "title": "Setujui semua yang diminta add-on", + "turnOff": "Matikan", + "turnOn": "Nyalakan", + "typeLabel": "Untuk mengonfirmasi, ketik: {{phrase}}", + "withOptional": "Operasi opsional disertakan." + }, + "cannotInstallBlocked": "Add-on ini butuh {{ops}}, yang diblokir oleh kebijakan administrator. Add-on tidak dapat dipasang.", + "cannotInstallUnsupported": "Add-on ini butuh {{ops}}, yang tidak didukung LocalAI ini. Add-on tidak dapat dipasang.", + "chip": "Menunggu persetujuan", + "confirm": { + "revokeBody": "{{name}} kehilangan semua yang Anda atau sakelar umum izinkan. Izin dari deployment tetap. Jika kehilangan akses yang dibutuhkan, add-on berhenti.", + "revokeTitle": "Cabut semua akses host {{name}}?", + "stopAction": "Matikan dan hentikan", + "stopBody": "{{name}} tidak berjalan tanpa {{op}}. Add-on berhenti dan tetap berhenti sampai Anda mengizinkannya lagi.", + "stopTitle": "Matikan akses yang dibutuhkan {{name}}?" + }, + "deny": { + "add": "Tambah aturan", + "added": "Aturan ditambahkan.", + "affectsNone": "Tidak ada add-on terpasang yang kehilangan akses.", + "allAddons": "Semua add-on", + "checkFailed": "Tidak dapat memeriksa apa yang diubah aturan ini. Tidak ada yang disimpan.", + "confirmAction": "Tambah aturan", + "confirmBody": "Berlaku untuk: {{target}}. Langsung berlaku. Panggilan yang sedang berjalan diselesaikan.", + "confirmTitle": "Jangan pernah izinkan {{entry}}?", + "entry": "Operasi, keluarga, atau *", + "everything": "Semuanya", + "fromFile": "Ditetapkan di deployment", + "hint": "Keluarga diakhiri titik, misalnya egress. Nama persis seperti egress.check. Tanda bintang memblokir semuanya.", + "intro": "Operasi yang tidak boleh dipakai add-on mana pun, apa pun yang mengizinkannya. Aturan di sini selalu menang.", + "invalid": "Tulis operasi seperti egress.check, keluarga seperti egress., atau *.", + "none": "Belum ada aturan.", + "remove": "Hapus", + "removeNamed": "Hapus aturan {{entry}} untuk {{target}}", + "removed": "Aturan dihapus.", + "target": "Berlaku untuk", + "title": "Jangan pernah izinkan", + "willStop": "akan berhenti, membutuhkannya" + }, + "emptyBody": "Add-on yang meminta akses host muncul di sini setelah dipasang.", + "emptyTitle": "Belum ada add-on terpasang", + "errors": { + "approval_required": "Add-on ini butuh akses host. Setujui di dialog instalasi di browser.", + "approval_stale": "Paket berubah sejak Anda melihatnya. Tinjau daftar baru dan setujui lagi.", + "blocked_by_policy": "Kebijakan administrator memblokir operasi yang dibutuhkan add-on ini. Tidak ada yang diubah.", + "locked_by_deployment": "Deployment mengunci akses host. Anda tidak dapat mengubahnya di sini.", + "not_human_surface": "Administrator harus membuat perubahan ini di browser ini. Kunci API atau alat tidak bisa.", + "revision_conflict": "Akses host berubah di tempat lain. Halaman dibaca ulang. Periksa lalu coba lagi." + }, + "family": { + "connector": "Konektor", + "egress": "Pemeriksaan jaringan", + "finetune": "Fine-tuning", + "quantize": "Kuantisasi", + "workspace": "Berkas ruang kerja" + }, + "fileEntry": "Ditetapkan di berkas kebijakan atau flag deployment.", + "identityMismatch": "Izin ini diberikan untuk paket lain dengan nama yang sama. Izin tidak berlaku. Hapus.", + "intro": "Aksi yang boleh dijalankan add-on di server ini, siapa yang mengizinkan tiap aksi, dan cara menariknya kembali. Add-on hanya memegang apa yang Anda setujui.", + "keepOwn": "Jadikan milik saya", + "keepOwnNamed": "Jadikan izin umum {{name}} sebagai milik saya", + "loadError": "Akses host tidak termuat", + "loading": "Membaca apa yang diminta add-on ini...", + "loadingPage": "Memuat akses host", + "locked": "Deployment mematikan perubahan akses host. Anda dapat membaca halaman ini tetapi tidak mengubahnya.", + "newOptional": "Versi baru juga meminta {{ops}}. Ini opsional dan tidak diizinkan.", + "noLongerRequested": "Versi ini tidak lagi memintanya. Anda dapat mematikannya.", + "notOfficial": "Add-on ini berasal dari {{library}}, bukan pustaka resmi.", + "op": { + "alreadyAllowed": "Sudah diizinkan", + "blocked": "Diblokir oleh kebijakan administrator", + "byDeployment": "Sudah diizinkan oleh deployment", + "new": "Baru di versi ini", + "optional": "Opsional. Add-on berjalan tanpanya, dengan fitur lebih sedikit.", + "required": "Wajib. Add-on tidak berjalan tanpanya.", + "unsupported": "Tidak didukung LocalAI ini" + }, + "previewFailed": "Tidak dapat membaca akses host yang diminta add-on ini. Coba lagi. Jika add-on butuh akses yang belum Anda setujui, instalasi ditolak dan tidak ada yang berubah.", + "removeStale": "Hapus", + "revokeAll": "Cabut semua", + "revokeAllNamed": "Cabut semua akses host {{name}}", + "risk": { + "changes_data": "Mengubah data", + "network": "Jaringan", + "read": "Membaca", + "runs_jobs": "Menjalankan tugas" + }, + "source": { + "blanket": "Umum", + "deployment": "Deployment", + "you": "Anda" + }, + "stepFooter": "Memberi akses mengizinkan add-on ini menjalankan aksi tersebut di server ini bagi orang yang memakainya. Anda dapat meninjau atau mencabutnya di halaman Akses host.", + "stepIntro": "{{name}} meminta menjalankan aksi ini di server ini. Yang wajib dibutuhkan agar add-on berfungsi. Yang opsional tetap mati sampai Anda menyalakannya.", + "stepTitle": "Akses host", + "stopped": "Berhenti: butuh {{ops}}, yang tidak lagi diizinkan.", + "tabWaiting": "Persetujuan menunggu: {{count}}", + "waiting": { + "body": "Versi baru meminta akses host yang tidak dimiliki versi sekarang. Versi {{current}} tetap berjalan sampai Anda menyetujui. Operasi yang ditambahkan: {{count}}.", + "review": "Tinjau dan setujui", + "reviewNamed": "Tinjau dan setujui akses host {{name}}", + "title": "{{name}} {{version}} menunggu persetujuan Anda" + } } } diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json index d30c878506..e61b731880 100644 --- a/core/http/react-ui/public/locales/it/addons.json +++ b/core/http/react-ui/public/locales/it/addons.json @@ -357,7 +357,8 @@ "apps": "Installati", "browse": "Trova componenti", "label": "Sezioni dei componenti aggiuntivi", - "libraries": "Fonti" + "libraries": "Fonti", + "access": "Accesso all'host" }, "title": "Componenti aggiuntivi", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "Non è stato possibile leggere le impostazioni", "saveFailed": "Non è stato possibile salvare le impostazioni: {{message}}" } + }, + "hostAccess": { + "addonsTitle": "Add-on", + "approve": { + "action": "Approva e aggiorna", + "body": "La versione {{current}} di {{name}} continua a funzionare. Se approvi, la nuova versione può eseguire queste azioni. Le nuove sono segnate.", + "done": "{{name}} è stato aggiornato.", + "title": "Approva l'accesso all'host per {{name}} {{version}}" + }, + "asksNothing": "Questo add-on non chiede accesso all'host.", + "blanket": { + "alsoRevoke": "Revoca anche i permessi che ha dato", + "alsoRevokeHint": "Add-on che perderebbero l'accesso: {{names}}. Un add-on che perde un accesso necessario si ferma.", + "applyInstalled": "Applica anche agli add-on già installati", + "applyInstalledHint": "Spento: solo nuove installazioni e aggiornamenti.", + "change": "Modifica", + "fixed": "Impostato dal deployment. Non puoi cambiarlo qui.", + "held": "Add-on con permessi dati dall'interruttore generale: {{count}}", + "includeOptional": "Includi le operazioni facoltative", + "includeOptionalHint": "Spento: si concedono solo le operazioni obbligatorie.", + "intro": "Spento per impostazione predefinita. Se acceso, un add-on che installi o aggiorni riceve le operazioni dell'host che il suo manifest dice di richiedere, senza il passaggio nella finestra di installazione. Le operazioni in Non consentire mai restano bloccate.", + "modeAny": "Qualsiasi libreria", + "modeLabel": "Ambito", + "modeOfficial": "Libreria ufficiale", + "nowOff": "L'approvazione generale è spenta. I permessi che ha dato restano.", + "nowOn": "L'approvazione generale è accesa.", + "offBody": "Le nuove installazioni richiedono di nuovo la tua approvazione. I permessi già dati dall'interruttore restano, segnati come generali, così nulla si ferma di sorpresa.", + "offText": "Spenta. Approvi ogni add-on quando lo installi.", + "offTitle": "Spegni l'approvazione generale", + "onTitle": "Accendi l'approvazione generale", + "revokeAll": "Revoca tutti i permessi generali", + "revoked": "Permessi generali revocati.", + "risk": { + "any": "Con questa opzione accesa, ogni add-on che installi da qualsiasi libreria configurata riceve le operazioni dell'host che il suo manifest dice di richiedere, senza che tu le controlli. Qualsiasi voce di qualsiasi libreria riceve ciò che dichiara, quindi una voce malevola o compromessa ottiene subito quelle operazioni. Le operazioni bloccate in Non consentire mai restano bloccate.", + "official": "Con questa opzione accesa, ogni add-on che installi dalla libreria ufficiale riceve le operazioni dell'host che il suo manifest dice di richiedere, senza che tu le controlli. Una voce compromessa riceve esattamente le operazioni che dichiara. Le operazioni bloccate in Non consentire mai restano bloccate." + }, + "scope": { + "any": "Accesa per gli add-on di qualsiasi libreria.", + "official": "Accesa per gli add-on della libreria ufficiale." + }, + "switch": "Approvazione generale", + "title": "Approva tutto ciò che un add-on chiede", + "turnOff": "Spegni", + "turnOn": "Accendi", + "typeLabel": "Per confermare, scrivi: {{phrase}}", + "withOptional": "Le operazioni facoltative sono incluse." + }, + "cannotInstallBlocked": "Questo add-on richiede {{ops}}, che la politica dell'amministratore blocca. Non può essere installato.", + "cannotInstallUnsupported": "Questo add-on richiede {{ops}}, che questo LocalAI non supporta. Non può essere installato.", + "chip": "In attesa di approvazione", + "confirm": { + "revokeBody": "{{name}} perde tutto ciò che hai consentito tu o l'interruttore generale. I permessi del deployment restano. Se perde un accesso necessario, si ferma.", + "revokeTitle": "Revocare tutto l'accesso all'host di {{name}}?", + "stopAction": "Spegni e ferma", + "stopBody": "{{name}} non funziona senza {{op}}. Si ferma e resta fermo finché non lo consenti di nuovo.", + "stopTitle": "Spegnere l'accesso di cui {{name}} ha bisogno?" + }, + "deny": { + "add": "Aggiungi regola", + "added": "Regola aggiunta.", + "affectsNone": "Nessun add-on installato perde l'accesso.", + "allAddons": "Tutti gli add-on", + "checkFailed": "Non riesco a verificare cosa cambia questa regola. Non è stato salvato nulla.", + "confirmAction": "Aggiungi regola", + "confirmBody": "Vale per: {{target}}. Ha effetto subito. Le chiamate già in corso finiscono.", + "confirmTitle": "Non consentire mai {{entry}}?", + "entry": "Operazione, famiglia o *", + "everything": "Tutto", + "fromFile": "Impostata nel deployment", + "hint": "Una famiglia finisce con un punto, per esempio egress. Un nome esatto è come egress.check. Un asterisco blocca tutto.", + "intro": "Operazioni che nessun add-on può usare, qualunque altra cosa le consenta. Una regola qui vince sempre.", + "invalid": "Scrivi un'operazione come egress.check, una famiglia come egress. oppure *.", + "none": "Nessuna regola per ora.", + "remove": "Rimuovi", + "removeNamed": "Rimuovi la regola {{entry}} per {{target}}", + "removed": "Regola rimossa.", + "target": "Vale per", + "title": "Non consentire mai", + "willStop": "si fermerà, ne ha bisogno" + }, + "emptyBody": "Gli add-on che chiedono accesso all'host compaiono qui dopo l'installazione.", + "emptyTitle": "Nessun add-on installato", + "errors": { + "approval_required": "Questo add-on richiede accesso all'host. Approvalo nella finestra di installazione nel browser.", + "approval_stale": "Il pacchetto è cambiato da quando l'hai guardato. Controlla il nuovo elenco e approva di nuovo.", + "blocked_by_policy": "La politica dell'amministratore blocca un'operazione necessaria a questo add-on. Non è cambiato nulla.", + "locked_by_deployment": "Il deployment ha bloccato l'accesso all'host. Non puoi cambiarlo qui.", + "not_human_surface": "Questa modifica deve farla un amministratore in questo browser. Una chiave API o uno strumento non possono.", + "revision_conflict": "L'accesso all'host è cambiato altrove. La pagina è stata riletta. Controlla e riprova." + }, + "family": { + "connector": "Connettori", + "egress": "Controlli di rete", + "finetune": "Fine-tuning", + "quantize": "Quantizzazione", + "workspace": "File dello spazio di lavoro" + }, + "fileEntry": "Impostato nel file di politica o nei flag del deployment.", + "identityMismatch": "Questi permessi sono stati dati a un pacchetto diverso con lo stesso nome. Non hanno effetto. Rimuovili.", + "intro": "Le azioni che gli add-on possono eseguire su questo server, chi ha consentito ciascuna e come ritirarla. Un add-on ha solo ciò che approvi.", + "keepOwn": "Tieni come mio", + "keepOwnNamed": "Tieni come miei i permessi generali di {{name}}", + "loadError": "L'accesso all'host non si è caricato", + "loading": "Lettura di ciò che questo add-on richiede...", + "loadingPage": "Caricamento dell'accesso all'host", + "locked": "Il deployment ha disattivato le modifiche all'accesso all'host. Puoi leggere questa pagina ma non modificarla.", + "newOptional": "La nuova versione chiede anche {{ops}}. Sono facoltative e non consentite.", + "noLongerRequested": "Questa versione non lo chiede più. Puoi spegnerlo.", + "notOfficial": "Questo add-on viene da {{library}}, non dalla libreria ufficiale.", + "op": { + "alreadyAllowed": "Già consentito", + "blocked": "Bloccato dalla politica dell'amministratore", + "byDeployment": "Già consentito dal deployment", + "new": "Nuovo in questa versione", + "optional": "Facoltativo. L'add-on funziona anche senza, con meno funzioni.", + "required": "Obbligatorio. L'add-on non funziona senza.", + "unsupported": "Non supportato da questo LocalAI" + }, + "previewFailed": "Non riesco a leggere l'accesso all'host richiesto da questo add-on. Riprova. Se richiede un accesso che non hai approvato, l'installazione viene rifiutata e non cambia nulla.", + "removeStale": "Rimuovi", + "revokeAll": "Revoca tutto", + "revokeAllNamed": "Revoca tutto l'accesso all'host di {{name}}", + "risk": { + "changes_data": "Modifica dati", + "network": "Rete", + "read": "Legge", + "runs_jobs": "Avvia lavori" + }, + "source": { + "blanket": "Generale", + "deployment": "Deployment", + "you": "Tu" + }, + "stepFooter": "Concedere l'accesso permette a questo add-on di eseguire queste azioni su questo server per chi lo usa. Puoi controllarlo o revocarlo nella pagina Accesso all'host.", + "stepIntro": "{{name}} chiede di eseguire queste azioni su questo server. Quelle obbligatorie servono per farlo funzionare. Quelle facoltative restano spente finché non le attivi.", + "stepTitle": "Accesso all'host", + "stopped": "Fermato: richiede {{ops}}, che non è più consentito.", + "tabWaiting": "Approvazioni in attesa: {{count}}", + "waiting": { + "body": "La nuova versione chiede un accesso all'host che quella attuale non ha. La versione {{current}} continua a funzionare finché non approvi. Operazioni aggiunte: {{count}}.", + "review": "Controlla e approva", + "reviewNamed": "Controlla e approva l'accesso all'host di {{name}}", + "title": "{{name}} {{version}} aspetta la tua approvazione" + } } } diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json index 089773e8ca..dc42d8338f 100644 --- a/core/http/react-ui/public/locales/ko/addons.json +++ b/core/http/react-ui/public/locales/ko/addons.json @@ -357,7 +357,8 @@ "apps": "설치됨", "browse": "애드온 찾기", "label": "애드온 섹션", - "libraries": "소스" + "libraries": "소스", + "access": "호스트 접근" }, "title": "애드온", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "설정을 읽을 수 없습니다", "saveFailed": "설정을 저장할 수 없습니다: {{message}}" } + }, + "hostAccess": { + "addonsTitle": "애드온", + "approve": { + "action": "승인 후 업그레이드", + "body": "{{name}} {{current}} 버전은 계속 실행됩니다. 승인하면 새 버전이 이 작업을 실행할 수 있습니다. 새 항목은 표시됩니다.", + "done": "{{name}}을(를) 업그레이드했습니다.", + "title": "{{name}} {{version}}의 호스트 접근 승인" + }, + "asksNothing": "이 애드온은 호스트 접근을 요청하지 않습니다.", + "blanket": { + "alsoRevoke": "부여한 허용도 함께 취소", + "alsoRevokeHint": "접근을 잃을 애드온: {{names}}. 필요한 접근을 잃은 애드온은 중지됩니다.", + "applyInstalled": "이미 설치된 애드온에도 적용", + "applyInstalledHint": "꺼짐: 새 설치와 업그레이드에만 적용합니다.", + "change": "변경", + "fixed": "배포 설정으로 정해졌습니다. 여기서 바꿀 수 없습니다.", + "held": "일괄 스위치가 부여한 허용을 가진 애드온: {{count}}", + "includeOptional": "선택 작업 포함", + "includeOptionalHint": "꺼짐: 필수 작업만 부여합니다.", + "intro": "기본값은 꺼짐입니다. 켜면 설치하거나 업그레이드하는 애드온이 설치 대화 상자의 단계 없이, 매니페스트에 필요하다고 적힌 호스트 작업을 받습니다. 허용 안 함에 있는 작업은 계속 차단됩니다.", + "modeAny": "모든 라이브러리", + "modeLabel": "범위", + "modeOfficial": "공식 라이브러리", + "nowOff": "일괄 승인이 꺼졌습니다. 이미 부여한 허용은 유지됩니다.", + "nowOn": "일괄 승인이 켜졌습니다.", + "offBody": "새로 설치할 때 다시 승인이 필요합니다. 스위치가 이미 부여한 허용은 일괄로 표시된 채 유지되어 아무것도 갑자기 멈추지 않습니다.", + "offText": "꺼짐. 설치할 때 애드온마다 승인합니다.", + "offTitle": "일괄 승인 끄기", + "onTitle": "일괄 승인 켜기", + "revokeAll": "모든 일괄 허용 취소", + "revoked": "일괄 허용을 취소했습니다.", + "risk": { + "any": "이 기능을 켜면 설정한 모든 라이브러리에서 설치하는 모든 애드온이 검토 없이, 매니페스트에 필요하다고 적힌 호스트 작업을 받습니다. 어떤 라이브러리의 어떤 항목이든 선언한 것을 받으므로, 악의적이거나 손상된 항목도 그 작업을 즉시 얻습니다. 허용 안 함에서 차단한 작업은 계속 차단됩니다.", + "official": "이 기능을 켜면 공식 라이브러리에서 설치하는 모든 애드온이 검토 없이, 매니페스트에 필요하다고 적힌 호스트 작업을 받습니다. 손상된 항목은 선언한 작업을 정확히 받게 됩니다. 허용 안 함에서 차단한 작업은 계속 차단됩니다." + }, + "scope": { + "any": "모든 라이브러리의 애드온에 켜져 있습니다.", + "official": "공식 라이브러리의 애드온에 켜져 있습니다." + }, + "switch": "일괄 승인", + "title": "애드온이 요청하는 모든 것을 승인", + "turnOff": "끄기", + "turnOn": "켜기", + "typeLabel": "확인하려면 다음을 입력하세요: {{phrase}}", + "withOptional": "선택 작업도 포함됩니다." + }, + "cannotInstallBlocked": "이 애드온은 {{ops}}이(가) 필요하지만 관리자 정책이 차단합니다. 설치할 수 없습니다.", + "cannotInstallUnsupported": "이 애드온은 {{ops}}이(가) 필요하지만 이 LocalAI가 지원하지 않습니다. 설치할 수 없습니다.", + "chip": "승인 대기 중", + "confirm": { + "revokeBody": "{{name}}은(는) 내가 또는 일괄 스위치가 허용한 모든 것을 잃습니다. 배포 설정의 허용은 유지됩니다. 필요한 접근을 잃으면 중지됩니다.", + "revokeTitle": "{{name}}의 모든 호스트 접근을 취소할까요?", + "stopAction": "끄고 중지", + "stopBody": "{{name}}은(는) {{op}} 없이 실행되지 않습니다. 중지되며 다시 허용할 때까지 중지된 채로 남습니다.", + "stopTitle": "{{name}}에 필요한 접근을 끌까요?" + }, + "deny": { + "add": "규칙 추가", + "added": "규칙을 추가했습니다.", + "affectsNone": "접근을 잃는 설치된 애드온이 없습니다.", + "allAddons": "모든 애드온", + "checkFailed": "이 규칙이 무엇을 바꾸는지 확인하지 못했습니다. 아무것도 저장하지 않았습니다.", + "confirmAction": "규칙 추가", + "confirmBody": "적용 대상: {{target}}. 즉시 적용됩니다. 이미 실행 중인 호출은 끝까지 진행됩니다.", + "confirmTitle": "{{entry}}을(를) 허용 안 함으로 할까요?", + "entry": "작업, 그룹 또는 *", + "everything": "전체", + "fromFile": "배포에서 설정됨", + "hint": "그룹은 점으로 끝납니다. 예: egress. 정확한 이름은 egress.check 같은 형태입니다. 별표는 모두 차단합니다.", + "intro": "다른 무엇이 허용하더라도 어떤 애드온도 쓸 수 없는 작업입니다. 여기의 규칙이 항상 우선합니다.", + "invalid": "egress.check 같은 작업, egress. 같은 그룹 또는 *를 입력하세요.", + "none": "아직 규칙이 없습니다.", + "remove": "제거", + "removeNamed": "{{target}}의 {{entry}} 규칙 제거", + "removed": "규칙을 제거했습니다.", + "target": "적용 대상", + "title": "허용 안 함", + "willStop": "중지됨, 이 작업이 필요함" + }, + "emptyBody": "호스트 접근을 요청하는 애드온은 설치 후 여기에 표시됩니다.", + "emptyTitle": "설치된 애드온이 없습니다", + "errors": { + "approval_required": "이 애드온에는 호스트 접근이 필요합니다. 브라우저의 설치 대화 상자에서 승인하세요.", + "approval_stale": "확인한 뒤 패키지가 바뀌었습니다. 새 목록을 검토하고 다시 승인하세요.", + "blocked_by_policy": "관리자 정책이 이 애드온에 필요한 작업을 차단합니다. 아무것도 바뀌지 않았습니다.", + "locked_by_deployment": "배포 설정이 호스트 접근을 잠갔습니다. 여기서 바꿀 수 없습니다.", + "not_human_surface": "관리자가 이 브라우저에서 직접 변경해야 합니다. API 키나 도구로는 할 수 없습니다.", + "revision_conflict": "다른 곳에서 호스트 접근이 바뀌었습니다. 페이지를 다시 읽었습니다. 확인한 뒤 다시 시도하세요." + }, + "family": { + "connector": "커넥터", + "egress": "네트워크 확인", + "finetune": "파인튜닝", + "quantize": "양자화", + "workspace": "작업 공간 파일" + }, + "fileEntry": "배포의 정책 파일 또는 플래그에서 설정됨.", + "identityMismatch": "이 허용은 이름이 같은 다른 패키지에 부여된 것입니다. 효력이 없습니다. 제거하세요.", + "intro": "애드온이 이 서버에서 실행할 수 있는 작업, 각 작업을 허용한 주체, 취소하는 방법입니다. 애드온은 승인한 것만 가집니다.", + "keepOwn": "내 허용으로 유지", + "keepOwnNamed": "{{name}}의 일괄 허용을 내 허용으로 유지", + "loadError": "호스트 접근을 불러오지 못했습니다", + "loading": "이 애드온이 요청하는 내용을 읽는 중...", + "loadingPage": "호스트 접근을 불러오는 중", + "locked": "배포 설정이 호스트 접근 변경을 꺼 두었습니다. 이 페이지는 읽을 수만 있습니다.", + "newOptional": "새 버전은 {{ops}}도 요청합니다. 선택 항목이며 허용되지 않았습니다.", + "noLongerRequested": "이 버전은 더 이상 요청하지 않습니다. 끌 수 있습니다.", + "notOfficial": "이 애드온은 공식 라이브러리가 아니라 {{library}}에서 왔습니다.", + "op": { + "alreadyAllowed": "이미 허용됨", + "blocked": "관리자 정책이 차단함", + "byDeployment": "배포 설정이 이미 허용함", + "new": "이 버전에서 새로 추가됨", + "optional": "선택. 없어도 애드온은 실행되지만 기능이 줄어듭니다.", + "required": "필수. 이것이 없으면 애드온이 실행되지 않습니다.", + "unsupported": "이 LocalAI가 지원하지 않음" + }, + "previewFailed": "이 애드온이 요청하는 호스트 접근을 읽지 못했습니다. 다시 시도하세요. 승인하지 않은 접근이 필요하면 설치가 거부되고 아무것도 바뀌지 않습니다.", + "removeStale": "제거", + "revokeAll": "모두 취소", + "revokeAllNamed": "{{name}}의 모든 호스트 접근 취소", + "risk": { + "changes_data": "데이터 변경", + "network": "네트워크", + "read": "읽기", + "runs_jobs": "작업 실행" + }, + "source": { + "blanket": "일괄", + "deployment": "배포", + "you": "나" + }, + "stepFooter": "허용하면 이 애드온이 이 서버에서 사용자를 위해 이 작업을 실행할 수 있습니다. 호스트 접근 페이지에서 검토하거나 취소할 수 있습니다.", + "stepIntro": "{{name}}이(가) 이 서버에서 다음 작업을 실행하려고 합니다. 필수 항목은 작동에 꼭 필요합니다. 선택 항목은 켜기 전까지 꺼져 있습니다.", + "stepTitle": "호스트 접근", + "stopped": "중지됨: {{ops}}이(가) 필요하지만 더 이상 허용되지 않습니다.", + "tabWaiting": "대기 중인 승인: {{count}}", + "waiting": { + "body": "새 버전은 현재 버전에 없는 호스트 접근을 요청합니다. 승인할 때까지 {{current}} 버전이 계속 실행됩니다. 추가되는 작업: {{count}}.", + "review": "검토 후 승인", + "reviewNamed": "{{name}}의 호스트 접근 검토 후 승인", + "title": "{{name}} {{version}}이(가) 승인을 기다립니다" + } } } diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json index b855e52840..3e06eb8734 100644 --- a/core/http/react-ui/public/locales/pt-BR/addons.json +++ b/core/http/react-ui/public/locales/pt-BR/addons.json @@ -357,7 +357,8 @@ "apps": "Instalados", "browse": "Encontrar complementos", "label": "Seções de complementos", - "libraries": "Fontes" + "libraries": "Fontes", + "access": "Acesso ao host" }, "title": "Complementos", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "Não foi possível ler as configurações", "saveFailed": "Não foi possível salvar as configurações: {{message}}" } + }, + "hostAccess": { + "addonsTitle": "Add-ons", + "approve": { + "action": "Aprovar e atualizar", + "body": "A versão {{current}} de {{name}} continua rodando. Se você aprovar, a nova versão pode executar estas ações. As novas estão marcadas.", + "done": "{{name}} foi atualizado.", + "title": "Aprovar o acesso ao host de {{name}} {{version}}" + }, + "asksNothing": "Este add-on não pede acesso ao host.", + "blanket": { + "alsoRevoke": "Revogar também as permissões que ela deu", + "alsoRevokeHint": "Add-ons que perderiam acesso: {{names}}. Um add-on que perde um acesso de que precisa para.", + "applyInstalled": "Aplicar também aos add-ons já instalados", + "applyInstalledHint": "Desligado: só novas instalações e atualizações.", + "change": "Alterar", + "fixed": "Definido pela implantação. Você não pode alterar aqui.", + "held": "Add-ons com permissões dadas pela chave geral: {{count}}", + "includeOptional": "Incluir operações opcionais", + "includeOptionalHint": "Desligado: só as operações obrigatórias são concedidas.", + "intro": "Desligado por padrão. Ligado, um add-on que você instala ou atualiza recebe as operações do host que seu manifesto diz precisar, sem a etapa da janela de instalação. As operações em Nunca permitir continuam bloqueadas.", + "modeAny": "Qualquer biblioteca", + "modeLabel": "Alcance", + "modeOfficial": "Biblioteca oficial", + "nowOff": "A aprovação geral está desligada. As permissões que ela deu ficam.", + "nowOn": "A aprovação geral está ligada.", + "offBody": "Novas instalações voltam a precisar da sua aprovação. As permissões que a chave já deu ficam, marcadas como gerais, para que nada pare de surpresa.", + "offText": "Desligada. Você aprova cada add-on ao instalar.", + "offTitle": "Desligar a aprovação geral", + "onTitle": "Ligar a aprovação geral", + "revokeAll": "Revogar todas as permissões gerais", + "revoked": "Permissões gerais revogadas.", + "risk": { + "any": "Com isto ligado, cada add-on que você instala de qualquer biblioteca configurada recebe as operações do host que seu manifesto diz precisar, sem você revisar. Qualquer entrada de qualquer biblioteca recebe o que declara, então uma entrada maliciosa ou comprometida obtém essas operações na hora. As operações que você bloqueia em Nunca permitir continuam bloqueadas.", + "official": "Com isto ligado, cada add-on que você instala da biblioteca oficial recebe as operações do host que seu manifesto diz precisar, sem você revisar. Uma entrada comprometida recebe exatamente as operações que declara. As operações que você bloqueia em Nunca permitir continuam bloqueadas." + }, + "scope": { + "any": "Ligada para add-ons de qualquer biblioteca.", + "official": "Ligada para add-ons da biblioteca oficial." + }, + "switch": "Aprovação geral", + "title": "Aprovar tudo o que um add-on pede", + "turnOff": "Desligar", + "turnOn": "Ligar", + "typeLabel": "Para confirmar, digite: {{phrase}}", + "withOptional": "As operações opcionais estão incluídas." + }, + "cannotInstallBlocked": "Este add-on precisa de {{ops}}, que a política do administrador bloqueia. Ele não pode ser instalado.", + "cannotInstallUnsupported": "Este add-on precisa de {{ops}}, que este LocalAI não suporta. Ele não pode ser instalado.", + "chip": "Aguardando aprovação", + "confirm": { + "revokeBody": "{{name}} perde tudo o que você ou a chave geral permitiu. As permissões da implantação ficam. Se perder um acesso de que precisa, ele para.", + "revokeTitle": "Revogar todo o acesso ao host de {{name}}?", + "stopAction": "Desligar e parar", + "stopBody": "{{name}} não funciona sem {{op}}. Ele para e continua parado até você permitir de novo.", + "stopTitle": "Desligar o acesso de que {{name}} precisa?" + }, + "deny": { + "add": "Adicionar regra", + "added": "Regra adicionada.", + "affectsNone": "Nenhum add-on instalado perde acesso.", + "allAddons": "Todos os add-ons", + "checkFailed": "Não foi possível verificar o que esta regra muda. Nada foi salvo.", + "confirmAction": "Adicionar regra", + "confirmBody": "Vale para: {{target}}. Tem efeito na hora. As chamadas em andamento terminam.", + "confirmTitle": "Nunca permitir {{entry}}?", + "entry": "Operação, família ou *", + "everything": "Tudo", + "fromFile": "Definida na implantação", + "hint": "Uma família termina com ponto, por exemplo egress. Um nome exato é como egress.check. Um asterisco bloqueia tudo.", + "intro": "Operações que nenhum add-on pode usar, não importa o que mais as permita. Uma regra aqui sempre vence.", + "invalid": "Escreva uma operação como egress.check, uma família como egress. ou *.", + "none": "Ainda sem regras.", + "remove": "Remover", + "removeNamed": "Remover a regra {{entry}} para {{target}}", + "removed": "Regra removida.", + "target": "Vale para", + "title": "Nunca permitir", + "willStop": "vai parar, precisa disto" + }, + "emptyBody": "Os add-ons que pedem acesso ao host aparecem aqui depois de instalados.", + "emptyTitle": "Nenhum add-on instalado", + "errors": { + "approval_required": "Este add-on precisa de acesso ao host. Aprove na janela de instalação do navegador.", + "approval_stale": "O pacote mudou desde que você o viu. Revise a nova lista e aprove de novo.", + "blocked_by_policy": "A política do administrador bloqueia uma operação de que este add-on precisa. Nada foi alterado.", + "locked_by_deployment": "A implantação bloqueou o acesso ao host. Você não pode alterá-lo aqui.", + "not_human_surface": "Um administrador precisa fazer esta mudança neste navegador. Uma chave de API ou uma ferramenta não pode.", + "revision_conflict": "O acesso ao host mudou em outro lugar. A página foi lida de novo. Confira e tente de novo." + }, + "family": { + "connector": "Conectores", + "egress": "Verificações de rede", + "finetune": "Ajuste fino", + "quantize": "Quantização", + "workspace": "Arquivos do espaço de trabalho" + }, + "fileEntry": "Definido no arquivo de política ou nas opções da implantação.", + "identityMismatch": "Estas permissões foram dadas a outro pacote com o mesmo nome. Não têm efeito. Remova-as.", + "intro": "As ações que os add-ons podem executar neste servidor, quem permitiu cada uma e como retirá-la. Um add-on só tem o que você aprova.", + "keepOwn": "Manter como meu", + "keepOwnNamed": "Manter como minhas as permissões gerais de {{name}}", + "loadError": "O acesso ao host não carregou", + "loading": "Lendo o que este add-on pede...", + "loadingPage": "Carregando o acesso ao host", + "locked": "A implantação desativou mudanças no acesso ao host. Você pode ler esta página, mas não alterá-la.", + "newOptional": "A nova versão também pede {{ops}}. São opcionais e não permitidas.", + "noLongerRequested": "Esta versão não pede mais. Você pode desligar.", + "notOfficial": "Este add-on vem de {{library}}, não da biblioteca oficial.", + "op": { + "alreadyAllowed": "Já permitido", + "blocked": "Bloqueado pela política do administrador", + "byDeployment": "Já permitido pela implantação", + "new": "Novo nesta versão", + "optional": "Opcional. O add-on funciona sem isso, com menos recursos.", + "required": "Obrigatório. O add-on não funciona sem isso.", + "unsupported": "Não suportado por este LocalAI" + }, + "previewFailed": "Não foi possível ler o acesso ao host que este add-on pede. Tente de novo. Se ele precisar de um acesso que você não aprovou, a instalação é recusada e nada muda.", + "removeStale": "Remover", + "revokeAll": "Revogar tudo", + "revokeAllNamed": "Revogar todo o acesso ao host de {{name}}", + "risk": { + "changes_data": "Altera dados", + "network": "Rede", + "read": "Lê", + "runs_jobs": "Executa tarefas" + }, + "source": { + "blanket": "Geral", + "deployment": "Implantação", + "you": "Você" + }, + "stepFooter": "Conceder o acesso permite que este add-on execute estas ações neste servidor para quem o usa. Você pode revisar ou revogar na página Acesso ao host.", + "stepIntro": "{{name}} pede para executar estas ações neste servidor. As obrigatórias são necessárias para ele funcionar. As opcionais ficam desligadas até você ligá-las.", + "stepTitle": "Acesso ao host", + "stopped": "Parado: precisa de {{ops}}, que não é mais permitido.", + "tabWaiting": "Aprovações pendentes: {{count}}", + "waiting": { + "body": "A nova versão pede um acesso ao host que a atual não tem. A versão {{current}} continua rodando até você aprovar. Operações adicionadas: {{count}}.", + "review": "Revisar e aprovar", + "reviewNamed": "Revisar e aprovar o acesso ao host de {{name}}", + "title": "{{name}} {{version}} aguarda sua aprovação" + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json index e1a9964b5e..8c134bb6de 100644 --- a/core/http/react-ui/public/locales/zh-CN/addons.json +++ b/core/http/react-ui/public/locales/zh-CN/addons.json @@ -357,7 +357,8 @@ "apps": "已安装", "browse": "查找附加组件", "label": "附加组件分区", - "libraries": "来源" + "libraries": "来源", + "access": "主机访问" }, "title": "附加组件", "trust": { @@ -750,5 +751,149 @@ "loadFailed": "无法读取设置", "saveFailed": "无法保存设置:{{message}}" } + }, + "hostAccess": { + "addonsTitle": "插件", + "approve": { + "action": "批准并升级", + "body": "{{name}} 的 {{current}} 版本继续运行。批准后,新版本可以运行这些操作。新增项已标出。", + "done": "{{name}} 已升级。", + "title": "批准 {{name}} {{version}} 的主机访问" + }, + "asksNothing": "此插件不请求主机访问。", + "blanket": { + "alsoRevoke": "同时撤销它授予的权限", + "alsoRevokeHint": "将失去访问的插件:{{names}}。失去必需访问的插件会停止。", + "applyInstalled": "同时应用于已安装的插件", + "applyInstalledHint": "关闭:只针对新的安装和升级。", + "change": "更改", + "fixed": "由部署设置。你不能在此更改。", + "held": "持有统一开关所授权限的插件:{{count}}", + "includeOptional": "包含可选操作", + "includeOptionalHint": "关闭:只授予必需操作。", + "intro": "默认关闭。开启后,你安装或升级的插件会获得其清单声明所需的主机操作,无需安装对话框中的这一步。“始终禁止”中的操作仍然被禁止。", + "modeAny": "任何库", + "modeLabel": "范围", + "modeOfficial": "官方库", + "nowOff": "统一批准已关闭。它已授予的权限保留。", + "nowOn": "统一批准已开启。", + "offBody": "新的安装将重新需要你的批准。开关已授予的权限保留,并标记为统一授权,因此不会有东西突然停止。", + "offText": "已关闭。安装时你逐个批准插件。", + "offTitle": "关闭统一批准", + "onTitle": "开启统一批准", + "revokeAll": "撤销全部统一授权", + "revoked": "已撤销统一授权。", + "risk": { + "any": "开启后,你从所配置的任何库安装的每个插件都会获得其清单声明所需的主机操作,而无需你审查。任何库的任何条目都会获得它所声明的内容,因此恶意或被入侵的条目会立即获得这些操作。你在“始终禁止”中禁止的操作仍然被禁止。", + "official": "开启后,你从官方库安装的每个插件都会获得其清单声明所需的主机操作,而无需你审查。被入侵的条目将恰好获得它所声明的操作。你在“始终禁止”中禁止的操作仍然被禁止。" + }, + "scope": { + "any": "对任何库的插件开启。", + "official": "对官方库的插件开启。" + }, + "switch": "统一批准", + "title": "批准插件请求的一切", + "turnOff": "关闭", + "turnOn": "开启", + "typeLabel": "要确认,请输入:{{phrase}}", + "withOptional": "包含可选操作。" + }, + "cannotInstallBlocked": "此插件需要 {{ops}},但管理员策略禁止。无法安装。", + "cannotInstallUnsupported": "此插件需要 {{ops}},但此 LocalAI 不支持。无法安装。", + "chip": "等待批准", + "confirm": { + "revokeBody": "{{name}} 会失去你或统一开关允许的全部内容。部署的授权保留。如果它失去必需的访问,就会停止。", + "revokeTitle": "撤销 {{name}} 的全部主机访问?", + "stopAction": "关闭并停止", + "stopBody": "没有 {{op}},{{name}} 无法运行。它会停止,并保持停止,直到你再次允许。", + "stopTitle": "关闭 {{name}} 所需的访问?" + }, + "deny": { + "add": "添加规则", + "added": "已添加规则。", + "affectsNone": "没有已安装的插件失去访问。", + "allAddons": "所有插件", + "checkFailed": "无法检查此规则的影响。未保存任何内容。", + "confirmAction": "添加规则", + "confirmBody": "适用于:{{target}}。立即生效。正在进行的调用会完成。", + "confirmTitle": "始终禁止 {{entry}}?", + "entry": "操作、族或 *", + "everything": "全部", + "fromFile": "在部署中设置", + "hint": "族以点结尾,例如 egress. 。精确名称形如 egress.check 。星号表示全部禁止。", + "intro": "无论其他设置是否允许,任何插件都不能使用的操作。这里的规则始终优先。", + "invalid": "请输入操作(如 egress.check)、族(如 egress.)或 *。", + "none": "还没有规则。", + "remove": "移除", + "removeNamed": "移除 {{target}} 的规则 {{entry}}", + "removed": "已移除规则。", + "target": "适用于", + "title": "始终禁止", + "willStop": "将停止,它需要此项" + }, + "emptyBody": "请求主机访问的插件在安装后会显示在这里。", + "emptyTitle": "尚未安装插件", + "errors": { + "approval_required": "此插件需要主机访问。请在浏览器的安装对话框中批准。", + "approval_stale": "自你查看以来软件包已变化。请查看新列表并重新批准。", + "blocked_by_policy": "管理员策略禁止此插件所需的某项操作。未做任何更改。", + "locked_by_deployment": "部署已锁定主机访问。你不能在此更改。", + "not_human_surface": "必须由管理员在此浏览器中进行此更改。API 密钥或工具不能。", + "revision_conflict": "主机访问已在别处更改。页面已重新读取。请检查后重试。" + }, + "family": { + "connector": "连接器", + "egress": "网络检查", + "finetune": "微调", + "quantize": "量化", + "workspace": "工作区文件" + }, + "fileEntry": "在部署的策略文件或参数中设置。", + "identityMismatch": "这些授权是给同名的另一个软件包的,不起作用。请移除。", + "intro": "插件可以在此服务器上运行的操作、每项由谁允许,以及如何收回。插件只拥有你批准的内容。", + "keepOwn": "保留为我的授权", + "keepOwnNamed": "将 {{name}} 的统一授权保留为我的授权", + "loadError": "主机访问未能加载", + "loading": "正在读取此插件的请求...", + "loadingPage": "正在加载主机访问", + "locked": "部署已关闭对主机访问的修改。你可以查看此页面,但不能修改。", + "newOptional": "新版本还请求 {{ops}}。这些是可选的,且未被允许。", + "noLongerRequested": "此版本不再请求它。你可以将其关闭。", + "notOfficial": "此插件来自 {{library}},不是官方库。", + "op": { + "alreadyAllowed": "已允许", + "blocked": "被管理员策略禁止", + "byDeployment": "部署已允许", + "new": "此版本新增", + "optional": "可选。没有它插件也能运行,但功能较少。", + "required": "必需。没有它,插件无法运行。", + "unsupported": "此 LocalAI 不支持" + }, + "previewFailed": "无法读取此插件请求的主机访问。请重试。如果它需要你未批准的访问,安装会被拒绝,且不会有任何改变。", + "removeStale": "移除", + "revokeAll": "全部撤销", + "revokeAllNamed": "撤销 {{name}} 的全部主机访问", + "risk": { + "changes_data": "修改数据", + "network": "网络", + "read": "读取", + "runs_jobs": "运行任务" + }, + "source": { + "blanket": "统一", + "deployment": "部署", + "you": "你" + }, + "stepFooter": "授权后,此插件可以在此服务器上为使用它的人运行这些操作。你可以在“主机访问”页面查看或撤销。", + "stepIntro": "{{name}} 请求在此服务器上运行以下操作。必需项是它正常工作所必需的。可选项在你开启之前保持关闭。", + "stepTitle": "主机访问", + "stopped": "已停止:它需要 {{ops}},但已不再允许。", + "tabWaiting": "待批准:{{count}}", + "waiting": { + "body": "新版本请求当前版本没有的主机访问。批准之前,{{current}} 版本继续运行。新增的操作数:{{count}}。", + "review": "查看并批准", + "reviewNamed": "查看并批准 {{name}} 的主机访问", + "title": "{{name}} {{version}} 等待你的批准" + } } } diff --git a/core/http/react-ui/src/components/addons/AddonBits.jsx b/core/http/react-ui/src/components/addons/AddonBits.jsx index d4a7914051..9fde40ba97 100644 --- a/core/http/react-ui/src/components/addons/AddonBits.jsx +++ b/core/http/react-ui/src/components/addons/AddonBits.jsx @@ -35,6 +35,18 @@ export function UpdateChip({ version }) { ) } +// An upgrade that waits for the administrator to approve the host access the new +// version asks for. The old version keeps running. +export function ApprovalChip() { + const { t } = useTranslation('addons') + const text = t('hostAccess.chip') + return ( + + + + ) +} + // The identity tile of an add-on: its icon from the host's own icon route, or // its first letter on a colour of its own (a hue from its id), so two add-ons // in the sidebar or the list never look the same. Never a remote address: the diff --git a/core/http/react-ui/src/components/addons/AddonRow.jsx b/core/http/react-ui/src/components/addons/AddonRow.jsx index 1dd6b97dd8..516dfd7895 100644 --- a/core/http/react-ui/src/components/addons/AddonRow.jsx +++ b/core/http/react-ui/src/components/addons/AddonRow.jsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import Icon from '../Icon' import Button from '../ui/Button' import { apiUrl } from '../../utils/basePath' -import { AppIcon, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits' +import { AppIcon, ApprovalChip, BuiltinIcon, StateChip, UpdateChip, VersionText } from './AddonBits' import AddonMenu from './AddonMenu' import { addedByAddon, stateChip } from './addonState' @@ -12,7 +12,7 @@ import { addedByAddon, stateChip } from './addonState' // The row body selects the add-on (its details open beside the list). On the // right: the actions that fit its state (Upgrade, Open), Pin, and the overflow // menu with reordering, Details and Remove. -export default function AddonRow({ tile, pinned, selected = false, attention = '', source, updateVersion = '', onPin, onMove, onOpenDetail, onRemove, upgrade, upgradeInline = false }) { +export default function AddonRow({ tile, pinned, selected = false, attention = '', source, updateVersion = '', waitingApproval = false, onPin, onMove, onOpenDetail, onRemove, upgrade, upgradeInline = false }) { const { t } = useTranslation('addons') const { t: tn } = useTranslation('nav') const title = tile.titleKey ? tn(tile.titleKey) : tile.title @@ -36,7 +36,8 @@ export default function AddonRow({ tile, pinned, selected = false, attention = ' {title} {app && } - {app && (app.update === 'update-pending' || updateVersion) && } + {app && !waitingApproval && (app.update === 'update-pending' || updateVersion) && } + {app && waitingApproval && } {tile.description && {tile.description}} diff --git a/core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx b/core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx new file mode 100644 index 0000000000..08ede691f7 --- /dev/null +++ b/core/http/react-ui/src/components/addons/ApproveUpgradeDialog.jsx @@ -0,0 +1,89 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Callout } from '../ui' +import { newOperationId } from '../catalog/catalogState' +import { hostGrantsApi } from '../../utils/addonsClient' +import { normalizeOperation } from '../../utils/addonsApi' +import HostAccessList from './HostAccessList' +import HostDialog from './HostDialog' +import { useHostPreview } from './useHostPreview' +import { approvalBody, installBlock } from './hostGrantsState' +import { describeError, describeOutcome } from './errors' + +// Runs the approval of a held upgrade as one action of the administrator, with +// its own operation identity and the unknown-outcome rule of every lifecycle. +export function approveUpgrade(actions, api, addon, body) { + return actions.run(`approve:${addon.id}:${addon.pendingUpgrade.version}`, { + addonId: addon.id, + submit: async (operationId, signal) => { + const out = await hostGrantsApi.install({ ...body, operationId }, signal) + return normalizeOperation(out && out.payload !== undefined ? out.payload : out) + }, + verify: async (signal) => (await api.installed(signal)).some(a => a.id === addon.id && a.version === addon.pendingUpgrade.version), + }) +} + +// The approval of an upgrade that asks for more than the old version holds. The +// old version keeps running until the administrator approves here. Only the +// operations that are new are highlighted; the rest is context. +export default function ApproveUpgradeDialog({ addon, sourceId, api, actions, onClose, onDone }) { + const { t } = useTranslation('addons') + const entry = { sourceId: sourceId || addon.sourceId, id: addon.id, version: addon.pendingUpgrade.version } + const host = useHostPreview(entry) + const [busy, setBusy] = useState(false) + const [failure, setFailure] = useState(null) + const ready = host.status === 'ready' + const block = ready ? installBlock(host.preview) : null + const added = ready ? (host.preview.added.length > 0 ? host.preview.added : addon.pendingUpgrade.delta) : addon.pendingUpgrade.delta + const approve = async () => { + setBusy(true); setFailure(null) + const body = approvalBody(host.preview, host.ticked, { action: 'upgrade', sourceId: entry.sourceId, addonId: addon.id, version: entry.version }) + const out = await approveUpgrade(actions, api, addon, body) + setBusy(false) + if (out && out.outcome === 'completed') { onDone?.(); return } + if (out) setFailure(describeOutcome(out.outcome, t, out.error)) + } + return ( + + + + + )} + > +

{t('hostAccess.approve.body', { name: addon.title, current: addon.version })}

+ {host.status === 'loading' &&

{t('hostAccess.loading')}

} + {host.status === 'error' && ( + {t('catalog.retry')}}> + {describeError(host.error, t)} + + )} + {ready && } + {block && {t(block.kind === 'blocked' ? 'hostAccess.cannotInstallBlocked' : 'hostAccess.cannotInstallUnsupported', { ops: block.ops.join(', ') })}} + {failure && {failure}} +
+ ) +} + +// The banner for an upgrade that waits: the old version keeps running. +export function WaitingBanner({ addon, onReview, busy = false }) { + const { t } = useTranslation('addons') + return ( + {t('hostAccess.waiting.review')}} + > + {t('hostAccess.waiting.body', { current: addon.version, count: addon.pendingUpgrade.delta.length })} + + ) +} diff --git a/core/http/react-ui/src/components/addons/AppsView.jsx b/core/http/react-ui/src/components/addons/AppsView.jsx index cd77121085..43b23fba88 100644 --- a/core/http/react-ui/src/components/addons/AppsView.jsx +++ b/core/http/react-ui/src/components/addons/AppsView.jsx @@ -29,6 +29,8 @@ import { upgradeFailureWords } from './updateReview' import FirstRun from './FirstRun' import { addedByAddon, attentionOf, filterByState, listState, sortByAttention, STATE_FILTERS } from './addonState' import { describeError, describeOutcome } from './errors' +import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog' +import { pendingApprovals } from './hostGrantsState' import { useAddonActions } from './useAddonActions' // The apps every caller with the add-ons feature may use. The owner decides @@ -36,7 +38,7 @@ import { useAddonActions } from './useAddonActions' // cadence, slower in a hidden tab and backing off while the owner is // unreachable. Start and stop are offered to administrators and the server // refuses everyone else whatever this view shows. -export default function AppsView({ api, adapter, onSummary }) { +export default function AppsView({ api, adapter, onSummary, grants }) { const { t, i18n } = useTranslation('addons') const [storedState, setState] = useState({ status: 'loading', apps: [], error: null, stale: false }) const [reload, setReload] = useState(0) @@ -122,6 +124,7 @@ export default function AppsView({ api, adapter, onSummary }) { // Remove is the typed uninstall action: the data is kept, so installing the // add-on again picks it up. The owner refuses anyone but an administrator. const [removing, setRemoving] = useState(null) + const [approving, setApproving] = useState(null) const remove = useCallback((tile) => { actions.run(`uninstall:${tile.id}:${tile.app.generation}`, { addonId: tile.id, @@ -304,7 +307,12 @@ export default function AppsView({ api, adapter, onSummary }) { const view = listState({ apps: state.apps, shownCount: shown.length, query, isAdmin, canList }) const firstRun = view === 'first-run' - const showAttention = !firstRun && (attention.failed.length > 0 || attention.updates.length > 0) + // Upgrades held for host access: the old version runs on, and the + // administrator approves what the new one asks for. The plain Upgrade of + // such an add-on would be refused, so only the banner offers it. + const approvals = isAdmin && grants && grants.data ? pendingApprovals(grants.data) : [] + const waitingIds = new Set(approvals.map(a => a.id)) + const showAttention = !firstRun && (attention.failed.length > 0 || attention.updates.length > 0 || approvals.length > 0) return (
{state.stale && ( @@ -337,7 +345,8 @@ export default function AppsView({ api, adapter, onSummary }) { ) })} - {attention.updates.map(({ tile, rec, version }) => { + {approvals.map(a => setApproving(a)} />)} + {attention.updates.filter(({ tile }) => !waitingIds.has(tile.id)).map(({ tile, rec, version }) => { const working = Boolean(actions.unknown[tile.id]) || upgrade.working(tile.id) return ( {t('installed.missing', { id: manageId })} )} + {approving && ( + setApproving(null)} + onDone={() => { setApproving(null); grants?.reload(); reloadRef.current() }} + /> + )} {limit && ( setLimit(false)} closeLabel={t('drawer.close')} testId="pin-limit"> diff --git a/core/http/react-ui/src/components/addons/BlanketPanel.jsx b/core/http/react-ui/src/components/addons/BlanketPanel.jsx new file mode 100644 index 0000000000..778f9b0a33 --- /dev/null +++ b/core/http/react-ui/src/components/addons/BlanketPanel.jsx @@ -0,0 +1,144 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useId, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Callout, Segmented, Switch } from '../ui' +import HostDialog from './HostDialog' +import { BLANKET_CONFIRM, blanketBody, confirmMatches, normalizeOverview } from './hostGrantsState' + +function Toggle({ label, hint, checked, onChange, disabled, testId }) { + const id = useId() + return ( +
+
+ {label} + {hint && {hint}} +
+ +
+ ) +} + +// The blanket switch: "approve everything an add-on asks for". Off by +// default. Turning it on takes the administrator's own browser session and a +// typed sentence; the risk is stated before the field. Turning it off keeps +// the grants it made, marked blanket, and offers to revoke them all. +export default function BlanketPanel({ overview, api, locked, busy, run, holders }) { + const { t } = useTranslation('addons') + const b = overview.blanket + const on = b.mode !== 'off' + const fixed = b.setBy === 'deployment' + const [dialog, setDialog] = useState(null) + const readOnly = locked || fixed + const labelId = useId() + + const openOn = () => setDialog({ kind: 'on', mode: on ? b.mode : 'official', includeOptional: b.includeOptional, applyToInstalled: false, typed: '' }) + const openOff = () => setDialog({ kind: 'off', revokeOnOff: false }) + const submit = async () => { + const d = dialog + const body = d.kind === 'on' + ? blanketBody(overview, { mode: d.mode, includeOptional: d.includeOptional, applyToInstalled: d.applyToInstalled, confirm: d.typed.trim() }) + : blanketBody(overview, { mode: 'off', includeOptional: b.includeOptional, revokeOnOff: d.revokeOnOff }) + const ok = await run(() => api.setBlanket(body), t(d.kind === 'on' ? 'hostAccess.blanket.nowOn' : 'hostAccess.blanket.nowOff')) + if (ok) setDialog(null) + } + const revokeBlanket = () => run(async () => { + // One add-on at a time, each against the revision the owner holds now. + const fresh = async () => normalizeOverview(await api.overview()) + for (const h of holders) { + const cur = await fresh() + const addon = cur.addons.find(a => a.id === h.id) + if (!addon) continue + const keep = [...addon.ops, ...addon.noLongerRequested].filter(o => o.status === 'allowed' && o.source !== 'deployment' && o.source !== 'blanket').map(o => o.name) + if (keep.length === 0) await api.revoke(h.id, cur.revision) + else await api.setOps(h.id, { revision: cur.revision, ops: keep }) + } + }, t('hostAccess.blanket.revoked')) + + return ( +
+

{t('hostAccess.blanket.title')}

+

{t('hostAccess.blanket.intro')}

+ {fixed && {t('hostAccess.blanket.fixed')}} +
+
+ {t('hostAccess.blanket.switch')} + {on ? t(`hostAccess.blanket.scope.${b.mode}`) : t('hostAccess.blanket.offText')}{on && b.includeOptional ? ` ${t('hostAccess.blanket.withOptional')}` : ''} +
+ (next ? openOn() : openOff())} testId="blanket-switch" /> +
+ {on && !readOnly &&
} + {holders.length > 0 && ( +
+

{t('hostAccess.blanket.held', { count: holders.length })}

+
    {holders.map(h =>
  • {h.title} {h.ops.join(', ')}
  • )}
+ {!locked && } +
+ )} + + {dialog && dialog.kind === 'on' && ( + setDialog(null)} + busy={busy} + actions={( + <> + + + + )} + > + setDialog(d => ({ ...d, mode, typed: '' }))} + label={t('hostAccess.blanket.modeLabel')} + /> + {t(`hostAccess.blanket.risk.${dialog.mode}`)} + setDialog(d => ({ ...d, includeOptional: v }))} testId="blanket-optional" /> + setDialog(d => ({ ...d, applyToInstalled: v }))} testId="blanket-installed" /> + + {t(`hostAccess.blanket.risk.${dialog.mode}`)} + + )} + {dialog && dialog.kind === 'off' && ( + setDialog(null)} + busy={busy} + actions={( + <> + + + + )} + > +

{t('hostAccess.blanket.offBody')}

+ {holders.length > 0 && ( + h.title).join(', ') })} + checked={dialog.revokeOnOff} + onChange={(v) => setDialog(d => ({ ...d, revokeOnOff: v }))} + testId="blanket-also-revoke" + /> + )} +
+ )} +
+ ) +} diff --git a/core/http/react-ui/src/components/addons/DenySection.jsx b/core/http/react-ui/src/components/addons/DenySection.jsx new file mode 100644 index 0000000000..a2ea16d224 --- /dev/null +++ b/core/http/react-ui/src/components/addons/DenySection.jsx @@ -0,0 +1,125 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useId, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Callout } from '../ui' +import Icon from '../Icon' +import HostDialog from './HostDialog' +import { denyRows, pageOps, validateDenyDraft, withDenyRule } from './hostGrantsState' + +// "Never allow": operations an add-on may never hold, whatever else grants +// them. Rules from the deployment's policy file or flags are shown locked and +// cannot be removed here. Rules made here apply at once. Adding one first asks +// the owner what it would take away, and says which add-ons would stop. +export default function DenySection({ overview, api, locked, busy, run }) { + const { t } = useTranslation('addons') + const [draft, setDraft] = useState({ target: '*', entry: '' }) + const [error, setError] = useState('') + const [check, setCheck] = useState(null) + const listId = useId() + const files = denyRows(overview.deny.file, 'file') + const ui = denyRows(overview.deny.ui, 'ui') + const known = [...new Set(overview.addons.flatMap(a => pageOps(a).map(o => o.name)))] + const families = [...new Set(known.map(n => `${n.split('.')[0]}.`))] + const targetLabel = (target) => (target === '*' ? t('hostAccess.deny.allAddons') : (overview.addons.find(a => a.id === target)?.title || target)) + const entryLabel = (entry) => (entry === '*' ? t('hostAccess.deny.everything') : entry) + + const submitRule = async (rule, add) => { + const deny = withDenyRule(overview.deny.ui, rule, add) + if (!add) { await run(() => api.setDeny({ revision: overview.revision, deny }), t('hostAccess.deny.removed')); return } + setError('') + let affects = [] + try { + const dry = await api.setDeny({ revision: overview.revision, deny, dryRun: true }) + affects = Array.isArray(dry && dry.affects) ? dry.affects : [] + } catch (e) { + setError(t('hostAccess.deny.checkFailed')) + return + } + setCheck({ rule, deny, affects }) + } + const add = (e) => { + e.preventDefault() + const entry = draft.entry.trim().toLowerCase() + if (validateDenyDraft({ target: draft.target, entry })) { setError(t('hostAccess.deny.invalid')); return } + submitRule({ target: draft.target, entry }, true) + } + const confirmAdd = async () => { + const ok = await run(() => api.setDeny({ revision: overview.revision, deny: check.deny }), t('hostAccess.deny.added')) + if (ok) { setCheck(null); setDraft(d => ({ ...d, entry: '' })) } + } + + return ( +
+

{t('hostAccess.deny.title')}

+

{t('hostAccess.deny.intro')}

+ {files.length + ui.length === 0 &&

{t('hostAccess.deny.none')}

} +
    + {files.map(r => ( +
  • +
  • + ))} + {ui.map(r => ( +
  • +
  • + ))} +
+ {!locked && ( +
+ + + +
+ )} + {!locked &&

{t('hostAccess.deny.hint')}

} + {error && {error}} + {check && ( + setCheck(null)} + busy={busy} + actions={( + <> + + + + )} + > +

{t('hostAccess.deny.confirmBody', { target: targetLabel(check.rule.target) })}

+ {check.affects.length === 0 + ?

{t('hostAccess.deny.affectsNone')}

+ : ( +
    + {check.affects.map(a => ( +
  • + {targetLabel(a.id)} {(a.loses || []).join(', ')} + {a.willStop && <> {t('hostAccess.deny.willStop')}} +
  • + ))} +
+ )} +
+ )} +
+ ) +} diff --git a/core/http/react-ui/src/components/addons/HostAccessList.jsx b/core/http/react-ui/src/components/addons/HostAccessList.jsx new file mode 100644 index 0000000000..7205010cf9 --- /dev/null +++ b/core/http/react-ui/src/components/addons/HostAccessList.jsx @@ -0,0 +1,50 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useTranslation } from 'react-i18next' +import { SwitchGroup, SwitchRow } from '../chat/SwitchList' +import { groupOps, tickable } from './hostGrantsState' + +// The words under one operation: why its switch cannot move, or what leaving +// it off means. The owner decides every status; this only says it. +export function opNote(op, t) { + if (op.status === 'blocked') return { reason: t('hostAccess.op.blocked') } + if (op.status === 'unsupported') return { reason: t('hostAccess.op.unsupported') } + if (op.status === 'allowed') return { reason: t(op.source === 'deployment' ? 'hostAccess.op.byDeployment' : 'hostAccess.op.alreadyAllowed') } + return { desc: t(op.required ? 'hostAccess.op.required' : 'hostAccess.op.optional') } +} + +// The operations an add-on asks for, in groups, one switch each. Required ones +// are on and cannot be turned off; optional ones start off. A blocked one is +// greyed with the reason. `added` highlights what a new version asks for on +// top of what the old one held. +export default function HostAccessList({ ops, ticked, onTick, added = [], testId = 'hostaccess' }) { + const { t } = useTranslation('addons') + return ( +
+ {groupOps(ops).map(g => ( + + {g.ops.map(op => { + const on = tickable(op) ? ticked.includes(op.name) : op.status === 'allowed' + const note = opNote(op, t) + const isNew = added.includes(op.name) + return ( + onTick(op.name, next)} + stacked + testId={`${testId}-op`} + data={{ op: op.name, required: op.required ? 'true' : 'false', status: op.status, added: isNew ? 'true' : undefined }} + /> + ) + })} + + ))} +
+ ) +} diff --git a/core/http/react-ui/src/components/addons/HostAccessView.jsx b/core/http/react-ui/src/components/addons/HostAccessView.jsx new file mode 100644 index 0000000000..d37e21e7c7 --- /dev/null +++ b/core/http/react-ui/src/components/addons/HostAccessView.jsx @@ -0,0 +1,160 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Button, Callout, EmptyState, LoadError, Switch, SkeletonRows } from '../ui' +import { SwitchGroup, SwitchRow } from '../chat/SwitchList' +import ConfirmDialog from '../ConfirmDialog' +import { hostGrantsApi, addonsApi } from '../../utils/addonsClient' +import { useAddonActions } from './useAddonActions' +import ApproveUpgradeDialog, { WaitingBanner } from './ApproveUpgradeDialog' +import BlanketPanel from './BlanketPanel' +import DenySection from './DenySection' +import { opNote } from './HostAccessList' +import { stateChip } from './addonState' +import { describeError } from './errors' +import { blanketHolders, editedOps, groupOps, opEditable, pageOps, pendingApprovals } from './hostGrantsState' + +const SOURCE_KEY = { deployment: 'deployment', you: 'you', blanket: 'blanket' } + +function when(at, language) { + const ms = Date.parse(at) + if (!at || Number.isNaN(ms)) return '' + try { return new Date(ms).toLocaleDateString(language || undefined, { dateStyle: 'medium' }) } catch { return at.slice(0, 10) } +} + +// The Host access page: what each add-on holds on this server, where each +// permission came from, and the switches that revoke it. Administrators only; +// the owner checks again on every call, and only the administrator's own +// browser session can change anything here. +export default function HostAccessView({ grants, api = hostGrantsApi }) { + const { t, i18n } = useTranslation('addons') + const actions = useAddonActions(addonsApi, { onSettled: () => grants.reload() }) + const [notice, setNotice] = useState(null) + const [confirm, setConfirm] = useState(null) + const [approving, setApproving] = useState(null) + + const data = grants.data + const pending = useMemo(() => (data ? pendingApprovals(data) : []), [data]) + const holders = useMemo(() => (data ? blanketHolders(data) : []), [data]) + + const run = async (fn, okText) => { + setNotice(null) + const { error } = await grants.mutate(fn) + if (error) setNotice({ tone: 'error', text: describeError(error, t) }) + else if (okText) setNotice({ tone: 'ok', text: okText }) + return !error + } + + if (grants.status === 'loading') return + if (grants.status === 'error') { + return ( + + {describeError(grants.error, t)} + + ) + } + + const locked = data.locked + const setOne = (addon, name, next) => { + const ops = editedOps(addon, { [name]: next }) + const op = pageOps(addon).find(o => o.name === name) + const go = () => run(() => api.setOps(addon.id, { revision: data.revision, ops })) + if (!next && op && op.required) setConfirm({ kind: 'stop', addon, op, go }) + else go() + } + const revokeAll = (addon) => run(() => api.revoke(addon.id, data.revision)) + const keepOwn = (addon, names) => run(() => api.setOps(addon.id, { revision: data.revision, ops: editedOps(addon, {}), keep: names })) + + return ( +
+

{t('hostAccess.intro')}

+ {grants.stale && {t('apps.stale')}} + {locked && {t('hostAccess.locked')}} + {notice && {notice.text}} + {pending.map(a => setApproving(a)} />)} + +
+

{t('hostAccess.addonsTitle')}

+ {data.addons.length === 0 && } + {data.addons.map(addon => { + const ops = pageOps(addon) + const stateKey = stateChip({ state: addon.state }).key + const blanketOps = ops.filter(o => o.source === 'blanket' && o.status === 'allowed').map(o => o.name) + const holdsUi = ops.some(o => o.status === 'allowed' && o.source !== 'deployment') + const stoppedText = addon.stopped ? t('hostAccess.stopped', { ops: addon.stopped }) : '' + return ( +
+
+
+

{addon.title}

+ {[addon.version, t(`state.${stateKey}`)].filter(Boolean).join(' · ')} +
+
+ {blanketOps.length > 0 && !locked && ( + + )} + {holdsUi && !locked && ( + + )} +
+
+ {stoppedText && {stoppedText}} + {addon.identityMismatch && revokeAll(addon)}>{t('hostAccess.removeStale')}}>{t('hostAccess.identityMismatch')}} + {addon.newOptional.length > 0 && {t('hostAccess.newOptional', { ops: addon.newOptional.join(', ') })}} + {ops.length === 0 &&

{t('hostAccess.asksNothing')}

} + {groupOps(ops).map(g => ( + + {g.ops.map(op => { + const on = op.status === 'allowed' + const note = opNote(op, t) + const stale = addon.noLongerRequested.some(o => o.name === op.name) + const by = [on && op.source ? t(`hostAccess.source.${SOURCE_KEY[op.source]}`) : '', on ? when(op.at, i18n.language) : ''].filter(Boolean).join(' · ') + return ( + setOne(addon, op.name, next)} + stacked + testId="host-op" + data={{ op: op.name, status: op.status, source: op.source || undefined, app: addon.id }} + /> + ) + })} + + ))} +
+ ) + })} +
+ + + + + {approving && ( + setApproving(null)} + onDone={() => { setApproving(null); grants.reload(); setNotice({ tone: 'ok', text: t('hostAccess.approve.done', { name: approving.title }) }) }} + /> + )} + {confirm.kind === 'stop' ? t('hostAccess.confirm.stopBody', { name: confirm.addon.title, op: confirm.op.title }) : t('hostAccess.confirm.revokeBody', { name: confirm.addon.title })}

: null} + confirmLabel={confirm ? t(confirm.kind === 'stop' ? 'hostAccess.confirm.stopAction' : 'hostAccess.revokeAll') : ''} + onConfirm={() => { const c = confirm; setConfirm(null); c.go() }} + onCancel={() => setConfirm(null)} + /> +
+ ) +} diff --git a/core/http/react-ui/src/components/addons/HostDialog.jsx b/core/http/react-ui/src/components/addons/HostDialog.jsx new file mode 100644 index 0000000000..78069075d6 --- /dev/null +++ b/core/http/react-ui/src/components/addons/HostDialog.jsx @@ -0,0 +1,55 @@ +/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */ +import { useEffect, useId, useRef } from 'react' +import { pushLayer } from '../../utils/modalLayers' + +// The modal of the host access screens: a title, a body that scrolls, and the +// actions. Escape closes it unless `busy`; Tab stays inside; focus goes to the +// first control and comes back to the opener. The same behavior as the install +// dialog, for dialogs that carry a list or a field. +export default function HostDialog({ title, children, actions, onClose, busy = false, testId }) { + const ref = useRef(null) + const titleId = useId() + const closeRef = useRef(onClose) + closeRef.current = onClose + const busyRef = useRef(busy) + busyRef.current = busy + useEffect(() => { + const opener = document.activeElement + const layer = pushLayer() + const dialog = ref.current + const focusables = () => Array.from(dialog.querySelectorAll('button:not([disabled]), a[href], input:not([disabled])')) + focusables().find(el => el.dataset.autofocus === 'true')?.focus() + if (!dialog.contains(document.activeElement)) dialog.focus() + const onKey = (e) => { + if (!layer.isTop()) return + if (e.key === 'Escape') { + if (busyRef.current) return + e.preventDefault(); closeRef.current(); return + } + if (e.key !== 'Tab') return + const items = focusables() + if (items.length === 0) { e.preventDefault(); return } + const first = items[0] + const last = items[items.length - 1] + if (e.shiftKey && (document.activeElement === first || document.activeElement === dialog)) { e.preventDefault(); last.focus() } + else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() } + } + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('keydown', onKey) + layer.pop() + if (opener && opener !== document.body && document.contains(opener)) opener.focus?.() + } + }, []) + return ( +
+
e.stopPropagation()}> +
+ {title} +
+
{children}
+
{actions}
+
+
+ ) +} diff --git a/core/http/react-ui/src/components/addons/InstallDialog.jsx b/core/http/react-ui/src/components/addons/InstallDialog.jsx index d1a92f0eb1..d8e574a42e 100644 --- a/core/http/react-ui/src/components/addons/InstallDialog.jsx +++ b/core/http/react-ui/src/components/addons/InstallDialog.jsx @@ -10,6 +10,9 @@ import { shortDigest, skillShadowNotes } from './addonState' import { skillInstallNoticeKey } from '../../utils/chatSkills' import { describeOutcome } from './errors' import Callout from '../ui/Callout' +import HostAccessList from './HostAccessList' +import { useHostPreview } from './useHostPreview' +import { approvalBody, hostErrorKey, installBlock, needsStep } from './hostGrantsState' // The built-in chat tools that the entry replaces and that an installed add-on // already replaces: only one of them can serve the tool, so the dialog says so @@ -34,6 +37,11 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed const running = step === 'install' || step === 'turn-on' const closeRef = useRef(onClose) closeRef.current = onClose + // What the verified package asks of this server. Read while the dialog asks; + // the approval it builds is checked again by the owner against the package it + // really stages. + const host = useHostPreview(entry, { enabled: Boolean(entry) && step === 'ask' }) + const approval = useRef(null) useEffect(() => { if (!entry) return undefined @@ -64,8 +72,15 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed }, [entry]) if (!entry) return null - const start = () => actions.installAndTurnOn(entry) - const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) + const showHost = host.status === 'ready' && needsStep(host.preview) + const block = showHost ? installBlock(host.preview) : null + const start = () => { + approval.current = showHost + ? approvalBody(host.preview, host.ticked, { action: 'install', sourceId: entry.sourceId, addonId: entry.id, version: entry.version }) + : null + return actions.installAndTurnOn(entry, approval.current ? { approval: approval.current } : {}) + } + const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on', approval: approval.current }) const stepState = (id) => { if (step === 'ask') return 'pending' if (step === 'done') return 'done' @@ -75,7 +90,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed const reason = step === 'failed' ? describeOutcome(flow.outcome, t, flow.error) : '' return (
-
e.stopPropagation()}> +
e.stopPropagation()}>
{t('flow.confirmTitle', { name: entry.title })}
@@ -94,6 +109,28 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed {t(skillInstallNoticeKey(c), { skill: c.skill, name: c.name })} ))} + {host.status === 'loading' &&

{t('hostAccess.loading')}

} + {host.status === 'error' && ( + {t('catalog.retry')}}> + {t('hostAccess.previewFailed')} + + )} + {showHost && ( +
+

{t('hostAccess.stepTitle')}

+

{t('hostAccess.stepIntro', { name: entry.title })}

+ {host.preview.official === false && ( +

{t('hostAccess.notOfficial', { library: entry.sourceId })}

+ )} + + {block && ( + + {t(block.kind === 'blocked' ? 'hostAccess.cannotInstallBlocked' : 'hostAccess.cannotInstallUnsupported', { ops: block.ops.join(', ') })} + + )} +

{t('hostAccess.stepFooter')}

+
+ )}
{t('trust.digest')}
{shortDigest(entry.digest)}
{t('trust.verification')}
{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
@@ -130,7 +167,7 @@ export default function InstallDialog({ entry, flow, actions, onClose, installed
{step === 'ask' && } - {step === 'ask' && } + {step === 'ask' && } {running && } {step === 'failed' && } {step === 'failed' && !actions.unknown[entry.id] && } diff --git a/core/http/react-ui/src/components/addons/addonState.js b/core/http/react-ui/src/components/addons/addonState.js index c6de38d07e..4e0128c7f4 100644 --- a/core/http/react-ui/src/components/addons/addonState.js +++ b/core/http/react-ui/src/components/addons/addonState.js @@ -5,8 +5,8 @@ import { updateView } from './updateState.js' // the server checks administration again on every call they make. What the old // Installed tab showed is now the Details of an add-on in My add-ons, so an old // ?tab=installed address lands on My add-ons. -export const TABS = ['apps', 'browse', 'libraries'] -export const ADMIN_TABS = new Set(['browse', 'libraries']) +export const TABS = ['apps', 'browse', 'libraries', 'access'] +export const ADMIN_TABS = new Set(['browse', 'libraries', 'access']) export function resolveTab(requested, isAdmin) { if (requested && TABS.includes(requested) && (isAdmin || !ADMIN_TABS.has(requested))) return requested diff --git a/core/http/react-ui/src/components/addons/addons.css b/core/http/react-ui/src/components/addons/addons.css index 2323bb98ce..24bfc2e2fe 100644 --- a/core/http/react-ui/src/components/addons/addons.css +++ b/core/http/react-ui/src/components/addons/addons.css @@ -495,3 +495,52 @@ @media (max-width: 639px) { .ad-split[data-view="settings"] .ad-pane { width: 100%; } } + +/* ---- Host access: the install step, the approval and the page ------------- */ + +.ad-tab__count--warn { background: var(--dk-warn-wash); color: var(--dk-warn-text, var(--dk-warn)); } +/* Dialogs that carry a list or a field: wider than a confirmation, and the + body scrolls so the actions stay in view on a phone. */ +.ad-dialog { max-width: 560px; width: min(560px, calc(100vw - 2 * var(--sp-4))); max-height: calc(100dvh - 2 * var(--sp-4)); display: flex; flex-direction: column; } +.ad-dialog .confirm-dialog-body { overflow-y: auto; min-height: 0; flex: 1 1 auto; } +.ad-dialog .confirm-dialog-header, .ad-dialog .confirm-dialog-actions { flex: none; } +.ad-dialog:focus { outline: none; } +.ad-hostaccess { display: flex; flex-direction: column; gap: var(--sp-2); } +.ad-hostaccess__title { margin: 0; font-size: var(--fs-body); font-weight: 600; color: var(--dk-text); } +.ad-hostlist { display: flex; flex-direction: column; gap: var(--sp-2); } +.ad-hostlist .swl-row { padding-inline: var(--sp-2); } + +.ad-host { display: flex; flex-direction: column; gap: var(--sp-5); min-width: 0; } +.ad-host__intro { margin: 0; color: var(--dk-muted); max-width: 70ch; } +.ad-host__section { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0; } +.ad-host__heading { margin: 0; font-size: var(--fs-body); font-weight: 600; color: var(--dk-text); } +.ad-host-card { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border: 1px solid var(--dk-edge); border-radius: var(--r-md); background: var(--dk-surface, transparent); min-width: 0; } +.ad-host-card__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; } +.ad-host-card__title { margin: 0; font-size: var(--fs-body); font-weight: 600; color: var(--dk-text); } +.ad-host-card__acts { display: flex; gap: var(--sp-2); flex-wrap: wrap; } +.ad-host-card .swl-row[data-source="deployment"] .swl-row__src { color: var(--dk-muted); } +.ad-host-card .swl-row[data-source="blanket"] .swl-row__src { color: var(--dk-warn-text, var(--dk-warn)); } + +.ad-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } +.ad-toggle-row__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.ad-toggle-row__label { color: var(--dk-text); font-weight: 500; } +.ad-toggle-row--main { padding: var(--sp-3); border: 1px solid var(--dk-edge); border-radius: var(--r-md); } +.ad-blanket-held { display: flex; flex-direction: column; gap: var(--sp-2); align-items: flex-start; } +.ad-blanket-held p { margin: 0; } +.ad-plain-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--sp-1); } +.ad-warn-text { color: var(--dk-warn-text, var(--dk-warn)); } +.ad-field { display: flex; flex-direction: column; gap: var(--sp-1); min-width: 0; } +.ad-field > span { color: var(--dk-text); font-size: var(--fs-small); } + +.ad-deny-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--sp-1); } +.ad-deny-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2); border: 1px solid var(--dk-edge); border-radius: var(--r-sm); min-width: 0; } +.ad-deny-row__text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } +.ad-deny-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: var(--sp-2); align-items: end; } +@media (max-width: 639px) { + .ad-deny-form { grid-template-columns: minmax(0, 1fr); } + .ad-toggle-row { align-items: flex-start; } +} +/* Operation names are short sentences: let them wrap instead of cutting them. */ +.ad-hostlist .swl-row__name, .ad-host-card .swl-row__name { white-space: normal; overflow: visible; text-overflow: clip; } +.ad-host-card .swl-row__src, .ad-hostlist .swl-row__src { white-space: normal; text-align: right; max-width: 40%; } +.ad-dialog .confirm-dialog-body { padding: 2px; margin-inline: -2px; } diff --git a/core/http/react-ui/src/components/addons/useHostGrants.js b/core/http/react-ui/src/components/addons/useHostGrants.js new file mode 100644 index 0000000000..d361283e4d --- /dev/null +++ b/core/http/react-ui/src/components/addons/useHostGrants.js @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { hostGrantsApi } from '../../utils/addonsClient' +import { normalizeOverview } from './hostGrantsState' + +// The overview of the host access of every add-on. `mutate(fn)` runs one +// write of the administrator, then reads the overview again; a refusal comes +// back as { error } and nothing is hidden: the page reads again either way, so +// a stale tab shows what the owner holds now. +export function useHostGrants({ api = hostGrantsApi, enabled = true } = {}) { + const [state, setState] = useState({ status: 'loading', data: null, error: null }) + const [busy, setBusy] = useState(false) + const alive = useRef(true) + useEffect(() => { alive.current = true; return () => { alive.current = false } }, []) + const load = useCallback(async (signal) => { + try { + const data = normalizeOverview(await api.overview(signal)) + if (alive.current) setState({ status: 'ready', data, error: null }) + return data + } catch (error) { + if (error && error.name === 'AbortError') return null + if (alive.current) setState(s => ({ status: s.data ? 'ready' : 'error', data: s.data, error, stale: Boolean(s.data) })) + return null + } + }, [api]) + useEffect(() => { + if (!enabled) return undefined + const ctl = new AbortController() + load(ctl.signal) + return () => ctl.abort() + }, [load, enabled]) + const mutate = useCallback(async (fn) => { + setBusy(true) + let result = {} + try { + result = { value: await fn(state.data) } + } catch (error) { + result = { error } + } + await load() + if (alive.current) setBusy(false) + return result + }, [load, state.data]) + return { ...state, busy, reload: () => load(), mutate } +} diff --git a/core/http/react-ui/src/components/addons/useHostPreview.js b/core/http/react-ui/src/components/addons/useHostPreview.js new file mode 100644 index 0000000000..2a56dbe2f7 --- /dev/null +++ b/core/http/react-ui/src/components/addons/useHostPreview.js @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react' +import { hostGrantsApi } from '../../utils/addonsClient' +import { initialTicks, normalizePreview } from './hostGrantsState' + +// Asks the owner what a library entry would need, from the package it +// verifies itself (the library's own listing is not trusted for this). It +// reads and grants nothing. Returns { status: 'loading'|'ready'|'error', +// preview, error, ticked, setTick }. +export function useHostPreview(entry, { enabled = true, api = hostGrantsApi } = {}) { + const key = entry ? `${entry.sourceId}/${entry.id}/${entry.version}` : '' + const [state, setState] = useState({ key: '', status: 'loading', preview: null, error: null, ticked: [] }) + const [attempt, setAttempt] = useState(0) + useEffect(() => { + if (!enabled || !entry) return undefined + const ctl = new AbortController() + api.preview({ sourceId: entry.sourceId, addonId: entry.id, version: entry.version }, ctl.signal) + .then(raw => { + const preview = normalizePreview(raw) + setState({ key, status: 'ready', preview, error: null, ticked: initialTicks(preview) }) + }) + .catch(error => { + if (error && error.name === 'AbortError') return + setState({ key, status: 'error', preview: null, error, ticked: [] }) + }) + return () => ctl.abort() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, enabled, attempt, api]) + const mine = state.key === key + const setTick = (name, on) => setState(s => ({ ...s, ticked: on ? [...new Set([...s.ticked, name])] : s.ticked.filter(n => n !== name) })) + const retry = () => { setState({ key: '', status: 'loading', preview: null, error: null, ticked: [] }); setAttempt(n => n + 1) } + return mine ? { ...state, setTick, retry } : { status: 'loading', preview: null, error: null, ticked: [], setTick, retry } +} diff --git a/core/http/react-ui/src/pages/Addons.jsx b/core/http/react-ui/src/pages/Addons.jsx index 103ef459bc..4f4e5384e0 100644 --- a/core/http/react-ui/src/pages/Addons.jsx +++ b/core/http/react-ui/src/pages/Addons.jsx @@ -8,6 +8,9 @@ import { addonsApi, addonsCatalog } from '../utils/addonsClient' import AppsView from '../components/addons/AppsView' import BrowseView from '../components/addons/BrowseView' import LibrariesView from '../components/addons/LibrariesView' +import HostAccessView from '../components/addons/HostAccessView' +import { useHostGrants } from '../components/addons/useHostGrants' +import { pendingApprovals } from '../components/addons/hostGrantsState' import { resolveManage, resolveTab, visibleTabs } from '../components/addons/addonState' import '../components/addons/addons.css' @@ -26,6 +29,10 @@ export default function Addons() { const tab = manage ? 'apps' : resolveTab(params.get('tab'), isAdmin) const tabs = visibleTabs(isAdmin) const refs = useRef({}) + // Host access is read once here: the tab shows how many approvals wait, and + // the list of add-ons shows the same banners. + const grants = useHostGrants({ enabled: Boolean(isAdmin) }) + const waiting = grants.data ? pendingApprovals(grants.data).length : 0 const select = useCallback((next) => { setParams(prev => { @@ -81,15 +88,17 @@ export default function Addons() { > {t(`tabs.${id}`)} {id === 'apps' && summary && summary.count > 0 && {summary.count}} + {id === 'access' && waiting > 0 && {waiting}} ))}
)}
1 ? `ad-tab-${tab}` : undefined} aria-label={tabs.length > 1 ? undefined : t('tabs.apps')} className="ad-panel"> - {tab === 'apps' && } + {tab === 'apps' && } {tab === 'browse' && } {tab === 'libraries' && } + {tab === 'access' && }
)