diff --git a/packages/ui/src/container.ts b/packages/ui/src/container.ts index 4dc13233..39238b43 100644 --- a/packages/ui/src/container.ts +++ b/packages/ui/src/container.ts @@ -821,14 +821,20 @@ function wireContainerHandlers( } return fromPromise( - showPermissionRequestModal(label, "Notifications").then(() => { - setPermissionStatus(label, "Notifications", "granted"); - }), - () => "denied" as const, + showPermissionRequestModal(label, "Notifications"), + () => "dismissed" as const, ) - .map(() => true) + .map((decision) => { + if (decision === "granted") { + setPermissionStatus(label, "Notifications", "granted"); + return true; + } + if (decision === "denied") { + setPermissionStatus(label, "Notifications", "denied"); + } + return false; + }) .orElse(() => { - setPermissionStatus(label, "Notifications", "denied"); return ok(false); }); } @@ -858,29 +864,31 @@ function wireContainerHandlers( // status === 'ask': show consent modal return fromPromise( - showPermissionRequestModal(label, permission).then(() => { - setPermissionStatus(label, permission, "granted"); - // Defer the reload to the next event loop tick so the - // container can finish sending the response before being - // disposed. Without this, cleanup() runs synchronously - // inside the dispatch, disposing the transport mid-response. - setTimeout(() => { - window.dispatchEvent( - new CustomEvent("dotli:device-permission-changed", { - detail: { label, permission }, - }), - ); - }, 0); - }), - () => "denied" as const, + showPermissionRequestModal(label, permission), + () => "dismissed" as const, ) - .map(() => { - // User allowed, but the iframe reloads, so return false for now + .map((decision) => { + if (decision === "granted") { + setPermissionStatus(label, permission, "granted"); + // Defer the reload to the next event loop tick so the + // container can finish sending the response before being + // disposed. Without this, cleanup() runs synchronously + // inside the dispatch, disposing the transport mid-response. + setTimeout(() => { + window.dispatchEvent( + new CustomEvent("dotli:device-permission-changed", { + detail: { label, permission }, + }), + ); + }, 0); + } else if (decision === "denied") { + setPermissionStatus(label, permission, "denied"); + } + // User allowed reloads the iframe, so return false for now. Deny and + // dismiss also fail closed for this request. return false; }) .orElse(() => { - // User denied (rejected promise) or dialog dismissed - setPermissionStatus(label, permission, "denied"); return ok(false); }); }), @@ -1290,19 +1298,25 @@ function promptCachedSubmitPermission( } return fromPromise( - showPermissionRequestModal(productLabel, storageKey).then(() => { - setPermissionStatus(productLabel, storageKey, "granted"); - window.dispatchEvent( - new CustomEvent("dotli:permission-changed", { - detail: { label: productLabel }, - }), - ); - }), - () => "denied" as const, + showPermissionRequestModal(productLabel, storageKey), + () => "dismissed" as const, ) - .map(() => true) + .map((decision) => { + if (decision === "granted") { + setPermissionStatus(productLabel, storageKey, "granted"); + window.dispatchEvent( + new CustomEvent("dotli:permission-changed", { + detail: { label: productLabel }, + }), + ); + return true; + } + if (decision === "denied") { + setPermissionStatus(productLabel, storageKey, "denied"); + } + return false; + }) .orElse(() => { - setPermissionStatus(productLabel, storageKey, "denied"); return okAsync(false); }); } diff --git a/packages/ui/src/permission-modal.ts b/packages/ui/src/permission-modal.ts index d99f53fc..16bf18fc 100644 --- a/packages/ui/src/permission-modal.ts +++ b/packages/ui/src/permission-modal.ts @@ -14,7 +14,8 @@ import { // Biometrics, Notifications) and the internal submitted gates (ChainSubmit, // PreimageSubmit, StatementSubmit). `OpenUrl` is auto-granted at the // container level and never reaches this modal. -// Returns a Promise that resolves on "Allow" and rejects on "Deny". +// Returns an explicit decision so callers can distinguish "Deny" from +// dismissing the dialog without storing a denial. // // DOM structure follows the signing modal pattern (signing.css). @@ -93,15 +94,16 @@ const PERMISSION_ICONS: Record = { '', }; +export type PermissionPromptDecision = "granted" | "denied" | "dismissed"; + /** - * Show a permission request modal. Resolves when the user clicks "Allow", - * rejects when the user clicks "Deny". + * Show a permission request modal. */ export function showPermissionRequestModal( label: string, permission: EnforceablePermissionName, -): Promise { - return new Promise((resolve, reject) => { +): Promise { + return new Promise((resolve) => { const backdrop = document.createElement("div"); backdrop.className = "signing-modal-backdrop"; @@ -187,19 +189,19 @@ export function showPermissionRequestModal( denyBtn.addEventListener("click", () => { cleanup(); - reject(new Error("User denied permission")); + resolve("denied"); }); allowBtn.addEventListener("click", () => { cleanup(); - resolve(); + resolve("granted"); }); // Close on backdrop click (outside modal) backdrop.addEventListener("click", (e) => { if (e.target === backdrop) { cleanup(); - reject(new Error("User dismissed permission dialog")); + resolve("dismissed"); } }); }); diff --git a/packages/ui/tests/permission-modal.test.ts b/packages/ui/tests/permission-modal.test.ts new file mode 100644 index 00000000..bb3d657a --- /dev/null +++ b/packages/ui/tests/permission-modal.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { showPermissionRequestModal } from "@dotli/ui/permission-modal"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("permission request modal", () => { + it("resolves granted when the user allows", async () => { + const decision = showPermissionRequestModal("myapp", "Camera"); + + document.querySelector(".signing-btn-sign")?.click(); + + await expect(decision).resolves.toBe("granted"); + }); + + it("resolves denied when the user denies", async () => { + const decision = showPermissionRequestModal("myapp", "Camera"); + + document.querySelector(".signing-btn-cancel")?.click(); + + await expect(decision).resolves.toBe("denied"); + }); + + it("resolves dismissed when the backdrop is clicked", async () => { + const decision = showPermissionRequestModal("myapp", "Camera"); + + document.querySelector(".signing-modal-backdrop")?.click(); + + await expect(decision).resolves.toBe("dismissed"); + }); +});