diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 33443a705..6cfe99585 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -104,6 +104,7 @@ import type { TutorialsResponse, UIDismissalKey, UIDismissalsResponse, + NotificationSettings, ImportOutcome, ArchivedCollection, CollectionFieldUsage @@ -1290,6 +1291,10 @@ export interface AuthSession { // replace "we emailed you a link" with host-recovery guidance, since no // email can actually be sent. Absent on older servers — treat as true. email_configured?: boolean; + // push_available is true when this server can deliver push notifications + // (a gateway is configured). Sent only to signed-in callers (TASK-3938); + // absent means off, so the Notifications settings section stays hidden. + push_available?: boolean; // account_disabled is true when the request's credential belongs to a // disabled account (BUG-3349): authenticated is false, and the sign-in // page explains why instead of offering a sign-in that cannot work. @@ -2778,6 +2783,17 @@ export const api = { request(`/me/ui-dismissals/${encodeURIComponent(key)}`, { method: 'PUT' }) }, + // Which events send the caller a push notification (TASK-3940). PUT is a + // full replace: the server refuses a body that omits or adds a key. + notificationSettings: { + get: () => request('/me/notification-settings'), + put: (settings: NotificationSettings) => + request('/me/notification-settings', { + method: 'PUT', + body: JSON.stringify(settings) + }) + }, + activity: { list: ( ws: string, diff --git a/web/src/lib/components/settings/NotificationSettings.svelte b/web/src/lib/components/settings/NotificationSettings.svelte new file mode 100644 index 000000000..c077ecdd2 --- /dev/null +++ b/web/src/lib/components/settings/NotificationSettings.svelte @@ -0,0 +1,174 @@ + + +

+ Choose what sends a push notification to your phone. Notifications never include item titles or + comment text. +

+ +{#if loading} +

Loading notification settings...

+{:else if loadError || !settings} + + +{:else} +
+ {#each ROWS as row, i (row.key)} +
+ toggle(row.key, e.currentTarget)} + /> +
+ + {#if row.hint} +

{row.hint}

+ {/if} +
+
+ {#if i === 0 && !settings.push_enabled} +

Push is off, so nothing is sent to your devices.

+ {/if} + {/each} +
+ {#if saveError} + + {/if} +{/if} + + diff --git a/web/src/lib/components/settings/NotificationSettings.svelte.test.ts b/web/src/lib/components/settings/NotificationSettings.svelte.test.ts new file mode 100644 index 000000000..6e30f5bfb --- /dev/null +++ b/web/src/lib/components/settings/NotificationSettings.svelte.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; + +/** + * TASK-3940: the Notifications settings section. Every save is a full replace + * of seven values, so these tests read the PUT bodies the component sent. + */ +const KEYS = [ + 'push_enabled', + 'notify_assigned', + 'notify_comments', + 'notify_replies', + 'notify_reminders', + 'notify_mentions', + 'include_agent_activity' +] as const; +const LABELS: Record<(typeof KEYS)[number], string> = { + push_enabled: 'Push notifications', + notify_assigned: 'Assigned to you', + notify_comments: 'Comments on your items', + notify_replies: 'Replies to your comments', + notify_reminders: 'Reminders', + notify_mentions: 'Mentions', + include_agent_activity: 'Include activity by agents' +}; + +const state = vi.hoisted(() => ({ + server: {} as Record, + puts: [] as Record[], + failPut: false, + failGet: false, + hold: null as null | { release: () => void } +})); + +vi.mock('$lib/api/client', () => ({ + api: { + notificationSettings: { + get: vi.fn(async () => { + if (state.failGet) throw new Error('Load refused'); + return { ...state.server }; + }), + put: vi.fn(async (body: Record) => { + state.puts.push(body); + if (state.hold) await new Promise((r) => (state.hold!.release = r)); + if (state.failPut) throw new Error('Save refused'); + state.server = { ...body }; + return { ...body }; + }) + } + } +})); + +const { default: NotificationSettings } = await import('./NotificationSettings.svelte'); + +beforeEach(() => { + state.server = Object.fromEntries(KEYS.map((k) => [k, k !== 'include_agent_activity'])); + state.puts.length = 0; + state.failPut = false; + state.failGet = false; + state.hold = null; +}); + +const box = (key: (typeof KEYS)[number]) => + screen.getByRole('checkbox', { name: LABELS[key] }) as HTMLInputElement; + +async function shown() { + render(NotificationSettings); + await waitFor(() => expect(screen.getAllByRole('checkbox').length).toBeGreaterThan(0)); +} + +describe('NotificationSettings', () => { + it('shows all seven toggles with the server values', async () => { + await shown(); + expect(screen.getAllByRole('checkbox')).toHaveLength(7); + for (const k of KEYS) expect(box(k).checked).toBe(k !== 'include_agent_activity'); + expect(screen.getByText(/never include item titles or comment text/)).toBeInTheDocument(); + }); + + it.each(KEYS)('toggling %s sends that key flipped and the rest unchanged', async (key) => { + await shown(); + const was = box(key).checked; + await fireEvent.click(box(key)); + await waitFor(() => expect(state.puts).toHaveLength(1)); + const expected = Object.fromEntries(KEYS.map((k) => [k, k === key ? !was : k !== 'include_agent_activity'])); + expect(state.puts[0]).toEqual(expected); + await waitFor(() => expect(box(key).checked).toBe(!was)); + }); + + it('every PUT carries exactly the seven keys, all booleans', async () => { + await shown(); + await fireEvent.click(box('notify_mentions')); + await waitFor(() => expect(state.puts).toHaveLength(1)); + expect(Object.keys(state.puts[0]).sort()).toEqual([...KEYS].sort()); + expect(Object.values(state.puts[0]).every((v) => typeof v === 'boolean')).toBe(true); + }); + + it('a failed save reverts the toggle, says why, and is not wedged', async () => { + await shown(); + state.failPut = true; + await fireEvent.click(box('notify_assigned')); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Save refused')); + expect(box('notify_assigned').checked).toBe(true); + state.failPut = false; + await fireEvent.click(box('notify_assigned')); + await waitFor(() => expect(box('notify_assigned').checked).toBe(false)); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('marks every toggle aria-disabled while a save is in flight, and a second toggle sends no PUT and snaps back', async () => { + await shown(); + state.hold = { release: () => {} }; + await fireEvent.click(box('notify_assigned')); + await waitFor(() => expect(box('notify_replies').getAttribute('aria-disabled')).toBe('true')); + const stored = box('notify_replies').checked; + await fireEvent.click(box('notify_replies')); + expect(state.puts).toHaveLength(1); + expect(box('notify_replies').checked).toBe(stored); + state.hold.release(); + await waitFor(() => expect(box('notify_replies').getAttribute('aria-disabled')).toBe('false')); + }); + + it('keeps focus on the toggled input across a pending save', async () => { + await shown(); + state.hold = { release: () => {} }; + box('notify_assigned').focus(); + await fireEvent.click(box('notify_assigned')); + await waitFor(() => expect(box('notify_assigned').getAttribute('aria-disabled')).toBe('true')); + expect(document.activeElement).toBe(box('notify_assigned')); + state.hold.release(); + await waitFor(() => expect(box('notify_assigned').getAttribute('aria-disabled')).toBe('false')); + expect(document.activeElement).toBe(box('notify_assigned')); + }); + + describe('#notifications deep link', () => { + let section: HTMLElement; + let spy: ReturnType; + beforeEach(() => { + section = document.createElement('section'); + section.id = 'notifications'; + spy = vi.fn(); + section.scrollIntoView = spy; + document.body.appendChild(section); + }); + afterEach(() => { + section.remove(); + history.replaceState(null, '', location.pathname); + }); + + it('scrolls the section into view exactly once when the hash matches, not again on retry', async () => { + history.replaceState(null, '', '#notifications'); + state.failGet = true; + render(NotificationSettings); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Load refused')); + state.failGet = false; + await fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.getAllByRole('checkbox')).toHaveLength(7)); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('never scrolls without the hash', async () => { + history.replaceState(null, '', '#other'); + await shown(); + expect(spy).not.toHaveBeenCalled(); + }); + }); + + it('a failed load shows the error and Retry, with no toggles; Retry loads', async () => { + state.failGet = true; + render(NotificationSettings); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Load refused')); + expect(screen.queryAllByRole('checkbox')).toHaveLength(0); + state.failGet = false; + await fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + await waitFor(() => expect(screen.getAllByRole('checkbox')).toHaveLength(7)); + }); + + it('with push off, the other toggles stay editable and a note says nothing is sent', async () => { + state.server.push_enabled = false; + await shown(); + expect(screen.getByText('Push is off, so nothing is sent to your devices.')).toBeInTheDocument(); + for (const k of KEYS) expect(box(k).getAttribute('aria-disabled')).not.toBe('true'); + }); + + it('with push on there is no off note', async () => { + await shown(); + expect(screen.queryByText(/Push is off/)).toBeNull(); + }); +}); diff --git a/web/src/lib/eachKeyCensus.ts b/web/src/lib/eachKeyCensus.ts index 5df51b008..c9ee8d149 100644 --- a/web/src/lib/eachKeyCensus.ts +++ b/web/src/lib/eachKeyCensus.ts @@ -163,6 +163,7 @@ export const EACH_KEY_CENSUS: Record ({ value: null as unknown })); +vi.mock('$lib/api/client', () => ({ + api: { auth: { session: vi.fn(async () => session.value) } } +})); + +async function store(extra: Record) { + session.value = { authenticated: true, user: { id: 'u1', email: 'u1@example.com' }, ...extra }; + const { authStore } = await import('./auth.svelte'); + await authStore.load(); + return authStore; +} + +describe('authStore.pushAvailable', () => { + beforeEach(() => vi.resetModules()); + + it('is true when the session says push is available', async () => { + expect((await store({ push_available: true })).pushAvailable).toBe(true); + }); + + it('is false when the session omits it', async () => { + expect((await store({})).pushAvailable).toBe(false); + }); +}); diff --git a/web/src/lib/types/index.ts b/web/src/lib/types/index.ts index b0d204e5c..84d92296c 100644 --- a/web/src/lib/types/index.ts +++ b/web/src/lib/types/index.ts @@ -3409,6 +3409,20 @@ export interface TutorialsResponse { /** One-time UI suggestions the user dismissed (models.UIDismissalKeys). */ export type UIDismissalKey = 'tutorials.console' | 'tutorials.launchpad'; +/** + * The caller's push notification settings (models.NotificationSettings). + * PUT replaces all seven; the server refuses a body missing or adding a key. + */ +export interface NotificationSettings { + push_enabled: boolean; + notify_assigned: boolean; + notify_comments: boolean; + notify_replies: boolean; + notify_reminders: boolean; + notify_mentions: boolean; + include_agent_activity: boolean; +} + export interface UIDismissalsResponse { dismissed: UIDismissalKey[]; } diff --git a/web/src/routes/console/settings/+page.svelte b/web/src/routes/console/settings/+page.svelte index 5b348b091..b1d64f45c 100644 --- a/web/src/routes/console/settings/+page.svelte +++ b/web/src/routes/console/settings/+page.svelte @@ -11,6 +11,7 @@ import { exportAndDownloadAccountData } from '$lib/utils/artifacts'; import Button from '$lib/components/common/Button.svelte'; import Modal from '$lib/components/common/Modal.svelte'; + import NotificationSettings from '$lib/components/settings/NotificationSettings.svelte'; import CharacterShortcutsToggle from '$lib/components/common/CharacterShortcutsToggle.svelte'; import type { User, APIToken, APITokenWithSecret, TOTPSetupResponse } from '$lib/types'; import { describeTokenScopes } from '$lib/settings/tokenScopes'; @@ -684,6 +685,15 @@ + {#if authStore.pushAvailable} +
+

Notifications

+
+ +
+
+ {/if} +

Password

diff --git a/web/src/routes/console/settings/task3940Notifications.svelte.test.ts b/web/src/routes/console/settings/task3940Notifications.svelte.test.ts new file mode 100644 index 000000000..2d8a471d7 --- /dev/null +++ b/web/src/routes/console/settings/task3940Notifications.svelte.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/svelte'; + +/** TASK-3940: the account settings page shows Notifications only where push is available. */ +const state = vi.hoisted(() => ({ push: false, gets: 0 })); + +vi.mock('$lib/api/client', () => ({ + PadApiError: class extends Error {}, + isPlanLimitError: () => false, + api: { + notificationSettings: { + get: vi.fn(async () => { + state.gets++; + return { + push_enabled: true, + notify_assigned: true, + notify_comments: true, + notify_replies: true, + notify_reminders: true, + notify_mentions: true, + include_agent_activity: false + }; + }), + put: vi.fn() + }, + auth: { + me: vi.fn(async () => ({ id: 'u1', name: 'Pat', email: 'pat@example.com', username: 'pat', password_set: true })), + totp: { disable: vi.fn(), setup: vi.fn(), verify: vi.fn() }, + tokens: { list: vi.fn(async () => []), create: vi.fn(), rotate: vi.fn(), delete: vi.fn() }, + updateProfile: vi.fn(), + unlinkProvider: vi.fn(), + deleteAccount: vi.fn(), + logout: vi.fn(), + forgotPassword: vi.fn() + } + } +})); +vi.mock('$lib/stores/auth.svelte', () => ({ + authStore: { + get cloudMode() { return false; }, + get emailConfigured() { return true; }, + get pushAvailable() { return state.push; }, + get user() { return { id: 'u1', name: 'Pat', email: 'pat@example.com' }; }, + get userId() { return 'u1'; }, + get identityEpoch() { return 0; }, + identityFence() { return () => true; }, + onIdentityChange() { return () => {}; }, + load: vi.fn(async () => {}), + ensureLoaded: vi.fn(async () => {}) + } +})); + +const { default: AccountSettings } = await import('./+page.svelte'); + +beforeEach(() => { + state.push = false; + state.gets = 0; +}); + +describe('TASK-3940: Notifications section gating', () => { + it('is absent, and loads nothing, when push is not available', async () => { + render(AccountSettings); + await waitFor(() => expect(screen.getByText('Keyboard')).toBeInTheDocument()); + expect(screen.queryByRole('heading', { name: 'Notifications' })).toBeNull(); + expect(state.gets).toBe(0); + }); + + it('shows the heading and seven toggles when push is available', async () => { + state.push = true; + const { container } = render(AccountSettings); + await waitFor(() => expect(screen.getByRole('heading', { name: 'Notifications' })).toBeInTheDocument()); + const section = container.querySelector('#notifications') as HTMLElement; + await waitFor(() => expect(section.querySelectorAll('input[type=checkbox]')).toHaveLength(7)); + }); +});