@rapidmx/web-client 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -41,13 +41,13 @@ export default function InformationPanel({ versions, plugins, information }: Inf
41
41
  <SettingsTable
42
42
  id="diagnostics-environment"
43
43
  title="Environment variables"
44
- description="The server process's environment. Only variables known to be harmless show their value; the value of a secret, or of anything unrecognized, is withheld by the server and never sent."
44
+ description="The server process's environment. Every variable shows its value except those on the server's list of hidden settings, whose value is withheld by the server and never sent."
45
45
  settings={information.data.environment ?? []}
46
46
  />
47
47
  <SettingsTable
48
48
  id="diagnostics-configuration"
49
49
  title="Configuration"
50
- description="The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Passwords, tokens, keys, certificates and the credentials in URLs are withheld by the server and never sent."
50
+ description="The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Settings on the server's list of hidden settings, such as passwords, tokens and keys, are withheld by the server and never sent, and so are the credentials in URLs."
51
51
  settings={information.data.configuration ?? []}
52
52
  />
53
53
  </>
@@ -76,8 +76,8 @@ export interface DiagnosticsVersions {
76
76
  }
77
77
 
78
78
  /**
79
- * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds every secret-like
80
- * or unrecognized value and never sends it, so there is nothing for the client to hide.
79
+ * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds the value of every setting on its
80
+ * hidden list and never sends it, so there is nothing for the client to hide.
81
81
  */
82
82
  export interface DiagnosticsSetting {
83
83
  name: string;
@@ -33,7 +33,9 @@ import {
33
33
  retryPluginPurge,
34
34
  searchPlugins,
35
35
  updatePlugin,
36
+ uploadPlugin,
36
37
  } from "../../../../../lib/admin/pluginsApi.js";
38
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
37
39
  import Alert from "../ActionAlert.js";
38
40
  import Button from "../../../../../lib/components/buttons/Button.js";
39
41
  import Modal from "../../../../../lib/components/overlays/Modal.js";
@@ -84,6 +86,21 @@ function purgeFailure(purge: PluginPurgeInfo): string {
84
86
  /** A day, in the administrator's own format. */
85
87
  const formatDate = (iso: string): string => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
86
88
 
89
+ /** The file names an uploaded plugin may have: what `npm pack` produces (`<name>-<version>.tgz`), or the same gzipped. */
90
+ const PLUGIN_PACK_NAME = /\.(tgz|tar\.gz|gz)$/i;
91
+
92
+ /** What the server answers (409) an upload of a plugin that is already installed, which the administrator may replace. */
93
+ const ALREADY_INSTALLED = /already installed/i;
94
+
95
+ /** An upload the administrator is being asked to let replace the plugin of the same name. */
96
+ interface PendingReplace {
97
+ file: File;
98
+ /** What the replacement would do, as a question. */
99
+ question: string;
100
+ /** Why the server refused, in its words. */
101
+ reason: string;
102
+ }
103
+
87
104
  /** A change waiting for the administrator to confirm the other plugins it also installs or enables. */
88
105
  interface PendingChange {
89
106
  displayName: string;
@@ -225,6 +242,11 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
225
242
  const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState<Plugin[] | null>(null);
226
243
  /** How many changes (or the lookups that come before them) are waiting on the server. */
227
244
  const [working, setWorking] = useState(0);
245
+ /** Set while an uploaded plugin is being sent to the server. */
246
+ const [uploading, setUploading] = useState(false);
247
+ /** An upload that would replace an installed plugin, waiting for the administrator to confirm it. */
248
+ const [replacing, setReplacing] = useState<PendingReplace | null>(null);
249
+ const fileInput = useRef<HTMLInputElement>(null);
228
250
  const statusRequest = useRef<Promise<void> | null>(null);
229
251
  /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
230
252
  const seenPurges = useRef<Map<string, PluginPurgeState>>(new Map());
@@ -517,6 +539,54 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
517
539
  return onRow(plugin, () => changeVersion(plugin, packageVersion));
518
540
  }
519
541
 
542
+ /** Sends `file` to the server as a plugin to install - replacing the installed plugin of that name only when `replace` is set. A plugin that is
543
+ * already installed isn't an error but a question, asked of the administrator before it is replaced. */
544
+ async function runUpload(file: File, replace: boolean) {
545
+ setUploading(true);
546
+ setError(null);
547
+ setRetryEnableUid(null);
548
+ try {
549
+ const uploaded: Plugin = await track(uploadPlugin(file, { replace }));
550
+ applied(uploaded);
551
+ notify({
552
+ kind: "success",
553
+ title: `${uploaded.name}@${uploaded.packageVersion} uploaded`,
554
+ message: "The servers restart one at a time to load it.",
555
+ });
556
+ } catch (err) {
557
+ if (!replace && err instanceof ApiRequestError && err.status === 409 && ALREADY_INSTALLED.test(err.message)) {
558
+ const installed: Plugin | undefined = plugins.find((plugin) => err.message.includes(plugin.name));
559
+ setReplacing({
560
+ file,
561
+ question: installed
562
+ ? `Replace ${installed.name} ${installed.packageVersion} with the uploaded pack?`
563
+ : "Replace the installed plugin with the uploaded pack?",
564
+ reason: err.message,
565
+ });
566
+ } else if (err instanceof ApiRequestError && err.status === 413) {
567
+ setError("That file is too large for the server to accept.");
568
+ } else {
569
+ setError(errorMessage(err, "Could not upload the plugin."));
570
+ }
571
+ }
572
+ setUploading(false);
573
+ }
574
+
575
+ /** Takes the file the administrator chose, unless it can't be what `npm pack` produces. */
576
+ function onFileChosen(e: React.ChangeEvent<HTMLInputElement>) {
577
+ const file: File | undefined = e.target.files?.[0];
578
+ // So choosing the same file again (after fixing what was wrong) still counts as a change.
579
+ e.target.value = "";
580
+ if (!file) {
581
+ return;
582
+ }
583
+ if (!PLUGIN_PACK_NAME.test(file.name)) {
584
+ setError(`${file.name} isn't a plugin pack. Choose the .tgz file that npm pack produces.`);
585
+ return;
586
+ }
587
+ void runUpload(file, false);
588
+ }
589
+
520
590
  /** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
521
591
  function reinstall(purge: PluginPurgeInfo) {
522
592
  const displayName: string = purgeName(purge);
@@ -682,12 +752,34 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
682
752
  const displayNameOf = (name: string): string => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
683
753
 
684
754
  const SectionHeading = embedded ? "h3" : "h2";
755
+ const bulkBusy: boolean = bulk !== null;
685
756
  const addButton = (
686
- <Button type="button" variant="secondary" className="!w-auto shrink-0" onClick={() => setAdding(true)}>
687
- Add by name
688
- </Button>
757
+ <div className="flex flex-wrap gap-2 shrink-0">
758
+ <input
759
+ ref={fileInput}
760
+ type="file"
761
+ className="hidden"
762
+ accept=".tgz,.tar.gz,application/gzip,application/x-gzip"
763
+ aria-label="Plugin pack file"
764
+ tabIndex={-1}
765
+ onChange={onFileChosen}
766
+ />
767
+ <Button
768
+ type="button"
769
+ variant="secondary"
770
+ className="!w-auto"
771
+ title="Install a plugin from the .tgz file that npm pack produces"
772
+ loading={uploading}
773
+ disabled={uploading || bulkBusy}
774
+ onClick={() => fileInput.current?.click()}
775
+ >
776
+ {uploading ? "Uploading\u2026" : "Upload"}
777
+ </Button>
778
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setAdding(true)}>
779
+ Add by name
780
+ </Button>
781
+ </div>
689
782
  );
690
- const bulkBusy: boolean = bulk !== null;
691
783
 
692
784
  return (
693
785
  <>
@@ -741,24 +833,18 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
741
833
  </div>
742
834
  </Alert>
743
835
  )}
744
- {error && (
745
- <Alert>
746
- {error}
747
- {retryEnable && (
748
- <>
749
- {" "}
750
- <Button
751
- type="button"
752
- variant="text"
753
- className="!w-auto !p-0"
754
- onClick={() => void toggle(retryEnable)}
755
- >
756
- Try again
757
- </Button>
758
- </>
759
- )}
760
- </Alert>
761
- )}
836
+ {/* A message on its own is a plain string child, which is what lets the alert offer to confirm the identity again. */}
837
+ {error &&
838
+ (retryEnable ? (
839
+ <Alert>
840
+ {error}{" "}
841
+ <Button type="button" variant="text" className="!w-auto !p-0" onClick={() => void toggle(retryEnable)}>
842
+ Try again
843
+ </Button>
844
+ </Alert>
845
+ ) : (
846
+ <Alert>{error}</Alert>
847
+ ))}
762
848
  <RolloutBanner status={status} />
763
849
  {statusStale && status && (
764
850
  <p className="mb-4 text-xs text-text-muted">Couldn&apos;t refresh server status. Showing the last status reported.</p>
@@ -871,7 +957,9 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
871
957
  <tbody role="rowgroup" className="block lg:table-row-group">
872
958
  {plugins.map((plugin) => {
873
959
  const update: PluginUpdateInfo | undefined = updates.get(plugin.uid);
874
- const latest: string | undefined = update?.updateAvailable ? update.latestVersion : undefined;
960
+ const uploaded: boolean = plugin.source === "upload";
961
+ // An uploaded pack has no registry version to be newer than.
962
+ const latest: string | undefined = !uploaded && update?.updateAvailable ? update.latestVersion : undefined;
875
963
  const busy: boolean = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
876
964
  const requires: string[] = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
877
965
  const requiredBy: string[] = plugins
@@ -895,6 +983,13 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
895
983
  {plugin.manifest.description && (
896
984
  <div className="text-xs text-text-muted mt-1 max-w-md">{plugin.manifest.description}</div>
897
985
  )}
986
+ {uploaded && (
987
+ <div className="text-xs text-text-muted mt-1 break-words">
988
+ <span className={`${BADGE_CLASS} bg-surface-alt text-text-muted`}>Uploaded</span>{" "}
989
+ {plugin.uploadFilename}
990
+ {plugin.uploadedAt ? `, ${formatDate(plugin.uploadedAt)}` : ""}
991
+ </div>
992
+ )}
898
993
  {requires.length > 0 && <div className="text-xs text-text-muted mt-1">Requires: {requires.join(", ")}</div>}
899
994
  {requiredBy.length > 0 && (
900
995
  <div className="text-xs text-text-muted mt-1">Required by: {requiredBy.join(", ")}</div>
@@ -942,7 +1037,7 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
942
1037
  </div>
943
1038
  <div className="flex flex-wrap gap-x-4 lg:justify-end">
944
1039
  <Button variant="text" type="button" onClick={() => setUpgrading(plugin)}>
945
- Change version
1040
+ {uploaded ? "Install the registry version" : "Change version"}
946
1041
  </Button>
947
1042
  <Button variant="text" type="button" onClick={() => setRemoving(plugin)}>
948
1043
  Uninstall
@@ -1002,6 +1097,28 @@ export default function PluginsManager({ embedded = false }: PluginsManagerProps
1002
1097
  }}
1003
1098
  />
1004
1099
  )}
1100
+ {replacing && (
1101
+ <Modal open onClose={() => setReplacing(null)} title="Replace plugin?">
1102
+ <p className="text-sm mb-2">{replacing.question}</p>
1103
+ <p className="text-sm text-text-muted mb-4 break-words">{replacing.reason}</p>
1104
+ <div className="flex gap-2 justify-end">
1105
+ <Button type="button" variant="secondary" className="!w-auto" onClick={() => setReplacing(null)}>
1106
+ Cancel
1107
+ </Button>
1108
+ <Button
1109
+ type="button"
1110
+ className="!w-auto"
1111
+ onClick={() => {
1112
+ const { file } = replacing;
1113
+ setReplacing(null);
1114
+ void runUpload(file, true);
1115
+ }}
1116
+ >
1117
+ Replace
1118
+ </Button>
1119
+ </div>
1120
+ </Modal>
1121
+ )}
1005
1122
  {confirming && (
1006
1123
  <ConfirmDependenciesModal
1007
1124
  change={confirming}
@@ -1570,15 +1687,20 @@ function ChangeVersionModal({
1570
1687
  const [selected, setSelected] = useState(plugin.packageVersion);
1571
1688
  const [error, setError] = useState<string | null>(null);
1572
1689
  const [busy, setBusy] = useState(false);
1690
+ // An uploaded pack isn't a registry version, so any registry version is a change from it - the newest is offered first.
1691
+ const uploaded: boolean = plugin.source === "upload";
1573
1692
 
1574
1693
  useEffect(() => {
1575
1694
  lookupPluginPackage(plugin.name, undefined, { prerelease })
1576
1695
  .then((found) => {
1577
1696
  setVersions(found.package.versions);
1578
1697
  setLatest(found.package.latest);
1698
+ if (uploaded && found.package.latest) {
1699
+ setSelected(found.package.latest);
1700
+ }
1579
1701
  })
1580
1702
  .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
1581
- }, [plugin.name, prerelease]);
1703
+ }, [plugin.name, prerelease, uploaded]);
1582
1704
 
1583
1705
  async function save() {
1584
1706
  setBusy(true);
@@ -1591,12 +1713,17 @@ function ChangeVersionModal({
1591
1713
  }
1592
1714
 
1593
1715
  return (
1594
- <Modal open onClose={onClose} title={`${plugin.manifest.displayName} version`}>
1716
+ <Modal open onClose={onClose} title={uploaded ? `Install the registry version of ${plugin.manifest.displayName}` : `${plugin.manifest.displayName} version`}>
1595
1717
  {error && <Alert>{error}</Alert>}
1596
1718
  {versions === null ? (
1597
1719
  !error && <p className="text-sm text-text-muted">Loading&hellip;</p>
1598
1720
  ) : (
1599
1721
  <div className="flex flex-col gap-3">
1722
+ {uploaded && (
1723
+ <p className="text-sm text-text-muted">
1724
+ {plugin.manifest.displayName} {plugin.packageVersion} was uploaded as {plugin.uploadFilename}. This replaces it with the version from the registry.
1725
+ </p>
1726
+ )}
1600
1727
  <label className="flex flex-col gap-1.5 text-sm">
1601
1728
  <span className="font-semibold">Version</span>
1602
1729
  <select aria-label="Version" className={INPUT_CLASS} value={selected} onChange={(e) => setSelected(e.target.value)}>
@@ -1604,7 +1731,7 @@ function ChangeVersionModal({
1604
1731
  <option key={version} value={version}>
1605
1732
  {version}
1606
1733
  {version === latest ? " (latest)" : ""}
1607
- {version === plugin.packageVersion ? " (installed)" : ""}
1734
+ {!uploaded && version === plugin.packageVersion ? " (installed)" : ""}
1608
1735
  </option>
1609
1736
  ))}
1610
1737
  </select>
@@ -1617,10 +1744,10 @@ function ChangeVersionModal({
1617
1744
  type="button"
1618
1745
  className="!w-auto"
1619
1746
  loading={busy}
1620
- disabled={busy || selected === plugin.packageVersion}
1747
+ disabled={busy || (!uploaded && selected === plugin.packageVersion)}
1621
1748
  onClick={() => void save()}
1622
1749
  >
1623
- Save
1750
+ {uploaded ? "Install" : "Save"}
1624
1751
  </Button>
1625
1752
  </div>
1626
1753
  </div>
@@ -12,5 +12,5 @@ import SettingsTable from "./SettingsTable.js";
12
12
  */
13
13
  export default function InformationPanel({ versions, plugins, information }) {
14
14
  const { data, error, loading } = versions;
15
- return (_jsxs("div", { className: "space-y-6", children: [error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data && (_jsxs(_Fragment, { children: [_jsx(ServerVersionCard, { server: data.server }), _jsx(ComponentsTable, { components: data.components ?? [], kubernetes: data.kubernetes ?? { available: false } }), _jsx(InstalledPlugins, { data: plugins.data, error: plugins.error, loading: plugins.loading }), information.error && _jsx(Alert, { children: information.error }), !information.data && information.loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading the environment\u2026" }), information.data && (_jsxs(_Fragment, { children: [_jsx(SettingsTable, { id: "diagnostics-environment", title: "Environment variables", description: "The server process's environment. Only variables known to be harmless show their value; the value of a secret, or of anything unrecognized, is withheld by the server and never sent.", settings: information.data.environment ?? [] }), _jsx(SettingsTable, { id: "diagnostics-configuration", title: "Configuration", description: "The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Passwords, tokens, keys, certificates and the credentials in URLs are withheld by the server and never sent.", settings: information.data.configuration ?? [] })] })), _jsx(PackagesTable, { packages: data.packages ?? [] })] }))] }));
15
+ return (_jsxs("div", { className: "space-y-6", children: [error && _jsx(Alert, { children: error }), !data && loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" }), data && (_jsxs(_Fragment, { children: [_jsx(ServerVersionCard, { server: data.server }), _jsx(ComponentsTable, { components: data.components ?? [], kubernetes: data.kubernetes ?? { available: false } }), _jsx(InstalledPlugins, { data: plugins.data, error: plugins.error, loading: plugins.loading }), information.error && _jsx(Alert, { children: information.error }), !information.data && information.loading && _jsx("p", { className: "text-sm text-text-muted", children: "Loading the environment\u2026" }), information.data && (_jsxs(_Fragment, { children: [_jsx(SettingsTable, { id: "diagnostics-environment", title: "Environment variables", description: "The server process's environment. Every variable shows its value except those on the server's list of hidden settings, whose value is withheld by the server and never sent.", settings: information.data.environment ?? [] }), _jsx(SettingsTable, { id: "diagnostics-configuration", title: "Configuration", description: "The effective settings the server runs with: the environment, saved plugin settings and defaults combined. Settings on the server's list of hidden settings, such as passwords, tokens and keys, are withheld by the server and never sent, and so are the credentials in URLs.", settings: information.data.configuration ?? [] })] })), _jsx(PackagesTable, { packages: data.packages ?? [] })] }))] }));
16
16
  }
@@ -63,8 +63,8 @@ export interface DiagnosticsVersions {
63
63
  };
64
64
  }
65
65
  /**
66
- * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds every secret-like
67
- * or unrecognized value and never sends it, so there is nothing for the client to hide.
66
+ * One environment variable or configuration setting. A `redacted` one has no `value`: the server withholds the value of every setting on its
67
+ * hidden list and never sends it, so there is nothing for the client to hide.
68
68
  */
69
69
  export interface DiagnosticsSetting {
70
70
  name: string;
@@ -4,7 +4,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useCallback, useEffect, useId, useRef, useState } from "react";
7
- import { addPlugin, expectedPlanOf, getPluginStatus, getPluginUpdates, isSecretSetting, isSecretSettingKey, listPluginNamespaces, listPlugins, lookupPluginPackage, planPluginChange, removePlugin, retryPluginPurge, searchPlugins, updatePlugin, } from "../../../../../lib/admin/pluginsApi.js";
7
+ import { addPlugin, expectedPlanOf, getPluginStatus, getPluginUpdates, isSecretSetting, isSecretSettingKey, listPluginNamespaces, listPlugins, lookupPluginPackage, planPluginChange, removePlugin, retryPluginPurge, searchPlugins, updatePlugin, uploadPlugin, } from "../../../../../lib/admin/pluginsApi.js";
8
+ import { ApiRequestError } from "../../../../../lib/util/api.js";
8
9
  import Alert from "../ActionAlert.js";
9
10
  import Button from "../../../../../lib/components/buttons/Button.js";
10
11
  import Modal from "../../../../../lib/components/overlays/Modal.js";
@@ -43,6 +44,10 @@ function purgeFailure(purge) {
43
44
  }
44
45
  /** A day, in the administrator's own format. */
45
46
  const formatDate = (iso) => new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" });
47
+ /** The file names an uploaded plugin may have: what `npm pack` produces (`<name>-<version>.tgz`), or the same gzipped. */
48
+ const PLUGIN_PACK_NAME = /\.(tgz|tar\.gz|gz)$/i;
49
+ /** What the server answers (409) an upload of a plugin that is already installed, which the administrator may replace. */
50
+ const ALREADY_INSTALLED = /already installed/i;
46
51
  const BULK_WORDS = {
47
52
  upgrade: { doing: "Upgrading", done: "upgraded" },
48
53
  disable: { doing: "Disabling", done: "disabled" },
@@ -136,6 +141,11 @@ export default function PluginsManager({ embedded = false } = {}) {
136
141
  const [confirmingBulkUninstall, setConfirmingBulkUninstall] = useState(null);
137
142
  /** How many changes (or the lookups that come before them) are waiting on the server. */
138
143
  const [working, setWorking] = useState(0);
144
+ /** Set while an uploaded plugin is being sent to the server. */
145
+ const [uploading, setUploading] = useState(false);
146
+ /** An upload that would replace an installed plugin, waiting for the administrator to confirm it. */
147
+ const [replacing, setReplacing] = useState(null);
148
+ const fileInput = useRef(null);
139
149
  const statusRequest = useRef(null);
140
150
  /** The state each data deletion was last seen in, so a notification is raised when one that was under way ends. */
141
151
  const seenPurges = useRef(new Map());
@@ -373,6 +383,55 @@ export default function PluginsManager({ embedded = false } = {}) {
373
383
  function upgrade(plugin, packageVersion) {
374
384
  return onRow(plugin, () => changeVersion(plugin, packageVersion));
375
385
  }
386
+ /** Sends `file` to the server as a plugin to install - replacing the installed plugin of that name only when `replace` is set. A plugin that is
387
+ * already installed isn't an error but a question, asked of the administrator before it is replaced. */
388
+ async function runUpload(file, replace) {
389
+ setUploading(true);
390
+ setError(null);
391
+ setRetryEnableUid(null);
392
+ try {
393
+ const uploaded = await track(uploadPlugin(file, { replace }));
394
+ applied(uploaded);
395
+ notify({
396
+ kind: "success",
397
+ title: `${uploaded.name}@${uploaded.packageVersion} uploaded`,
398
+ message: "The servers restart one at a time to load it.",
399
+ });
400
+ }
401
+ catch (err) {
402
+ if (!replace && err instanceof ApiRequestError && err.status === 409 && ALREADY_INSTALLED.test(err.message)) {
403
+ const installed = plugins.find((plugin) => err.message.includes(plugin.name));
404
+ setReplacing({
405
+ file,
406
+ question: installed
407
+ ? `Replace ${installed.name} ${installed.packageVersion} with the uploaded pack?`
408
+ : "Replace the installed plugin with the uploaded pack?",
409
+ reason: err.message,
410
+ });
411
+ }
412
+ else if (err instanceof ApiRequestError && err.status === 413) {
413
+ setError("That file is too large for the server to accept.");
414
+ }
415
+ else {
416
+ setError(errorMessage(err, "Could not upload the plugin."));
417
+ }
418
+ }
419
+ setUploading(false);
420
+ }
421
+ /** Takes the file the administrator chose, unless it can't be what `npm pack` produces. */
422
+ function onFileChosen(e) {
423
+ const file = e.target.files?.[0];
424
+ // So choosing the same file again (after fixing what was wrong) still counts as a change.
425
+ e.target.value = "";
426
+ if (!file) {
427
+ return;
428
+ }
429
+ if (!PLUGIN_PACK_NAME.test(file.name)) {
430
+ setError(`${file.name} isn't a plugin pack. Choose the .tgz file that npm pack produces.`);
431
+ return;
432
+ }
433
+ void runUpload(file, false);
434
+ }
376
435
  /** Installs an uninstalled plugin again, at its latest version - as adding it by name would. */
377
436
  function reinstall(purge) {
378
437
  const displayName = purgeName(purge);
@@ -523,9 +582,10 @@ export default function PluginsManager({ embedded = false } = {}) {
523
582
  const retryEnable = plugins.find((plugin) => plugin.uid === retryEnableUid && !plugin.enabled);
524
583
  const displayNameOf = (name) => plugins.find((plugin) => plugin.name === name)?.manifest.displayName ?? name;
525
584
  const SectionHeading = embedded ? "h3" : "h2";
526
- const addButton = (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setAdding(true), children: "Add by name" }));
527
585
  const bulkBusy = bulk !== null;
528
- return (_jsxs(_Fragment, { children: [(working > 0 || pending) && _jsx(PluginProgressBar, { label: working > 0 ? "Working on plugins" : "Applying plugin changes" }), embedded ? (_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), addButton] }), _jsx("p", { className: "text-sm text-text-muted mb-6 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })] })), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 h-4 w-4 shrink-0", checked: allowPrerelease, onChange: (e) => changePrerelease(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: "Allow pre-release versions" }), _jsx("span", { className: "text-xs text-text-muted", children: "Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to install. They can be unstable. This is remembered in this browser." })] })] }), bulkFailure && (_jsx(Alert, { children: _jsxs("div", { children: [bulkFailure.title, _jsx("ul", { className: "list-disc pl-5 mt-1", children: bulkFailure.reasons.map((reason) => (_jsx("li", { className: "break-words", children: reason }, reason))) })] }) })), error && (_jsxs(Alert, { children: [error, retryEnable && (_jsxs(_Fragment, { children: [" ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0", onClick: () => void toggle(retryEnable), children: "Try again" })] }))] })), _jsx(RolloutBanner, { status: status }), statusStale && status && (_jsx("p", { className: "mb-4 text-xs text-text-muted", children: "Couldn't refresh server status. Showing the last status reported." })), listStale && (_jsxs("p", { className: "mb-4 text-xs text-text-muted", children: ["Couldn't re-read the plugin list, so it may be out of date.", " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0 !text-xs", onClick: () => void reload(), children: "Retry" })] })), _jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide", children: "Installed plugins" }), embedded && addButton] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 && purges.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsxs("div", { className: "overflow-x-auto", children: [plugins.length > 0 && (_jsxs("div", { role: "toolbar", "aria-label": "Actions for several plugins", className: "flex flex-wrap items-center gap-2 py-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm mr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "h-4 w-4 shrink-0", "aria-label": "Select all plugins", checked: allSelected, disabled: bulkBusy, ref: (input) => {
586
+ const addButton = (_jsxs("div", { className: "flex flex-wrap gap-2 shrink-0", children: [_jsx("input", { ref: fileInput, type: "file", className: "hidden", accept: ".tgz,.tar.gz,application/gzip,application/x-gzip", "aria-label": "Plugin pack file", tabIndex: -1, onChange: onFileChosen }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", title: "Install a plugin from the .tgz file that npm pack produces", loading: uploading, disabled: uploading || bulkBusy, onClick: () => fileInput.current?.click(), children: uploading ? "Uploading\u2026" : "Upload" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setAdding(true), children: "Add by name" })] }));
587
+ return (_jsxs(_Fragment, { children: [(working > 0 || pending) && _jsx(PluginProgressBar, { label: working > 0 ? "Working on plugins" : "Applying plugin changes" }), embedded ? (_jsx("p", { className: "text-sm text-text-muted mb-5 max-w-3xl", children: "Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide", children: "Plugins" }), addButton] }), _jsx("p", { className: "text-sm text-text-muted mb-6 max-w-3xl", children: "Plugins add protocols and features to every server. Changes are applied by restarting the servers one at a time, so mail keeps flowing. Plugins run with full access to the server, so only add ones you trust." })] })), _jsxs("label", { className: "flex items-start gap-2 text-sm mb-5 max-w-3xl cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-0.5 h-4 w-4 shrink-0", checked: allowPrerelease, onChange: (e) => changePrerelease(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: "Allow pre-release versions" }), _jsx("span", { className: "text-xs text-text-muted", children: "Also offer beta and other pre-release builds, such as 1.0.0-beta.2, as updates and as versions to install. They can be unstable. This is remembered in this browser." })] })] }), bulkFailure && (_jsx(Alert, { children: _jsxs("div", { children: [bulkFailure.title, _jsx("ul", { className: "list-disc pl-5 mt-1", children: bulkFailure.reasons.map((reason) => (_jsx("li", { className: "break-words", children: reason }, reason))) })] }) })), error &&
588
+ (retryEnable ? (_jsxs(Alert, { children: [error, " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0", onClick: () => void toggle(retryEnable), children: "Try again" })] })) : (_jsx(Alert, { children: error }))), _jsx(RolloutBanner, { status: status }), statusStale && status && (_jsx("p", { className: "mb-4 text-xs text-text-muted", children: "Couldn't refresh server status. Showing the last status reported." })), listStale && (_jsxs("p", { className: "mb-4 text-xs text-text-muted", children: ["Couldn't re-read the plugin list, so it may be out of date.", " ", _jsx(Button, { type: "button", variant: "text", className: "!w-auto !p-0 !text-xs", onClick: () => void reload(), children: "Retry" })] })), _jsxs("div", { className: "flex items-center justify-between gap-4 mb-2", children: [_jsx(SectionHeading, { className: "text-sm font-bold uppercase tracking-wide", children: "Installed plugins" }), embedded && addButton] }), loading ? (_jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : plugins.length === 0 && purges.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: "No plugins installed." })) : (_jsxs("div", { className: "overflow-x-auto", children: [plugins.length > 0 && (_jsxs("div", { role: "toolbar", "aria-label": "Actions for several plugins", className: "flex flex-wrap items-center gap-2 py-2", children: [_jsxs("label", { className: "flex items-center gap-2 text-sm mr-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "h-4 w-4 shrink-0", "aria-label": "Select all plugins", checked: allSelected, disabled: bulkBusy, ref: (input) => {
529
589
  if (input) {
530
590
  input.indeterminate = selectedPlugins.length > 0 && !allSelected;
531
591
  }
@@ -533,13 +593,15 @@ export default function PluginsManager({ embedded = false } = {}) {
533
593
  versions: new Map(selectedUpgrades.map((plugin) => [plugin.uid, updates.get(plugin.uid).latestVersion])),
534
594
  }), children: ["Upgrade", selectedUpgrades.length > 0 ? ` (${selectedUpgrades.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Disable selected plugins", disabled: bulkBusy || selectedEnabled.length === 0, onClick: () => void runBulk("disable", selectedEnabled), children: ["Disable", selectedEnabled.length > 0 ? ` (${selectedEnabled.length})` : ""] }), _jsxs(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Uninstall selected plugins", disabled: bulkBusy || selectedPlugins.length === 0, onClick: () => setConfirmingBulkUninstall(selectedPlugins), children: ["Uninstall", selectedPlugins.length > 0 ? ` (${selectedPlugins.length})` : ""] }), _jsx(Button, { type: "button", className: "!w-auto ml-auto", "aria-label": "Upgrade all plugins", loading: bulk?.kind === "upgrade", disabled: bulkBusy, onClick: () => void upgradeAll(), children: "Upgrade all" }), bulk && (_jsxs("span", { "aria-live": "polite", className: "basis-full text-xs text-text-muted", children: [BULK_WORDS[bulk.kind].doing, " ", bulk.current, " (", bulk.done + 1, " of ", bulk.total, ")\u2026"] }))] })), _jsxs("table", { role: "table", className: "w-full text-sm border-collapse block lg:table", children: [_jsx("thead", { role: "rowgroup", className: "hidden lg:table-header-group", children: _jsxs("tr", { role: "row", children: [_jsx("th", { role: "columnheader", className: TH_CLASS, children: "Plugin" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Version" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: "Status" }), _jsx("th", { role: "columnheader", className: TH_CLASS, children: _jsx("span", { className: "sr-only", children: "Actions" }) })] }) }), _jsxs("tbody", { role: "rowgroup", className: "block lg:table-row-group", children: [plugins.map((plugin) => {
535
595
  const update = updates.get(plugin.uid);
536
- const latest = update?.updateAvailable ? update.latestVersion : undefined;
596
+ const uploaded = plugin.source === "upload";
597
+ // An uploaded pack has no registry version to be newer than.
598
+ const latest = !uploaded && update?.updateAvailable ? update.latestVersion : undefined;
537
599
  const busy = busyUids.has(plugin.uid) || confirming?.uid === plugin.uid || bulkBusy;
538
600
  const requires = Object.keys(plugin.manifest.requires ?? {}).map(displayNameOf);
539
601
  const requiredBy = plugins
540
602
  .filter((other) => other.uid !== plugin.uid && other.manifest.requires?.[plugin.name] !== undefined)
541
603
  .map((other) => other.manifest.displayName);
542
- return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("input", { type: "checkbox", className: "mt-1 h-4 w-4 shrink-0", "aria-label": `Select ${plugin.manifest.displayName}`, checked: selected.has(plugin.uid), disabled: bulkBusy, onChange: () => toggleSelected(plugin.uid) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] })] }) }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
604
+ return (_jsxs("tr", { role: "row", className: INSTALLED_ROW_CLASS, children: [_jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-3 pb-2`, children: _jsxs("div", { className: "flex items-start gap-3", children: [_jsx("input", { type: "checkbox", className: "mt-1 h-4 w-4 shrink-0", "aria-label": `Select ${plugin.manifest.displayName}`, checked: selected.has(plugin.uid), disabled: bulkBusy, onChange: () => toggleSelected(plugin.uid) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "font-semibold", children: plugin.manifest.displayName }), _jsx("div", { className: "text-xs text-text-muted", children: plugin.name }), plugin.manifest.description && (_jsx("div", { className: "text-xs text-text-muted mt-1 max-w-md", children: plugin.manifest.description })), uploaded && (_jsxs("div", { className: "text-xs text-text-muted mt-1 break-words", children: [_jsx("span", { className: `${BADGE_CLASS} bg-surface-alt text-text-muted`, children: "Uploaded" }), " ", plugin.uploadFilename, plugin.uploadedAt ? `, ${formatDate(plugin.uploadedAt)}` : ""] })), requires.length > 0 && _jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Requires: ", requires.join(", ")] }), requiredBy.length > 0 && (_jsxs("div", { className: "text-xs text-text-muted mt-1", children: ["Required by: ", requiredBy.join(", ")] }))] })] }) }), _jsxs("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1 whitespace-nowrap`, children: [_jsx("div", { children: plugin.packageVersion }), latest && _jsxs("div", { className: "mt-1 text-xs font-semibold text-primary-dark", children: ["Update available: ", latest] })] }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} pt-1 pb-1`, children: _jsx(PluginStatusCell, { plugin: plugin, status: status }) }), _jsx("td", { role: "cell", className: `${INSTALLED_TD_CLASS} basis-full pt-2 pb-3`, children: _jsxs("div", { className: "flex flex-col items-start gap-1.5 lg:items-end lg:min-w-[11rem]", children: [_jsxs("div", { className: "flex flex-wrap gap-2 lg:justify-end", children: [latest && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": `Upgrade ${plugin.manifest.displayName} to ${latest}`, disabled: busy, onClick: () => void upgrade(plugin, latest), children: "Upgrade" })), (plugin.manifest.settings ?? []).length > 0 && (_jsx(Button, { variant: "secondary", type: "button", className: "!w-auto", onClick: () => setConfiguring(plugin), children: "Settings" })), _jsx(Button, { variant: plugin.enabled ? "secondary" : "primary", type: "button", className: "!w-auto min-w-[5.5rem]", "aria-label": `${plugin.enabled ? "Disable" : "Enable"} ${plugin.manifest.displayName}`, disabled: busy, onClick: () => void toggle(plugin), children: plugin.enabled ? "Disable" : "Enable" })] }), _jsxs("div", { className: "flex flex-wrap gap-x-4 lg:justify-end", children: [_jsx(Button, { variant: "text", type: "button", onClick: () => setUpgrading(plugin), children: uploaded ? "Install the registry version" : "Change version" }), _jsx(Button, { variant: "text", type: "button", onClick: () => setRemoving(plugin), children: "Uninstall" })] })] }) })] }, plugin.uid));
543
605
  }), purges.map((purge) => (_jsx(UninstalledPluginRow, { purge: purge, busy: busyUids.has(purge.uid), onRetry: () => void retryPurge(purge), onInstall: () => void reinstall(purge) }, purge.uid)))] })] })] })), _jsx(PluginBrowser, { headingLevel: SectionHeading, plugins: plugins, prerelease: allowPrerelease, onInstall: (result) => install(result.name, result.version, result.name),
544
606
  // The browser only offers an upgrade for a result it matched to one of these same `plugins`.
545
607
  onUpgrade: (uid, packageVersion) => upgrade(plugins.find((p) => p.uid === uid), packageVersion) }), _jsx(AddPluginModal, { open: adding, prerelease: allowPrerelease, onClose: () => setAdding(false), onAdd: async (name, packageVersion, displayName) => {
@@ -554,7 +616,11 @@ export default function PluginsManager({ embedded = false } = {}) {
554
616
  setUpgrading(null);
555
617
  }
556
618
  return problem;
557
- } })), confirming && (_jsx(ConfirmDependenciesModal, { change: confirming, displayNameOf: displayNameOf, onClose: () => setConfirming(null), onDone: () => setConfirming(null) })), confirmingBulkUninstall && (_jsx(BulkUninstallModal, { plugins: confirmingBulkUninstall, onClose: () => setConfirmingBulkUninstall(null), onConfirm: (purgeData) => {
619
+ } })), replacing && (_jsxs(Modal, { open: true, onClose: () => setReplacing(null), title: "Replace plugin?", children: [_jsx("p", { className: "text-sm mb-2", children: replacing.question }), _jsx("p", { className: "text-sm text-text-muted mb-4 break-words", children: replacing.reason }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setReplacing(null), children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", onClick: () => {
620
+ const { file } = replacing;
621
+ setReplacing(null);
622
+ void runUpload(file, true);
623
+ }, children: "Replace" })] })] })), confirming && (_jsx(ConfirmDependenciesModal, { change: confirming, displayNameOf: displayNameOf, onClose: () => setConfirming(null), onDone: () => setConfirming(null) })), confirmingBulkUninstall && (_jsx(BulkUninstallModal, { plugins: confirmingBulkUninstall, onClose: () => setConfirmingBulkUninstall(null), onConfirm: (purgeData) => {
558
624
  const targets = confirmingBulkUninstall;
559
625
  setConfirmingBulkUninstall(null);
560
626
  void runBulk("uninstall", targets, { purgeData });
@@ -777,14 +843,19 @@ function ChangeVersionModal({ plugin, prerelease, onClose, onSave, }) {
777
843
  const [selected, setSelected] = useState(plugin.packageVersion);
778
844
  const [error, setError] = useState(null);
779
845
  const [busy, setBusy] = useState(false);
846
+ // An uploaded pack isn't a registry version, so any registry version is a change from it - the newest is offered first.
847
+ const uploaded = plugin.source === "upload";
780
848
  useEffect(() => {
781
849
  lookupPluginPackage(plugin.name, undefined, { prerelease })
782
850
  .then((found) => {
783
851
  setVersions(found.package.versions);
784
852
  setLatest(found.package.latest);
853
+ if (uploaded && found.package.latest) {
854
+ setSelected(found.package.latest);
855
+ }
785
856
  })
786
857
  .catch((err) => setError(errorMessage(err, "Could not load the available versions.")));
787
- }, [plugin.name, prerelease]);
858
+ }, [plugin.name, prerelease, uploaded]);
788
859
  async function save() {
789
860
  setBusy(true);
790
861
  setError(null);
@@ -794,7 +865,7 @@ function ChangeVersionModal({ plugin, prerelease, onClose, onSave, }) {
794
865
  setBusy(false);
795
866
  }
796
867
  }
797
- return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} version`, children: [error && _jsx(Alert, { children: error }), versions === null ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selected, onChange: (e) => setSelected(e.target.value), children: versions.map((version) => (_jsxs("option", { value: version, children: [version, version === latest ? " (latest)" : "", version === plugin.packageVersion ? " (installed)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || selected === plugin.packageVersion, onClick: () => void save(), children: "Save" })] })] }))] }));
868
+ return (_jsxs(Modal, { open: true, onClose: onClose, title: uploaded ? `Install the registry version of ${plugin.manifest.displayName}` : `${plugin.manifest.displayName} version`, children: [error && _jsx(Alert, { children: error }), versions === null ? (!error && _jsx("p", { className: "text-sm text-text-muted", children: "Loading\u2026" })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [uploaded && (_jsxs("p", { className: "text-sm text-text-muted", children: [plugin.manifest.displayName, " ", plugin.packageVersion, " was uploaded as ", plugin.uploadFilename, ". This replaces it with the version from the registry."] })), _jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: "Version" }), _jsx("select", { "aria-label": "Version", className: INPUT_CLASS, value: selected, onChange: (e) => setSelected(e.target.value), children: versions.map((version) => (_jsxs("option", { value: version, children: [version, version === latest ? " (latest)" : "", !uploaded && version === plugin.packageVersion ? " (installed)" : ""] }, version))) })] }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "button", className: "!w-auto", loading: busy, disabled: busy || (!uploaded && selected === plugin.packageVersion), onClick: () => void save(), children: uploaded ? "Install" : "Save" })] })] }))] }));
798
869
  }
799
870
  /** Lists the plugins a change also installs or enables, and makes the change once confirmed. */
800
871
  function ConfirmDependenciesModal({ change, displayNameOf, onClose, onDone, }) {
@@ -64,6 +64,14 @@ export interface Plugin {
64
64
  * predates it, which reads as nothing being configured. */
65
65
  configured?: Record<string, PluginConfiguredSetting>;
66
66
  manifest: PluginManifest;
67
+ /** Where the installed package came from: the npm registry (also what a server that predates uploads means), or a `.tgz` an administrator uploaded. */
68
+ source?: "registry" | "upload";
69
+ /** The name of the uploaded file, for a plugin with `source` `upload`. */
70
+ uploadFilename?: string;
71
+ /** ISO time the file was uploaded. */
72
+ uploadedAt?: string;
73
+ /** The uid of the administrator who uploaded it. */
74
+ uploadedByUserUid?: string;
67
75
  }
68
76
  /** Mirrors `@rapidmx/restapi`'s `RegistryPackageVersion`. `manifest` is a message when the version isn't a
69
77
  * loadable plugin. */
@@ -260,6 +268,16 @@ export declare function planPluginChange(name: string, packageVersion?: string,
260
268
  * requires first. Refused (409) when a requirement conflicts with an installed plugin's version, or when
261
269
  * `expectedPlan` is given and no longer matches what adding it would install and enable. */
262
270
  export declare function addPlugin(name: string, packageVersion?: string, expectedPlan?: PluginExpectedPlan, client?: ApiClient): Promise<AddPluginResult>;
271
+ /**
272
+ * Installs a plugin from `file`, the `.tgz` that `npm pack` produces, instead of from the registry - to try a new version before it is released, or an
273
+ * alternative distribution. The file's raw bytes are the request body (not multipart); its name travels in the query string. Needs an elevated
274
+ * administrator (403 `api-104` otherwise) and uploads switched on by the operator (403). Refused with 409 when a plugin of that name is already
275
+ * installed - unless `replace` is set, which swaps it for the uploaded one - or when a plugin it requires isn't installed; 400 when it isn't a
276
+ * loadable plugin and 413 when it is too large. Like adding one, it starts a rollout.
277
+ */
278
+ export declare function uploadPlugin(file: File, options?: {
279
+ replace?: boolean;
280
+ }, client?: ApiClient): Promise<Plugin>;
263
281
  export declare function updatePlugin(uid: string, input: UpdatePluginInput, client?: ApiClient): Promise<Plugin>;
264
282
  /**
265
283
  * Removes a plugin. Refused (409) while an enabled plugin requires it, as is disabling it with `updatePlugin()`.
@@ -8,7 +8,7 @@
8
8
  * each server copy installs the new plugin set and restarts itself, one copy at a time, and reports back through
9
9
  * `getPluginStatus()`.
10
10
  */
11
- import { withClient } from "../util/api.js";
11
+ import { withClient, withClientRaw } from "../util/api.js";
12
12
  /** Whether `value` is the placeholder a saved secret setting is shown as. */
13
13
  export function isSecretSetting(value) {
14
14
  return typeof value === "object" && value !== null && value.secret === true;
@@ -85,6 +85,20 @@ export function planPluginChange(name, packageVersion, options = {}, client) {
85
85
  export function addPlugin(name, packageVersion, expectedPlan, client) {
86
86
  return withClient(client, BASE, { method: "POST", body: JSON.stringify({ name, packageVersion, expectedPlan }) });
87
87
  }
88
+ /**
89
+ * Installs a plugin from `file`, the `.tgz` that `npm pack` produces, instead of from the registry - to try a new version before it is released, or an
90
+ * alternative distribution. The file's raw bytes are the request body (not multipart); its name travels in the query string. Needs an elevated
91
+ * administrator (403 `api-104` otherwise) and uploads switched on by the operator (403). Refused with 409 when a plugin of that name is already
92
+ * installed - unless `replace` is set, which swaps it for the uploaded one - or when a plugin it requires isn't installed; 400 when it isn't a
93
+ * loadable plugin and 413 when it is too large. Like adding one, it starts a rollout.
94
+ */
95
+ export function uploadPlugin(file, options = {}, client) {
96
+ const params = new URLSearchParams({ filename: file.name });
97
+ if (options.replace) {
98
+ params.set("replace", "true");
99
+ }
100
+ return withClientRaw(client, `${BASE}/upload?${params.toString()}`, "POST", file, "application/gzip", "Could not upload the plugin.");
101
+ }
88
102
  export function updatePlugin(uid, input, client) {
89
103
  return withClient(client, `${BASE}/${encodeURIComponent(uid)}`, { method: "PUT", body: JSON.stringify(input) });
90
104
  }
@@ -8,7 +8,7 @@
8
8
  * each server copy installs the new plugin set and restarts itself, one copy at a time, and reports back through
9
9
  * `getPluginStatus()`.
10
10
  */
11
- import { ApiClient, withClient } from "../util/api.js";
11
+ import { ApiClient, withClient, withClientRaw } from "../util/api.js";
12
12
 
13
13
  /** Mirrors `@rapidmx/restapi`'s `PluginSettingDefinition`. */
14
14
  export interface PluginSettingDefinition {
@@ -78,6 +78,14 @@ export interface Plugin {
78
78
  * predates it, which reads as nothing being configured. */
79
79
  configured?: Record<string, PluginConfiguredSetting>;
80
80
  manifest: PluginManifest;
81
+ /** Where the installed package came from: the npm registry (also what a server that predates uploads means), or a `.tgz` an administrator uploaded. */
82
+ source?: "registry" | "upload";
83
+ /** The name of the uploaded file, for a plugin with `source` `upload`. */
84
+ uploadFilename?: string;
85
+ /** ISO time the file was uploaded. */
86
+ uploadedAt?: string;
87
+ /** The uid of the administrator who uploaded it. */
88
+ uploadedByUserUid?: string;
81
89
  }
82
90
 
83
91
  /** Mirrors `@rapidmx/restapi`'s `RegistryPackageVersion`. `manifest` is a message when the version isn't a
@@ -331,6 +339,21 @@ export function addPlugin(
331
339
  return withClient(client, BASE, { method: "POST", body: JSON.stringify({ name, packageVersion, expectedPlan }) });
332
340
  }
333
341
 
342
+ /**
343
+ * Installs a plugin from `file`, the `.tgz` that `npm pack` produces, instead of from the registry - to try a new version before it is released, or an
344
+ * alternative distribution. The file's raw bytes are the request body (not multipart); its name travels in the query string. Needs an elevated
345
+ * administrator (403 `api-104` otherwise) and uploads switched on by the operator (403). Refused with 409 when a plugin of that name is already
346
+ * installed - unless `replace` is set, which swaps it for the uploaded one - or when a plugin it requires isn't installed; 400 when it isn't a
347
+ * loadable plugin and 413 when it is too large. Like adding one, it starts a rollout.
348
+ */
349
+ export function uploadPlugin(file: File, options: { replace?: boolean } = {}, client?: ApiClient): Promise<Plugin> {
350
+ const params = new URLSearchParams({ filename: file.name });
351
+ if (options.replace) {
352
+ params.set("replace", "true");
353
+ }
354
+ return withClientRaw(client, `${BASE}/upload?${params.toString()}`, "POST", file, "application/gzip", "Could not upload the plugin.");
355
+ }
356
+
334
357
  export function updatePlugin(uid: string, input: UpdatePluginInput, client?: ApiClient): Promise<Plugin> {
335
358
  return withClient(client, `${BASE}/${encodeURIComponent(uid)}`, { method: "PUT", body: JSON.stringify(input) });
336
359
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.31.0",
3
+ "version": "0.32.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",