@k2b/cloud 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -4
- package/src/_internal/define-app.ts +3 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +7 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/global.css +5 -2
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { dates } from "@k2b/stdlib";
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
ButtonLink,
|
|
5
|
+
type DropdownItem,
|
|
6
|
+
dialogCore,
|
|
7
|
+
MarkdownView,
|
|
8
|
+
PanelDialog,
|
|
9
|
+
Placeholder,
|
|
10
|
+
panelDialogOptions,
|
|
11
|
+
prompts,
|
|
12
|
+
SplitButton,
|
|
13
|
+
useLocale,
|
|
14
|
+
} from "@k2b/ui";
|
|
15
|
+
import { createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
|
|
16
|
+
import { refreshAppBadges } from "../browser/app-badges";
|
|
17
|
+
import { coreClient } from "../clients/core";
|
|
18
|
+
import {
|
|
19
|
+
APPROVALS_PAGE_PATH,
|
|
20
|
+
type ApprovalDecisionInput,
|
|
21
|
+
type ApprovalListQuery,
|
|
22
|
+
type ApprovalPage,
|
|
23
|
+
ApprovalPageSchema,
|
|
24
|
+
type PendingApproval,
|
|
25
|
+
} from "../contracts/approvals";
|
|
26
|
+
import type { CapabilityActionReview } from "../contracts/capabilities";
|
|
27
|
+
import { approvalMessages } from "./messages";
|
|
28
|
+
|
|
29
|
+
/** How a decided request ends in this list. It stays in place as a receipt until the next load. */
|
|
30
|
+
type Outcome = "approved" | "rejected" | "elsewhere" | "gone";
|
|
31
|
+
/** Approved and rejected are this person's decision; the other two mean the click changed nothing. */
|
|
32
|
+
const decidedHere = (outcome: Outcome) => outcome === "approved" || outcome === "rejected";
|
|
33
|
+
/** One icon per outcome, so a rejection or a decision made elsewhere never looks like a success. */
|
|
34
|
+
const outcomeIcon = (outcome: Outcome, band = false) =>
|
|
35
|
+
outcome === "approved"
|
|
36
|
+
? band
|
|
37
|
+
? "ti ti-circle-check"
|
|
38
|
+
: "ti ti-check"
|
|
39
|
+
: outcome === "rejected"
|
|
40
|
+
? band
|
|
41
|
+
? "ti ti-circle-x"
|
|
42
|
+
: "ti ti-x"
|
|
43
|
+
: "ti ti-info-circle";
|
|
44
|
+
type ReviewDetail = NonNullable<CapabilityActionReview["details"]>[number];
|
|
45
|
+
|
|
46
|
+
export type ApprovalsProps = {
|
|
47
|
+
/** List the requests whose effect belongs to this app or that this app asked for. Without, every app's, grouped. */
|
|
48
|
+
app?: string;
|
|
49
|
+
/** Only requests whose review links to this same-origin path, such as the page of the draft a send would send. */
|
|
50
|
+
link?: string;
|
|
51
|
+
/** The first page for the same query, loaded during SSR with `loadPendingApprovals`; without, the browser loads it. */
|
|
52
|
+
initial?: ApprovalPage;
|
|
53
|
+
/**
|
|
54
|
+
* `list` shows the requests with their review, a count, and an empty state. `band` shows each request as one compact
|
|
55
|
+
* row above the resource it would change, and nothing when none waits.
|
|
56
|
+
*/
|
|
57
|
+
layout?: "list" | "band";
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A click meant for the page must not approve a row that just appeared: decision buttons arm after this delay. */
|
|
61
|
+
const ARM_DELAY_MS = 500;
|
|
62
|
+
|
|
63
|
+
const useMessages = () => {
|
|
64
|
+
const locale = useLocale();
|
|
65
|
+
return () => approvalMessages.resolve([locale()]).t;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const lines = (item: PendingApproval) =>
|
|
69
|
+
item.sentence
|
|
70
|
+
.split("\n")
|
|
71
|
+
.map((line) => line.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
|
|
74
|
+
/** The first line names the app and the Action; the app already shows as the row's icon and origin. */
|
|
75
|
+
const approvalTitle = (item: PendingApproval): string => {
|
|
76
|
+
const first = lines(item)[0] ?? item.action;
|
|
77
|
+
const prefix = `${item.app.name}: `;
|
|
78
|
+
return first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** Without a review, the sentence's later `Label: value` lines are what the person approves. */
|
|
82
|
+
const sentenceFields = (item: PendingApproval) =>
|
|
83
|
+
lines(item)
|
|
84
|
+
.slice(1)
|
|
85
|
+
.map((line) => {
|
|
86
|
+
const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
|
|
87
|
+
return match ? { label: match[1], value: match[2] ?? "" } : { label: null, value: line };
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const detailText = (detail: ReviewDetail, locale: string): string => {
|
|
91
|
+
if (detail.format === "date")
|
|
92
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(detail.value)
|
|
93
|
+
? dates.formatDate(`${detail.value}T12:00:00.000Z`, { locale, timeZone: "UTC" })
|
|
94
|
+
: dates.formatDate(detail.value, { locale });
|
|
95
|
+
if (detail.format === "date-time") return dates.formatDateTime(detail.value, { locale });
|
|
96
|
+
return detail.value;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const inlineDetails = (item: PendingApproval) => (item.review?.details ?? []).filter((detail) => (detail.display ?? "inline") === "inline");
|
|
100
|
+
const blockDetails = (item: PendingApproval) => (item.review?.details ?? []).filter((detail) => detail.display === "block");
|
|
101
|
+
|
|
102
|
+
function Fields(props: { item: PendingApproval }) {
|
|
103
|
+
const locale = useLocale();
|
|
104
|
+
const fields = () =>
|
|
105
|
+
props.item.review
|
|
106
|
+
? inlineDetails(props.item).map((detail) => ({ label: detail.label, value: detailText(detail, locale()) }))
|
|
107
|
+
: sentenceFields(props.item).filter((field): field is { label: string; value: string } => field.label !== null);
|
|
108
|
+
return (
|
|
109
|
+
<Show when={fields().length > 0}>
|
|
110
|
+
<dl class="cloud-approval__fields">
|
|
111
|
+
<For each={fields()}>
|
|
112
|
+
{(field) => (
|
|
113
|
+
<>
|
|
114
|
+
<dt>{field.label}</dt>
|
|
115
|
+
<dd>{field.value}</dd>
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
</For>
|
|
119
|
+
</dl>
|
|
120
|
+
</Show>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The review's message, or the sentence's plain lines when the Action has no review. */
|
|
125
|
+
function Description(props: { item: PendingApproval }) {
|
|
126
|
+
const text = () => {
|
|
127
|
+
const message = props.item.review?.message.trim();
|
|
128
|
+
if (message) return message === approvalTitle(props.item) ? null : message;
|
|
129
|
+
const plain = sentenceFields(props.item)
|
|
130
|
+
.filter((field) => field.label === null)
|
|
131
|
+
.map((field) => field.value);
|
|
132
|
+
return plain.length ? plain.join("\n") : null;
|
|
133
|
+
};
|
|
134
|
+
return (
|
|
135
|
+
<Show when={text()}>
|
|
136
|
+
<p class="cloud-approval__text">{text()}</p>
|
|
137
|
+
</Show>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Origin of the request: owning app, the chat it waits in, and when Cloud asked. The chat title is content, not frame. */
|
|
142
|
+
function Origin(props: { item: PendingApproval; withApp?: boolean }) {
|
|
143
|
+
const t = useMessages();
|
|
144
|
+
const locale = useLocale();
|
|
145
|
+
return (
|
|
146
|
+
<p class="cloud-approval__origin">
|
|
147
|
+
<Show when={props.withApp !== false}>
|
|
148
|
+
<span>{props.item.app.name}</span>
|
|
149
|
+
<span aria-hidden="true"> · </span>
|
|
150
|
+
</Show>
|
|
151
|
+
<a href={props.item.requester.href}>
|
|
152
|
+
{props.item.requester.title ? t().fromChat({ title: props.item.requester.title }) : t().fromUntitledChat}
|
|
153
|
+
</a>
|
|
154
|
+
<span aria-hidden="true"> · </span>
|
|
155
|
+
<time dateTime={props.item.createdAt}>{dates.formatDateTimeRelative(props.item.createdAt, { locale: locale() })}</time>
|
|
156
|
+
</p>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** "Asks every time · Expires in 23 hours", or the error of the last decision in the same place. */
|
|
161
|
+
function Note(props: { item: PendingApproval; outcome?: Outcome; failed?: boolean }) {
|
|
162
|
+
const t = useMessages();
|
|
163
|
+
const locale = useLocale();
|
|
164
|
+
const text = () => {
|
|
165
|
+
if (props.outcome) return decidedHere(props.outcome) ? t().decidedHere : t().notApplied;
|
|
166
|
+
if (props.failed) return t().decisionFailed;
|
|
167
|
+
const expires = t().expiresIn({ time: dates.formatTimeSpan(props.item.expiresAt, { locale: locale() }) });
|
|
168
|
+
return props.item.assurance === "confirm" ? `${t().asksEveryTime} · ${expires}` : expires;
|
|
169
|
+
};
|
|
170
|
+
const icon = () =>
|
|
171
|
+
props.outcome
|
|
172
|
+
? decidedHere(props.outcome)
|
|
173
|
+
? "ti ti-clock"
|
|
174
|
+
: "ti ti-info-circle"
|
|
175
|
+
: props.failed
|
|
176
|
+
? "ti ti-alert-circle"
|
|
177
|
+
: props.item.assurance === "confirm"
|
|
178
|
+
? "ti ti-lock"
|
|
179
|
+
: "ti ti-clock";
|
|
180
|
+
return (
|
|
181
|
+
<span
|
|
182
|
+
class="cloud-approval__note"
|
|
183
|
+
data-tone={props.failed && !props.outcome ? "danger" : undefined}
|
|
184
|
+
role={props.failed ? "alert" : undefined}
|
|
185
|
+
>
|
|
186
|
+
<i class={icon()} aria-hidden="true" />
|
|
187
|
+
<span title={text()}>{text()}</span>
|
|
188
|
+
</span>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function Receipt(props: { outcome: Outcome }) {
|
|
193
|
+
const t = useMessages();
|
|
194
|
+
const label = () => ({ approved: t().approved, rejected: t().rejected, elsewhere: t().decidedElsewhere, gone: t().gone })[props.outcome];
|
|
195
|
+
return (
|
|
196
|
+
<span class="cloud-approval__receipt" data-outcome={props.outcome}>
|
|
197
|
+
<i class={outcomeIcon(props.outcome)} aria-hidden="true" />
|
|
198
|
+
{label()}
|
|
199
|
+
</span>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Reject, then approve; remember options only where the request offers them, never for `confirm`. */
|
|
204
|
+
function Decision(props: { item: PendingApproval; busy: boolean; details?: () => void; decide: (input: ApprovalDecisionInput) => void }) {
|
|
205
|
+
const t = useMessages();
|
|
206
|
+
const [armed, setArmed] = createSignal(false);
|
|
207
|
+
onMount(() => {
|
|
208
|
+
const timer = setTimeout(() => setArmed(true), ARM_DELAY_MS);
|
|
209
|
+
onCleanup(() => clearTimeout(timer));
|
|
210
|
+
});
|
|
211
|
+
const rememberItems = (): DropdownItem[] => [
|
|
212
|
+
...(props.item.remember.chat
|
|
213
|
+
? [
|
|
214
|
+
{
|
|
215
|
+
label: props.item.remember.website ? t().allowWebsiteForChat : t().approveForChat,
|
|
216
|
+
icon: props.item.remember.website ? "ti ti-world-check" : "ti ti-message-check",
|
|
217
|
+
action: () => props.decide({ approved: true, remember: "chat" }),
|
|
218
|
+
},
|
|
219
|
+
]
|
|
220
|
+
: []),
|
|
221
|
+
...(props.item.remember.always
|
|
222
|
+
? [{ label: t().alwaysApprove, icon: "ti ti-shield-check", action: () => props.decide({ approved: true, remember: "always" }) }]
|
|
223
|
+
: []),
|
|
224
|
+
];
|
|
225
|
+
return (
|
|
226
|
+
// Inert, not disabled, while arming: the buttons look the same from the first frame, so nothing flickers.
|
|
227
|
+
<div class="cloud-approval__actions" inert={armed() ? undefined : true}>
|
|
228
|
+
<Show when={props.details}>
|
|
229
|
+
{(open) => (
|
|
230
|
+
<Button size="sm" variant="ghost" onClick={() => open()()}>
|
|
231
|
+
{t().details}
|
|
232
|
+
</Button>
|
|
233
|
+
)}
|
|
234
|
+
</Show>
|
|
235
|
+
<Button size="sm" variant="ghost" disabled={props.busy} onClick={() => props.decide({ approved: false })}>
|
|
236
|
+
{t().reject}
|
|
237
|
+
</Button>
|
|
238
|
+
<Show
|
|
239
|
+
when={props.item.assurance === "standard" && rememberItems().length > 0}
|
|
240
|
+
fallback={
|
|
241
|
+
<Button size="sm" variant="primary" loading={props.busy} onClick={() => props.decide({ approved: true })}>
|
|
242
|
+
{t().approve}
|
|
243
|
+
</Button>
|
|
244
|
+
}
|
|
245
|
+
>
|
|
246
|
+
<SplitButton
|
|
247
|
+
size="sm"
|
|
248
|
+
variant="primary"
|
|
249
|
+
loading={props.busy}
|
|
250
|
+
menuLabel={t().moreOptions}
|
|
251
|
+
menuPosition="bottom-right"
|
|
252
|
+
items={rememberItems()}
|
|
253
|
+
onClick={() => props.decide({ approved: true })}
|
|
254
|
+
>
|
|
255
|
+
{t().approve}
|
|
256
|
+
</SplitButton>
|
|
257
|
+
</Show>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function ReviewLinks(props: { item: PendingApproval }) {
|
|
263
|
+
const t = useMessages();
|
|
264
|
+
const title = (rel: "open" | "edit" | "status" | "preview" | "download") =>
|
|
265
|
+
({
|
|
266
|
+
open: t().openIn({ app: props.item.app.name }),
|
|
267
|
+
edit: t().editIn({ app: props.item.app.name }),
|
|
268
|
+
status: t().statusIn({ app: props.item.app.name }),
|
|
269
|
+
preview: t().preview,
|
|
270
|
+
download: t().download,
|
|
271
|
+
})[rel];
|
|
272
|
+
return (
|
|
273
|
+
<Show when={(props.item.review?.links ?? []).length > 0}>
|
|
274
|
+
<nav class="cloud-approval__links" aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
|
|
275
|
+
<For each={props.item.review?.links ?? []}>
|
|
276
|
+
{(link) => (
|
|
277
|
+
<ButtonLink href={link.href} size="sm" variant="ghost">
|
|
278
|
+
<i class="ti ti-external-link" aria-hidden="true" />
|
|
279
|
+
{link.title ?? title(link.rel)}
|
|
280
|
+
</ButtonLink>
|
|
281
|
+
)}
|
|
282
|
+
</For>
|
|
283
|
+
</nav>
|
|
284
|
+
</Show>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function BlockDetails(props: { item: PendingApproval; clamp?: boolean }) {
|
|
289
|
+
const locale = useLocale();
|
|
290
|
+
return (
|
|
291
|
+
<For each={props.clamp ? blockDetails(props.item).slice(0, 1) : blockDetails(props.item)}>
|
|
292
|
+
{(detail) => (
|
|
293
|
+
<section class="cloud-approval__block" aria-label={detail.label}>
|
|
294
|
+
<Show when={!props.clamp}>
|
|
295
|
+
<h4 class="cloud-approval__label">{detail.label}</h4>
|
|
296
|
+
</Show>
|
|
297
|
+
<Show
|
|
298
|
+
when={detail.format === "markdown" && !props.clamp}
|
|
299
|
+
fallback={
|
|
300
|
+
<p class="cloud-approval__quote" data-clamp={props.clamp ? "true" : undefined}>
|
|
301
|
+
<span>{detailText(detail, locale())}</span>
|
|
302
|
+
</p>
|
|
303
|
+
}
|
|
304
|
+
>
|
|
305
|
+
<MarkdownView class="cloud-approval__markdown" markdown={detail.value} headingScale="compact" allowImages={false} />
|
|
306
|
+
</Show>
|
|
307
|
+
</section>
|
|
308
|
+
)}
|
|
309
|
+
</For>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Opens the full request in a panel; resolves with the decision made there, if any. */
|
|
314
|
+
const openDetails = (item: PendingApproval, locale: string) =>
|
|
315
|
+
dialogCore.open<ApprovalDecisionInput>(
|
|
316
|
+
(close) => {
|
|
317
|
+
const t = () => approvalMessages.resolve([locale]).t;
|
|
318
|
+
return (
|
|
319
|
+
<PanelDialog>
|
|
320
|
+
<PanelDialog.Header
|
|
321
|
+
icon={item.app.icon ?? "ti ti-shield-check"}
|
|
322
|
+
title={item.app.name}
|
|
323
|
+
subtitle={<Origin item={item} withApp={false} />}
|
|
324
|
+
close={() => close()}
|
|
325
|
+
closeLabel={t().close}
|
|
326
|
+
/>
|
|
327
|
+
<PanelDialog.Body>
|
|
328
|
+
<div class="cloud-approval__sheet">
|
|
329
|
+
<h3 class="cloud-approval__sheet-title">{approvalTitle(item)}</h3>
|
|
330
|
+
<Description item={item} />
|
|
331
|
+
<Fields item={item} />
|
|
332
|
+
<BlockDetails item={item} />
|
|
333
|
+
<ReviewLinks item={item} />
|
|
334
|
+
<p class="cloud-approval__callout">
|
|
335
|
+
<i class="ti ti-info-circle" aria-hidden="true" />
|
|
336
|
+
{t().exactlyThis}
|
|
337
|
+
</p>
|
|
338
|
+
</div>
|
|
339
|
+
</PanelDialog.Body>
|
|
340
|
+
<PanelDialog.Footer>
|
|
341
|
+
<div class="cloud-approval__sheet-foot">
|
|
342
|
+
<div class="cloud-approval__foot">
|
|
343
|
+
<Note item={item} />
|
|
344
|
+
<Decision item={item} busy={false} decide={(input) => close(input)} />
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</PanelDialog.Footer>
|
|
348
|
+
</PanelDialog>
|
|
349
|
+
);
|
|
350
|
+
},
|
|
351
|
+
{ ...panelDialogOptions, history: true, ariaLabel: approvalMessages.resolve([locale]).t.approvalFor({ title: approvalTitle(item) }) },
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
function Row(props: {
|
|
355
|
+
item: PendingApproval;
|
|
356
|
+
layout: "list" | "band";
|
|
357
|
+
outcome?: Outcome;
|
|
358
|
+
busy: boolean;
|
|
359
|
+
failed: boolean;
|
|
360
|
+
decide: (input: ApprovalDecisionInput) => void;
|
|
361
|
+
}) {
|
|
362
|
+
const t = useMessages();
|
|
363
|
+
const locale = useLocale();
|
|
364
|
+
const details = async () => {
|
|
365
|
+
const input = await openDetails(props.item, locale());
|
|
366
|
+
if (input) props.decide(input);
|
|
367
|
+
};
|
|
368
|
+
const foot = () => (
|
|
369
|
+
<>
|
|
370
|
+
<Note item={props.item} outcome={props.outcome} failed={props.failed} />
|
|
371
|
+
<Show when={props.outcome} fallback={<Decision item={props.item} busy={props.busy} details={details} decide={props.decide} />}>
|
|
372
|
+
{(outcome) => (
|
|
373
|
+
<div class="cloud-approval__actions">
|
|
374
|
+
<Receipt outcome={outcome()} />
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</Show>
|
|
378
|
+
</>
|
|
379
|
+
);
|
|
380
|
+
return (
|
|
381
|
+
<Show
|
|
382
|
+
when={props.layout === "band"}
|
|
383
|
+
fallback={
|
|
384
|
+
<article class="cloud-approval" data-decided={props.outcome} aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
|
|
385
|
+
<span class="cloud-approval__icon" aria-hidden="true">
|
|
386
|
+
<i class={props.outcome ? outcomeIcon(props.outcome) : (props.item.app.icon ?? "ti ti-shield-check")} />
|
|
387
|
+
</span>
|
|
388
|
+
<div class="cloud-approval__main">
|
|
389
|
+
<div class="cloud-approval__content">
|
|
390
|
+
<h3 class="cloud-approval__title">{approvalTitle(props.item)}</h3>
|
|
391
|
+
<Origin item={props.item} />
|
|
392
|
+
<Description item={props.item} />
|
|
393
|
+
<Fields item={props.item} />
|
|
394
|
+
<BlockDetails item={props.item} clamp />
|
|
395
|
+
</div>
|
|
396
|
+
<div class="cloud-approval__foot">{foot()}</div>
|
|
397
|
+
</div>
|
|
398
|
+
</article>
|
|
399
|
+
}
|
|
400
|
+
>
|
|
401
|
+
<article class="cloud-approval-band" data-decided={props.outcome} aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
|
|
402
|
+
<i class={props.outcome ? outcomeIcon(props.outcome, true) : "ti ti-alert-triangle"} aria-hidden="true" />
|
|
403
|
+
<div class="cloud-approval-band__text">
|
|
404
|
+
<strong>{approvalTitle(props.item)}</strong>
|
|
405
|
+
<Origin item={props.item} withApp={false} />
|
|
406
|
+
</div>
|
|
407
|
+
<div class="cloud-approval__foot">{foot()}</div>
|
|
408
|
+
</article>
|
|
409
|
+
</Show>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Open Action approvals of the signed-in person, as every app lists and decides them. Rows that someone decides stay
|
|
415
|
+
* in place, at their height, as a receipt; the list only changes when it loads again.
|
|
416
|
+
*/
|
|
417
|
+
export default function Approvals(props: ApprovalsProps) {
|
|
418
|
+
const t = useMessages();
|
|
419
|
+
const layout = () => props.layout ?? "list";
|
|
420
|
+
const query = (cursor?: string): ApprovalListQuery => ({
|
|
421
|
+
...(props.app ? { app: props.app } : {}),
|
|
422
|
+
...(props.link ? { link: props.link } : {}),
|
|
423
|
+
...(cursor ? { cursor } : {}),
|
|
424
|
+
});
|
|
425
|
+
const [page, setPage] = createSignal<ApprovalPage | null>(props.initial ?? null);
|
|
426
|
+
const [items, setItems] = createSignal<PendingApproval[]>(props.initial?.items ?? []);
|
|
427
|
+
const [outcomes, setOutcomes] = createSignal<ReadonlyMap<string, Outcome>>(new Map());
|
|
428
|
+
const [busy, setBusy] = createSignal<ReadonlySet<string>>(new Set());
|
|
429
|
+
const [failed, setFailed] = createSignal<ReadonlySet<string>>(new Set());
|
|
430
|
+
const [loadError, setLoadError] = createSignal(false);
|
|
431
|
+
const [loadingMore, setLoadingMore] = createSignal(false);
|
|
432
|
+
// A failed next page keeps the rows already shown; the error sits next to the button that retries it.
|
|
433
|
+
const [moreError, setMoreError] = createSignal(false);
|
|
434
|
+
|
|
435
|
+
const fetchPage = async (cursor?: string): Promise<ApprovalPage> => {
|
|
436
|
+
const response = await coreClient.approvals.$get({ query: query(cursor) });
|
|
437
|
+
if (!response.ok) throw new Error(`Approvals answered ${response.status}`);
|
|
438
|
+
return ApprovalPageSchema.parse(await response.json());
|
|
439
|
+
};
|
|
440
|
+
const reload = async () => {
|
|
441
|
+
setLoadError(false);
|
|
442
|
+
try {
|
|
443
|
+
const next = await fetchPage();
|
|
444
|
+
setPage(next);
|
|
445
|
+
setItems(next.items);
|
|
446
|
+
setOutcomes(new Map());
|
|
447
|
+
} catch {
|
|
448
|
+
setLoadError(true);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
const loadMore = async () => {
|
|
452
|
+
const cursor = page()?.nextCursor;
|
|
453
|
+
if (!cursor || loadingMore()) return;
|
|
454
|
+
setLoadingMore(true);
|
|
455
|
+
setMoreError(false);
|
|
456
|
+
try {
|
|
457
|
+
const next = await fetchPage(cursor);
|
|
458
|
+
const known = new Set(items().map((item) => item.id));
|
|
459
|
+
setItems([...items(), ...next.items.filter((item) => !known.has(item.id))]);
|
|
460
|
+
setPage({ ...next, count: page()?.count ?? next.count });
|
|
461
|
+
} catch {
|
|
462
|
+
setMoreError(true);
|
|
463
|
+
} finally {
|
|
464
|
+
setLoadingMore(false);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
onMount(() => {
|
|
468
|
+
if (!props.initial) void reload();
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const toggle = (set: ReadonlySet<string>, id: string, on: boolean) => {
|
|
472
|
+
const next = new Set(set);
|
|
473
|
+
if (on) next.add(id);
|
|
474
|
+
else next.delete(id);
|
|
475
|
+
return next;
|
|
476
|
+
};
|
|
477
|
+
const decide = async (item: PendingApproval, input: ApprovalDecisionInput): Promise<void> => {
|
|
478
|
+
if (busy().has(item.id) || outcomes().has(item.id)) return;
|
|
479
|
+
setBusy((current) => toggle(current, item.id, true));
|
|
480
|
+
setFailed((current) => toggle(current, item.id, false));
|
|
481
|
+
try {
|
|
482
|
+
const response = await coreClient.approvals[":id"].decision.$post({ param: { id: item.id }, json: input });
|
|
483
|
+
const outcome: Outcome | null = response.ok
|
|
484
|
+
? input.approved
|
|
485
|
+
? "approved"
|
|
486
|
+
: "rejected"
|
|
487
|
+
: response.status === 409
|
|
488
|
+
? "elsewhere"
|
|
489
|
+
: response.status === 404
|
|
490
|
+
? "gone"
|
|
491
|
+
: null;
|
|
492
|
+
if (!outcome) throw new Error(`Decision answered ${response.status}`);
|
|
493
|
+
setOutcomes((current) => new Map(current).set(item.id, outcome));
|
|
494
|
+
refreshAppBadges();
|
|
495
|
+
} catch {
|
|
496
|
+
setFailed((current) => toggle(current, item.id, true));
|
|
497
|
+
} finally {
|
|
498
|
+
setBusy((current) => toggle(current, item.id, false));
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const open = createMemo(() => items().filter((item) => !outcomes().has(item.id)));
|
|
503
|
+
const openCount = () => Math.max(0, (page()?.count ?? items().length) - outcomes().size);
|
|
504
|
+
const rejectAll = async () => {
|
|
505
|
+
const targets = open();
|
|
506
|
+
if (targets.length === 0) return;
|
|
507
|
+
const confirmed = await prompts.confirm(t().rejectAllBody({ count: targets.length }), {
|
|
508
|
+
title: t().rejectAllTitle,
|
|
509
|
+
confirmText: t().rejectAll,
|
|
510
|
+
variant: "danger",
|
|
511
|
+
});
|
|
512
|
+
// One after another: rejecting is cheap, and the list stays readable while the receipts appear.
|
|
513
|
+
if (confirmed) for (const item of targets) await decide(item, { approved: false });
|
|
514
|
+
};
|
|
515
|
+
// Without an app filter, the Core page groups the requests by the app that owns the effect.
|
|
516
|
+
const groups = createMemo(() => {
|
|
517
|
+
const byApp = new Map<string, PendingApproval[]>();
|
|
518
|
+
for (const item of items()) byApp.set(item.app.id, [...(byApp.get(item.app.id) ?? []), item]);
|
|
519
|
+
return [...byApp.values()];
|
|
520
|
+
});
|
|
521
|
+
const rows = (list: PendingApproval[]) => (
|
|
522
|
+
<For each={list}>
|
|
523
|
+
{(item) => (
|
|
524
|
+
<Row
|
|
525
|
+
item={item}
|
|
526
|
+
layout={layout()}
|
|
527
|
+
outcome={outcomes().get(item.id)}
|
|
528
|
+
busy={busy().has(item.id)}
|
|
529
|
+
failed={failed().has(item.id)}
|
|
530
|
+
decide={(input) => void decide(item, input)}
|
|
531
|
+
/>
|
|
532
|
+
)}
|
|
533
|
+
</For>
|
|
534
|
+
);
|
|
535
|
+
return (
|
|
536
|
+
<Show
|
|
537
|
+
when={layout() === "list"}
|
|
538
|
+
fallback={
|
|
539
|
+
<Show when={items().length > 0}>
|
|
540
|
+
<div class="cloud-approval-bands">{rows(items())}</div>
|
|
541
|
+
</Show>
|
|
542
|
+
}
|
|
543
|
+
>
|
|
544
|
+
<section class="cloud-approvals" aria-label={t().title}>
|
|
545
|
+
<Show
|
|
546
|
+
when={page()}
|
|
547
|
+
fallback={
|
|
548
|
+
<Show when={loadError()} fallback={<Placeholder state="loading" title={t().loading} variant="panel" />}>
|
|
549
|
+
<Placeholder
|
|
550
|
+
state="error"
|
|
551
|
+
title={t().loadFailed}
|
|
552
|
+
variant="panel"
|
|
553
|
+
action={
|
|
554
|
+
<Button size="sm" variant="secondary" onClick={() => void reload()}>
|
|
555
|
+
{t().retry}
|
|
556
|
+
</Button>
|
|
557
|
+
}
|
|
558
|
+
/>
|
|
559
|
+
</Show>
|
|
560
|
+
}
|
|
561
|
+
>
|
|
562
|
+
<Show
|
|
563
|
+
when={items().length > 0}
|
|
564
|
+
fallback={<Placeholder icon="ti ti-circle-check" title={t().emptyTitle} description={t().emptyBody} variant="panel" />}
|
|
565
|
+
>
|
|
566
|
+
<div class="cloud-approvals__toolbar">
|
|
567
|
+
<span>{t().open({ count: openCount() })}</span>
|
|
568
|
+
<Show when={open().length > 1}>
|
|
569
|
+
<Button size="sm" variant="ghost" onClick={() => void rejectAll()}>
|
|
570
|
+
<i class="ti ti-x" aria-hidden="true" />
|
|
571
|
+
{t().rejectAll}
|
|
572
|
+
</Button>
|
|
573
|
+
</Show>
|
|
574
|
+
</div>
|
|
575
|
+
<Show
|
|
576
|
+
when={props.app || props.link}
|
|
577
|
+
fallback={<For each={groups()}>{(group) => <AppGroup items={group}>{rows(group)}</AppGroup>}</For>}
|
|
578
|
+
>
|
|
579
|
+
<div class="cloud-approvals__list">{rows(items())}</div>
|
|
580
|
+
</Show>
|
|
581
|
+
<Show when={page()?.nextCursor}>
|
|
582
|
+
<div class="cloud-approvals__more">
|
|
583
|
+
<Show when={moreError()}>
|
|
584
|
+
<p class="cloud-approvals__more-error" role="alert">
|
|
585
|
+
<i class="ti ti-alert-circle" aria-hidden="true" />
|
|
586
|
+
{t().loadMoreFailed}
|
|
587
|
+
</p>
|
|
588
|
+
</Show>
|
|
589
|
+
<Button size="sm" variant="secondary" loading={loadingMore()} onClick={() => void loadMore()}>
|
|
590
|
+
{moreError() ? t().retry : t().showMore}
|
|
591
|
+
</Button>
|
|
592
|
+
</div>
|
|
593
|
+
</Show>
|
|
594
|
+
</Show>
|
|
595
|
+
<Show when={(page()?.elsewhere ?? 0) > 0}>
|
|
596
|
+
<p class="cloud-approvals__elsewhere">
|
|
597
|
+
<span>{t().elsewhere({ count: page()?.elsewhere ?? 0 })}</span>
|
|
598
|
+
<a href={APPROVALS_PAGE_PATH}>
|
|
599
|
+
{t().allApprovals}
|
|
600
|
+
<i class="ti ti-arrow-up-right" aria-hidden="true" />
|
|
601
|
+
</a>
|
|
602
|
+
</p>
|
|
603
|
+
</Show>
|
|
604
|
+
</Show>
|
|
605
|
+
</section>
|
|
606
|
+
</Show>
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function AppGroup(props: { items: PendingApproval[]; children: JSX.Element }) {
|
|
611
|
+
const app = () => props.items[0]?.app;
|
|
612
|
+
return (
|
|
613
|
+
<section class="cloud-approvals__group" aria-label={app()?.name}>
|
|
614
|
+
<h2 class="cloud-approvals__group-title">
|
|
615
|
+
<i class={app()?.icon ?? "ti ti-apps"} aria-hidden="true" />
|
|
616
|
+
{app()?.name}
|
|
617
|
+
</h2>
|
|
618
|
+
<div class="cloud-approvals__list">{props.children}</div>
|
|
619
|
+
</section>
|
|
620
|
+
);
|
|
621
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AppWorkspace, useLocale } from "@k2b/ui";
|
|
2
|
+
import { liveApprovalCount } from "../ssr/app-badges";
|
|
3
|
+
import { approvalMessages } from "./messages";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The count for an app's "Approvals" navigation row: the count the page rendered with, then the count the rail reads
|
|
7
|
+
* once a minute and after every decision. A count that drops to zero stays as a muted 0 until the row goes away.
|
|
8
|
+
*/
|
|
9
|
+
export default function ApprovalsNavStatus(props: { app: string; initial: number }) {
|
|
10
|
+
const locale = useLocale();
|
|
11
|
+
const count = () => liveApprovalCount(props.app) ?? props.initial;
|
|
12
|
+
return (
|
|
13
|
+
<AppWorkspace.SidebarItemStatus
|
|
14
|
+
waiting={{ count: count(), label: approvalMessages.resolve([locale()]).t.waiting({ count: count() }) }}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The current count for one app, or the rendered one until the browser has read it. */
|
|
20
|
+
export const approvalNavCount = (app: string, initial: number): number => liveApprovalCount(app) ?? initial;
|