Skip to content

Commit 85d1cd9

Browse files
committed
feat(fe): manage notification access in settings
Adds a Notifications card to Settings listing the apps allowed to notify the identity, each with a "Turn off" control. Turning one off revokes consent on the canister and drops the service worker's pull credential for that app; a failed revoke rolls the row back, and the credential purge is best-effort. Gated behind the PUSH_NOTIFICATIONS flag.
1 parent 5ea3ba5 commit 85d1cd9

2 files changed

Lines changed: 130 additions & 0 deletions

File tree

src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import { Trans } from "$lib/components/locale";
44
import { t } from "$lib/stores/locale.store";
55
import { fromCanisterMcpConfig } from "$lib/utils/mcpConfig";
6+
import { PUSH_NOTIFICATIONS } from "$lib/state/featureFlags";
67
import CliAccessSection from "./components/CliAccessSection.svelte";
78
import McpTrustedServersSection from "./components/McpTrustedServersSection.svelte";
9+
import NotificationsSection from "./components/NotificationsSection.svelte";
810
import type { PageProps } from "./$types";
911
1012
const { data }: PageProps = $props();
@@ -32,4 +34,7 @@
3234
identityNumber={$authenticatedStore.identityNumber}
3335
{mcpConfig}
3436
/>
37+
{#if $PUSH_NOTIFICATIONS}
38+
<NotificationsSection identityNumber={$authenticatedStore.identityNumber} />
39+
{/if}
3540
</div>
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<script lang="ts">
2+
import { onMount } from "svelte";
3+
import { BellIcon, BellOffIcon } from "@lucide/svelte";
4+
import { toaster } from "$lib/components/utils/toaster";
5+
import { t } from "$lib/stores/locale.store";
6+
import { authenticatedStore } from "$lib/stores/authentication.store";
7+
import { throwTextCanisterError } from "$lib/utils/utils";
8+
import { getDapps } from "$lib/legacy/flows/dappsExplorer/dapps";
9+
10+
interface Props {
11+
identityNumber: bigint;
12+
}
13+
14+
const { identityNumber }: Props = $props();
15+
const titleId = $props.id();
16+
17+
const dapps = getDapps();
18+
const appName = (origin: string): string =>
19+
dapps.find((dapp) => dapp.hasOrigin(origin))?.name ?? origin;
20+
21+
// `undefined` while the first read is in flight.
22+
let origins = $state<string[]>();
23+
let revoking = $state<string>();
24+
25+
onMount(() => {
26+
void (async () => {
27+
try {
28+
origins =
29+
await $authenticatedStore.actor.notification_consented_origins(
30+
identityNumber,
31+
);
32+
} catch {
33+
origins = [];
34+
}
35+
})();
36+
});
37+
38+
const revoke = async (origin: string) => {
39+
revoking = origin;
40+
const previous = origins ?? [];
41+
origins = previous.filter((consented) => consented !== origin);
42+
try {
43+
await $authenticatedStore.actor
44+
.notification_revoke_consent(identityNumber, origin)
45+
.then(throwTextCanisterError);
46+
} catch {
47+
// Only a failed revoke rolls back — the row must not read "off" while the
48+
// app can still notify.
49+
origins = previous;
50+
toaster.error({
51+
title: $t`Couldn't turn off notifications. Please try again.`,
52+
duration: 4000,
53+
});
54+
revoking = undefined;
55+
return;
56+
}
57+
// Consent is gone; drop the service worker's credential for this app.
58+
// Best-effort — a leftover is inert without consent — and lazily imported
59+
// so its IndexedDB open stays off this page.
60+
void import("$lib/utils/notifications/pullCredential")
61+
.then(({ purgeNotificationCredential }) =>
62+
purgeNotificationCredential(origin),
63+
)
64+
.catch(() => {});
65+
revoking = undefined;
66+
};
67+
</script>
68+
69+
<section
70+
class="border-border-secondary bg-bg-secondary flex flex-col rounded-xl border p-4 sm:p-5"
71+
>
72+
<div class="flex flex-row items-start gap-3 sm:gap-4">
73+
<span
74+
class="border-border-tertiary text-fg-secondary bg-bg-primary flex size-10 shrink-0 items-center justify-center rounded-lg border"
75+
aria-hidden="true"
76+
>
77+
<BellIcon class="size-5" />
78+
</span>
79+
80+
<div class="flex min-w-0 flex-1 flex-col gap-1">
81+
<h3 id={titleId} class="text-text-primary text-base font-semibold">
82+
{$t`Notifications`}
83+
</h3>
84+
<p class="text-text-tertiary text-sm">
85+
{$t`Choose which apps can send notifications to your devices.`}
86+
</p>
87+
</div>
88+
</div>
89+
90+
{#if origins !== undefined && origins.length > 0}
91+
<ul
92+
class="border-border-tertiary mt-5 flex flex-col gap-3 border-t pt-4"
93+
aria-labelledby={titleId}
94+
>
95+
{#each origins as origin (origin)}
96+
<li
97+
class="border-border-tertiary bg-bg-primary flex flex-row items-center gap-3 rounded-lg border px-3 py-3 sm:px-4"
98+
>
99+
<div class="flex min-w-0 flex-1 flex-col">
100+
<span class="text-text-primary truncate text-sm font-semibold">
101+
{appName(origin)}
102+
</span>
103+
<span class="text-text-tertiary truncate text-xs" title={origin}>
104+
{origin}
105+
</span>
106+
</div>
107+
<button
108+
class="btn btn-secondary btn-sm shrink-0 gap-2"
109+
onclick={() => revoke(origin)}
110+
disabled={revoking === origin}
111+
>
112+
<BellOffIcon class="size-4" />
113+
{$t`Turn off`}
114+
</button>
115+
</li>
116+
{/each}
117+
</ul>
118+
{:else if origins !== undefined}
119+
<div class="border-border-tertiary mt-5 border-t pt-4">
120+
<p class="text-text-tertiary text-sm">
121+
{$t`No apps can send you notifications yet.`}
122+
</p>
123+
</div>
124+
{/if}
125+
</section>

0 commit comments

Comments
 (0)