feat(web): Notifications section in account settings (TASK-3940) (#2276)

* feat(web): Notifications section in account settings (TASK-3940)

Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE

* fix(web): notification toggles keep focus while saving; #notifications deep link scrolls (TASK-3940 r1)

Claude-Session: https://claude.ai/code/session_01VQbeqVujjtTDyGqeYj6wxE
This commit is contained in:
xarmian
2026-10-21 17:30:11 -04:00
committed by GitHub
parent 527fa47e0f
commit dd93b7cbc2
9 changed files with 508 additions and 0 deletions
+16
View File
@@ -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<UIDismissalsResponse>(`/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<NotificationSettings>('/me/notification-settings'),
put: (settings: NotificationSettings) =>
request<NotificationSettings>('/me/notification-settings', {
method: 'PUT',
body: JSON.stringify(settings)
})
},
activity: {
list: (
ws: string,
@@ -0,0 +1,174 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import type { NotificationSettings } from '$lib/types';
// Which events send the caller a push notification (TASK-3940). Every save
// is a full replace of all seven values, so the body is built from KEYS
// rather than spread from the response, and writes are serialized: two
// overlapping full-replace PUTs would lose one of the edits.
const uid = $props.id();
const id = `notification-settings-${uid}`;
const ROWS: { key: keyof NotificationSettings; label: string; hint?: string }[] = [
{ key: 'push_enabled', label: 'Push notifications' },
{ key: 'notify_assigned', label: 'Assigned to you' },
{ key: 'notify_comments', label: 'Comments on your items' },
{ key: 'notify_replies', label: 'Replies to your comments' },
{ key: 'notify_reminders', label: 'Reminders' },
{ key: 'notify_mentions', label: 'Mentions' },
{
key: 'include_agent_activity',
label: 'Include activity by agents',
hint: "Off by default, so agents working in your workspace don't buzz your phone."
}
];
let settings = $state<NotificationSettings | null>(null);
let loading = $state(true);
let loadError = $state('');
let saveError = $state('');
let saving = $state(false);
async function load() {
loading = true;
loadError = '';
try {
settings = await api.notificationSettings.get();
} catch (e) {
loadError = e instanceof Error ? e.message : 'Could not load notification settings.';
} finally {
loading = false;
}
}
async function toggle(key: keyof NotificationSettings, input: HTMLInputElement) {
if (!settings) return;
// aria-disabled does not block activation, so the guard lives here. The
// box has already flipped in the DOM and `checked` is one-way, so put it
// back to the stored value.
if (saving) {
input.checked = settings[key];
return;
}
const checked = input.checked;
const before = settings;
const next = {} as NotificationSettings;
for (const row of ROWS) next[row.key] = row.key === key ? checked : before[row.key];
settings = next;
saving = true;
saveError = '';
try {
const saved = await api.notificationSettings.put(next);
const confirmed = {} as NotificationSettings;
for (const row of ROWS) confirmed[row.key] = saved[row.key];
settings = confirmed;
} catch (e) {
settings = before;
saveError = e instanceof Error ? e.message : 'Could not save notification settings.';
} finally {
saving = false;
}
}
onMount(() => {
// The page renders this section after its own load, so the browser has
// already resolved the hash by the time the target exists. Scroll once.
if (location.hash === '#notifications') {
document.getElementById('notifications')?.scrollIntoView();
}
load();
});
</script>
<p class="lead">
Choose what sends a push notification to your phone. Notifications never include item titles or
comment text.
</p>
{#if loading}
<p class="status" aria-busy="true">Loading notification settings...</p>
{:else if loadError || !settings}
<p class="error" role="alert">{loadError || 'Could not load notification settings.'}</p>
<button type="button" class="retry" onclick={load}>Retry</button>
{:else}
<div class="rows" class:off={!settings.push_enabled}>
{#each ROWS as row, i (row.key)}
<div class="toggle" class:master={i === 0}>
<input
id="{id}-{row.key}"
type="checkbox"
checked={settings[row.key]}
aria-disabled={saving}
aria-describedby={row.hint ? `${id}-${row.key}-hint` : undefined}
onchange={(e) => toggle(row.key, e.currentTarget)}
/>
<div>
<label for="{id}-{row.key}">{row.label}</label>
{#if row.hint}
<p id="{id}-{row.key}-hint" class="desc">{row.hint}</p>
{/if}
</div>
</div>
{#if i === 0 && !settings.push_enabled}
<p class="desc note">Push is off, so nothing is sent to your devices.</p>
{/if}
{/each}
</div>
{#if saveError}
<p class="error" role="alert">{saveError}</p>
{/if}
{/if}
<style>
.lead,
.status {
margin-bottom: var(--space-3);
color: var(--text-secondary);
}
.rows {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.rows.off .toggle:not(.master) {
opacity: 0.6;
}
.toggle {
display: flex;
align-items: flex-start;
gap: var(--space-2);
}
input {
margin-top: 3px;
}
label {
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
}
.desc {
margin-top: var(--space-1);
font-size: 0.85em;
color: var(--text-muted);
}
.note {
margin-top: 0;
}
.error {
margin-top: var(--space-2);
color: var(--accent-red);
}
.retry {
margin-top: var(--space-2);
}
</style>
@@ -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<string, boolean>,
puts: [] as Record<string, boolean>[],
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<string, boolean>) => {
state.puts.push(body);
if (state.hold) await new Promise<void>((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<typeof vi.fn>;
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();
});
});
+1
View File
@@ -163,6 +163,7 @@ export const EACH_KEY_CENSUS: Record<string, { reason: EachKeyReason; note: stri
"lib/components/search/CommandPalette.svelte :: each group.results as r (r.item.id || r.item.slug)": { reason: 'deduped', note: "results; Load more appends through uniqueBy on the item id" },
"lib/components/search/CommandPalette.svelte :: each recentSearches as recent, i (recent)": { reason: 'deduped', note: "`recentSearches` from localStorage | the only writer, `saveRecentSearch` (line 884), always dedupes before writing (and has since it was added). Caveat: `loadRecentSearches` does n" },
"lib/components/search/CommandPalette.svelte :: each results as r, i (r.item.id || r.item.slug)": { reason: 'deduped', note: "Load more appends through uniqueBy on the item id" },
"lib/components/settings/NotificationSettings.svelte :: each ROWS as row, i (row.key)": { reason: 'static-literal', note: "`ROWS` literal (l.10) | seven distinct notification setting keys." },
"lib/components/settings/StorageTab.svelte :: each attachments as att (att.id)": { reason: 'db-primary-key', note: "`WorkspaceAttachments` page, replaced per page (not appended) | Primary key; the LEFT JOIN to items is 1:1." },
"lib/components/settings/StorageTab.svelte :: each collections as coll (coll.id)": { reason: 'db-primary-key', note: "settings page `api.collections.list` | Primary key from a single SELECT." },
"lib/components/settings/apps/AppArtifactReview.svelte :: each artifact.changes as ch (ch)": { reason: 'db-primary-key', note: "`norm.Warnings` \u2190 `preprocessArtifactFields` (handlers_artifact_import.go:249) | Each warning names its own field key. The first hit clears the value, so a repeated schema key can'" },
+3
View File
@@ -217,6 +217,9 @@ export const authStore = {
// before the session loads) so reset/invite flows keep their normal copy
// unless the server explicitly says email is off. The /forgot-password
// page swaps to host-recovery guidance when this is false.
// pushAvailable gates the Notifications settings section. The server sends
// it only to signed-in callers; absent means off (TASK-3940).
get pushAvailable() { return session?.push_available ?? false; },
get emailConfigured() { return session?.email_configured ?? true; },
// emailVerified is false ONLY for a Pad Cloud self-serve signup that hasn't
// confirmed its email yet (PLAN-1933 DR-3 / TASK-1940). Defaults to TRUE
@@ -0,0 +1,27 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
/** TASK-3940: authStore.pushAvailable follows the session's push_available; absent is off. */
const session = vi.hoisted(() => ({ value: null as unknown }));
vi.mock('$lib/api/client', () => ({
api: { auth: { session: vi.fn(async () => session.value) } }
}));
async function store(extra: Record<string, unknown>) {
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);
});
});
+14
View File
@@ -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[];
}
@@ -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 @@
</div>
</section>
{#if authStore.pushAvailable}
<section class="card" id="notifications">
<h2 class="card-title">Notifications</h2>
<div class="card-body">
<NotificationSettings />
</div>
</section>
{/if}
<!-- Password -->
<section class="card">
<h2 class="card-title">Password</h2>
@@ -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));
});
});