Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 50 additions & 36 deletions packages/ui/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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);
});
}),
Expand Down Expand Up @@ -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);
});
}
Expand Down
18 changes: 10 additions & 8 deletions packages/ui/src/permission-modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -93,15 +94,16 @@ const PERMISSION_ICONS: Record<EnforceablePermissionName, string> = {
'<circle cx="12" cy="7" r="4"/></svg>',
};

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<void> {
return new Promise((resolve, reject) => {
): Promise<PermissionPromptDecision> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "signing-modal-backdrop";

Expand Down Expand Up @@ -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");
}
});
});
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/tests/permission-modal.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLDivElement>(".signing-modal-backdrop")?.click();

await expect(decision).resolves.toBe("dismissed");
});
});
Loading