Files
openclaw/ui/src/app/device-scope-upgrade.runtime.ts
Peter Steinberger cef071582e feat: let limited browsers request admin access (#121459)
* feat(gateway): add live device scope upgrades

* feat(ui): add limited-access upgrade flow

* fix(protocol): refresh Swift scope upgrade models

* perf(ui): lazy-load device scope upgrades

* fix(ci): complete scope upgrade generated surfaces

* perf(ui): lazy-load GitHub link hovercards

* fix(ui): keep admin repair guidance focusable

* fix(ui): gate and refresh scope upgrade banner

* refactor(ui): keep gateway client within line budget

* fix(ci): align rebased scope upgrade checks

* fix(ui): resolve scope upgrade in browser tests

* fix(gateway): honor refreshed scope upgrade deadline

* fix(gateway): honor refreshed scope upgrade deadline

* fix(gateway): coalesce scope upgrade waiters

* fix(ui): gate scope upgrade actions

* chore(plugin-sdk): refresh rebased API baseline

* fix(scope-upgrade): return canonical request ids

* fix(ui): preserve gateway event type binding

* fix(protocol): generate scope upgrade result models

* fix(ui): preserve scope upgrade recovery guidance

* chore(plugin-sdk): refresh rebased API baseline

* test(ui): avoid scope upgrade navigation race

* docs(control-ui): clarify scope upgrade approver

* test(gateway): align appended method counts

* chore(plugin-sdk): refresh rebased API baseline

* refactor(ui): keep place picker within line budget

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* chore(plugin-sdk): refresh rebased API baseline

* fix(gateway): preserve scope-upgrade browser origin
2026-08-12 18:32:00 -07:00

218 lines
6.7 KiB
TypeScript

import { html, nothing } from "lit";
import { property } from "lit/decorators.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { t } from "../i18n/index.ts";
import { formatUiError } from "../lib/format-error.ts";
import { OpenClawLightDomContentsElement } from "../lit/openclaw-element.ts";
import { readScopeUpgradeAvailability, type ScopeUpgradeState } from "./device-scope-upgrade.ts";
import type { ApplicationGatewaySnapshot } from "./gateway.ts";
type UpgradeOperation = {
client: GatewayBrowserClient;
};
/** Owns the explicit live scope-upgrade action and its cross-route banner state. */
export class ScopeUpgradeController {
private current: ApplicationGatewaySnapshot;
private operation: UpgradeOperation | null = null;
private value: ScopeUpgradeState = { phase: "hidden" };
constructor(
initial: ApplicationGatewaySnapshot,
private readonly onChange: () => void,
) {
this.current = initial;
this.sync(initial);
}
get state(): ScopeUpgradeState {
return this.value;
}
sync(snapshot: ApplicationGatewaySnapshot): void {
this.current = snapshot;
const client = snapshot.client;
const availability = readScopeUpgradeAvailability(snapshot);
if (!client || availability.phase !== "available") {
this.retireOperation();
this.setState(availability);
return;
}
if (this.operation && this.operation.client !== client) {
this.retireOperation();
this.setState({ phase: "available" });
}
if (this.value.phase === "hidden" || this.value.phase === "guidance") {
this.setState({ phase: "available" });
}
}
request(): void {
this.start(false);
}
retry(): void {
this.start(true);
}
cancel(): void {
this.retireOperation();
this.setState(readScopeUpgradeAvailability(this.current));
}
dispose(): void {
this.retireOperation();
}
private start(retry: boolean): void {
const client = this.current.client;
if (!client || readScopeUpgradeAvailability(this.current).phase !== "available") {
return;
}
if (this.operation) {
if (!retry) {
return;
}
this.retireOperation();
}
const operation = { client };
this.operation = operation;
this.setState({ phase: "requesting" });
void client
.requestScopeUpgrade({
onPending: (requestId) => {
if (this.isCurrent(operation)) {
this.setState({ phase: "pending", requestId });
}
},
})
.then((result) => {
if (!this.isCurrent(operation) || result.status === "approved") {
return;
}
this.setState({
phase: "rejected",
requestId: result.requestId,
expired: result.status === "expired",
});
})
.catch((error: unknown) => {
if (!this.isCurrent(operation) || (error instanceof Error && error.name === "AbortError")) {
return;
}
this.setState({ phase: "error", message: formatUiError(error) });
})
.finally(() => {
if (this.isCurrent(operation)) {
this.operation = null;
}
});
}
private isCurrent(operation: UpgradeOperation): boolean {
return this.operation === operation && this.current.client === operation.client;
}
private retireOperation(): void {
const operation = this.operation;
this.operation = null;
operation?.client.cancelScopeUpgrade();
}
private setState(next: ScopeUpgradeState): void {
if (JSON.stringify(this.value) === JSON.stringify(next)) {
return;
}
this.value = next;
this.onChange();
}
}
type ScopeUpgradeBannerProps = {
snapshot: ApplicationGatewaySnapshot;
chromeOffset: boolean;
};
class ScopeUpgradeBanner extends OpenClawLightDomContentsElement {
@property({ attribute: false }) props?: ScopeUpgradeBannerProps;
private controller?: ScopeUpgradeController;
protected override updated(): void {
const snapshot = this.props?.snapshot;
if (!snapshot) {
return;
}
if (this.controller) {
this.controller.sync(snapshot);
} else {
this.controller = new ScopeUpgradeController(snapshot, () => this.requestUpdate());
this.requestUpdate();
}
}
override disconnectedCallback(): void {
this.controller?.dispose();
this.controller = undefined;
super.disconnectedCallback();
}
override render() {
const props = this.props;
const state =
this.controller?.state ??
(props ? readScopeUpgradeAvailability(props.snapshot) : { phase: "hidden" as const });
if (!props || state.phase === "hidden") {
return nothing;
}
const retryable =
state.phase === "pending" || state.phase === "rejected" || state.phase === "error";
const text =
state.phase === "guidance"
? t("connection.scopeUpgrade.guidance")
: state.phase === "available"
? t("connection.scopeUpgrade.limited")
: state.phase === "requesting"
? t("connection.scopeUpgrade.requesting")
: state.phase === "pending"
? t("connection.scopeUpgrade.pending")
: state.phase === "rejected"
? t(
state.expired
? "connection.scopeUpgrade.expired"
: "connection.scopeUpgrade.rejected",
)
: t("connection.scopeUpgrade.error", { error: state.message });
return html`<div
class="callout ${state.phase === "error" || state.phase === "rejected"
? "danger"
: "warn"} callout--action"
style=${props.chromeOffset ? "margin-left: 72px" : nothing}
role="status"
>
<span class="callout__content">${text}</span>
${state.phase === "available"
? html`<button class="btn btn--sm" type="button" @click=${() => this.controller?.request()}>
${t("connection.scopeUpgrade.request")}
</button>`
: state.phase === "requesting"
? html`<button class="btn btn--sm" type="button" disabled>
${t("connection.scopeUpgrade.requestingAction")}
</button>`
: retryable
? html`
<button class="btn btn--sm" type="button" @click=${() => this.controller?.retry()}>
${t("connection.scopeUpgrade.retry")}
</button>
<button class="btn btn--sm" type="button" @click=${() => this.controller?.cancel()}>
${t("connection.scopeUpgrade.cancel")}
</button>
`
: nothing}
</div>`;
}
}
if (!customElements.get("openclaw-device-scope-upgrade-banner")) {
customElements.define("openclaw-device-scope-upgrade-banner", ScopeUpgradeBanner);
}