@fungi.computer/tourtois 0.1.0 → 0.2.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.
Files changed (52) hide show
  1. package/README.md +19 -0
  2. package/dist/AppAccessConsent.d.ts +6 -1
  3. package/dist/AppAccessConsent.js +4 -5
  4. package/dist/AppDesktopSettings.d.ts +2 -2
  5. package/dist/AppPane.d.ts +11 -4
  6. package/dist/AppPane.js +50 -10
  7. package/dist/AppWorkbench.d.ts +1 -1
  8. package/dist/AppWorkbench.js +10 -5
  9. package/dist/AvatarPreview.d.ts +8 -1
  10. package/dist/AvatarPreview.js +9 -1
  11. package/dist/Keys.d.ts +42 -121
  12. package/dist/Keys.js +200 -157
  13. package/dist/OysterComputers.d.ts +22 -0
  14. package/dist/OysterComputers.js +177 -0
  15. package/dist/SettingsNavigation.d.ts +1 -1
  16. package/dist/SettingsNavigation.js +1 -1
  17. package/dist/SystemSettings.d.ts +2 -2
  18. package/dist/SystemSettings.js +1 -1
  19. package/dist/TeamDesktop.d.ts +1 -1
  20. package/dist/app-authority.d.ts +37 -37
  21. package/dist/app-authority.js +1 -1
  22. package/dist/app-directory-reconciliation.js +7 -2
  23. package/dist/app-directory.d.ts +6 -5
  24. package/dist/app-directory.js +4 -1
  25. package/dist/app-launch.d.ts +1 -1
  26. package/dist/app-launch.js +6 -3
  27. package/dist/app-menus.d.ts +1 -1
  28. package/dist/app-surface.d.ts +2 -1
  29. package/dist/app-windows.d.ts +5 -2
  30. package/dist/app-windows.js +1 -1
  31. package/dist/command-dialogs.d.ts +1 -1
  32. package/dist/command-dialogs.js +4 -4
  33. package/dist/desktop/Desktop.d.ts +1 -1
  34. package/dist/desktop/consent.d.ts +1 -1
  35. package/dist/desktop/consent.js +1 -1
  36. package/dist/desktop/row-gill.d.ts +1 -1
  37. package/dist/oyster-platform-controls.d.ts +1 -1
  38. package/dist/oyster-platform-controls.js +64 -23
  39. package/dist/platform-controls.d.ts +6 -5
  40. package/dist/platform-controls.js +3 -3
  41. package/dist/queries.d.ts +4 -0
  42. package/dist/queries.js +4 -0
  43. package/dist/shell-commands.d.ts +1 -1
  44. package/dist/shell-commands.js +4 -4
  45. package/dist/shell-host.d.ts +7 -9
  46. package/dist/shell-host.js +36 -10
  47. package/dist/shell-module.d.ts +1 -1
  48. package/dist/shell.d.ts +1 -1
  49. package/dist/shell.js +5 -9
  50. package/dist/useAppWindowManager.d.ts +4 -1
  51. package/dist/useAppWindowManager.js +3 -0
  52. package/package.json +39 -18
package/dist/Keys.js CHANGED
@@ -1,177 +1,227 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { SectionHeading } from "@fungi.computer/caps/blocks/section-heading";
3
- import { ActionRow } from "@fungi.computer/caps/components/action-row";
4
- import { Badge } from "@fungi.computer/caps/components/badge";
5
3
  import { Button } from "@fungi.computer/caps/components/button";
6
4
  import { Checkbox } from "@fungi.computer/caps/components/checkbox";
7
5
  import { DataList, DataRow } from "@fungi.computer/caps/components/data-list";
8
6
  import { Field, FormFooter } from "@fungi.computer/caps/components/field";
9
- import { Select } from "@fungi.computer/caps/components/select";
7
+ import { Input } from "@fungi.computer/caps/components/input";
10
8
  import { FormFields, SchemaForm, field, useSchemaForm, useSchemaFormStatus, } from "@fungi.computer/caps/form";
11
9
  import { defineIdFamily } from "@fungi.computer/cairn/id";
12
- import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
13
11
  import { useState } from "react";
14
12
  import * as z from "zod";
15
- /** One App's level a key may hold: read-only, or read and change. */
16
- const keyAppAccessLevelSchema = z.enum(["read", "write"]);
17
- /** What a grant reaches: every installed App (including future installs), or named ones. */
18
- export const keyGrantTargetSchema = z.discriminatedUnion("kind", [
19
- z.strictObject({ kind: z.literal("all") }),
20
- z.strictObject({
21
- kind: z.literal("selected"),
22
- installationIds: z.array(z.string().min(1)),
23
- }),
24
- ]);
25
- /** One App-scoped grant a key holds. */
26
- export const keyGrantSchema = z.strictObject({
27
- level: keyAppAccessLevelSchema,
28
- target: keyGrantTargetSchema,
29
- });
30
- /** One key as the host lists it; its secret never comes back after issuing. */
31
- export const keyRecordSchema = z.strictObject({
32
- keyId: z.string().min(1),
33
- name: z.string().min(1),
34
- grants: z.array(keyGrantSchema),
35
- /** The computer.use grant: off unless the owner turns it on for this key. */
36
- computerAccess: z.boolean(),
37
- expiresAt: z.number().int().nonnegative().nullable(),
38
- createdAt: z.number().int().nonnegative(),
39
- lastUsedAt: z.number().int().nonnegative().nullable(),
40
- revokedAt: z.number().int().nonnegative().nullable(),
41
- });
42
- /** Every key this owner has issued. */
43
- export const keysViewSchema = z.strictObject({
44
- keys: z.array(keyRecordSchema),
45
- });
46
- /** The plaintext secret, shown exactly once, plus the issued record. */
47
- export const keyIssueResultSchema = z.strictObject({
48
- plaintext: z.string().min(1),
49
- key: keyRecordSchema,
50
- });
51
- const revokeIds = defineIdFamily({
52
- family: "tourtois-keys-revoke-request",
13
+ const mutationIds = defineIdFamily({
14
+ family: "tourtois-keys-mutation",
53
15
  version: 1,
54
16
  kinds: { request: { prefix: "request" } },
55
17
  });
56
- const queryKey = ["tourtois", "keys"];
57
- const accessLevelLabel = {
58
- read: "Read",
59
- write: "Read and change",
18
+ const keyQueryKeys = {
19
+ list: (scope) => ["tourtois", "keys", scope],
20
+ options: (scope, apps) => [...keyQueryKeys.list(scope), "options", apps],
60
21
  };
61
- function grantSummary(grants, apps) {
62
- const all = grants.find((grant) => grant.target.kind === "all");
63
- if (all)
64
- return `All Apps, including future installs (${accessLevelLabel[all.level]})`;
65
- if (grants.length === 0)
66
- return "No App access";
67
- const names = new Map(apps.map((app) => [app.installationId, app.displayName]));
68
- return grants
69
- .flatMap((grant) => grant.target.kind === "selected"
70
- ? grant.target.installationIds.map((id) => `${names.get(id) ?? id} (${accessLevelLabel[grant.level]})`)
71
- : [])
72
- .join(", ");
73
- }
74
- function expirySummary(key) {
75
- if (key.expiresAt === null)
76
- return "Never expires";
77
- const date = new Date(key.expiresAt).toLocaleDateString();
78
- return key.expiresAt <= Date.now() ? `Expired ${date}` : `Expires ${date}`;
22
+ /** Scope changes remount the secret-once state as well as separating query caches. */
23
+ export function Keys(props) {
24
+ return _jsx(KeysContent, { ...props }, props.port.scope);
79
25
  }
80
- function lastUsedSummary(key) {
81
- return key.lastUsedAt === null
82
- ? "Never used"
83
- : `Last used ${new Date(key.lastUsedAt).toLocaleDateString()}`;
84
- }
85
- /**
86
- * The Keys settings on a standalone host: issue, list and revoke keys that
87
- * act with the owner's own access, limited to the Apps, level and expiry
88
- * chosen here. A key never gets its own identity; it narrows the owner's.
89
- */
90
- export function Keys({ port, apps, }) {
26
+ function KeysContent({ port, apps, }) {
91
27
  const client = useQueryClient();
92
- const view = useQuery({ queryKey, queryFn: () => port.list() });
93
- const refresh = () => client.invalidateQueries({ queryKey });
94
- const revoke = useMutation({
95
- mutationFn: (keyId) => port.revoke(keyId, revokeIds.request.generate()),
96
- onSuccess: refresh,
28
+ const queryKey = keyQueryKeys.list(port.scope);
29
+ const view = useQuery({ queryKey, queryFn: () => port.list(), retry: false });
30
+ const options = useQuery({
31
+ queryKey: keyQueryKeys.options(port.scope, apps),
32
+ queryFn: ({ signal }) => port.options(apps, signal),
33
+ retry: false,
97
34
  });
98
- const keys = view.data?.keys ?? [];
99
- const [formKey, setFormKey] = useState(0);
100
35
  const [issued, setIssued] = useState(null);
101
- return (_jsxs("section", { className: "tourtois-settings-content tourtois-keys", "aria-label": "Keys", children: [_jsx(SectionHeading, { children: "Keys" }), _jsx("p", { className: "tourtois-settings-lede", children: "Anyone holding this key can read the Apps you pick. Where you choose \u201CRead and change,\u201D it can change data without asking you first. If it can change Workflows, it can also run Workflows with your access, but only on the Apps it can reach." }), view.isPending ? _jsx("p", { children: "Loading keys\u2026" }) : null, view.isError ? (_jsxs("div", { role: "status", children: [_jsx("p", { children: "Keys could not be loaded." }), _jsx(Button, { size: "sm", onClick: () => void view.refetch(), children: "Retry" })] })) : null, view.isSuccess && keys.length === 0 ? _jsx("p", { children: "No keys yet." }) : null, keys.length > 0 ? (_jsx(DataList, { "aria-label": "Keys", children: keys.map((key) => {
102
- const revoked = key.revokedAt !== null;
103
- return (_jsx(DataRow, { className: "p-0", children: _jsx(ActionRow, { label: _jsxs(_Fragment, { children: [key.name, " ", revoked ? (_jsx(Badge, { size: "sm", variant: "quiet", children: "Revoked" })) : null, key.computerAccess ? (_jsx(Badge, { size: "sm", variant: "quiet", children: "Computer" })) : null] }), description: `${grantSummary(key.grants, apps)} · ${expirySummary(key)} · ${lastUsedSummary(key)}`, trailing: revoked ? null : (_jsx(Button, { size: "sm", variant: "ghost", "aria-label": `Revoke ${key.name}`, disabled: revoke.isPending, onClick: () => {
104
- revoke.mutate(key.keyId);
105
- }, children: "Revoke" })) }) }, key.keyId));
106
- }) })) : null, revoke.isError ? (_jsx("p", { role: "alert", children: "The key could not be revoked." })) : null, _jsx(SectionHeading, { children: "New key" }), issued === null ? (_jsx(IssueKey, { apps: apps, onIssue: async (draft) => {
36
+ const [editing, setEditing] = useState(null);
37
+ const [command, setCommand] = useState(null);
38
+ const [busy, setBusy] = useState(false);
39
+ const [notice, setNotice] = useState(null);
40
+ const [error, setError] = useState(null);
41
+ const refresh = () => client.invalidateQueries({ queryKey });
42
+ const mutate = async (pending) => {
43
+ setCommand(pending);
44
+ setBusy(true);
45
+ setError(null);
46
+ setNotice(null);
47
+ try {
48
+ const change = pending.kind === "revoke"
49
+ ? await port.revoke(pending.keyId, pending.requestId)
50
+ : await (() => {
51
+ if (port.updateGrants === undefined)
52
+ throw new Error("Grant editing is not offered by this host");
53
+ return port.updateGrants(pending.input);
54
+ })();
55
+ setNotice(change.notice);
56
+ setEditing(null);
57
+ if (!change.retry)
58
+ setCommand(null);
59
+ await refresh();
60
+ }
61
+ catch (cause) {
62
+ setError(cause instanceof Error
63
+ ? cause.message
64
+ : "The key change could not be confirmed.");
65
+ }
66
+ finally {
67
+ setBusy(false);
68
+ }
69
+ };
70
+ const labels = new Map(options.data?.map((option) => [option.capability, option.label]));
71
+ return (_jsxs("section", { className: "tourtois-settings-content tourtois-keys", "aria-label": "Keys", children: [_jsx(SectionHeading, { children: "Keys" }), _jsx("p", { className: "tourtois-settings-lede", children: "Anyone holding a key can act with your current access, limited to its grants and expiry, without asking again. Read grants cannot make changes. Grants which run Workflows can also run them with your access, within the key's targets." }), view.isPending ? _jsx("p", { children: "Loading keys\u2026" }) : null, view.isError ? (_jsxs("p", { role: "alert", children: ["Keys could not be loaded.", " ", _jsx(Button, { onClick: () => void view.refetch(), children: "Retry" })] })) : null, options.isError ? (_jsxs("p", { role: "alert", children: ["Capability targets could not be loaded.", " ", _jsx(Button, { onClick: () => void options.refetch(), children: "Retry targets" })] })) : null, view.isSuccess && view.data.keys.length === 0 ? (_jsx("p", { children: "No keys yet." })) : null, _jsx(DataList, { "aria-label": "Keys", children: (view.data?.keys ?? []).map((key) => (_jsx(DataRow, { style: { gridTemplateColumns: "minmax(0, 1fr)" }, children: _jsxs("div", { className: "grid min-w-0 gap-sm", children: [_jsxs("div", { className: "grid min-w-0 gap-xs break-words", children: [_jsxs("strong", { children: [key.name, key.revokedAt !== null ? " · Revoked" : ""] }), _jsx("p", { children: key.grants.length === 0
72
+ ? "Authority unavailable; revoke and issue a new key."
73
+ : key.grants
74
+ .map((grant) => `${labels.get(grant.capability) ?? grant.capability}: ${grant.targets.kind === "all" ? "all current and future targets" : grant.targets.ids.join(", ")}`)
75
+ .join(" · ") }), _jsxs("p", { children: [key.expiresAt === null
76
+ ? "Never expires"
77
+ : `${key.expiresAt <= Date.now() ? "Expired" : "Expires"} ${new Date(key.expiresAt).toLocaleString()}`, " ", "\u00B7", " ", key.lastUsedAt === null
78
+ ? "Use not recorded"
79
+ : `Last used ${new Date(key.lastUsedAt).toLocaleString()}`] })] }), key.revokedAt === null ? (_jsxs("div", { className: "flex flex-wrap gap-sm", children: [port.updateGrants !== undefined && key.grants.length > 0 ? (_jsx(Button, { "aria-label": `Edit ${key.name}`, disabled: busy || command !== null, onClick: () => {
80
+ setEditing(key);
81
+ }, children: "Edit" })) : null, _jsx(Button, { "aria-label": `Revoke ${key.name}`, disabled: busy || command !== null, onClick: () => void mutate({
82
+ kind: "revoke",
83
+ keyId: key.keyId,
84
+ requestId: mutationIds.request.generate(),
85
+ }), children: "Revoke" })] })) : null] }) }, key.keyId))) }), notice ? _jsx("p", { role: "status", children: notice }) : null, error ? _jsx("p", { role: "alert", children: error }) : null, command !== null ? (_jsxs("div", { className: "flex gap-sm", children: [_jsx(Button, { disabled: busy, onClick: () => void mutate(command), children: "Retry key change" }), _jsx(Button, { disabled: busy, onClick: () => {
86
+ setCommand(null);
87
+ setEditing(null);
88
+ void refresh();
89
+ }, children: "Reload keys" })] })) : null, editing !== null && options.data !== undefined ? (_jsx(EditKey, { record: editing, port: port, options: options.data, disabled: busy || command !== null, onCancel: () => {
90
+ setEditing(null);
91
+ }, onSave: (grants) => mutate({
92
+ kind: "grants",
93
+ input: {
94
+ requestId: mutationIds.request.generate(),
95
+ keyId: editing.keyId,
96
+ expectedGrantVersion: editing.grantVersion,
97
+ grants,
98
+ },
99
+ }) }, editing.keyId + ":" + String(editing.grantVersion))) : null, _jsx(SectionHeading, { children: "New key" }), issued === null ? (options.data === undefined ? (_jsx("p", { children: "Loading capability targets\u2026" })) : (_jsx(IssueKey, { port: port, options: options.data, onIssue: async (draft) => {
107
100
  const result = await port.issue(draft);
108
101
  setIssued(result);
109
102
  await refresh();
110
- } }, formKey)) : (_jsxs("div", { className: "grid gap-sm", role: "status", children: [_jsx("p", { children: "Copy this key now. It is shown once and cannot be retrieved later." }), _jsx("code", { className: "break-all", children: issued.plaintext }), _jsx(Button, { type: "button", variant: "outline", onClick: () => {
103
+ } }))) : (_jsxs("div", { className: "grid gap-sm", role: "status", children: [_jsx("p", { children: "Copy this key now. It is shown once and cannot be retrieved later." }), _jsx("code", { className: "break-all", children: issued.plaintext }), _jsx(Button, { onClick: () => {
111
104
  setIssued(null);
112
- setFormKey((current) => current + 1);
113
105
  }, children: "I copied the key" })] }))] }));
114
106
  }
115
- const expiryChoiceSchema = z.enum(["never", "30", "90", "365"]);
116
- const issueFormFields = z.object({
117
- name: z
118
- .string()
119
- .trim()
120
- .min(1, "Give this key a name.")
121
- .max(128)
122
- .meta(field({ label: "Name", required: true, maxLength: 128 })),
123
- expiry: expiryChoiceSchema.meta(field({
124
- label: "Expires after",
125
- options: {
126
- never: "Never",
127
- "30": "30 days",
128
- "90": "90 days",
129
- "365": "1 year",
130
- },
131
- })),
132
- });
133
- const issueFormDefaults = { name: "", expiry: "never" };
134
- const perAppChoiceSchema = z.enum(["none", "read", "write"]);
135
- function IssueKey({ apps, onIssue, }) {
136
- const [allApps, setAllApps] = useState(false);
137
- const [allAppsLevel, setAllAppsLevel] = useState("read");
138
- const [perApp, setPerApp] = useState(() => Object.fromEntries(apps.map((app) => [app.installationId, "none"])));
139
- const [computerAccess, setComputerAccess] = useState(false);
140
- const selectedCount = Object.values(perApp).filter((choice) => choice !== "none").length;
141
- const hasAccess = allApps || selectedCount > 0;
107
+ function GrantEditor({ grants, onChange, options, disabled, }) {
108
+ return (_jsxs("fieldset", { className: "grid gap-sm", disabled: disabled, children: [_jsx("legend", { children: "Capability grants" }), options.map((option) => {
109
+ const grant = grants.find((current) => current.capability === option.capability);
110
+ const replace = (targets) => {
111
+ onChange(grants.map((current) => current.capability === option.capability
112
+ ? { ...current, targets }
113
+ : current));
114
+ };
115
+ return (_jsxs("div", { className: "grid gap-xs", children: [_jsx(Field, { label: option.label, orientation: "inline", children: _jsx(Checkbox, { name: "cap:" + option.capability, checked: grant !== undefined, onChange: (event) => {
116
+ onChange(event.currentTarget.checked
117
+ ? [
118
+ ...grants,
119
+ {
120
+ capability: option.capability,
121
+ targets: { kind: "all" },
122
+ },
123
+ ]
124
+ : grants.filter((current) => current.capability !== option.capability));
125
+ } }) }), grant !== undefined && option.targets !== undefined ? (_jsxs("div", { className: "grid gap-xs pl-md", children: [_jsx(Field, { label: "All current and future targets", orientation: "inline", children: _jsx(Checkbox, { name: "all:" + option.capability, checked: grant.targets.kind === "all", onChange: (event) => {
126
+ replace(event.currentTarget.checked
127
+ ? { kind: "all" }
128
+ : { kind: "selected", ids: [] });
129
+ } }) }), grant.targets.kind === "selected" ? (_jsxs(_Fragment, { children: [option.targets.length === 0 ? (_jsx("p", { children: "No targets available." })) : null, option.targets.map((target) => (_jsx(Field, { label: target.name, orientation: "inline", children: _jsx(Checkbox, { name: option.capability + ":" + target.id, checked: grant.targets.kind === "selected" &&
130
+ grant.targets.ids.includes(target.id), onChange: (event) => {
131
+ const ids = grant.targets.kind === "selected"
132
+ ? grant.targets.ids
133
+ : [];
134
+ replace({
135
+ kind: "selected",
136
+ ids: event.currentTarget.checked
137
+ ? [...ids, target.id]
138
+ : ids.filter((id) => id !== target.id),
139
+ });
140
+ } }) }, target.id))), grant.targets.ids
141
+ .filter((id) => !option.targets?.some((target) => target.id === id))
142
+ .map((id) => (_jsxs("p", { children: ["Unavailable target: ", id] }, id)))] })) : null] })) : null] }, option.capability));
143
+ }), grants
144
+ .filter((grant) => !options.some((option) => option.capability === grant.capability))
145
+ .map((grant) => (_jsxs("div", { children: [_jsxs("p", { children: [grant.capability, " is no longer offered by this host."] }), _jsxs(Button, { type: "button", onClick: () => {
146
+ onChange(grants.filter((current) => current !== grant));
147
+ }, children: ["Remove ", grant.capability] })] }, grant.capability)))] }));
148
+ }
149
+ function GrantDraft({ port, options, initial, disabled = false, children, }) {
150
+ const [grants, setGrants] = useState(initial);
151
+ const [review, setReview] = useState(null);
152
+ const Preview = port.Preview;
153
+ const valid = grants.length > 0 &&
154
+ grants.every((grant) => options.some((option) => option.capability === grant.capability)) &&
155
+ !grants.some((grant) => grant.targets.kind === "selected" && grant.targets.ids.length === 0);
156
+ const ready = valid &&
157
+ (Preview === undefined || (review?.grants === grants && review.ready));
158
+ return (_jsxs(_Fragment, { children: [_jsx(GrantEditor, { grants: grants, onChange: setGrants, options: options, disabled: disabled }), Preview ? (_jsx(Preview, { grants: grants, onReady: (ready) => {
159
+ setReview((current) => current?.grants === grants && current.ready === ready
160
+ ? current
161
+ : { grants, ready });
162
+ } })) : null, children(grants, ready)] }));
163
+ }
164
+ function IssueKey({ port, options, onIssue, }) {
165
+ const [busy, setBusy] = useState(false);
166
+ return (_jsx(GrantDraft, { port: port, options: options, initial: [], disabled: busy, children: (grants, ready) => (_jsx(IssueForm, { port: port, grants: grants, ready: ready, onIssue: async (draft) => {
167
+ setBusy(true);
168
+ try {
169
+ await onIssue(draft);
170
+ }
171
+ finally {
172
+ setBusy(false);
173
+ }
174
+ } })) }));
175
+ }
176
+ function IssueForm({ port, grants, ready, onIssue, }) {
177
+ const [custom, setCustom] = useState("");
178
+ const expiryValues = port.expiry === "required"
179
+ ? ["30", "90", "365", "custom"]
180
+ : ["30", "90", "365", "custom", "never"];
181
+ const expiry = z.enum(expiryValues);
182
+ const finiteExpiryOptions = {
183
+ "30": "30 days",
184
+ "90": "90 days",
185
+ "365": "1 year",
186
+ custom: "Custom date",
187
+ };
188
+ const schema = z.object({
189
+ name: z
190
+ .string()
191
+ .trim()
192
+ .min(1, "Give this key a name.")
193
+ .max(128)
194
+ .meta(field({ label: "Name", required: true, maxLength: 128 })),
195
+ expiry: expiry.meta(field({
196
+ label: "Expires after",
197
+ options: port.expiry === "optional"
198
+ ? { ...finiteExpiryOptions, never: "Never" }
199
+ : finiteExpiryOptions,
200
+ })),
201
+ });
142
202
  const model = useSchemaForm({
143
- schema: issueFormFields,
144
- defaultValues: issueFormDefaults,
203
+ schema,
204
+ defaultValues: {
205
+ name: "",
206
+ expiry: port.expiry === "required" ? "30" : "never",
207
+ },
145
208
  onSubmit: async (value) => {
146
- if (!hasAccess)
209
+ if (!ready)
147
210
  return {
148
211
  kind: "refused",
149
- error: "Choose at least one App before issuing a key.",
212
+ error: "Choose grants and finish their review before issuing.",
150
213
  };
151
- const grants = allApps
152
- ? [{ level: allAppsLevel, target: { kind: "all" } }]
153
- : ["write", "read"].flatMap((level) => {
154
- const installationIds = Object.entries(perApp)
155
- .filter(([, choice]) => choice === level)
156
- .map(([installationId]) => installationId);
157
- return installationIds.length === 0
158
- ? []
159
- : [
160
- {
161
- level,
162
- target: { kind: "selected", installationIds },
163
- },
164
- ];
165
- });
214
+ const expiresAt = value.expiry === "never"
215
+ ? undefined
216
+ : value.expiry === "custom"
217
+ ? new Date(custom).getTime()
218
+ : Date.now() + Number(value.expiry) * 86_400_000;
219
+ if (expiresAt !== undefined &&
220
+ (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()))
221
+ return { kind: "refused", error: "Choose a finite future expiry." };
166
222
  try {
167
- await onIssue({
168
- name: value.name,
169
- grants,
170
- computerAccess,
171
- expiresAt: value.expiry === "never"
172
- ? null
173
- : Date.now() + Number(value.expiry) * 24 * 60 * 60 * 1000,
174
- });
223
+ const draft = { name: value.name, grants };
224
+ await onIssue(expiresAt === undefined ? draft : { ...draft, expiresAt });
175
225
  return { kind: "saved" };
176
226
  }
177
227
  catch (cause) {
@@ -184,18 +234,11 @@ function IssueKey({ apps, onIssue, }) {
184
234
  }
185
235
  },
186
236
  });
187
- const { submitting: issuing } = useSchemaFormStatus(model);
188
- return (_jsxs(SchemaForm, { model: model, "aria-label": "Issue a key", disabled: issuing, footer: _jsx(FormFooter, { error: model.error, children: _jsx(Button, { type: "submit", disabled: !hasAccess || issuing, children: issuing ? "Issuing…" : "Issue key" }) }), children: [_jsx(FormFields, { model: model, disabled: issuing }), _jsx(Field, { label: "All Apps, including future installs", orientation: "inline", children: _jsx(Checkbox, { name: "allApps", checked: allApps, disabled: issuing, onChange: (event) => {
189
- setAllApps(event.currentTarget.checked);
190
- } }) }), allApps ? (_jsx(Field, { label: "Access level for all Apps", orientation: "horizontal", children: _jsxs(Select, { name: "allAppsLevel", disabled: issuing, value: allAppsLevel, onChange: (event) => {
191
- setAllAppsLevel(keyAppAccessLevelSchema.parse(event.currentTarget.value));
192
- }, children: [_jsx("option", { value: "read", children: "Read" }), _jsx("option", { value: "write", children: "Read and change" })] }) })) : apps.length === 0 ? (_jsx("p", { children: "No Apps installed yet." })) : (apps.map((app) => (_jsx(Field, { label: app.displayName, orientation: "horizontal", children: _jsxs(Select, { name: `app:${app.installationId}`, disabled: issuing, value: perApp[app.installationId] ?? "none", onChange: (event) => {
193
- const choice = perAppChoiceSchema.parse(event.currentTarget.value);
194
- setPerApp((current) => ({
195
- ...current,
196
- [app.installationId]: choice,
197
- }));
198
- }, children: [_jsx("option", { value: "none", children: "No access" }), _jsx("option", { value: "read", children: "Read" }), _jsx("option", { value: "write", children: "Read and change" })] }) }, app.installationId)))), _jsx(Field, { label: "Computer access", description: "Off by default. On lets this key run commands on this Oyster\u2019s Computer.", orientation: "inline", children: _jsx(Checkbox, { name: "computerAccess", checked: computerAccess, disabled: issuing, onChange: (event) => {
199
- setComputerAccess(event.currentTarget.checked);
237
+ const { submitting } = useSchemaFormStatus(model);
238
+ return (_jsxs(SchemaForm, { model: model, "aria-label": "Issue a key", footer: _jsx(FormFooter, { error: model.error, children: _jsx(Button, { type: "submit", disabled: !ready || submitting, children: "Issue key" }) }), children: [_jsx(FormFields, { model: model }), _jsx(Field, { label: "Custom expiry", children: _jsx(Input, { type: "datetime-local", name: "customExpiry", value: custom, disabled: submitting, onChange: (event) => {
239
+ setCustom(event.currentTarget.value);
200
240
  } }) })] }));
201
241
  }
242
+ function EditKey({ record, port, options, disabled, onSave, onCancel, }) {
243
+ return (_jsxs("section", { "aria-label": "Edit " + record.name, className: "grid gap-sm", children: [_jsxs(SectionHeading, { children: ["Edit ", record.name] }), _jsx(GrantDraft, { port: port, options: options, initial: record.grants, disabled: disabled, children: (grants, ready) => (_jsxs("div", { className: "flex gap-sm", children: [_jsx(Button, { disabled: disabled || !ready, onClick: () => void onSave(grants), children: "Save grants" }), _jsx(Button, { disabled: disabled, onClick: onCancel, children: "Cancel" })] })) })] }));
244
+ }
@@ -0,0 +1,22 @@
1
+ import { teamComputerDirectorySchema, type TeamComputer } from "@fungi.computer/nacre/workbench";
2
+ import * as z from "zod";
3
+ /** One owner-fleet page returned by the public Computer controls port. */
4
+ export type ComputerPage = z.output<typeof teamComputerDirectorySchema>;
5
+ /** Owner controls over the existing standalone fleet, not a second inventory. */
6
+ export type OysterComputersPort = Readonly<{
7
+ list(offset: number): Promise<ComputerPage>;
8
+ selected(): Promise<string | null>;
9
+ create(name: string | undefined, requestId: string): Promise<TeamComputer>;
10
+ start(computerId: string): Promise<void>;
11
+ select(computerId: string): Promise<TeamComputer>;
12
+ internetAccess(computerId: string): Promise<boolean>;
13
+ setInternetAccess(computerId: string, enabled: boolean): Promise<boolean>;
14
+ }>;
15
+ /** Bind owner-admitted fleet controls to this trusted shell's host origin. */
16
+ export declare function createOysterComputersPort(origin: URL): OysterComputersPort;
17
+ /** Owner Settings over the host's existing inventory and default selection. */
18
+ export declare function OysterComputers({ teamId, port, onChanged, }: {
19
+ teamId: string;
20
+ port: OysterComputersPort;
21
+ onChanged: () => void;
22
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,177 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { AppError } from "@fungi.computer/agar";
3
+ import { optimisticMutation } from "@fungi.computer/agar/query";
4
+ import { SectionHeading } from "@fungi.computer/caps/blocks/section-heading";
5
+ import { ActionRow } from "@fungi.computer/caps/components/action-row";
6
+ import { Button } from "@fungi.computer/caps/components/button";
7
+ import { DataList, DataRow } from "@fungi.computer/caps/components/data-list";
8
+ import { Input } from "@fungi.computer/caps/components/input";
9
+ import { Switch } from "@fungi.computer/caps/components/switch";
10
+ import { teamComputerDirectorySchema, teamComputerSchema, } from "@fungi.computer/nacre/workbench";
11
+ import { workbenchDirectorySchema } from "@fungi.computer/nacre/workbench-directory";
12
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
13
+ import { useRef, useState } from "react";
14
+ import * as z from "zod";
15
+ import { requestIds } from "./team-directory.js";
16
+ import { tourtoisQueryKeys } from "./queries.js";
17
+ const internetAccessSchema = z.strictObject({ enabled: z.boolean() });
18
+ async function request(url, init) {
19
+ const response = await fetch(url, {
20
+ ...init,
21
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
22
+ credentials: "same-origin",
23
+ cache: "no-store",
24
+ redirect: "error",
25
+ });
26
+ if (!response.ok)
27
+ throw new AppError(`Computer request refused (${String(response.status)}).`, {
28
+ code: response.status === 401 || response.status === 403
29
+ ? "CAPABILITY_DENIED"
30
+ : "HOST_FAILURE",
31
+ retryable: response.status >= 500,
32
+ });
33
+ return response;
34
+ }
35
+ /** Bind owner-admitted fleet controls to this trusted shell's host origin. */
36
+ export function createOysterComputersPort(origin) {
37
+ const read = (path, init) => request(new URL(path, origin), init);
38
+ return {
39
+ list: async (offset) => {
40
+ const response = await read(`/api/computers?${new URLSearchParams({ limit: "100", offset: String(offset) })}`);
41
+ return teamComputerDirectorySchema.parse(await response.json());
42
+ },
43
+ selected: async () => {
44
+ const response = await read("/api/workbench/directory");
45
+ return (workbenchDirectorySchema.parse(await response.json()).computer
46
+ ?.computerId ?? null);
47
+ },
48
+ create: async (name, requestId) => {
49
+ const response = await read("/api/computers", {
50
+ method: "POST",
51
+ body: JSON.stringify(name === undefined ? { requestId } : { requestId, name }),
52
+ });
53
+ return teamComputerSchema.parse(await response.json());
54
+ },
55
+ start: async (computerId) => {
56
+ // Success acknowledges this start, not a durable or polled health state.
57
+ const response = await read(`/api/computers/${encodeURIComponent(computerId)}/start`, {
58
+ method: "POST",
59
+ });
60
+ await response.json();
61
+ },
62
+ select: async (computerId) => {
63
+ const response = await read("/api/computers/selected", {
64
+ method: "PUT",
65
+ body: JSON.stringify({ computerId }),
66
+ });
67
+ return teamComputerSchema.parse(await response.json());
68
+ },
69
+ internetAccess: async (computerId) => {
70
+ const response = await read(`/api/computers/${encodeURIComponent(computerId)}/internet-access`);
71
+ return internetAccessSchema.parse(await response.json()).enabled;
72
+ },
73
+ setInternetAccess: async (computerId, enabled) => {
74
+ const response = await read(`/api/computers/${encodeURIComponent(computerId)}/internet-access`, { method: "PUT", body: JSON.stringify({ enabled }) });
75
+ return internetAccessSchema.parse(await response.json()).enabled;
76
+ },
77
+ };
78
+ }
79
+ function InternetAccess({ teamId, computer, port, }) {
80
+ const queryKey = tourtoisQueryKeys.oysterInternetAccess(teamId, computer.computerId);
81
+ const current = useQuery({
82
+ queryKey,
83
+ queryFn: () => port.internetAccess(computer.computerId),
84
+ });
85
+ const update = useMutation(optimisticMutation({
86
+ queryKey,
87
+ apply: (_previous, enabled) => enabled,
88
+ mutationFn: (enabled) => port.setInternetAccess(computer.computerId, enabled),
89
+ }));
90
+ return (_jsxs("div", { className: "grid gap-sm", children: [_jsxs("label", { className: "flex gap-sm", children: ["Internet access", _jsx(Switch, { "aria-label": `Internet access for ${computer.name}`, checked: current.data ?? false, disabled: current.data === undefined || update.isPending, onCheckedChange: (enabled) => {
91
+ update.mutate(enabled);
92
+ } })] }), current.isPending ? (_jsx("p", { role: "status", children: "Checking Internet access\u2026" })) : null, current.isError ? (_jsxs("div", { role: "alert", children: [_jsxs("p", { children: ["Could not check Internet access for ", computer.name, "."] }), _jsx(Button, { onClick: () => {
93
+ void current.refetch();
94
+ }, children: "Retry Internet access" })] })) : null, update.isError ? (_jsxs("p", { role: "alert", children: ["Could not save Internet access for ", computer.name, "."] })) : null] }));
95
+ }
96
+ /** Owner Settings over the host's existing inventory and default selection. */
97
+ export function OysterComputers({ teamId, port, onChanged, }) {
98
+ const client = useQueryClient();
99
+ const [offset, setOffset] = useState(0);
100
+ const [name, setName] = useState("");
101
+ const intent = useRef(null);
102
+ const inventoryKey = tourtoisQueryKeys.oysterComputers(teamId);
103
+ const selectionKey = tourtoisQueryKeys.oysterSelectedComputer(teamId);
104
+ const inventory = useQuery({
105
+ queryKey: tourtoisQueryKeys.oysterComputerPage(teamId, offset),
106
+ queryFn: () => port.list(offset),
107
+ });
108
+ const selected = useQuery({
109
+ queryKey: selectionKey,
110
+ queryFn: () => port.selected(),
111
+ });
112
+ const action = useMutation({
113
+ mutationFn: async (input) => {
114
+ switch (input.kind) {
115
+ case "create":
116
+ await port.create(input.name, input.requestId);
117
+ return;
118
+ case "start":
119
+ await port.start(input.computerId);
120
+ return;
121
+ case "select":
122
+ await port.select(input.computerId);
123
+ }
124
+ },
125
+ onSuccess: async (_result, input) => {
126
+ if (input.kind === "create") {
127
+ intent.current = null;
128
+ setName("");
129
+ setOffset(0);
130
+ }
131
+ await Promise.all([
132
+ client.invalidateQueries({ queryKey: inventoryKey }),
133
+ client.invalidateQueries({ queryKey: selectionKey }),
134
+ ]);
135
+ onChanged();
136
+ },
137
+ });
138
+ const unavailable = inventory.isError ||
139
+ selected.isError ||
140
+ inventory.isFetching ||
141
+ selected.isFetching ||
142
+ action.isPending;
143
+ return (_jsxs("section", { className: "grid gap-sm", "aria-label": "Computers", children: [_jsx(SectionHeading, { children: "Computers" }), _jsx("p", { children: "Choose the default Computer used for new App configurations." }), inventory.isPending ? _jsx("p", { role: "status", children: "Loading Computers\u2026" }) : null, inventory.isError || selected.isError ? (_jsxs("div", { role: "alert", children: [_jsx("p", { children: "Computer inventory or selection could not be loaded." }), _jsx(Button, { onClick: () => {
144
+ void inventory.refetch();
145
+ void selected.refetch();
146
+ }, children: "Retry" })] })) : null, inventory.isSuccess && inventory.data.computers.length === 0 ? (_jsx("p", { children: "No Computers on this page." })) : null, inventory.data !== undefined ? (_jsx(DataList, { "aria-label": "Computer inventory", children: inventory.data.computers.map((computer) => (_jsxs(DataRow, { className: "p-0", children: [_jsx(ActionRow, { label: computer.name, description: selected.isSuccess && selected.data === computer.computerId
147
+ ? "Default for new Apps"
148
+ : computer.computerId, trailing: _jsxs("div", { className: "flex gap-sm", children: [_jsx(Button, { "aria-label": `Start ${computer.name}`, disabled: unavailable, onClick: () => {
149
+ action.mutate({
150
+ kind: "start",
151
+ computerId: computer.computerId,
152
+ });
153
+ }, children: "Start" }), _jsx(Button, { "aria-label": `Use ${computer.name} for new Apps`, disabled: unavailable || selected.data === computer.computerId, onClick: () => {
154
+ action.mutate({
155
+ kind: "select",
156
+ computerId: computer.computerId,
157
+ });
158
+ }, children: "Use for new Apps" })] }) }), _jsx(InternetAccess, { teamId: teamId, computer: computer, port: port })] }, computer.computerId))) })) : null, _jsxs("div", { className: "flex gap-sm", "aria-label": "Computer pages", children: [_jsx(Button, { disabled: unavailable || offset === 0, onClick: () => {
159
+ setOffset(Math.max(0, offset - 100));
160
+ }, children: "Previous" }), _jsx(Button, { disabled: unavailable || inventory.data?.nextOffset == null, onClick: () => {
161
+ const next = inventory.data?.nextOffset;
162
+ if (next != null)
163
+ setOffset(next);
164
+ }, children: "Next" })] }), _jsxs("form", { className: "grid gap-sm", "aria-label": "Create Computer", onSubmit: (event) => {
165
+ event.preventDefault();
166
+ const requestedName = name === "" ? undefined : name;
167
+ if (intent.current === null || intent.current.name !== requestedName)
168
+ intent.current = {
169
+ kind: "create",
170
+ name: requestedName,
171
+ requestId: requestIds.request.generate(),
172
+ };
173
+ action.mutate(intent.current);
174
+ }, children: [_jsxs("label", { children: ["Computer name (optional)", _jsx(Input, { name: "computerName", maxLength: 256, value: name, disabled: unavailable, placeholder: "Leave blank for a generated name", onChange: (event) => {
175
+ setName(event.currentTarget.value);
176
+ } })] }), _jsx(Button, { type: "submit", disabled: unavailable, children: "Create Computer" })] }), action.isError ? _jsx("p", { role: "alert", children: action.error.message }) : null, action.isSuccess ? (_jsx("p", { role: "status", children: "Computer request completed." })) : null] }));
177
+ }
@@ -1,4 +1,4 @@
1
- import { type AppAddress } from "@fungi.computer/oyster/shell/app-directory";
1
+ import { type AppAddress } from "@fungi.computer/nacre/app-directory";
2
2
  import type { SettingsLocation } from "./settings-routes.js";
3
3
  export type NavigationApp = Readonly<{
4
4
  address: AppAddress;
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { Button } from "@fungi.computer/caps/components/button";
3
3
  import { Select } from "@fungi.computer/caps/components/select";
4
4
  import { AppIcon } from "./app-icon.js";
5
- import { addressKey, } from "@fungi.computer/oyster/shell/app-directory";
5
+ import { addressKey, } from "@fungi.computer/nacre/app-directory";
6
6
  import { OwnSprite } from "./member-avatar.js";
7
7
  export function SettingsNavigation({ location, apps, sections, desktopOnly = false, onNavigate, }) {
8
8
  const groups = navigationGroups(apps, sections, desktopOnly);