@skillcdn/console 0.4.0 → 0.4.2

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.
Files changed (308) hide show
  1. package/README.md +20 -2
  2. package/SKILLCDN.md +7 -5
  3. package/dist/api.d.ts +5 -4
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +5 -4
  6. package/dist/api.js.map +1 -1
  7. package/dist/cli/cli.d.ts.map +1 -1
  8. package/dist/cli/cli.js +579 -45
  9. package/dist/cli/cli.js.map +1 -1
  10. package/dist/cli/format.d.ts +34 -3
  11. package/dist/cli/format.d.ts.map +1 -1
  12. package/dist/cli/format.js +277 -6
  13. package/dist/cli/format.js.map +1 -1
  14. package/dist/cli/help.d.ts.map +1 -1
  15. package/dist/cli/help.js +60 -7
  16. package/dist/cli/help.js.map +1 -1
  17. package/dist/cli/plugin-check.d.ts +21 -0
  18. package/dist/cli/plugin-check.d.ts.map +1 -0
  19. package/dist/cli/plugin-check.js +374 -0
  20. package/dist/cli/plugin-check.js.map +1 -0
  21. package/dist/cli/plugin-cli.d.ts +20 -0
  22. package/dist/cli/plugin-cli.d.ts.map +1 -0
  23. package/dist/cli/plugin-cli.js +123 -0
  24. package/dist/cli/plugin-cli.js.map +1 -0
  25. package/dist/cli/plugin-init.d.ts +20 -0
  26. package/dist/cli/plugin-init.d.ts.map +1 -0
  27. package/dist/cli/plugin-init.js +74 -0
  28. package/dist/cli/plugin-init.js.map +1 -0
  29. package/dist/cli/plugin-main.d.ts +3 -0
  30. package/dist/cli/plugin-main.d.ts.map +1 -0
  31. package/dist/cli/plugin-main.js +28 -0
  32. package/dist/cli/plugin-main.js.map +1 -0
  33. package/dist/client.d.ts +109 -2
  34. package/dist/client.d.ts.map +1 -1
  35. package/dist/client.js +67 -2
  36. package/dist/client.js.map +1 -1
  37. package/dist/compat.d.ts +1 -1
  38. package/dist/compat.js +2 -2
  39. package/dist/components/board.d.ts +8 -0
  40. package/dist/components/board.d.ts.map +1 -1
  41. package/dist/components/board.js +59 -15
  42. package/dist/components/board.js.map +1 -1
  43. package/dist/components/event-feed.d.ts +1 -1
  44. package/dist/components/event-feed.d.ts.map +1 -1
  45. package/dist/components/event-feed.js +33 -1
  46. package/dist/components/event-feed.js.map +1 -1
  47. package/dist/components/markdown.d.ts.map +1 -1
  48. package/dist/components/markdown.js +6 -3
  49. package/dist/components/markdown.js.map +1 -1
  50. package/dist/components/pack-connect.d.ts +3 -1
  51. package/dist/components/pack-connect.d.ts.map +1 -1
  52. package/dist/components/pack-connect.js +4 -4
  53. package/dist/components/pack-connect.js.map +1 -1
  54. package/dist/components/pack-token.d.ts +3 -1
  55. package/dist/components/pack-token.d.ts.map +1 -1
  56. package/dist/components/pack-token.js +1 -1
  57. package/dist/components/pack-token.js.map +1 -1
  58. package/dist/components/people.d.ts.map +1 -1
  59. package/dist/components/people.js +14 -3
  60. package/dist/components/people.js.map +1 -1
  61. package/dist/components/plugin-frame.d.ts +52 -0
  62. package/dist/components/plugin-frame.d.ts.map +1 -0
  63. package/dist/components/plugin-frame.js +314 -0
  64. package/dist/components/plugin-frame.js.map +1 -0
  65. package/dist/components/plugin-settings.d.ts +38 -0
  66. package/dist/components/plugin-settings.d.ts.map +1 -0
  67. package/dist/components/plugin-settings.js +348 -0
  68. package/dist/components/plugin-settings.js.map +1 -0
  69. package/dist/components/remote-pictures.d.ts +6 -0
  70. package/dist/components/remote-pictures.d.ts.map +1 -0
  71. package/dist/components/remote-pictures.js +8 -0
  72. package/dist/components/remote-pictures.js.map +1 -0
  73. package/dist/components/shell.d.ts +2 -0
  74. package/dist/components/shell.d.ts.map +1 -1
  75. package/dist/components/shell.js +26 -1
  76. package/dist/components/shell.js.map +1 -1
  77. package/dist/components/sign-in.d.ts.map +1 -1
  78. package/dist/components/sign-in.js +49 -2
  79. package/dist/components/sign-in.js.map +1 -1
  80. package/dist/components/ui.d.ts +3 -2
  81. package/dist/components/ui.d.ts.map +1 -1
  82. package/dist/components/ui.js +6 -3
  83. package/dist/components/ui.js.map +1 -1
  84. package/dist/components/workspace.d.ts +7 -1
  85. package/dist/components/workspace.d.ts.map +1 -1
  86. package/dist/components/workspace.js +1 -1
  87. package/dist/components/workspace.js.map +1 -1
  88. package/dist/console.css +384 -5
  89. package/dist/console.d.ts.map +1 -1
  90. package/dist/console.js +146 -29
  91. package/dist/console.js.map +1 -1
  92. package/dist/data.d.ts +11 -2
  93. package/dist/data.d.ts.map +1 -1
  94. package/dist/data.js +35 -21
  95. package/dist/data.js.map +1 -1
  96. package/dist/i18n/en.d.ts +163 -1
  97. package/dist/i18n/en.d.ts.map +1 -1
  98. package/dist/i18n/en.js +174 -1
  99. package/dist/i18n/en.js.map +1 -1
  100. package/dist/i18n/ko.d.ts.map +1 -1
  101. package/dist/i18n/ko.js +154 -1
  102. package/dist/i18n/ko.js.map +1 -1
  103. package/dist/index.d.ts +3 -1
  104. package/dist/index.d.ts.map +1 -1
  105. package/dist/index.js +3 -1
  106. package/dist/index.js.map +1 -1
  107. package/dist/limits.d.ts +14 -0
  108. package/dist/limits.d.ts.map +1 -1
  109. package/dist/limits.js +14 -0
  110. package/dist/limits.js.map +1 -1
  111. package/dist/plugin/args.d.ts +148 -0
  112. package/dist/plugin/args.d.ts.map +1 -0
  113. package/dist/plugin/args.js +369 -0
  114. package/dist/plugin/args.js.map +1 -0
  115. package/dist/plugin/bridge.d.ts +146 -0
  116. package/dist/plugin/bridge.d.ts.map +1 -0
  117. package/dist/plugin/bridge.js +125 -0
  118. package/dist/plugin/bridge.js.map +1 -0
  119. package/dist/plugin/capabilities.d.ts +20 -0
  120. package/dist/plugin/capabilities.d.ts.map +1 -0
  121. package/dist/plugin/capabilities.js +133 -0
  122. package/dist/plugin/capabilities.js.map +1 -0
  123. package/dist/plugin/context.d.ts +374 -0
  124. package/dist/plugin/context.d.ts.map +1 -0
  125. package/dist/plugin/context.js +2 -0
  126. package/dist/plugin/context.js.map +1 -0
  127. package/dist/plugin/define.d.ts +208 -0
  128. package/dist/plugin/define.d.ts.map +1 -0
  129. package/dist/plugin/define.js +482 -0
  130. package/dist/plugin/define.js.map +1 -0
  131. package/dist/plugin/definition-error.d.ts +11 -0
  132. package/dist/plugin/definition-error.d.ts.map +1 -0
  133. package/dist/plugin/definition-error.js +17 -0
  134. package/dist/plugin/definition-error.js.map +1 -0
  135. package/dist/plugin/limits.d.ts +67 -0
  136. package/dist/plugin/limits.d.ts.map +1 -0
  137. package/dist/plugin/limits.js +70 -0
  138. package/dist/plugin/limits.js.map +1 -0
  139. package/dist/plugin/load-worker.d.ts +7 -0
  140. package/dist/plugin/load-worker.d.ts.map +1 -0
  141. package/dist/plugin/load-worker.js +41 -0
  142. package/dist/plugin/load-worker.js.map +1 -0
  143. package/dist/plugin/load.d.ts +18 -0
  144. package/dist/plugin/load.d.ts.map +1 -0
  145. package/dist/plugin/load.js +62 -0
  146. package/dist/plugin/load.js.map +1 -0
  147. package/dist/plugin/manifest.d.ts +237 -0
  148. package/dist/plugin/manifest.d.ts.map +1 -0
  149. package/dist/plugin/manifest.js +411 -0
  150. package/dist/plugin/manifest.js.map +1 -0
  151. package/dist/plugin/refusals.d.ts +49 -0
  152. package/dist/plugin/refusals.d.ts.map +1 -0
  153. package/dist/plugin/refusals.js +79 -0
  154. package/dist/plugin/refusals.js.map +1 -0
  155. package/dist/plugin/rest-schemas.d.ts +556 -0
  156. package/dist/plugin/rest-schemas.d.ts.map +1 -0
  157. package/dist/plugin/rest-schemas.js +262 -0
  158. package/dist/plugin/rest-schemas.js.map +1 -0
  159. package/dist/plugin/sandbox.d.ts +23 -0
  160. package/dist/plugin/sandbox.d.ts.map +1 -0
  161. package/dist/plugin/sandbox.js +99 -0
  162. package/dist/plugin/sandbox.js.map +1 -0
  163. package/dist/plugin/testing.d.ts +45 -0
  164. package/dist/plugin/testing.d.ts.map +1 -0
  165. package/dist/plugin/testing.js +420 -0
  166. package/dist/plugin/testing.js.map +1 -0
  167. package/dist/plugin/web-react.d.ts +11 -0
  168. package/dist/plugin/web-react.d.ts.map +1 -0
  169. package/dist/plugin/web-react.js +34 -0
  170. package/dist/plugin/web-react.js.map +1 -0
  171. package/dist/plugin/web.d.ts +95 -0
  172. package/dist/plugin/web.d.ts.map +1 -0
  173. package/dist/plugin/web.js +245 -0
  174. package/dist/plugin/web.js.map +1 -0
  175. package/dist/plugin-nav.d.ts +29 -0
  176. package/dist/plugin-nav.d.ts.map +1 -0
  177. package/dist/plugin-nav.js +92 -0
  178. package/dist/plugin-nav.js.map +1 -0
  179. package/dist/plugin-testing.d.ts +2 -0
  180. package/dist/plugin-testing.d.ts.map +1 -0
  181. package/dist/plugin-testing.js +4 -0
  182. package/dist/plugin-testing.js.map +1 -0
  183. package/dist/plugin-web-react.d.ts +3 -0
  184. package/dist/plugin-web-react.d.ts.map +1 -0
  185. package/dist/plugin-web-react.js +3 -0
  186. package/dist/plugin-web-react.js.map +1 -0
  187. package/dist/plugin-web.d.ts +4 -0
  188. package/dist/plugin-web.d.ts.map +1 -0
  189. package/dist/plugin-web.js +5 -0
  190. package/dist/plugin-web.js.map +1 -0
  191. package/dist/plugin.d.ts +11 -0
  192. package/dist/plugin.d.ts.map +1 -0
  193. package/dist/plugin.js +15 -0
  194. package/dist/plugin.js.map +1 -0
  195. package/dist/router.d.ts +28 -0
  196. package/dist/router.d.ts.map +1 -1
  197. package/dist/router.js +47 -0
  198. package/dist/router.js.map +1 -1
  199. package/dist/routes.d.ts +6 -0
  200. package/dist/routes.d.ts.map +1 -1
  201. package/dist/routes.js +8 -0
  202. package/dist/routes.js.map +1 -1
  203. package/dist/schemas.d.ts +147 -1
  204. package/dist/schemas.d.ts.map +1 -1
  205. package/dist/schemas.js +55 -5
  206. package/dist/schemas.js.map +1 -1
  207. package/dist/tokens.d.ts +2 -0
  208. package/dist/tokens.d.ts.map +1 -0
  209. package/dist/tokens.js +101 -0
  210. package/dist/tokens.js.map +1 -0
  211. package/dist/vocabulary.d.ts +11 -1
  212. package/dist/vocabulary.d.ts.map +1 -1
  213. package/dist/vocabulary.js +24 -0
  214. package/dist/vocabulary.js.map +1 -1
  215. package/dist/web.d.ts.map +1 -1
  216. package/dist/web.js +5 -2
  217. package/dist/web.js.map +1 -1
  218. package/docs/specs/cli.md +16 -9
  219. package/docs/specs/plugins.md +487 -0
  220. package/docs/specs/rest.md +25 -13
  221. package/package.json +21 -3
  222. package/skills/building-a-console/SKILL.md +25 -26
  223. package/skills/building-a-console/references/configuring.md +40 -0
  224. package/skills/building-a-console/references/the-default-console.md +1 -1
  225. package/skills/building-a-console/references/the-look-from-a-website.md +5 -5
  226. package/skills/building-a-plugin/SKILL.md +137 -0
  227. package/skills/deploying-a-console/SKILL.md +24 -26
  228. package/skills/deploying-a-console/references/access.md +13 -9
  229. package/skills/deploying-a-console/references/resources.md +25 -10
  230. package/skills/deploying-a-console/references/running-it-again.md +15 -0
  231. package/skills/deploying-a-console/references/sizing.md +2 -0
  232. package/skills/planning-the-board/SKILL.md +96 -0
  233. package/skills/planning-the-board/references/a-task.md +36 -0
  234. package/skills/planning-the-board/references/setting-up.md +44 -0
  235. package/skills/working-the-board/SKILL.md +43 -34
  236. package/skills/working-the-board/references/the-pack.md +19 -0
  237. package/src/api.ts +16 -0
  238. package/src/cli/cli.ts +736 -45
  239. package/src/cli/format.ts +356 -9
  240. package/src/cli/help.ts +71 -6
  241. package/src/cli/plugin-check.ts +470 -0
  242. package/src/cli/plugin-cli.ts +148 -0
  243. package/src/cli/plugin-init.ts +90 -0
  244. package/src/cli/plugin-main.ts +30 -0
  245. package/src/client.ts +324 -1
  246. package/src/compat.ts +2 -2
  247. package/src/components/board.tsx +62 -14
  248. package/src/components/event-feed.tsx +42 -1
  249. package/src/components/markdown.tsx +6 -3
  250. package/src/components/pack-connect.tsx +10 -7
  251. package/src/components/pack-token.tsx +4 -1
  252. package/src/components/people.tsx +17 -7
  253. package/src/components/plugin-frame.tsx +437 -0
  254. package/src/components/plugin-settings.tsx +1111 -0
  255. package/src/components/remote-pictures.ts +19 -0
  256. package/src/components/shell.tsx +82 -14
  257. package/src/components/sign-in.tsx +114 -13
  258. package/src/components/ui.tsx +6 -3
  259. package/src/components/workspace.tsx +18 -0
  260. package/src/console.tsx +309 -23
  261. package/src/data.ts +47 -22
  262. package/src/i18n/en.ts +183 -1
  263. package/src/i18n/ko.ts +159 -1
  264. package/src/index.ts +12 -0
  265. package/src/limits.ts +14 -0
  266. package/src/plugin/args.ts +526 -0
  267. package/src/plugin/bridge.ts +314 -0
  268. package/src/plugin/capabilities.ts +150 -0
  269. package/src/plugin/context.ts +416 -0
  270. package/src/plugin/define.ts +792 -0
  271. package/src/plugin/definition-error.ts +19 -0
  272. package/src/plugin/limits.ts +70 -0
  273. package/src/plugin/load-worker.ts +50 -0
  274. package/src/plugin/load.ts +82 -0
  275. package/src/plugin/manifest.ts +561 -0
  276. package/src/plugin/refusals.ts +94 -0
  277. package/src/plugin/rest-schemas.ts +354 -0
  278. package/src/plugin/sandbox.ts +123 -0
  279. package/src/plugin/testing.ts +612 -0
  280. package/src/plugin/web-react.ts +45 -0
  281. package/src/plugin/web.ts +403 -0
  282. package/src/plugin-nav.ts +132 -0
  283. package/src/plugin-testing.ts +10 -0
  284. package/src/plugin-web-react.ts +4 -0
  285. package/src/plugin-web.ts +35 -0
  286. package/src/plugin.ts +126 -0
  287. package/src/router.ts +73 -0
  288. package/src/routes.ts +9 -0
  289. package/src/schemas.ts +60 -4
  290. package/src/styles/console.css +384 -5
  291. package/src/tokens.ts +101 -0
  292. package/src/vocabulary.ts +30 -0
  293. package/src/web.ts +5 -2
  294. package/template/plugin/.gitignore +2 -0
  295. package/template/plugin/README.md +26 -0
  296. package/template/plugin/SKILLCDN.md +13 -0
  297. package/template/plugin/build.mjs +40 -0
  298. package/template/plugin/console-plugin.json +50 -0
  299. package/template/plugin/package.json +27 -0
  300. package/template/plugin/skills/__key__/SKILL.md +23 -0
  301. package/template/plugin/src/check.test.ts +34 -0
  302. package/template/plugin/src/server/index.test.ts +41 -0
  303. package/template/plugin/src/server/index.ts +72 -0
  304. package/template/plugin/src/web/index.css +51 -0
  305. package/template/plugin/src/web/index.ts +115 -0
  306. package/template/plugin/tsconfig.json +17 -0
  307. package/skills/working-the-board/references/setting-up.md +0 -3
  308. /package/skills/{working-the-board → planning-the-board}/references/project-rules.md +0 -0
@@ -0,0 +1,1111 @@
1
+ import { type FormEvent, type ReactNode, useEffect, useId, useMemo, useState } from "react";
2
+ import type { ConsoleClient } from "../client.js";
3
+ import type { Messages } from "../i18n/en.js";
4
+ import { useWords } from "../i18n/index.js";
5
+ import { PLUGIN_CAPABILITIES, type PluginCapability } from "../plugin/capabilities.js";
6
+ import type {
7
+ RestEvent,
8
+ RestPerson,
9
+ RestPlugin,
10
+ RestPluginDetail,
11
+ RestPluginEnableInput,
12
+ RestPluginGrant,
13
+ RestPluginGrantInput,
14
+ RestPluginSetting,
15
+ RestProject,
16
+ } from "../schemas.js";
17
+ import { useAction } from "../use-action.js";
18
+ import { EventFeed } from "./event-feed.js";
19
+ import {
20
+ Badge,
21
+ Button,
22
+ Callout,
23
+ EmptyState,
24
+ formatInstant,
25
+ LinkButton,
26
+ personName,
27
+ Spinner,
28
+ } from "./ui.js";
29
+
30
+ // The plugins' settings (docs/specs/plugins.md, "The settings pages"): what is installed and how
31
+ // each stands; and one plugin, read at a glance: its state in the head with the way to disable
32
+ // it, what it is on and holds a row each, the form that enables it (the capabilities to consent
33
+ // to, each by a name with what it opens, the board project, the projects), the settings form
34
+ // from the manifest with the secrets set and never shown, the roles with who holds each and the
35
+ // way to grant one, what the plugin did, export and purge. No plugin code runs here: everything
36
+ // is the console's own.
37
+
38
+ /** A text in the person's language, else in English, else the first the manifest has. */
39
+ export function pluginWord(
40
+ texts: Readonly<Record<string, string>> | null | undefined,
41
+ language: string,
42
+ ): string {
43
+ if (texts === null || texts === undefined) {
44
+ return "";
45
+ }
46
+ return (
47
+ texts[language] ??
48
+ texts[language.split("-")[0] ?? language] ??
49
+ texts.en ??
50
+ Object.values(texts)[0] ??
51
+ ""
52
+ );
53
+ }
54
+
55
+ export interface CapabilityText {
56
+ /** What the capability is called on the pages. */
57
+ readonly name: string;
58
+ /** The sentence of what it opens, as the administrator reads it before consenting. */
59
+ readonly opens: string;
60
+ }
61
+
62
+ /**
63
+ * A capability by the name the pages give it and the sentence of what it opens, in the person's
64
+ * language; one the packs do not know, by its key alone.
65
+ */
66
+ export function capabilityText(words: Messages, capability: string): CapabilityText {
67
+ // Typed against the catalogue, so that a capability added there is named in the packs too.
68
+ const known: Readonly<Record<PluginCapability, CapabilityText>> = words.plugins.capability;
69
+ if ((PLUGIN_CAPABILITIES as readonly string[]).includes(capability)) {
70
+ return known[capability as PluginCapability];
71
+ }
72
+ if (capability.startsWith("outbound:")) {
73
+ return words.plugins.capability.outbound(capability.slice("outbound:".length));
74
+ }
75
+ if (capability.startsWith("plugins:")) {
76
+ return words.plugins.capability.plugins(capability.slice("plugins:".length));
77
+ }
78
+ return { name: capability, opens: "" };
79
+ }
80
+
81
+ const STATE_TONES: Readonly<
82
+ Record<RestPlugin["state"], "neutral" | "success" | "warning" | "danger">
83
+ > = {
84
+ installed: "neutral",
85
+ enabled: "success",
86
+ needs_consent: "warning",
87
+ disabled: "neutral",
88
+ failed: "danger",
89
+ orphaned: "warning",
90
+ };
91
+
92
+ export interface PluginSettingsListProps {
93
+ readonly plugins: readonly RestPlugin[];
94
+ readonly language: string;
95
+ readonly href: (plugin: RestPlugin) => string;
96
+ }
97
+
98
+ /** Every plugin in a row: its name, its key, its version, how it stands. */
99
+ export function PluginSettingsList(props: PluginSettingsListProps) {
100
+ const words = useWords();
101
+ const settings = words.plugins.settings;
102
+ if (props.plugins.length === 0) {
103
+ return <EmptyState title={settings.empty.title} body={settings.empty.body} />;
104
+ }
105
+ return (
106
+ <ul className="sc-task-rows" aria-label={settings.title}>
107
+ {props.plugins.map((plugin) => (
108
+ <li key={plugin.key} className="sc-task-row">
109
+ <a className="sc-task-row-title" href={props.href(plugin)}>
110
+ {pluginWord(plugin.name, props.language)}{" "}
111
+ <span className="sc-muted">
112
+ {plugin.key} {plugin.version}
113
+ </span>
114
+ </a>
115
+ <span className="sc-task-row-meta">
116
+ <Badge tone={STATE_TONES[plugin.state]}>
117
+ {words.vocabulary.pluginState[plugin.state] ?? plugin.state}
118
+ </Badge>
119
+ </span>
120
+ </li>
121
+ ))}
122
+ </ul>
123
+ );
124
+ }
125
+
126
+ export interface PluginSettingsViewProps {
127
+ readonly detail: RestPluginDetail;
128
+ readonly projects: readonly RestProject[];
129
+ /** The people of the workspace, to grant a role to; an administrator sees everyone. */
130
+ readonly people: readonly RestPerson[];
131
+ readonly language: string;
132
+ readonly client: ConsoleClient;
133
+ /** Called after a change, so that the page reads the plugin again. */
134
+ readonly onChanged: () => Promise<void>;
135
+ /** Where the list is, for after a purge. */
136
+ readonly onPurged: () => void;
137
+ }
138
+
139
+ /** A set with a value put in or taken out. */
140
+ function toggled(set: ReadonlySet<string>, value: string, on: boolean): ReadonlySet<string> {
141
+ const next = new Set(set);
142
+ if (on) {
143
+ next.add(value);
144
+ } else {
145
+ next.delete(value);
146
+ }
147
+ return next;
148
+ }
149
+
150
+ /** A row of a list to tick: the box, a name, a word under it. */
151
+ function TickRow(props: {
152
+ readonly checked: boolean;
153
+ readonly onChange: (checked: boolean) => void;
154
+ readonly name: string;
155
+ readonly under?: string | undefined;
156
+ }) {
157
+ return (
158
+ <li>
159
+ <label className="sc-plugin-tick">
160
+ <input
161
+ type="checkbox"
162
+ checked={props.checked}
163
+ onChange={(event) => props.onChange(event.target.checked)}
164
+ />
165
+ <span className="sc-plugin-tick-words">
166
+ <span className="sc-plugin-tick-name">{props.name}</span>
167
+ {props.under !== undefined && props.under !== "" && (
168
+ <span className="sc-plugin-tick-under">{props.under}</span>
169
+ )}
170
+ </span>
171
+ </label>
172
+ </li>
173
+ );
174
+ }
175
+
176
+ /** What the plugin is on and holds, a row each; nothing where there is nothing to say yet. */
177
+ function Overview(props: {
178
+ readonly detail: RestPluginDetail;
179
+ readonly projects: readonly RestProject[];
180
+ readonly language: string;
181
+ }) {
182
+ const words = useWords();
183
+ const settings = words.plugins.settings;
184
+ const { detail, language } = props;
185
+ const enabled = detail.state === "enabled";
186
+ const rows: { readonly key: string; readonly name: string; readonly value: ReactNode }[] = [];
187
+ if (detail.board !== null) {
188
+ rows.push({
189
+ key: "board",
190
+ name: settings.board,
191
+ value: (
192
+ <>
193
+ {detail.board.name} <span className="sc-muted">{detail.board.key}</span>
194
+ </>
195
+ ),
196
+ });
197
+ } else if (enabled && detail.boardNeeds !== "none") {
198
+ rows.push({ key: "board", name: settings.board, value: settings.nothing });
199
+ }
200
+ if (enabled && detail.projects !== null) {
201
+ const names = detail.projects;
202
+ rows.push({
203
+ key: "projects",
204
+ name: settings.projects,
205
+ value:
206
+ names === "all"
207
+ ? settings.projectsAll
208
+ : names.length === 0
209
+ ? settings.nothing
210
+ : names
211
+ .map((key) => props.projects.find((project) => project.key === key)?.name ?? key)
212
+ .join(", "),
213
+ });
214
+ }
215
+ if (enabled && detail.consent !== null) {
216
+ const consent = detail.consent;
217
+ rows.push({
218
+ key: "capabilities",
219
+ name: settings.capabilities,
220
+ value:
221
+ consent.length === 0 ? (
222
+ settings.nothing
223
+ ) : (
224
+ <ul className="sc-plugin-held">
225
+ {consent.map((capability) => {
226
+ const text = capabilityText(words, capability);
227
+ return (
228
+ <li key={capability}>
229
+ <span className="sc-plugin-tick-name">{text.name}</span>
230
+ {text.opens !== "" && <span className="sc-plugin-tick-under"> {text.opens}</span>}
231
+ </li>
232
+ );
233
+ })}
234
+ </ul>
235
+ ),
236
+ });
237
+ }
238
+ if (detail.operations.length > 0) {
239
+ rows.push({
240
+ key: "operations",
241
+ name: settings.operations,
242
+ value: (
243
+ <span className="sc-plugin-codes">
244
+ {detail.operations.map((operation) => (
245
+ <code key={operation.name}>{operation.name}</code>
246
+ ))}
247
+ </span>
248
+ ),
249
+ });
250
+ }
251
+ if (detail.agentNote !== null) {
252
+ rows.push({
253
+ key: "agentNote",
254
+ name: settings.agentNote,
255
+ value: pluginWord(detail.agentNote, language),
256
+ });
257
+ }
258
+ if (rows.length === 0) {
259
+ return null;
260
+ }
261
+ return (
262
+ <section className="sc-panel" aria-label={settings.about}>
263
+ <dl className="sc-settings-list">
264
+ {rows.map((row) => (
265
+ <div key={row.key} className="sc-settings-item">
266
+ <dt>{row.name}</dt>
267
+ <dd>{row.value}</dd>
268
+ </div>
269
+ ))}
270
+ </dl>
271
+ </section>
272
+ );
273
+ }
274
+
275
+ /**
276
+ * A setting's control, by its type: a person or a project is chosen among the workspace's, by
277
+ * name, and held as its id; a secret has a row of its own below.
278
+ */
279
+ function SettingControl(props: {
280
+ readonly setting: RestPluginSetting;
281
+ readonly value: unknown;
282
+ readonly onChange: (value: unknown) => void;
283
+ readonly id: string;
284
+ readonly language: string;
285
+ readonly people: readonly RestPerson[];
286
+ readonly projects: readonly RestProject[];
287
+ }) {
288
+ const { setting, value, onChange, id } = props;
289
+ const chosen = typeof value === "string" ? value : "";
290
+ const choose = (event: { readonly target: { readonly value: string } }) =>
291
+ onChange(event.target.value === "" ? null : event.target.value);
292
+ switch (setting.type) {
293
+ case "person": {
294
+ // The people in; the one chosen stays listed when they have left, so that the value shows.
295
+ const people = props.people.filter(
296
+ (person) => person.status === "active" || person.id === chosen,
297
+ );
298
+ return (
299
+ <select id={id} className="sc-input" value={chosen} onChange={choose}>
300
+ <option value="">—</option>
301
+ {people.map((person) => (
302
+ <option key={person.id} value={person.id}>
303
+ {personChoice(person)}
304
+ </option>
305
+ ))}
306
+ {chosen !== "" && !people.some((person) => person.id === chosen) && (
307
+ <option value={chosen}>{chosen}</option>
308
+ )}
309
+ </select>
310
+ );
311
+ }
312
+ case "project":
313
+ return (
314
+ <select id={id} className="sc-input" value={chosen} onChange={choose}>
315
+ <option value="">—</option>
316
+ {props.projects.map((project) => (
317
+ <option key={project.id} value={project.id}>
318
+ {project.name} ({project.key})
319
+ </option>
320
+ ))}
321
+ {chosen !== "" && !props.projects.some((project) => project.id === chosen) && (
322
+ <option value={chosen}>{chosen}</option>
323
+ )}
324
+ </select>
325
+ );
326
+ case "boolean":
327
+ return (
328
+ <input
329
+ id={id}
330
+ type="checkbox"
331
+ checked={value === true}
332
+ onChange={(event) => onChange(event.target.checked)}
333
+ />
334
+ );
335
+ case "number":
336
+ return (
337
+ <input
338
+ id={id}
339
+ className="sc-input"
340
+ type="number"
341
+ value={typeof value === "number" ? value : ""}
342
+ min={setting.min ?? undefined}
343
+ max={setting.max ?? undefined}
344
+ onChange={(event) =>
345
+ onChange(event.target.value === "" ? null : Number(event.target.value))
346
+ }
347
+ />
348
+ );
349
+ case "enum":
350
+ return (
351
+ <select
352
+ id={id}
353
+ className="sc-input"
354
+ value={typeof value === "string" ? value : ""}
355
+ onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
356
+ >
357
+ <option value="">—</option>
358
+ {(setting.options ?? []).map((option) => (
359
+ <option key={option.value} value={option.value}>
360
+ {pluginWord(option.name, props.language)}
361
+ </option>
362
+ ))}
363
+ </select>
364
+ );
365
+ case "markdown":
366
+ return (
367
+ <textarea
368
+ id={id}
369
+ className="sc-input"
370
+ rows={4}
371
+ value={typeof value === "string" ? value : ""}
372
+ onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
373
+ />
374
+ );
375
+ case "list":
376
+ return (
377
+ <input
378
+ id={id}
379
+ className="sc-input"
380
+ value={Array.isArray(value) ? value.join(", ") : ""}
381
+ onChange={(event) =>
382
+ onChange(
383
+ event.target.value.trim() === ""
384
+ ? null
385
+ : event.target.value
386
+ .split(",")
387
+ .map((item) => item.trim())
388
+ .filter((item) => item.length > 0)
389
+ .map((item) => (setting.of === "number" ? Number(item) : item)),
390
+ )
391
+ }
392
+ />
393
+ );
394
+ default:
395
+ return (
396
+ <input
397
+ id={id}
398
+ className="sc-input"
399
+ type={setting.type === "day" ? "date" : "text"}
400
+ value={typeof value === "string" ? value : ""}
401
+ maxLength={setting.max ?? undefined}
402
+ onChange={(event) => onChange(event.target.value === "" ? null : event.target.value)}
403
+ />
404
+ );
405
+ }
406
+ }
407
+
408
+ /** The settings form from the manifest, without the secrets: the values as they stand, saved as a whole. */
409
+ function SettingsForm(props: {
410
+ readonly detail: RestPluginDetail;
411
+ readonly language: string;
412
+ readonly busy: boolean;
413
+ readonly people: readonly RestPerson[];
414
+ readonly projects: readonly RestProject[];
415
+ readonly onSave: (values: Record<string, unknown>) => void;
416
+ }) {
417
+ const words = useWords();
418
+ const settings = props.detail.settings.filter((setting) => setting.type !== "secret");
419
+ const [values, setValues] = useState<Record<string, unknown>>(() =>
420
+ Object.fromEntries(settings.map((setting) => [setting.key, setting.value ?? setting.default])),
421
+ );
422
+ const prefix = useId();
423
+ if (settings.length === 0) {
424
+ return null;
425
+ }
426
+ const submit = (event: FormEvent) => {
427
+ event.preventDefault();
428
+ props.onSave(values);
429
+ };
430
+ return (
431
+ <form
432
+ className="sc-form sc-settings-form"
433
+ onSubmit={submit}
434
+ aria-label={words.plugins.settings.settingsForm}
435
+ >
436
+ {settings.map((setting) => (
437
+ <div key={setting.key} className="sc-settings-row">
438
+ <div className="sc-settings-words">
439
+ <label className="sc-settings-label" htmlFor={`${prefix}-${setting.key}`}>
440
+ {pluginWord(setting.name, props.language)}
441
+ {setting.required && " *"}
442
+ </label>
443
+ {setting.description !== null && (
444
+ <p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
445
+ )}
446
+ </div>
447
+ <div className="sc-settings-control">
448
+ <SettingControl
449
+ setting={setting}
450
+ value={values[setting.key]}
451
+ id={`${prefix}-${setting.key}`}
452
+ language={props.language}
453
+ people={props.people}
454
+ projects={props.projects}
455
+ onChange={(value) => setValues((current) => ({ ...current, [setting.key]: value }))}
456
+ />
457
+ </div>
458
+ </div>
459
+ ))}
460
+ <div className="sc-form-actions">
461
+ <Button type="submit" variant="primary" disabled={props.busy}>
462
+ {words.common.save}
463
+ </Button>
464
+ </div>
465
+ </form>
466
+ );
467
+ }
468
+
469
+ /** One secret: whether and when it is set, a field to set it anew with its buttons on the line. */
470
+ function SecretRow(props: {
471
+ readonly setting: RestPluginSetting;
472
+ readonly language: string;
473
+ readonly busy: boolean;
474
+ readonly onSet: (value: string) => void;
475
+ readonly onRemove: () => void;
476
+ }) {
477
+ const words = useWords();
478
+ const settings = words.plugins.settings;
479
+ const [value, setValue] = useState("");
480
+ const id = useId();
481
+ const { setting } = props;
482
+ return (
483
+ <div className="sc-settings-row">
484
+ <div className="sc-settings-words">
485
+ <label className="sc-settings-label" htmlFor={id}>
486
+ {pluginWord(setting.name, props.language)}
487
+ {setting.required && " *"}
488
+ </label>
489
+ <p className="sc-settings-hint">
490
+ {setting.set && setting.setAt !== null
491
+ ? settings.secretSet(formatInstant(setting.setAt, props.language))
492
+ : settings.secretUnset}
493
+ </p>
494
+ {setting.description !== null && (
495
+ <p className="sc-settings-hint">{pluginWord(setting.description, props.language)}</p>
496
+ )}
497
+ </div>
498
+ <div className="sc-settings-control">
499
+ <div className="sc-plugin-line">
500
+ <input
501
+ id={id}
502
+ className="sc-input sc-plugin-line-input"
503
+ type="password"
504
+ autoComplete="off"
505
+ placeholder={settings.secretNew}
506
+ value={value}
507
+ onChange={(event) => setValue(event.target.value)}
508
+ />
509
+ <Button
510
+ disabled={props.busy || value.length === 0}
511
+ onClick={() => {
512
+ props.onSet(value);
513
+ setValue("");
514
+ }}
515
+ >
516
+ {words.common.save}
517
+ </Button>
518
+ {setting.set && (
519
+ <Button variant="ghost" disabled={props.busy} onClick={props.onRemove}>
520
+ {words.common.remove}
521
+ </Button>
522
+ )}
523
+ </div>
524
+ </div>
525
+ </div>
526
+ );
527
+ }
528
+
529
+ /**
530
+ * Enabling: the capabilities to consent to, one by one, those consented to before ticked
531
+ * already; the board project, the one it had chosen already; the projects, as they were.
532
+ */
533
+ function EnableForm(props: {
534
+ readonly detail: RestPluginDetail;
535
+ readonly projects: readonly RestProject[];
536
+ readonly language: string;
537
+ readonly busy: boolean;
538
+ readonly onEnable: (input: RestPluginEnableInput) => void;
539
+ }) {
540
+ const words = useWords();
541
+ const settings = words.plugins.settings;
542
+ const { detail, language } = props;
543
+ const [consented, setConsented] = useState<ReadonlySet<string>>(
544
+ () =>
545
+ new Set(
546
+ (detail.consent ?? []).filter((capability) => detail.capabilities.includes(capability)),
547
+ ),
548
+ );
549
+ const needsBoard = detail.boardNeeds !== "none";
550
+ const [boardMode, setBoardMode] = useState<"none" | "create" | "choose">(
551
+ detail.boardNeeds === "none"
552
+ ? "none"
553
+ : detail.board !== null || detail.boardDefaults === null
554
+ ? "choose"
555
+ : "create",
556
+ );
557
+ const [boardKey, setBoardKey] = useState(detail.boardDefaults?.key ?? "");
558
+ const [boardName, setBoardName] = useState(pluginWord(detail.boardDefaults?.name, language));
559
+ const [boardDescription, setBoardDescription] = useState(
560
+ pluginWord(detail.boardDefaults?.description, language),
561
+ );
562
+ const [chosen, setChosen] = useState(detail.board?.key ?? props.projects[0]?.key ?? "");
563
+ const before = detail.projects;
564
+ const [allProjects, setAllProjects] = useState(!Array.isArray(before) || before.length === 0);
565
+ const [onProjects, setOnProjects] = useState<ReadonlySet<string>>(
566
+ () => new Set(Array.isArray(before) ? before : []),
567
+ );
568
+ const ids = { key: useId(), name: useId(), description: useId(), choose: useId() };
569
+ const everyCapability = detail.capabilities.every((capability) => consented.has(capability));
570
+ const boardReady =
571
+ boardMode === "none"
572
+ ? detail.boardNeeds !== "required"
573
+ : boardMode === "create"
574
+ ? boardKey.length > 0 && boardName.length > 0
575
+ : chosen.length > 0;
576
+ const submit = (event: FormEvent) => {
577
+ event.preventDefault();
578
+ props.onEnable({
579
+ consent: [...consented],
580
+ board:
581
+ boardMode === "none"
582
+ ? null
583
+ : boardMode === "create"
584
+ ? { create: { key: boardKey, name: boardName, description: boardDescription } }
585
+ : { project: chosen },
586
+ projects: allProjects ? "all" : [...onProjects],
587
+ });
588
+ };
589
+ return (
590
+ <form className="sc-form sc-plugin-enable" onSubmit={submit} aria-label={settings.enable}>
591
+ {detail.capabilities.length > 0 && (
592
+ <div className="sc-plugin-group">
593
+ <h3 className="sc-plugin-group-title">{settings.capabilities}</h3>
594
+ <ul className="sc-plugin-ticks">
595
+ {detail.capabilities.map((capability) => {
596
+ const text = capabilityText(words, capability);
597
+ return (
598
+ <TickRow
599
+ key={capability}
600
+ checked={consented.has(capability)}
601
+ onChange={(on) => setConsented((current) => toggled(current, capability, on))}
602
+ name={text.name}
603
+ under={text.opens}
604
+ />
605
+ );
606
+ })}
607
+ </ul>
608
+ </div>
609
+ )}
610
+ {needsBoard && (
611
+ <div className="sc-plugin-group">
612
+ <h3 className="sc-plugin-group-title">{settings.board}</h3>
613
+ <p className="sc-settings-hint">{settings.boardHint}</p>
614
+ <div className="sc-radio-row">
615
+ {detail.boardNeeds === "optional" && (
616
+ <label className="sc-check">
617
+ <input
618
+ type="radio"
619
+ name="board"
620
+ checked={boardMode === "none"}
621
+ onChange={() => setBoardMode("none")}
622
+ />
623
+ {settings.boardNone}
624
+ </label>
625
+ )}
626
+ <label className="sc-check">
627
+ <input
628
+ type="radio"
629
+ name="board"
630
+ checked={boardMode === "create"}
631
+ onChange={() => setBoardMode("create")}
632
+ />
633
+ {settings.boardCreate}
634
+ </label>
635
+ <label className="sc-check">
636
+ <input
637
+ type="radio"
638
+ name="board"
639
+ checked={boardMode === "choose"}
640
+ onChange={() => setBoardMode("choose")}
641
+ />
642
+ {settings.boardChoose}
643
+ </label>
644
+ </div>
645
+ {boardMode === "create" && (
646
+ <div className="sc-plugin-fields">
647
+ <div className="sc-field">
648
+ <label className="sc-field-label" htmlFor={ids.key}>
649
+ {settings.boardKey}
650
+ </label>
651
+ <input
652
+ id={ids.key}
653
+ className="sc-input"
654
+ value={boardKey}
655
+ onChange={(event) => setBoardKey(event.target.value)}
656
+ />
657
+ </div>
658
+ <div className="sc-field">
659
+ <label className="sc-field-label" htmlFor={ids.name}>
660
+ {settings.boardName}
661
+ </label>
662
+ <input
663
+ id={ids.name}
664
+ className="sc-input"
665
+ value={boardName}
666
+ onChange={(event) => setBoardName(event.target.value)}
667
+ />
668
+ </div>
669
+ <div className="sc-field">
670
+ <label className="sc-field-label" htmlFor={ids.description}>
671
+ {settings.boardDescription}
672
+ </label>
673
+ <input
674
+ id={ids.description}
675
+ className="sc-input"
676
+ value={boardDescription}
677
+ onChange={(event) => setBoardDescription(event.target.value)}
678
+ />
679
+ </div>
680
+ </div>
681
+ )}
682
+ {boardMode === "choose" && (
683
+ <div className="sc-plugin-fields">
684
+ <div className="sc-field">
685
+ <label className="sc-field-label" htmlFor={ids.choose}>
686
+ {settings.boardChoose}
687
+ </label>
688
+ <select
689
+ id={ids.choose}
690
+ className="sc-input"
691
+ value={chosen}
692
+ onChange={(event) => setChosen(event.target.value)}
693
+ >
694
+ {props.projects.map((project) => (
695
+ <option key={project.key} value={project.key}>
696
+ {project.name} ({project.key})
697
+ </option>
698
+ ))}
699
+ </select>
700
+ </div>
701
+ </div>
702
+ )}
703
+ </div>
704
+ )}
705
+ <div className="sc-plugin-group">
706
+ <h3 className="sc-plugin-group-title">{settings.projects}</h3>
707
+ <div className="sc-radio-row">
708
+ <label className="sc-check">
709
+ <input
710
+ type="radio"
711
+ name="projects"
712
+ checked={allProjects}
713
+ onChange={() => setAllProjects(true)}
714
+ />
715
+ {settings.projectsAll}
716
+ </label>
717
+ <label className="sc-check">
718
+ <input
719
+ type="radio"
720
+ name="projects"
721
+ checked={!allProjects}
722
+ onChange={() => setAllProjects(false)}
723
+ />
724
+ {settings.projectsSome}
725
+ </label>
726
+ </div>
727
+ {!allProjects && (
728
+ <ul className="sc-plugin-ticks">
729
+ {props.projects.map((project) => (
730
+ <TickRow
731
+ key={project.key}
732
+ checked={onProjects.has(project.key)}
733
+ onChange={(on) => setOnProjects((current) => toggled(current, project.key, on))}
734
+ name={project.name}
735
+ under={project.key}
736
+ />
737
+ ))}
738
+ </ul>
739
+ )}
740
+ </div>
741
+ <div className="sc-plugin-group">
742
+ <div className="sc-form-actions">
743
+ <Button
744
+ type="submit"
745
+ variant="primary"
746
+ disabled={props.busy || !everyCapability || !boardReady}
747
+ >
748
+ {settings.enable}
749
+ </Button>
750
+ </div>
751
+ </div>
752
+ </form>
753
+ );
754
+ }
755
+
756
+ /** Who a grant is to, as the chip says it. */
757
+ function holderOf(grant: RestPluginGrant, words: Messages["plugins"]["settings"]): string {
758
+ if (grant.everyone) {
759
+ return words.grantEveryone;
760
+ }
761
+ if (grant.project !== null) {
762
+ return words.grantMembersOf(grant.project.name);
763
+ }
764
+ if (grant.person !== null) {
765
+ return personName(grant.person);
766
+ }
767
+ return grant.role;
768
+ }
769
+
770
+ /** A person as the choice names them: the name, with the login where it is another word. */
771
+ function personChoice(person: RestPerson): string {
772
+ const name = personName(person);
773
+ return name === person.login ? name : `${name} (${person.login})`;
774
+ }
775
+
776
+ /**
777
+ * One role: its name and what it is for; who holds it, every member for the default role and
778
+ * each grant as a chip with the way to revoke it; and one choice to grant it to a person, the
779
+ * members of a project or everyone, those who hold it already left out.
780
+ */
781
+ function RoleRow(props: {
782
+ readonly role: RestPluginDetail["declaredRoles"][number];
783
+ readonly isDefault: boolean;
784
+ readonly grants: readonly RestPluginGrant[];
785
+ readonly projects: readonly RestProject[];
786
+ readonly people: readonly RestPerson[];
787
+ readonly language: string;
788
+ readonly busy: boolean;
789
+ readonly onGrant: (input: RestPluginGrantInput) => void;
790
+ readonly onRevoke: (id: string) => void;
791
+ }) {
792
+ const settings = useWords().plugins.settings;
793
+ const [whom, setWhom] = useState("");
794
+ const id = useId();
795
+ const { role, grants } = props;
796
+ const everyoneHas = grants.some((grant) => grant.everyone);
797
+ const projectsWith = new Set(
798
+ grants.flatMap((grant) => (grant.project === null ? [] : [grant.project.key])),
799
+ );
800
+ const peopleWith = new Set(
801
+ grants.flatMap((grant) => (grant.person === null ? [] : [grant.person.id])),
802
+ );
803
+ const projects = props.projects.filter((project) => !projectsWith.has(project.key));
804
+ const people = props.people.filter(
805
+ (person) => person.status === "active" && !peopleWith.has(person.id),
806
+ );
807
+ const description = pluginWord(role.description, props.language);
808
+ const submit = (event: FormEvent) => {
809
+ event.preventDefault();
810
+ if (whom === "everyone") {
811
+ props.onGrant({ role: role.key, everyone: true });
812
+ } else if (whom.startsWith("project:")) {
813
+ props.onGrant({ role: role.key, project: whom.slice("project:".length) });
814
+ } else if (whom.startsWith("person:")) {
815
+ props.onGrant({ role: role.key, person: whom.slice("person:".length) });
816
+ }
817
+ setWhom("");
818
+ };
819
+ return (
820
+ <li className="sc-plugin-role">
821
+ <div>
822
+ <p className="sc-plugin-role-name">
823
+ {pluginWord(role.name, props.language)}
824
+ <code className="sc-plugin-role-key">{role.key}</code>
825
+ {props.isDefault && <Badge tone="accent">{settings.roleDefault}</Badge>}
826
+ </p>
827
+ {description !== "" && <p className="sc-settings-hint">{description}</p>}
828
+ </div>
829
+ <div>
830
+ <div className="sc-plugin-holders">
831
+ {props.isDefault && (
832
+ <span className="sc-plugin-holder sc-plugin-holder-fixed">{settings.everyMember}</span>
833
+ )}
834
+ {grants.map((grant) => {
835
+ const holder = holderOf(grant, settings);
836
+ return (
837
+ <span key={grant.id} className="sc-plugin-holder">
838
+ {holder}
839
+ <Button
840
+ size="sm"
841
+ variant="ghost"
842
+ disabled={props.busy}
843
+ onClick={() => props.onRevoke(grant.id)}
844
+ aria-label={`${settings.revoke}: ${holder}`}
845
+ title={settings.revoke}
846
+ >
847
+ ×
848
+ </Button>
849
+ </span>
850
+ );
851
+ })}
852
+ </div>
853
+ <form className="sc-plugin-grant" onSubmit={submit}>
854
+ <label htmlFor={id} className="sc-visually-hidden">
855
+ {settings.grantWhom}
856
+ </label>
857
+ <select
858
+ id={id}
859
+ className="sc-input sc-plugin-grant-input"
860
+ value={whom}
861
+ onChange={(event) => setWhom(event.target.value)}
862
+ >
863
+ <option value="">{settings.grantWhom}</option>
864
+ {!props.isDefault && !everyoneHas && (
865
+ <option value="everyone">{settings.grantEveryone}</option>
866
+ )}
867
+ {projects.length > 0 && (
868
+ <optgroup label={settings.grantProjects}>
869
+ {projects.map((project) => (
870
+ <option key={project.key} value={`project:${project.key}`}>
871
+ {project.name}
872
+ </option>
873
+ ))}
874
+ </optgroup>
875
+ )}
876
+ {people.length > 0 && (
877
+ <optgroup label={settings.grantPeople}>
878
+ {people.map((person) => (
879
+ <option key={person.id} value={`person:${person.id}`}>
880
+ {personChoice(person)}
881
+ </option>
882
+ ))}
883
+ </optgroup>
884
+ )}
885
+ </select>
886
+ <Button type="submit" disabled={props.busy || whom === ""}>
887
+ {settings.grant}
888
+ </Button>
889
+ </form>
890
+ </div>
891
+ </li>
892
+ );
893
+ }
894
+
895
+ /** The roles the plugin declares, each with who holds it; a plugin without roles is every member's. */
896
+ function RolesSection(props: {
897
+ readonly detail: RestPluginDetail;
898
+ readonly projects: readonly RestProject[];
899
+ readonly people: readonly RestPerson[];
900
+ readonly language: string;
901
+ readonly busy: boolean;
902
+ readonly onGrant: (input: RestPluginGrantInput) => void;
903
+ readonly onRevoke: (id: string) => void;
904
+ }) {
905
+ const settings = useWords().plugins.settings;
906
+ const { detail } = props;
907
+ const grants = detail.grants ?? [];
908
+ return (
909
+ <section className="sc-panel" aria-label={settings.roles}>
910
+ <h2 className="sc-section-title">{settings.roles}</h2>
911
+ {detail.declaredRoles.length === 0 ? (
912
+ <p className="sc-settings-hint">{settings.rolesNone}</p>
913
+ ) : (
914
+ <ul className="sc-plugin-roles">
915
+ {detail.declaredRoles.map((role) => (
916
+ <RoleRow
917
+ key={role.key}
918
+ role={role}
919
+ isDefault={detail.defaultRole === role.key}
920
+ grants={grants.filter((grant) => grant.role === role.key)}
921
+ projects={props.projects}
922
+ people={props.people}
923
+ language={props.language}
924
+ busy={props.busy}
925
+ onGrant={props.onGrant}
926
+ onRevoke={props.onRevoke}
927
+ />
928
+ ))}
929
+ </ul>
930
+ )}
931
+ </section>
932
+ );
933
+ }
934
+
935
+ /** One plugin, whole, for an administrator. */
936
+ export function PluginSettingsView(props: PluginSettingsViewProps) {
937
+ const words = useWords();
938
+ const settings = words.plugins.settings;
939
+ const { detail, language, client } = props;
940
+ const { busy, error, act } = useAction();
941
+ const plugin = useMemo(() => client.plugin(detail.key), [client, detail.key]);
942
+ const [events, setEvents] = useState<readonly RestEvent[] | undefined>(undefined);
943
+ const [confirm, setConfirm] = useState("");
944
+ const [purged, setPurged] = useState(false);
945
+ // The plugin's events, read once the page is shown and again after every change.
946
+ // biome-ignore lint/correctness/useExhaustiveDependencies: the detail as read again is the trigger
947
+ useEffect(() => {
948
+ const controller = new AbortController();
949
+ plugin
950
+ .events({ limit: 50 }, controller.signal)
951
+ .then((page) => {
952
+ if (!controller.signal.aborted) {
953
+ setEvents([...page.items].reverse());
954
+ }
955
+ })
956
+ .catch(() => undefined);
957
+ return () => controller.abort();
958
+ }, [plugin, detail]);
959
+ const change = (work: () => Promise<unknown>) =>
960
+ void act(async () => {
961
+ await work();
962
+ await props.onChanged();
963
+ });
964
+ const enabled = detail.state === "enabled";
965
+ const canEnable =
966
+ detail.state === "installed" || detail.state === "disabled" || detail.state === "needs_consent";
967
+ return (
968
+ <>
969
+ <div className="sc-page-head">
970
+ <div className="sc-page-heading">
971
+ <h1 className="sc-page-title">{pluginWord(detail.name, language)}</h1>
972
+ <Badge tone={STATE_TONES[detail.state]}>
973
+ {words.vocabulary.pluginState[detail.state] ?? detail.state}
974
+ </Badge>
975
+ <span className="sc-muted">
976
+ <code>{detail.key}</code> {detail.version}
977
+ </span>
978
+ </div>
979
+ {enabled && (
980
+ <div className="sc-page-actions">
981
+ <Button size="sm" disabled={busy} onClick={() => change(() => plugin.disable())}>
982
+ {settings.disable}
983
+ </Button>
984
+ </div>
985
+ )}
986
+ </div>
987
+ <p className="sc-lead">{pluginWord(detail.description, language)}</p>
988
+ {error !== undefined && <Callout tone="danger">{error}</Callout>}
989
+ {detail.problems.length > 0 && (
990
+ <Callout tone="danger" title={settings.problems}>
991
+ <ul className="sc-plugin-problems">
992
+ {detail.problems.map((problem) => (
993
+ <li key={problem}>{problem}</li>
994
+ ))}
995
+ </ul>
996
+ </Callout>
997
+ )}
998
+ <Overview detail={detail} projects={props.projects} language={language} />
999
+ {canEnable && (
1000
+ <section className="sc-panel sc-panel-form" aria-label={settings.enable}>
1001
+ <h2 className="sc-section-title">{settings.enable}</h2>
1002
+ <EnableForm
1003
+ detail={detail}
1004
+ projects={props.projects}
1005
+ language={language}
1006
+ busy={busy}
1007
+ onEnable={(input) => change(() => plugin.enable(input))}
1008
+ />
1009
+ </section>
1010
+ )}
1011
+ {enabled && detail.settings.length > 0 && (
1012
+ <section className="sc-panel sc-panel-form" aria-label={settings.settingsForm}>
1013
+ <h2 className="sc-section-title">{settings.settingsForm}</h2>
1014
+ <div>
1015
+ <SettingsForm
1016
+ detail={detail}
1017
+ language={language}
1018
+ busy={busy}
1019
+ people={props.people}
1020
+ projects={props.projects}
1021
+ onSave={(values) => change(() => plugin.updateSettings({ values }))}
1022
+ />
1023
+ {detail.settings
1024
+ .filter((setting) => setting.type === "secret")
1025
+ .map((setting) => (
1026
+ <SecretRow
1027
+ key={setting.key}
1028
+ setting={setting}
1029
+ language={language}
1030
+ busy={busy}
1031
+ onSet={(value) => change(() => plugin.setSecret(setting.key, value))}
1032
+ onRemove={() => change(() => plugin.removeSecret(setting.key))}
1033
+ />
1034
+ ))}
1035
+ </div>
1036
+ </section>
1037
+ )}
1038
+ {detail.grants !== null && (
1039
+ <RolesSection
1040
+ detail={detail}
1041
+ projects={props.projects}
1042
+ people={props.people}
1043
+ language={language}
1044
+ busy={busy}
1045
+ onGrant={(input) => change(() => plugin.grant(input))}
1046
+ onRevoke={(id) => change(() => plugin.revoke(id))}
1047
+ />
1048
+ )}
1049
+ <section className="sc-panel" aria-label={settings.events}>
1050
+ <h2 className="sc-section-title">{settings.events}</h2>
1051
+ {events === undefined ? (
1052
+ <Spinner label={words.common.loading} />
1053
+ ) : (
1054
+ <EventFeed events={events} empty={<p className="sc-muted">{settings.noEvents}</p>} />
1055
+ )}
1056
+ </section>
1057
+ <section className="sc-panel sc-panel-form" aria-label={settings.data}>
1058
+ <h2 className="sc-section-title">{settings.data}</h2>
1059
+ <div>
1060
+ <div className="sc-settings-row">
1061
+ <div className="sc-settings-words">
1062
+ <p className="sc-settings-label">{settings.export}</p>
1063
+ <p className="sc-settings-hint">{settings.exportHint}</p>
1064
+ </div>
1065
+ <div className="sc-settings-control">
1066
+ <LinkButton href={`/api/v1/plugins/${detail.key}/export`}>
1067
+ {settings.exportLink}
1068
+ </LinkButton>
1069
+ </div>
1070
+ </div>
1071
+ <div className="sc-settings-row">
1072
+ <div className="sc-settings-words">
1073
+ <p className="sc-settings-label">{settings.purge}</p>
1074
+ <p className="sc-settings-hint">{settings.purgeHint}</p>
1075
+ </div>
1076
+ <div className="sc-settings-control">
1077
+ {purged ? (
1078
+ <p className="sc-settings-hint">{settings.purged}</p>
1079
+ ) : enabled ? (
1080
+ <p className="sc-settings-hint">{settings.purgeDisabledFirst}</p>
1081
+ ) : (
1082
+ <form
1083
+ className="sc-plugin-line"
1084
+ onSubmit={(event) => {
1085
+ event.preventDefault();
1086
+ void act(async () => {
1087
+ await plugin.purge(confirm);
1088
+ setPurged(true);
1089
+ props.onPurged();
1090
+ });
1091
+ }}
1092
+ >
1093
+ <input
1094
+ className="sc-input sc-plugin-line-input"
1095
+ value={confirm}
1096
+ placeholder={settings.purgeConfirm(detail.key)}
1097
+ aria-label={settings.purgeConfirm(detail.key)}
1098
+ onChange={(event) => setConfirm(event.target.value)}
1099
+ />
1100
+ <Button type="submit" variant="danger" disabled={busy || confirm !== detail.key}>
1101
+ {settings.purge}
1102
+ </Button>
1103
+ </form>
1104
+ )}
1105
+ </div>
1106
+ </div>
1107
+ </div>
1108
+ </section>
1109
+ </>
1110
+ );
1111
+ }