diff --git a/infrastructure/eid-wallet/src/lib/utils/index.ts b/infrastructure/eid-wallet/src/lib/utils/index.ts index c61d27e9c..670bf6eb1 100644 --- a/infrastructure/eid-wallet/src/lib/utils/index.ts +++ b/infrastructure/eid-wallet/src/lib/utils/index.ts @@ -7,3 +7,4 @@ export * from "./bindingDocHash"; export * from "./socialBinding"; export * from "./portal"; export * from "./identityLabels"; +export * from "./pendingSocialRequest"; diff --git a/infrastructure/eid-wallet/src/lib/utils/pendingSocialRequest.ts b/infrastructure/eid-wallet/src/lib/utils/pendingSocialRequest.ts new file mode 100644 index 000000000..c68b35854 --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/pendingSocialRequest.ts @@ -0,0 +1,61 @@ +import { + type BindingDocParsed, + fetchUnsignedSocialDocs, +} from "./socialBinding"; + +/** + * Requests the user closed without answering, for this app session. Shared + * because the home screen and the ePassport sheet both prompt for the same + * documents, so closing one must not leave the other still asking. + */ +const dismissedDocIds = new Set(); + +export function dismissSocialRequest(docId: string): void { + dismissedDocIds.add(docId); +} + +export function dismissedSocialRequests(): ReadonlySet { + return dismissedDocIds; +} + +export interface PendingSocialRequest { + docId: string; + parsed: BindingDocParsed; + signerEname: string; +} + +/** + * The pending social connection request to prompt for next, or null. + * + * Oldest first, so a backlog is worked through in the order it arrived. + * `dismissedDocIds` holds the requests the user closed without answering; + * they stay in the vault and in the bindings list, they just don't prompt + * again. Envelopes fetchUnsignedSocialDocs hides stay hidden. + */ +export async function findPendingSocialRequest( + ownGqlUrl: string, + callerEname: string, + dismissedDocIds: ReadonlySet = new Set(), +): Promise { + const edges = await fetchUnsignedSocialDocs(ownGqlUrl, callerEname); + + let oldest: PendingSocialRequest | null = null; + let oldestSentAt = ""; + + for (const edge of edges) { + if (dismissedDocIds.has(edge.node.id)) continue; + const parsed = edge.node.parsed; + const signature = parsed?.signatures?.[0]; + if (!parsed || !signature?.signer) continue; + const sentAt = signature.timestamp ?? ""; + if (oldest !== null && sentAt >= oldestSentAt) continue; + oldest = { + docId: edge.node.id, + parsed, + signerEname: signature.signer, + }; + oldestSentAt = sentAt; + } + + return oldest; +} diff --git a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts index b9ee1ccca..f6c3e6420 100644 --- a/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts +++ b/infrastructure/eid-wallet/src/lib/utils/socialBinding.spec.ts @@ -5,6 +5,7 @@ vi.mock("$env/static/public", () => ({ PUBLIC_REGISTRY_URL: "https://registry.test/", })); +import { findPendingSocialRequest } from "./pendingSocialRequest"; import { CANCEL_NOT_PENDING, ENAME_NOT_FOUND, @@ -20,6 +21,7 @@ import { const ME = "@me"; const BOB = "@bob"; +const CAROL = "@carol"; interface Sig { signer: string; @@ -486,3 +488,64 @@ describe("resolveVaultUri", () => { ); }); }); + +describe("choosing the request to prompt for", () => { + beforeEach(() => { + vaults.set(ME, [ + doc("P1", ME, [CAROL, ME], [{ signer: CAROL, timestamp: at(10) }]), + doc("P2", ME, [BOB, ME], [{ signer: BOB, timestamp: at(40) }]), + ]); + }); + + it("takes the oldest request first", async () => { + const request = await findPendingSocialRequest(gql(ME), ME); + expect(request?.docId).toBe("P2"); + expect(request?.signerEname).toBe(BOB); + expect(request?.parsed.subject).toBe(ME); + }); + + it("moves on to the next request when one was dismissed", async () => { + const request = await findPendingSocialRequest( + gql(ME), + ME, + new Set(["P2"]), + ); + expect(request?.docId).toBe("P1"); + }); + + it("stops prompting once every request has been dismissed", async () => { + const request = await findPendingSocialRequest( + gql(ME), + ME, + new Set(["P1", "P2"]), + ); + expect(request).toBeNull(); + expect(deletes).toEqual([]); + }); + + it("leaves a dismissed request in the vault", async () => { + await findPendingSocialRequest(gql(ME), ME, new Set(["P1", "P2"])); + expect((vaults.get(ME) as Doc[]).map((d) => d.id)).toEqual([ + "P1", + "P2", + ]); + }); + + it("does not prompt for an envelope the poll hides", async () => { + vaults.set(ME, [ + doc( + "D1", + ME, + [BOB, ME], + [ + { signer: BOB, timestamp: at(190) }, + { signer: ME, timestamp: at(180) }, + ], + ), + doc("D2", ME, [BOB, ME], [{ signer: BOB, timestamp: at(185) }]), + ]); + + expect(await findPendingSocialRequest(gql(ME), ME)).toBeNull(); + expect(deletes).toEqual([]); + }); +}); diff --git a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte index 215199b45..1df335bfd 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/ePassport/+page.svelte @@ -14,6 +14,7 @@ import { acceptSocialBinding, capitalize, declineSocialBinding, + dismissSocialRequest, fetchNameFromVault, fetchUnsignedSocialDocs, identityFieldLabel, @@ -555,38 +556,50 @@ async function confirmSocialBindingRequest() { async function declineSocialBindingRequest() { const docId = socialBindingPendingDocId; const declinedDoc = socialBindingPendingDocParsed; - socialBindingAwaitingConsent = false; - socialBindingPendingDocId = null; - socialBindingPendingDocParsed = null; - socialBindingSignerName = null; - socialBindingSignerEname = null; if (docId) { try { const vault = await globalState.vaultController.vault; - if (vault?.ename && vault?.uri) { - const callerEname = vault.ename.startsWith("@") - ? vault.ename - : `@${vault.ename}`; - const gqlUrl = new URL("/graphql", vault.uri).toString(); - await declineSocialBinding( - gqlUrl, - callerEname, - docId, - declinedDoc, - ); + if (!vault?.ename || !vault?.uri) { + throw new Error(m.social_drawer_no_vault()); } + const callerEname = vault.ename.startsWith("@") + ? vault.ename + : `@${vault.ename}`; + const gqlUrl = new URL("/graphql", vault.uri).toString(); + await declineSocialBinding(gqlUrl, callerEname, docId, declinedDoc); } catch (err) { + // The document is still in the vault, so keep the request on + // screen instead of resuming as if it had been declined. console.error( "[Social Binding] failed to delete declined doc:", err, ); + socialBindingError = + err instanceof Error + ? err.message + : m.social_drawer_error_generic(); + return; } } + socialBindingAwaitingConsent = false; + socialBindingPendingDocId = null; + socialBindingPendingDocParsed = null; + socialBindingSignerName = null; + socialBindingSignerEname = null; startSocialBindingPolling(); } +// Closing without answering leaves the request in the vault. Record it so the +// home screen doesn't prompt for the same one on the way back. +function dismissSocialBindingRequest() { + if (socialBindingPendingDocId) { + dismissSocialRequest(socialBindingPendingDocId); + } + closeSocialBindingDrawer(); +} + function startSocialBindingPolling() { socialBindingPolling = true; socialBindingPollInterval = setInterval(() => { @@ -710,7 +723,19 @@ onMount(async () => { {:else if socialBindingAwaitingConsent}
-

{m.social_drawer_request_title()}

+
+

{m.social_drawer_request_title()}

+ +

{socialBindingSignerName ?? diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte index 6f6af1aca..42f582950 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/+page.svelte @@ -23,6 +23,10 @@ let cachedSelfDocId: string | undefined; let cachedSocialBindingCount = 0; let cachedSocialBindingPreview: SocialBindingDisplay[] = []; let hasEverLoaded = false; + +// Requests the user closed without answering. Module-scope so leaving /main and +// coming back doesn't prompt for them again; an app restart clears them, and +// they stay answerable from the bindings list either way.