@fungi.computer/tourtois 0.0.1 → 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.
- package/README.md +19 -0
- package/dist/AppAccessConsent.d.ts +6 -1
- package/dist/AppAccessConsent.js +4 -5
- package/dist/AppDesktopSettings.d.ts +2 -2
- package/dist/AppPane.d.ts +11 -4
- package/dist/AppPane.js +86 -11
- package/dist/AppWorkbench.d.ts +1 -1
- package/dist/AppWorkbench.js +10 -5
- package/dist/AvatarPreview.d.ts +8 -1
- package/dist/AvatarPreview.js +9 -1
- package/dist/Keys.d.ts +42 -121
- package/dist/Keys.js +200 -157
- package/dist/OysterComputers.d.ts +22 -0
- package/dist/OysterComputers.js +177 -0
- package/dist/SettingsHome.js +4 -4
- package/dist/SettingsNavigation.d.ts +1 -1
- package/dist/SettingsNavigation.js +1 -1
- package/dist/SystemSettings.d.ts +2 -2
- package/dist/SystemSettings.js +1 -1
- package/dist/TeamDesktop.d.ts +1 -1
- package/dist/app-authority.d.ts +47 -47
- package/dist/app-authority.js +1 -1
- package/dist/app-directory-reconciliation.js +7 -2
- package/dist/app-directory.d.ts +6 -5
- package/dist/app-directory.js +4 -1
- package/dist/app-launch.d.ts +1 -1
- package/dist/app-launch.js +6 -3
- package/dist/app-menus.d.ts +1 -1
- package/dist/app-surface.d.ts +2 -1
- package/dist/app-windows.d.ts +32 -2
- package/dist/app-windows.js +1 -1
- package/dist/command-dialogs.d.ts +1 -1
- package/dist/command-dialogs.js +4 -4
- package/dist/desktop/Desktop.d.ts +1 -1
- package/dist/desktop/consent.d.ts +1 -1
- package/dist/desktop/consent.js +1 -1
- package/dist/desktop/launcher-gill.js +1 -1
- package/dist/desktop/row-gill.d.ts +1 -1
- package/dist/oyster-platform-controls.d.ts +1 -1
- package/dist/oyster-platform-controls.js +64 -23
- package/dist/platform-controls.d.ts +6 -5
- package/dist/platform-controls.js +3 -3
- package/dist/queries.d.ts +4 -0
- package/dist/queries.js +4 -0
- package/dist/shell-commands.d.ts +1 -1
- package/dist/shell-commands.js +4 -4
- package/dist/shell-host.d.ts +7 -9
- package/dist/shell-host.js +102 -12
- package/dist/shell-module.d.ts +1 -1
- package/dist/shell.d.ts +1 -1
- package/dist/shell.js +5 -9
- package/dist/useAppWindowManager.d.ts +32 -1
- package/dist/useAppWindowManager.js +3 -0
- package/package.json +40 -19
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 {
|
|
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 {
|
|
10
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
11
|
import { useState } from "react";
|
|
14
12
|
import * as z from "zod";
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
} }
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
144
|
-
defaultValues:
|
|
203
|
+
schema,
|
|
204
|
+
defaultValues: {
|
|
205
|
+
name: "",
|
|
206
|
+
expiry: port.expiry === "required" ? "30" : "never",
|
|
207
|
+
},
|
|
145
208
|
onSubmit: async (value) => {
|
|
146
|
-
if (!
|
|
209
|
+
if (!ready)
|
|
147
210
|
return {
|
|
148
211
|
kind: "refused",
|
|
149
|
-
error: "Choose
|
|
212
|
+
error: "Choose grants and finish their review before issuing.",
|
|
150
213
|
};
|
|
151
|
-
const
|
|
152
|
-
?
|
|
153
|
-
:
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
|
188
|
-
return (_jsxs(SchemaForm, { model: model, "aria-label": "Issue a key",
|
|
189
|
-
|
|
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
|
+
}
|