From 31d6d81630f692bd8c5fe88312a2a74d52f7d91e Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 11:56:44 +0100 Subject: [PATCH 01/14] feat(settings): types for portal welcome card Adds PortalWelcomeCard interface (enabled / title / TipTap body) and plumbs it through PortalConfig, DEFAULT_PORTAL_CONFIG, the partial update input, and the PublicPortalConfig projection. Off by default; absent / disabled = nothing rendered. --- .../portal-welcome-card-config.test.ts | 80 +++++++++++++++++++ .../src/lib/server/domains/settings/index.ts | 4 + .../server/domains/settings/settings.types.ts | 32 ++++++++ apps/web/src/lib/shared/types/settings.ts | 2 + 4 files changed, 118 insertions(+) create mode 100644 apps/web/src/lib/server/domains/settings/__tests__/portal-welcome-card-config.test.ts diff --git a/apps/web/src/lib/server/domains/settings/__tests__/portal-welcome-card-config.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/portal-welcome-card-config.test.ts new file mode 100644 index 00000000..e80dbae0 --- /dev/null +++ b/apps/web/src/lib/server/domains/settings/__tests__/portal-welcome-card-config.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { parseJsonConfig } from '../settings.helpers' +import { + DEFAULT_PORTAL_CONFIG, + type PortalConfig, + type PortalWelcomeCard, + type PublicPortalConfig, +} from '../settings.types' + +describe('PortalWelcomeCard defaults', () => { + it('is off by default', () => { + expect(DEFAULT_PORTAL_CONFIG.welcomeCard?.enabled).toBe(false) + }) + + it('has empty title by default', () => { + expect(DEFAULT_PORTAL_CONFIG.welcomeCard?.title).toBe('') + }) + + it('has an empty doc body by default', () => { + expect(DEFAULT_PORTAL_CONFIG.welcomeCard?.body).toEqual({ + type: 'doc', + content: [{ type: 'paragraph' }], + }) + }) +}) + +describe('PortalWelcomeCard type', () => { + it('accepts a fully-specified welcome card', () => { + const card: PortalWelcomeCard = { + enabled: true, + title: 'Share your product feedback!', + body: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Tell us what you think.' }], + }, + ], + }, + } + expect(card.enabled).toBe(true) + expect(card.title).toContain('Share') + }) + + it('is exposed as an optional field on PortalConfig', () => { + const cfg: PortalConfig = { + ...DEFAULT_PORTAL_CONFIG, + welcomeCard: { enabled: true, title: 'Hi', body: { type: 'doc' } as never }, + } + expect(cfg.welcomeCard?.enabled).toBe(true) + }) + + it('is exposed on PublicPortalConfig so the portal SSR loader can read it', () => { + const projection: PublicPortalConfig = { + oauth: { password: true }, + features: DEFAULT_PORTAL_CONFIG.features, + welcomeCard: { enabled: true, title: 'x', body: { type: 'doc' } as never }, + } + expect(projection.welcomeCard?.title).toBe('x') + }) +}) + +describe('parseJsonConfig deep-merges welcomeCard', () => { + it('preserves welcomeCard defaults when stored config omits it', () => { + const stored = JSON.stringify({ features: { voting: false } }) + const result = parseJsonConfig(stored, DEFAULT_PORTAL_CONFIG) + expect(result.welcomeCard).toEqual(DEFAULT_PORTAL_CONFIG.welcomeCard) + }) + + it('merges partial welcomeCard with defaults', () => { + const stored = JSON.stringify({ + welcomeCard: { enabled: true, title: 'Hello' }, + }) + const result = parseJsonConfig(stored, DEFAULT_PORTAL_CONFIG) + expect(result.welcomeCard?.enabled).toBe(true) + expect(result.welcomeCard?.title).toBe('Hello') + expect(result.welcomeCard?.body).toEqual(DEFAULT_PORTAL_CONFIG.welcomeCard?.body) + }) +}) diff --git a/apps/web/src/lib/server/domains/settings/index.ts b/apps/web/src/lib/server/domains/settings/index.ts index c4326666..213548f3 100644 --- a/apps/web/src/lib/server/domains/settings/index.ts +++ b/apps/web/src/lib/server/domains/settings/index.ts @@ -16,6 +16,7 @@ export type { PortalAuthMethods, PortalFeatures, PortalConfig, + PortalWelcomeCard, HeaderDisplayMode, ThemeColors, BrandingConfig, @@ -32,6 +33,9 @@ export type { HelpCenterSeoConfig, } from './settings.types' +// Welcome card constants (no DB dependency) +export { PORTAL_WELCOME_CARD_TITLE_MAX } from './settings.types' + // Default config values (no DB dependency) export { DEFAULT_AUTH_CONFIG, diff --git a/apps/web/src/lib/server/domains/settings/settings.types.ts b/apps/web/src/lib/server/domains/settings/settings.types.ts index 964211ef..3aa0d2f4 100644 --- a/apps/web/src/lib/server/domains/settings/settings.types.ts +++ b/apps/web/src/lib/server/domains/settings/settings.types.ts @@ -5,6 +5,8 @@ * This allows adding new settings without migrations. */ +import type { TiptapContent } from '@/lib/shared/db-types' + // ============================================================================= // Auth Configuration (Team sign-in settings) // ============================================================================= @@ -201,6 +203,26 @@ export interface PortalFeatures { videoEmbedsInPosts?: boolean } +/** + * Welcome card shown above the post list on the portal index. + * Title is plain text (server trims + caps at 120 chars). Body is + * sanitized TipTap JSON — same shape as post / help-center content, + * sanitized via `sanitizeTiptapContent` on every write. + * + * Default off. Renders only when `enabled` and at least one of + * `title` / `body` has content. + */ +export interface PortalWelcomeCard { + enabled: boolean + /** Plain text. Server trims and rejects > 120 chars. */ + title: string + /** Sanitized TipTap JSON doc. */ + body: TiptapContent +} + +/** Max length of {@link PortalWelcomeCard.title} after trimming. */ +export const PORTAL_WELCOME_CARD_TITLE_MAX = 120 + /** * Portal configuration * Controls the public feedback portal behavior @@ -210,6 +232,8 @@ export interface PortalConfig { oauth: PortalAuthMethods /** Feature toggles */ features: PortalFeatures + /** Welcome card on the portal index. Optional — absent = disabled. */ + welcomeCard?: PortalWelcomeCard } /** @@ -234,6 +258,11 @@ export const DEFAULT_PORTAL_CONFIG: PortalConfig = { anonymousCommenting: false, anonymousPosting: false, }, + welcomeCard: { + enabled: false, + title: '', + body: { type: 'doc', content: [{ type: 'paragraph' }] }, + }, } // ============================================================================= @@ -462,6 +491,7 @@ export interface UpdateAuthConfigInput { export interface UpdatePortalConfigInput { oauth?: Partial features?: Partial + welcomeCard?: Partial } // ============================================================================= @@ -484,6 +514,8 @@ export interface PublicPortalConfig { features: PortalFeatures /** Display name overrides for generic OAuth providers (e.g. custom-oidc → "Okta") */ customProviderNames?: Record + /** Welcome card on the portal index. Absent / disabled = nothing rendered. */ + welcomeCard?: PortalWelcomeCard } // ============================================================================= diff --git a/apps/web/src/lib/shared/types/settings.ts b/apps/web/src/lib/shared/types/settings.ts index d02ea11c..9f201e22 100644 --- a/apps/web/src/lib/shared/types/settings.ts +++ b/apps/web/src/lib/shared/types/settings.ts @@ -11,6 +11,7 @@ export type { PortalAuthMethods, + PortalWelcomeCard, TenantSettings, HelpCenterConfig, AuthConfig, @@ -24,4 +25,5 @@ export type { FeatureFlags } from '@/lib/server/domains/settings/settings.types' export { FEATURE_FLAG_REGISTRY, DEFAULT_PORTAL_CONFIG, + PORTAL_WELCOME_CARD_TITLE_MAX, } from '@/lib/server/domains/settings/settings.types' From 24b76bf15df1242f2587312f0b142424e0fa5172 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 11:58:35 +0100 Subject: [PATCH 02/14] feat(settings): sanitize + validate welcomeCard on write Adds normalizeWelcomeCardInput helper that trims the title, enforces the 120-char cap, and runs the TipTap body through sanitizeTiptapContent. Plugged into updatePortalConfig before the deep-merge, and the server-function Zod schema now admits the new welcomeCard field. --- .../normalize-welcome-card-input.test.ts | 55 +++++++++++++++++++ .../domains/settings/settings.helpers.ts | 30 ++++++++++ .../domains/settings/settings.service.ts | 7 ++- apps/web/src/lib/server/functions/settings.ts | 10 ++++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts diff --git a/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts new file mode 100644 index 00000000..420fa7ac --- /dev/null +++ b/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { ValidationError } from '@/lib/shared/errors' +import { normalizeWelcomeCardInput } from '../settings.helpers' + +describe('normalizeWelcomeCardInput', () => { + it('returns the input unchanged when undefined', () => { + expect(normalizeWelcomeCardInput(undefined)).toBeUndefined() + }) + + it('passes through enabled with no validation', () => { + const out = normalizeWelcomeCardInput({ enabled: true }) + expect(out).toEqual({ enabled: true }) + }) + + it('trims the title', () => { + const out = normalizeWelcomeCardInput({ title: ' Hello ' }) + expect(out?.title).toBe('Hello') + }) + + it('rejects a title longer than 120 chars', () => { + expect(() => normalizeWelcomeCardInput({ title: 'a'.repeat(121) })).toThrow(ValidationError) + }) + + it('accepts a title of exactly 120 chars', () => { + const out = normalizeWelcomeCardInput({ title: 'a'.repeat(120) }) + expect(out?.title?.length).toBe(120) + }) + + it('strips disallowed nodes from the body', () => { + const out = normalizeWelcomeCardInput({ + body: { + type: 'doc', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'safe' }] }, + // Disallowed node type — must be stripped. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { type: 'rogueNode', attrs: { evil: 'true' } } as any, + ], + }, + }) + const body = out?.body + expect(body?.type).toBe('doc') + const types = body?.content?.map((c) => c.type) ?? [] + expect(types).not.toContain('rogueNode') + expect(types).toContain('paragraph') + }) + + it('returns an empty doc when body sanitizes to nothing usable', () => { + const out = normalizeWelcomeCardInput({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: { type: 'notDoc' } as any, + }) + expect(out?.body).toEqual({ type: 'doc' }) + }) +}) diff --git a/apps/web/src/lib/server/domains/settings/settings.helpers.ts b/apps/web/src/lib/server/domains/settings/settings.helpers.ts index 187a0b51..fcbc0d1f 100644 --- a/apps/web/src/lib/server/domains/settings/settings.helpers.ts +++ b/apps/web/src/lib/server/domains/settings/settings.helpers.ts @@ -5,6 +5,8 @@ import { db } from '@/lib/server/db' import { cacheDel, CACHE_KEYS } from '@/lib/server/redis' import { NotFoundError, InternalError, ValidationError } from '@/lib/shared/errors' +import { sanitizeTiptapContent } from '@/lib/server/sanitize-tiptap' +import { PORTAL_WELCOME_CARD_TITLE_MAX, type PortalWelcomeCard } from './settings.types' export type SettingsRecord = NonNullable>> @@ -72,3 +74,31 @@ export async function invalidateSettingsCache(): Promise { console.log(`[domain:settings] Invalidating settings cache`) await cacheDel(CACHE_KEYS.TENANT_SETTINGS) } + +/** + * Normalize a partial `welcomeCard` update before it's merged into stored + * portalConfig. Trims the title, enforces the length cap, and runs the + * TipTap body through the standard sanitizer. + * + * @internal + */ +export function normalizeWelcomeCardInput( + input: Partial | undefined +): Partial | undefined { + if (!input) return input + const normalized: Partial = { ...input } + if (typeof input.title === 'string') { + const trimmed = input.title.trim() + if (trimmed.length > PORTAL_WELCOME_CARD_TITLE_MAX) { + throw new ValidationError( + 'WELCOME_CARD_TITLE_TOO_LONG', + `Welcome card title must be ${PORTAL_WELCOME_CARD_TITLE_MAX} characters or fewer` + ) + } + normalized.title = trimmed + } + if (input.body !== undefined) { + normalized.body = sanitizeTiptapContent(input.body) + } + return normalized +} diff --git a/apps/web/src/lib/server/domains/settings/settings.service.ts b/apps/web/src/lib/server/domains/settings/settings.service.ts index e6198c80..fe87a0e7 100644 --- a/apps/web/src/lib/server/domains/settings/settings.service.ts +++ b/apps/web/src/lib/server/domains/settings/settings.service.ts @@ -35,6 +35,7 @@ import { requireSettings, wrapDbError, invalidateSettingsCache, + normalizeWelcomeCardInput, } from './settings.helpers' async function getConfiguredAuthTypes(): Promise> { @@ -600,9 +601,13 @@ export async function getPortalConfig(): Promise { export async function updatePortalConfig(input: UpdatePortalConfigInput): Promise { console.log(`[domain:settings] updatePortalConfig`) try { + const normalized: UpdatePortalConfigInput = { + ...input, + welcomeCard: normalizeWelcomeCardInput(input.welcomeCard), + } const org = await requireSettings() const existing = parseJsonConfig(org.portalConfig, DEFAULT_PORTAL_CONFIG) - const updated = deepMerge(existing, input as Partial) + const updated = deepMerge(existing, normalized as Partial) const hasAuthMethod = Object.values(updated.oauth).some(Boolean) if (!hasAuthMethod) { diff --git a/apps/web/src/lib/server/functions/settings.ts b/apps/web/src/lib/server/functions/settings.ts index 267ceef9..329c0787 100644 --- a/apps/web/src/lib/server/functions/settings.ts +++ b/apps/web/src/lib/server/functions/settings.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { createServerFn } from '@tanstack/react-start' +import { tiptapContentSchema } from '@/lib/shared/schemas/posts' // Import types from barrel export (client-safe) import { DEFAULT_PORTAL_CONFIG, @@ -302,6 +303,15 @@ const updatePortalConfigSchema = z.object({ anonymousPosting: z.boolean().optional(), }) .optional(), + welcomeCard: z + .object({ + enabled: z.boolean().optional(), + title: z.string().optional(), + // Body is re-sanitized server-side by normalizeWelcomeCardInput; + // tiptapContentSchema gates the shape at the boundary. + body: tiptapContentSchema.optional(), + }) + .optional(), }) const saveLogoKeySchema = z.object({ From 4a87e0ef693bb37e6c3b9d1233d1bdd253be0fc1 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 11:58:57 +0100 Subject: [PATCH 03/14] feat(settings): surface welcomeCard on public portal config getPublicPortalConfig and getTenantSettings now pass through portalConfig.welcomeCard so the SSR loader can render it without an extra fetch. Absent / disabled stays absent in the projection. --- apps/web/src/lib/server/domains/settings/settings.service.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/lib/server/domains/settings/settings.service.ts b/apps/web/src/lib/server/domains/settings/settings.service.ts index fe87a0e7..f3a9f808 100644 --- a/apps/web/src/lib/server/domains/settings/settings.service.ts +++ b/apps/web/src/lib/server/domains/settings/settings.service.ts @@ -750,6 +750,7 @@ export async function getPublicPortalConfig(): Promise { oauth: filteredOAuth, features: portalConfig.features, ...(customProviderNames && { customProviderNames }), + ...(portalConfig.welcomeCard && { welcomeCard: portalConfig.welcomeCard }), } } catch (error) { console.error(`[domain:settings] getPublicPortalConfig failed:`, error) @@ -830,6 +831,7 @@ export async function getTenantSettings(): Promise { oauth: filteredPortalOAuth, features: portalConfig.features, ...(portalCustomNames && { customProviderNames: portalCustomNames }), + ...(portalConfig.welcomeCard && { welcomeCard: portalConfig.welcomeCard }), }, publicWidgetConfig: { enabled: widgetConfig.enabled, From 316ff3e1e0dbad1d118ddc8d5d6029f8eee00a73 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 11:59:27 +0100 Subject: [PATCH 04/14] feat(utils): add isEmptyTiptapDoc helper Tells whether a TipTap doc carries any visible content. Treats empty paragraphs/headings/blockquotes and whitespace-only text as empty; images, lists, embeds and horizontal rules count as content. --- .../__tests__/is-empty-tiptap-doc.test.ts | 73 +++++++++++++++++++ .../lib/shared/utils/is-empty-tiptap-doc.ts | 32 ++++++++ 2 files changed, 105 insertions(+) create mode 100644 apps/web/src/lib/shared/utils/__tests__/is-empty-tiptap-doc.test.ts create mode 100644 apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts diff --git a/apps/web/src/lib/shared/utils/__tests__/is-empty-tiptap-doc.test.ts b/apps/web/src/lib/shared/utils/__tests__/is-empty-tiptap-doc.test.ts new file mode 100644 index 00000000..ccad5cd4 --- /dev/null +++ b/apps/web/src/lib/shared/utils/__tests__/is-empty-tiptap-doc.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { isEmptyTiptapDoc } from '../is-empty-tiptap-doc' +import type { TiptapContent } from '@/lib/shared/db-types' + +describe('isEmptyTiptapDoc', () => { + it('treats undefined as empty', () => { + expect(isEmptyTiptapDoc(undefined)).toBe(true) + }) + + it('treats a doc with no content as empty', () => { + expect(isEmptyTiptapDoc({ type: 'doc' })).toBe(true) + }) + + it('treats a single empty paragraph as empty', () => { + expect(isEmptyTiptapDoc({ type: 'doc', content: [{ type: 'paragraph' }] })).toBe(true) + }) + + it('treats a paragraph with only whitespace as empty', () => { + const doc: TiptapContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: ' ' }], + }, + ], + } + expect(isEmptyTiptapDoc(doc)).toBe(true) + }) + + it('treats a paragraph with real text as non-empty', () => { + const doc: TiptapContent = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'hello' }], + }, + ], + } + expect(isEmptyTiptapDoc(doc)).toBe(false) + }) + + it('treats a doc with an image node as non-empty', () => { + const doc: TiptapContent = { + type: 'doc', + content: [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }], + } + expect(isEmptyTiptapDoc(doc)).toBe(false) + }) + + it('treats a doc with a heading as non-empty', () => { + const doc: TiptapContent = { + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'Welcome' }], + }, + ], + } + expect(isEmptyTiptapDoc(doc)).toBe(false) + }) + + it('treats a doc with a horizontalRule as non-empty', () => { + const doc: TiptapContent = { + type: 'doc', + content: [{ type: 'horizontalRule' }], + } + expect(isEmptyTiptapDoc(doc)).toBe(false) + }) +}) diff --git a/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts b/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts new file mode 100644 index 00000000..e9cf05bc --- /dev/null +++ b/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts @@ -0,0 +1,32 @@ +import type { TiptapContent } from '@/lib/shared/db-types' + +/** + * Returns true when a TipTap document carries no visible content — either + * because it's undefined, has no children, or its only children are an + * empty paragraph or whitespace-only text. Used to decide whether to + * render rich-text-driven UI (e.g. the portal welcome card body). + * + * Non-text leaf nodes such as images, horizontal rules, hard breaks and + * embeds count as content, even when they carry no text. + */ +export function isEmptyTiptapDoc(doc: TiptapContent | undefined): boolean { + if (!doc) return true + const content = doc.content + if (!content || content.length === 0) return true + return content.every(isEmptyNode) +} + +function isEmptyNode(node: TiptapContent): boolean { + // Text-bearing block: empty if all text descendants are whitespace. + if (node.type === 'paragraph' || node.type === 'heading' || node.type === 'blockquote') { + const children = node.content + if (!children || children.length === 0) return true + return children.every(isEmptyNode) + } + if (node.type === 'text') { + return (node.text ?? '').trim().length === 0 + } + // Any other node type — image, horizontalRule, list, table, embed, etc. + // — counts as content even with no text. + return false +} From 5b7a747e69461eaceb54b7c49415070c5c1be597 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 12:00:10 +0100 Subject: [PATCH 05/14] feat(portal): add PortalWelcomeCard component Renders the admin-authored welcome card above the post list. Returns null when disabled, when the card is missing, or when both title and body are effectively empty so a stray enable=true doesn't leave a blank container on screen. --- .../__tests__/portal-welcome-card.test.tsx | 76 +++++++++++++++++++ .../public/feedback/portal-welcome-card.tsx | 35 +++++++++ 2 files changed, 111 insertions(+) create mode 100644 apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx create mode 100644 apps/web/src/components/public/feedback/portal-welcome-card.tsx diff --git a/apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx b/apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx new file mode 100644 index 00000000..4f3748f2 --- /dev/null +++ b/apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PortalWelcomeCard } from '../portal-welcome-card' +import type { PortalWelcomeCard as PortalWelcomeCardData } from '@/lib/shared/types/settings' + +const emptyBody = { type: 'doc', content: [{ type: 'paragraph' }] } + +const richBody = { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'Tell us what you would like to see next.' }], + }, + ], +} as const + +describe('', () => { + it('renders nothing when welcomeCard is undefined', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when disabled', () => { + const data: PortalWelcomeCardData = { + enabled: false, + title: 'Share your product feedback!', + body: richBody, + } + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders nothing when enabled but title and body are both empty', () => { + const data: PortalWelcomeCardData = { + enabled: true, + title: ' ', + body: emptyBody, + } + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('renders title only when body is empty', () => { + const data: PortalWelcomeCardData = { + enabled: true, + title: 'Share your product feedback!', + body: emptyBody, + } + render() + expect(screen.getByRole('heading', { name: 'Share your product feedback!' })).toBeDefined() + }) + + it('renders body only when title is empty', () => { + const data: PortalWelcomeCardData = { + enabled: true, + title: '', + body: richBody, + } + render() + expect(screen.queryByRole('heading')).toBeNull() + expect(screen.getByText(/Tell us what you would like to see next\./)).toBeDefined() + }) + + it('renders both title and body when both are populated', () => { + const data: PortalWelcomeCardData = { + enabled: true, + title: 'Share your product feedback!', + body: richBody, + } + render() + expect(screen.getByRole('heading', { name: 'Share your product feedback!' })).toBeDefined() + expect(screen.getByText(/Tell us what you would like to see next\./)).toBeDefined() + }) +}) diff --git a/apps/web/src/components/public/feedback/portal-welcome-card.tsx b/apps/web/src/components/public/feedback/portal-welcome-card.tsx new file mode 100644 index 00000000..753f4aa3 --- /dev/null +++ b/apps/web/src/components/public/feedback/portal-welcome-card.tsx @@ -0,0 +1,35 @@ +import { RichTextContent } from '@/components/ui/rich-text-editor' +import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc' +import { cn } from '@/lib/shared/utils' +import type { PortalWelcomeCard as PortalWelcomeCardData } from '@/lib/shared/types/settings' + +interface PortalWelcomeCardProps { + welcomeCard: PortalWelcomeCardData | undefined +} + +export function PortalWelcomeCard({ welcomeCard }: PortalWelcomeCardProps) { + if (!welcomeCard?.enabled) return null + const trimmedTitle = welcomeCard.title.trim() + const hasTitle = trimmedTitle.length > 0 + const hasBody = !isEmptyTiptapDoc(welcomeCard.body) + if (!hasTitle && !hasBody) return null + + return ( +
+ {hasTitle && ( +

+ {trimmedTitle} +

+ )} + {hasBody && ( + + )} +
+ ) +} From e6f992709f7335e692ae34071ce90f0d33d69167 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 12:01:13 +0100 Subject: [PATCH 06/14] feat(portal): render welcome card above the feedback toolbar The _portal index loader pulls publicPortalConfig.welcomeCard from the already-fetched tenant settings and passes it through FeedbackContainer. The card renders as the first child of the main column on /portal/ and is absent from board pages, post detail, changelog, and roadmap. --- .../src/components/public/feedback/feedback-container.tsx | 7 +++++++ apps/web/src/routes/_portal/index.tsx | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/public/feedback/feedback-container.tsx b/apps/web/src/components/public/feedback/feedback-container.tsx index 3f5fb62d..baf0a8ff 100644 --- a/apps/web/src/components/public/feedback/feedback-container.tsx +++ b/apps/web/src/components/public/feedback/feedback-container.tsx @@ -4,6 +4,7 @@ import { useInfiniteScroll } from '@/lib/client/hooks/use-infinite-scroll' import { Spinner } from '@/components/shared/spinner' import { useRouter, useRouteContext } from '@tanstack/react-router' import { FeedbackHeader } from '@/components/public/feedback/feedback-header' +import { PortalWelcomeCard } from '@/components/public/feedback/portal-welcome-card' import { FeedbackSidebar } from '@/components/public/feedback/feedback-sidebar' import { FeedbackToolbar } from '@/components/public/feedback/feedback-toolbar' import { @@ -13,6 +14,7 @@ import { import { usePublicFilters } from '@/components/public/feedback/use-public-filters' import { PostCard } from '@/components/public/post-card' import type { BoardWithStats } from '@/lib/shared/types' +import type { PortalWelcomeCard as PortalWelcomeCardData } from '@/lib/shared/types/settings' import type { PostStatusEntity, Tag } from '@/lib/shared/db-types' import { useAuthBroadcast } from '@/lib/client/hooks/use-auth-broadcast' import { @@ -40,6 +42,8 @@ interface FeedbackContainerProps { user?: { name: string | null; email: string } | null /** Whether anonymous voting is enabled (visitors can vote without signing in) */ anonymousVotingEnabled?: boolean + /** Welcome card to render above the post list. Undefined / disabled = hidden. */ + welcomeCard?: PortalWelcomeCardData } export function FeedbackContainer({ @@ -57,6 +61,7 @@ export function FeedbackContainer({ defaultBoardId, user, anonymousVotingEnabled = false, + welcomeCard, }: FeedbackContainerProps): React.ReactElement { const intl = useIntl() const router = useRouter() @@ -208,6 +213,8 @@ export function FeedbackContainer({
+ + { @@ -100,7 +103,7 @@ function PublicPortalPage() { const intl = useIntl() const loaderData = Route.useLoaderData() const search = Route.useSearch() - const { org, session, anonymousVotingEnabled } = loaderData + const { org, session, anonymousVotingEnabled, welcomeCard } = loaderData // Read filters directly from URL for instant updates const currentBoard = search.board @@ -180,6 +183,7 @@ function PublicPortalPage() { defaultBoardId={portalData.boards[0]?.id} user={user} anonymousVotingEnabled={anonymousVotingEnabled} + welcomeCard={welcomeCard} />
) From ced571f4ff9b841a69c231c953bc29f07446fc12 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 12:12:23 +0100 Subject: [PATCH 07/14] feat(admin): portal settings page with welcome card editor New /admin/settings/portal route under Customization (between Branding and Widget) with an enable toggle, plain-text title input, TipTap rich-text body editor mirroring the post editor, and a live preview that re-renders the public component against form state. Auto-saves on 800 ms debounce; switch saves immediately. --- .../admin/settings/settings-nav.tsx | 2 + .../__tests__/portal-welcome-card.test.tsx | 2 +- apps/web/src/lib/shared/types/settings.ts | 1 + apps/web/src/routes/admin/settings.portal.tsx | 199 ++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/routes/admin/settings.portal.tsx diff --git a/apps/web/src/components/admin/settings/settings-nav.tsx b/apps/web/src/components/admin/settings/settings-nav.tsx index 5044a318..45ba98cc 100644 --- a/apps/web/src/components/admin/settings/settings-nav.tsx +++ b/apps/web/src/components/admin/settings/settings-nav.tsx @@ -16,6 +16,7 @@ import { BeakerIcon, BookOpenIcon, TagIcon, + MegaphoneIcon, } from '@heroicons/react/24/solid' import { cn } from '@/lib/shared/utils' import type { FeatureFlags } from '@/lib/shared/types' @@ -56,6 +57,7 @@ export function buildNavSections(flags?: { helpCenter?: boolean }): NavSection[] label: 'Customization', items: [ { label: 'Branding', to: '/admin/settings/branding', icon: PaintBrushIcon }, + { label: 'Portal', to: '/admin/settings/portal', icon: MegaphoneIcon }, { label: 'Widget', to: '/admin/settings/portal-widget', icon: ChatBubbleLeftRightIcon }, ], }, diff --git a/apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx b/apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx index 4f3748f2..1f6a4edb 100644 --- a/apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx +++ b/apps/web/src/components/public/feedback/__tests__/portal-welcome-card.test.tsx @@ -14,7 +14,7 @@ const richBody = { content: [{ type: 'text', text: 'Tell us what you would like to see next.' }], }, ], -} as const +} describe('', () => { it('renders nothing when welcomeCard is undefined', () => { diff --git a/apps/web/src/lib/shared/types/settings.ts b/apps/web/src/lib/shared/types/settings.ts index 9f201e22..33b71da2 100644 --- a/apps/web/src/lib/shared/types/settings.ts +++ b/apps/web/src/lib/shared/types/settings.ts @@ -11,6 +11,7 @@ export type { PortalAuthMethods, + PortalConfig, PortalWelcomeCard, TenantSettings, HelpCenterConfig, diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx new file mode 100644 index 00000000..e803e6e0 --- /dev/null +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo, useRef, useState, useTransition } from 'react' +import { createFileRoute, useRouter } from '@tanstack/react-router' +import { useSuspenseQuery } from '@tanstack/react-query' +import { MegaphoneIcon, ArrowPathIcon } from '@heroicons/react/24/solid' +import type { JSONContent } from '@tiptap/react' +import { BackLink } from '@/components/ui/back-link' +import { PageHeader } from '@/components/shared/page-header' +import { SettingsCard } from '@/components/admin/settings/settings-card' +import { Switch } from '@/components/ui/switch' +import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' +import { RichTextEditor } from '@/components/ui/rich-text-editor' +import { PortalWelcomeCard } from '@/components/public/feedback/portal-welcome-card' +import { settingsQueries } from '@/lib/client/queries/settings' +import { updatePortalConfigFn } from '@/lib/server/functions/settings' +import { useImageUpload } from '@/lib/client/hooks/use-image-upload' +import { DEFAULT_PORTAL_CONFIG, PORTAL_WELCOME_CARD_TITLE_MAX } from '@/lib/shared/types/settings' +import type { + PortalConfig, + PortalWelcomeCard as PortalWelcomeCardData, +} from '@/lib/shared/types/settings' +import type { TiptapContent } from '@/lib/shared/db-types' + +const DEBOUNCE_MS = 800 + +export const Route = createFileRoute('/admin/settings/portal')({ + loader: async ({ context }) => { + const { requireWorkspaceRole } = await import('@/lib/server/functions/workspace-utils') + await requireWorkspaceRole({ data: { allowedRoles: ['admin'] } }) + + const { queryClient } = context + await queryClient.ensureQueryData(settingsQueries.portalConfig()) + return {} + }, + component: PortalSettingsPage, +}) + +function InlineSpinner({ visible }: { visible: boolean }) { + if (!visible) return null + return +} + +function getInitialWelcomeCard(config: PortalConfig): PortalWelcomeCardData { + return { + ...DEFAULT_PORTAL_CONFIG.welcomeCard!, + ...config.welcomeCard, + } +} + +function PortalSettingsPage() { + const router = useRouter() + const portalConfigQuery = useSuspenseQuery(settingsQueries.portalConfig()) + const config = portalConfigQuery.data as PortalConfig + const initial = useMemo(() => getInitialWelcomeCard(config), [config]) + + const [enabled, setEnabled] = useState(initial.enabled) + const [title, setTitle] = useState(initial.title) + const [body, setBody] = useState(initial.body) + const [saving, setSaving] = useState(false) + const [isPending, startTransition] = useTransition() + const { upload: uploadImage } = useImageUpload({ prefix: 'portal-welcome' }) + + const titleTimeoutRef = useRef(null) + const bodyTimeoutRef = useRef(null) + + useEffect(() => { + return () => { + if (titleTimeoutRef.current) clearTimeout(titleTimeoutRef.current) + if (bodyTimeoutRef.current) clearTimeout(bodyTimeoutRef.current) + } + }, []) + + const isBusy = saving || isPending + + async function saveField(welcomeCard: Partial) { + setSaving(true) + try { + await updatePortalConfigFn({ data: { welcomeCard } }) + startTransition(() => router.invalidate()) + } finally { + setSaving(false) + } + } + + function handleEnabledToggle(checked: boolean) { + setEnabled(checked) + saveField({ enabled: checked }) + } + + function handleTitleChange(value: string) { + setTitle(value) + if (titleTimeoutRef.current) clearTimeout(titleTimeoutRef.current) + titleTimeoutRef.current = setTimeout(() => { + saveField({ title: value }) + }, DEBOUNCE_MS) + } + + function handleBodyChange(json: JSONContent) { + const next = json as TiptapContent + setBody(next) + if (bodyTimeoutRef.current) clearTimeout(bodyTimeoutRef.current) + bodyTimeoutRef.current = setTimeout(() => { + saveField({ body: next }) + }, DEBOUNCE_MS) + } + + const previewCard: PortalWelcomeCardData = { enabled: true, title, body } + + return ( +
+
+ Settings +
+ + + +
+
+
+ +

+ Shown at the top of the portal home page above the post list +

+
+
+ + +
+
+ +
+ + handleTitleChange(e.target.value)} + placeholder="Share your product feedback!" + maxLength={PORTAL_WELCOME_CARD_TITLE_MAX} + disabled={!enabled || isBusy} + /> +
+ +
+ + handleBodyChange(json)} + placeholder="Tell visitors what kind of feedback you'd love to hear…" + minHeight="160px" + disabled={!enabled || isBusy} + features={{ + headings: true, + images: true, + codeBlocks: true, + taskLists: true, + blockquotes: true, + tables: true, + dividers: true, + bubbleMenu: true, + slashMenu: true, + embeds: true, + }} + onImageUpload={uploadImage} + /> +
+ +
+

Preview

+
+ + {!title.trim() && !previewCard.body?.content?.length && ( +

+ Add a title or message to see the welcome card preview +

+ )} +
+
+
+
+
+ ) +} From 217e3ad4aa8b4de20fc816c716dd3cf13e36ff6b Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 12:19:40 +0100 Subject: [PATCH 08/14] fix(portal): tighten welcome-card save flow and renderer Code-review pass on the welcome card surfaced five concrete bugs: - updatePortalConfig no longer bumps the auth config version or calls resetAuth() for non-oauth edits, so every debounced welcome-card save doesn't trigger a Better-Auth rebuild on every pod. - Toggling the enable switch now flushes any pending title/body debounce so the timer can't fire afterwards and silently overwrite the new enabled state. - The admin form re-syncs from the server on background refetch, but skips the sync while the user has unflushed edits so typing isn't clobbered. - The preview empty-check now uses isEmptyTiptapDoc, matching the public renderer; previously the placeholder hint vanished as soon as the editor had a default empty paragraph. - isEmptyTiptapDoc enforces a recursion depth cap as a stack-safety guard for inputs that bypass the server sanitizer. --- .../domains/settings/settings.service.ts | 31 +++++++----- .../lib/shared/utils/is-empty-tiptap-doc.ts | 11 +++-- apps/web/src/routes/admin/settings.portal.tsx | 47 +++++++++++++++---- 3 files changed, 66 insertions(+), 23 deletions(-) diff --git a/apps/web/src/lib/server/domains/settings/settings.service.ts b/apps/web/src/lib/server/domains/settings/settings.service.ts index f3a9f808..64b52d55 100644 --- a/apps/web/src/lib/server/domains/settings/settings.service.ts +++ b/apps/web/src/lib/server/domains/settings/settings.service.ts @@ -617,21 +617,28 @@ export async function updatePortalConfig(input: UpdatePortalConfigInput): Promis ) } - // Provider registration in `auth/index.ts` reads - // portalConfig.oauth at build time — toggling a portal OAuth - // provider must invalidate other pods' Better-Auth instances or - // they'll keep serving the stale provider list until cache TTL. - // Mirrors the bump+resetAuth pattern in updateAuthConfig. - const { bumpAuthConfigVersionInTx } = await import('@/lib/server/auth/config-version') - const { resetAuth } = await import('@/lib/server/auth') - await db.transaction(async (tx) => { - await tx + // Provider registration in `auth/index.ts` reads portalConfig.oauth at + // build time — toggling a portal OAuth provider must invalidate other + // pods' Better-Auth instances or they'll keep serving the stale provider + // list until cache TTL. Skip the bump for non-oauth edits (e.g. the + // welcome card debounce-saves) to avoid an auth rebuild per keystroke. + if (input.oauth !== undefined) { + const { bumpAuthConfigVersionInTx } = await import('@/lib/server/auth/config-version') + const { resetAuth } = await import('@/lib/server/auth') + await db.transaction(async (tx) => { + await tx + .update(settings) + .set({ portalConfig: JSON.stringify(updated) }) + .where(eq(settings.id, org.id)) + await bumpAuthConfigVersionInTx(tx) + }) + resetAuth() + } else { + await db .update(settings) .set({ portalConfig: JSON.stringify(updated) }) .where(eq(settings.id, org.id)) - await bumpAuthConfigVersionInTx(tx) - }) - resetAuth() + } await invalidateSettingsCache() return updated } catch (error) { diff --git a/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts b/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts index e9cf05bc..a1310084 100644 --- a/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts +++ b/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts @@ -13,15 +13,20 @@ export function isEmptyTiptapDoc(doc: TiptapContent | undefined): boolean { if (!doc) return true const content = doc.content if (!content || content.length === 0) return true - return content.every(isEmptyNode) + return content.every((child) => isEmptyNode(child, 0)) } -function isEmptyNode(node: TiptapContent): boolean { +// Stack-safety guard for pathological inputs reaching this helper before +// server-side sanitization (e.g. cached SSR payloads, third-party clients). +const MAX_DEPTH = 50 + +function isEmptyNode(node: TiptapContent, depth: number): boolean { + if (depth > MAX_DEPTH) return false // Text-bearing block: empty if all text descendants are whitespace. if (node.type === 'paragraph' || node.type === 'heading' || node.type === 'blockquote') { const children = node.content if (!children || children.length === 0) return true - return children.every(isEmptyNode) + return children.every((child) => isEmptyNode(child, depth + 1)) } if (node.type === 'text') { return (node.text ?? '').trim().length === 0 diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx index e803e6e0..e39215d6 100644 --- a/apps/web/src/routes/admin/settings.portal.tsx +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -20,6 +20,7 @@ import type { PortalWelcomeCard as PortalWelcomeCardData, } from '@/lib/shared/types/settings' import type { TiptapContent } from '@/lib/shared/db-types' +import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc' const DEBOUNCE_MS = 800 @@ -62,13 +63,33 @@ function PortalSettingsPage() { const titleTimeoutRef = useRef(null) const bodyTimeoutRef = useRef(null) + // Tracks whether the user has unflushed edits. While true, we skip + // remote re-sync so a background refetch doesn't clobber the typed-but- + // not-yet-saved value with the older server copy. + const hasPendingEditsRef = useRef(false) + + function clearPendingTimers() { + if (titleTimeoutRef.current) { + clearTimeout(titleTimeoutRef.current) + titleTimeoutRef.current = null + } + if (bodyTimeoutRef.current) { + clearTimeout(bodyTimeoutRef.current) + bodyTimeoutRef.current = null + } + } + useEffect(() => clearPendingTimers, []) + + // Re-sync local form state when the server-side config changes (background + // refetch, another admin's edit, router invalidate) — but only if the user + // has nothing in-flight, so we never overwrite their unsaved input. useEffect(() => { - return () => { - if (titleTimeoutRef.current) clearTimeout(titleTimeoutRef.current) - if (bodyTimeoutRef.current) clearTimeout(bodyTimeoutRef.current) - } - }, []) + if (hasPendingEditsRef.current) return + setEnabled(initial.enabled) + setTitle(initial.title) + setBody(initial.body) + }, [initial]) const isBusy = saving || isPending @@ -76,6 +97,7 @@ function PortalSettingsPage() { setSaving(true) try { await updatePortalConfigFn({ data: { welcomeCard } }) + hasPendingEditsRef.current = false startTransition(() => router.invalidate()) } finally { setSaving(false) @@ -83,12 +105,18 @@ function PortalSettingsPage() { } function handleEnabledToggle(checked: boolean) { + // Toggling enabled is an immediate save — flush any in-flight title/body + // edits first so the debounce can't fire afterwards and silently + // overwrite the toggle's new state. + clearPendingTimers() setEnabled(checked) - saveField({ enabled: checked }) + hasPendingEditsRef.current = false + saveField({ enabled: checked, title, body }) } function handleTitleChange(value: string) { setTitle(value) + hasPendingEditsRef.current = true if (titleTimeoutRef.current) clearTimeout(titleTimeoutRef.current) titleTimeoutRef.current = setTimeout(() => { saveField({ title: value }) @@ -98,6 +126,7 @@ function PortalSettingsPage() { function handleBodyChange(json: JSONContent) { const next = json as TiptapContent setBody(next) + hasPendingEditsRef.current = true if (bodyTimeoutRef.current) clearTimeout(bodyTimeoutRef.current) bodyTimeoutRef.current = setTimeout(() => { saveField({ body: next }) @@ -105,6 +134,7 @@ function PortalSettingsPage() { } const previewCard: PortalWelcomeCardData = { enabled: true, title, body } + const isPreviewEmpty = !title.trim() && isEmptyTiptapDoc(body) return (
@@ -184,11 +214,12 @@ function PortalSettingsPage() {

Preview

- - {!title.trim() && !previewCard.body?.content?.length && ( + {isPreviewEmpty ? (

Add a title or message to see the welcome card preview

+ ) : ( + )}
From c741638bca9066bd365aef261dae2fa808ef9192 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 12:29:34 +0100 Subject: [PATCH 09/14] fix(portal): close codex-review gaps in welcome-card save path - Welcome card body now replaces wholesale on save instead of being deep-merged. A sanitized empty doc must clear previous content, not silently retain it. New mergeWelcomeCard helper handles the special semantics; covered by unit tests. - updatePortalConfig partial saves no longer race: the admin form now uses a single debounce timer that sends a full welcomeCard snapshot, so two concurrent saves write the same fields rather than each overwriting the other. A monotonic seq guard makes only the latest save reset the pending-edit flag, so an in-flight resolve doesn't reopen background re-sync while the user is still typing. - getPublicPortalConfig and getTenantSettings now mask the welcome card when enabled=false, so draft title/body content can't leak out through the public portal config endpoint. --- .../normalize-welcome-card-input.test.ts | 62 ++++++++++++++++- .../domains/settings/settings.helpers.ts | 36 +++++++++- .../domains/settings/settings.service.ts | 33 ++++++---- apps/web/src/routes/admin/settings.portal.tsx | 66 +++++++++---------- 4 files changed, 150 insertions(+), 47 deletions(-) diff --git a/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts index 420fa7ac..5fb0b84c 100644 --- a/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts +++ b/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { ValidationError } from '@/lib/shared/errors' -import { normalizeWelcomeCardInput } from '../settings.helpers' +import { mergeWelcomeCard, normalizeWelcomeCardInput, publicWelcomeCard } from '../settings.helpers' +import { DEFAULT_PORTAL_CONFIG } from '../settings.types' describe('normalizeWelcomeCardInput', () => { it('returns the input unchanged when undefined', () => { @@ -53,3 +54,62 @@ describe('normalizeWelcomeCardInput', () => { expect(out?.body).toEqual({ type: 'doc' }) }) }) + +describe('mergeWelcomeCard', () => { + const seed = DEFAULT_PORTAL_CONFIG.welcomeCard! + + it('returns existing when partial is undefined', () => { + expect(mergeWelcomeCard(seed, undefined)).toBe(seed) + }) + + it('overrides scalar fields shallowly', () => { + const out = mergeWelcomeCard(seed, { enabled: true, title: 'Hi' }) + expect(out.enabled).toBe(true) + expect(out.title).toBe('Hi') + expect(out.body).toEqual(seed.body) + }) + + it('replaces the body wholesale rather than deep-merging it', () => { + const existing = { + enabled: true, + title: 'Old', + body: { + type: 'doc', + content: [ + { + type: 'paragraph', + content: [{ type: 'text', text: 'old text' }], + }, + ], + }, + } + const out = mergeWelcomeCard(existing, { body: { type: 'doc' } }) + expect(out.body).toEqual({ type: 'doc' }) + expect(out.body.content).toBeUndefined() + expect(out.title).toBe('Old') + }) + + it('falls back to defaults when there is no existing card', () => { + const out = mergeWelcomeCard(undefined, { enabled: true }) + expect(out.enabled).toBe(true) + expect(out.title).toBe(seed.title) + expect(out.body).toEqual(seed.body) + }) +}) + +describe('publicWelcomeCard', () => { + it('returns undefined when the card is undefined', () => { + expect(publicWelcomeCard(undefined)).toBeUndefined() + }) + + it('returns undefined when the card is disabled — never expose drafts', () => { + expect( + publicWelcomeCard({ enabled: false, title: 'draft', body: { type: 'doc' } }) + ).toBeUndefined() + }) + + it('returns the card verbatim when enabled', () => { + const card = { enabled: true, title: 'Hi', body: { type: 'doc' } } + expect(publicWelcomeCard(card)).toEqual(card) + }) +}) diff --git a/apps/web/src/lib/server/domains/settings/settings.helpers.ts b/apps/web/src/lib/server/domains/settings/settings.helpers.ts index fcbc0d1f..baeaedbc 100644 --- a/apps/web/src/lib/server/domains/settings/settings.helpers.ts +++ b/apps/web/src/lib/server/domains/settings/settings.helpers.ts @@ -6,7 +6,11 @@ import { db } from '@/lib/server/db' import { cacheDel, CACHE_KEYS } from '@/lib/server/redis' import { NotFoundError, InternalError, ValidationError } from '@/lib/shared/errors' import { sanitizeTiptapContent } from '@/lib/server/sanitize-tiptap' -import { PORTAL_WELCOME_CARD_TITLE_MAX, type PortalWelcomeCard } from './settings.types' +import { + DEFAULT_PORTAL_CONFIG, + PORTAL_WELCOME_CARD_TITLE_MAX, + type PortalWelcomeCard, +} from './settings.types' export type SettingsRecord = NonNullable>> @@ -75,6 +79,36 @@ export async function invalidateSettingsCache(): Promise { await cacheDel(CACHE_KEYS.TENANT_SETTINGS) } +/** + * Merge a partial `welcomeCard` update into the stored card. Unlike + * {@link deepMerge}, the `body` field is replaced wholesale — a TipTap + * doc with no `content` must clear the previous content, not retain it. + * + * @internal + */ +export function mergeWelcomeCard( + existing: PortalWelcomeCard | undefined, + input: Partial | undefined +): PortalWelcomeCard { + const base = existing ?? DEFAULT_PORTAL_CONFIG.welcomeCard! + if (!input) return existing ?? base + return { ...base, ...input } +} + +/** + * Project a stored welcome card for public consumption. Disabled cards + * have draft title/body that must not leak through the public portal + * config endpoint. + * + * @internal + */ +export function publicWelcomeCard( + card: PortalWelcomeCard | undefined +): PortalWelcomeCard | undefined { + if (!card?.enabled) return undefined + return card +} + /** * Normalize a partial `welcomeCard` update before it's merged into stored * portalConfig. Trims the title, enforces the length cap, and runs the diff --git a/apps/web/src/lib/server/domains/settings/settings.service.ts b/apps/web/src/lib/server/domains/settings/settings.service.ts index 64b52d55..d6142782 100644 --- a/apps/web/src/lib/server/domains/settings/settings.service.ts +++ b/apps/web/src/lib/server/domains/settings/settings.service.ts @@ -36,6 +36,8 @@ import { wrapDbError, invalidateSettingsCache, normalizeWelcomeCardInput, + mergeWelcomeCard, + publicWelcomeCard, } from './settings.helpers' async function getConfiguredAuthTypes(): Promise> { @@ -601,13 +603,16 @@ export async function getPortalConfig(): Promise { export async function updatePortalConfig(input: UpdatePortalConfigInput): Promise { console.log(`[domain:settings] updatePortalConfig`) try { - const normalized: UpdatePortalConfigInput = { - ...input, - welcomeCard: normalizeWelcomeCardInput(input.welcomeCard), - } + const normalizedWelcome = normalizeWelcomeCardInput(input.welcomeCard) + const inputWithoutWelcome: UpdatePortalConfigInput = { ...input } + delete inputWithoutWelcome.welcomeCard const org = await requireSettings() const existing = parseJsonConfig(org.portalConfig, DEFAULT_PORTAL_CONFIG) - const updated = deepMerge(existing, normalized as Partial) + const updated = deepMerge(existing, inputWithoutWelcome as Partial) + // welcomeCard.body must replace, not deep-merge — see mergeWelcomeCard. + if (normalizedWelcome) { + updated.welcomeCard = mergeWelcomeCard(existing.welcomeCard, normalizedWelcome) + } const hasAuthMethod = Object.values(updated.oauth).some(Boolean) if (!hasAuthMethod) { @@ -753,11 +758,12 @@ export async function getPublicPortalConfig(): Promise { passthroughKeys ) const customProviderNames = await getCustomProviderNames(filteredOAuth, configuredTypes) + const welcome = publicWelcomeCard(portalConfig.welcomeCard) return { oauth: filteredOAuth, features: portalConfig.features, ...(customProviderNames && { customProviderNames }), - ...(portalConfig.welcomeCard && { welcomeCard: portalConfig.welcomeCard }), + ...(welcome && { welcomeCard: welcome }), } } catch (error) { console.error(`[domain:settings] getPublicPortalConfig failed:`, error) @@ -834,12 +840,15 @@ export async function getTenantSettings(): Promise { oauth: filteredAuthOAuth, openSignup: authConfig.openSignup, }, - publicPortalConfig: { - oauth: filteredPortalOAuth, - features: portalConfig.features, - ...(portalCustomNames && { customProviderNames: portalCustomNames }), - ...(portalConfig.welcomeCard && { welcomeCard: portalConfig.welcomeCard }), - }, + publicPortalConfig: (() => { + const welcome = publicWelcomeCard(portalConfig.welcomeCard) + return { + oauth: filteredPortalOAuth, + features: portalConfig.features, + ...(portalCustomNames && { customProviderNames: portalCustomNames }), + ...(welcome && { welcomeCard: welcome }), + } + })(), publicWidgetConfig: { enabled: widgetConfig.enabled, defaultBoard: widgetConfig.defaultBoard, diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx index e39215d6..4f828e12 100644 --- a/apps/web/src/routes/admin/settings.portal.tsx +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -61,25 +61,24 @@ function PortalSettingsPage() { const [isPending, startTransition] = useTransition() const { upload: uploadImage } = useImageUpload({ prefix: 'portal-welcome' }) - const titleTimeoutRef = useRef(null) - const bodyTimeoutRef = useRef(null) + const saveTimerRef = useRef(null) // Tracks whether the user has unflushed edits. While true, we skip // remote re-sync so a background refetch doesn't clobber the typed-but- // not-yet-saved value with the older server copy. const hasPendingEditsRef = useRef(false) - - function clearPendingTimers() { - if (titleTimeoutRef.current) { - clearTimeout(titleTimeoutRef.current) - titleTimeoutRef.current = null - } - if (bodyTimeoutRef.current) { - clearTimeout(bodyTimeoutRef.current) - bodyTimeoutRef.current = null + // Each save bumps this; only the latest save's resolution clears the + // pending flag, so an in-flight save returning while the user is still + // typing doesn't reopen the door to a stale background re-sync. + const saveSeqRef = useRef(0) + + function clearPendingTimer() { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + saveTimerRef.current = null } } - useEffect(() => clearPendingTimers, []) + useEffect(() => clearPendingTimer, []) // Re-sync local form state when the server-side config changes (background // refetch, another admin's edit, router invalidate) — but only if the user @@ -93,44 +92,45 @@ function PortalSettingsPage() { const isBusy = saving || isPending - async function saveField(welcomeCard: Partial) { + async function saveSnapshot(card: PortalWelcomeCardData) { + const seq = ++saveSeqRef.current setSaving(true) try { - await updatePortalConfigFn({ data: { welcomeCard } }) - hasPendingEditsRef.current = false - startTransition(() => router.invalidate()) + await updatePortalConfigFn({ data: { welcomeCard: card } }) + if (seq === saveSeqRef.current) { + hasPendingEditsRef.current = false + startTransition(() => router.invalidate()) + } } finally { - setSaving(false) + if (seq === saveSeqRef.current) setSaving(false) } } + function scheduleSave(card: PortalWelcomeCardData) { + hasPendingEditsRef.current = true + clearPendingTimer() + saveTimerRef.current = setTimeout(() => { + saveSnapshot(card) + }, DEBOUNCE_MS) + } + function handleEnabledToggle(checked: boolean) { - // Toggling enabled is an immediate save — flush any in-flight title/body - // edits first so the debounce can't fire afterwards and silently - // overwrite the toggle's new state. - clearPendingTimers() + clearPendingTimer() setEnabled(checked) - hasPendingEditsRef.current = false - saveField({ enabled: checked, title, body }) + // Toggling is immediate so admins see the public-portal state flip + // without waiting on a debounce. + saveSnapshot({ enabled: checked, title, body }) } function handleTitleChange(value: string) { setTitle(value) - hasPendingEditsRef.current = true - if (titleTimeoutRef.current) clearTimeout(titleTimeoutRef.current) - titleTimeoutRef.current = setTimeout(() => { - saveField({ title: value }) - }, DEBOUNCE_MS) + scheduleSave({ enabled, title: value, body }) } function handleBodyChange(json: JSONContent) { const next = json as TiptapContent setBody(next) - hasPendingEditsRef.current = true - if (bodyTimeoutRef.current) clearTimeout(bodyTimeoutRef.current) - bodyTimeoutRef.current = setTimeout(() => { - saveField({ body: next }) - }, DEBOUNCE_MS) + scheduleSave({ enabled, title, body: next }) } const previewCard: PortalWelcomeCardData = { enabled: true, title, body } From 290f202ffbe4ef05d91b4107368c630ee0373253 Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 12:37:58 +0100 Subject: [PATCH 10/14] test(auth): stub input-otp to stop post-teardown timer leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit input-otp schedules three real setTimeouts (0/10/50ms) on mount for password-manager detection. Under happy-dom those timers can fire after the test environment has torn down `window`, surfacing as an unhandled "window is not defined" error that fails the CI run. The portal-auth-form tests don't exercise OTP-input behaviour — they just fireEvent.change the rendered control — so stub the library with a plain text input. --- .../auth/__tests__/portal-auth-form.test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx b/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx index 2c89e67d..1423049c 100644 --- a/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx +++ b/apps/web/src/components/auth/__tests__/portal-auth-form.test.tsx @@ -46,6 +46,49 @@ vi.mock('@/lib/server/functions/auth', () => ({ 'Single sign-on is configured for your domain but is not currently available. Contact your administrator.', })) +// input-otp schedules three real setTimeouts (0/10/50 ms) on mount for +// password-manager detection. Under happy-dom those timers can fire +// after the test env has torn down `window`, surfacing as an unhandled +// "window is not defined" error that fails the run. The tests here +// don't exercise OTP-input behaviour — they just fire `change` on the +// rendered control — so stub the library with a plain text input. +vi.mock('@/components/ui/input-otp', () => ({ + InputOTP: ({ + children, + onChange, + onComplete, + value, + maxLength, + ['aria-label']: ariaLabel, + ...rest + }: { + children?: React.ReactNode + onChange?: (value: string) => void + onComplete?: (value: string) => void + value?: string + maxLength?: number + 'aria-label'?: string + } & React.InputHTMLAttributes) => ( + <> + { + const next = e.target.value + onChange?.(next) + if (maxLength && next.length === maxLength) onComplete?.(next) + }} + {...rest} + /> + {children} + + ), + InputOTPGroup: ({ children }: { children?: React.ReactNode }) => <>{children}, + InputOTPSlot: () => null, + InputOTPSeparator: () => null, +})) + import { PortalAuthForm } from '../portal-auth-form' import { authClient } from '@/lib/client/auth-client' From 8f47e8b624610752f84e2c1a6ce6f4623e18106b Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 12:44:04 +0100 Subject: [PATCH 11/14] refactor(settings): polish welcome-card and dedupe InlineSpinner Second simplify pass surfaced three concrete improvements: - Race fix in the admin form: scheduleSave now claims a saveSeqRef slot when it arms the debounce, so an in-flight earlier save (e.g. an immediate switch-toggle) can't resolve with a matching seq and prematurely clear hasPendingEditsRef while the typed-but-not-yet- saved snapshot is still queued. - Live preview no longer reruns DOMPurify.sanitize + generateContentHTML on every title keystroke: previewCard is now useMemo'd on title+body, and PortalWelcomeCard is wrapped in React.memo, so a title edit alone skips the body render path. - Extract InlineSpinner to a shared component (components/admin/settings/inline-spinner.tsx). settings.portal, settings.help-center, settings.portal-widget and settings.widget all carried byte-identical copies of the same 4-line component; the new file replaces all four. --- .../admin/settings/inline-spinner.tsx | 10 ++++++++ .../public/feedback/portal-welcome-card.tsx | 8 ++++++- .../src/routes/admin/settings.help-center.tsx | 8 ++----- .../routes/admin/settings.portal-widget.tsx | 6 +---- apps/web/src/routes/admin/settings.portal.tsx | 24 +++++++++++-------- apps/web/src/routes/admin/settings.widget.tsx | 6 +---- 6 files changed, 35 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/admin/settings/inline-spinner.tsx diff --git a/apps/web/src/components/admin/settings/inline-spinner.tsx b/apps/web/src/components/admin/settings/inline-spinner.tsx new file mode 100644 index 00000000..9b025a67 --- /dev/null +++ b/apps/web/src/components/admin/settings/inline-spinner.tsx @@ -0,0 +1,10 @@ +import { ArrowPathIcon } from '@heroicons/react/24/solid' + +/** + * Tiny inline saving indicator used by debounced-save settings pages. + * Renders nothing when `visible` is false. + */ +export function InlineSpinner({ visible }: { visible: boolean }) { + if (!visible) return null + return +} diff --git a/apps/web/src/components/public/feedback/portal-welcome-card.tsx b/apps/web/src/components/public/feedback/portal-welcome-card.tsx index 753f4aa3..9e2331e6 100644 --- a/apps/web/src/components/public/feedback/portal-welcome-card.tsx +++ b/apps/web/src/components/public/feedback/portal-welcome-card.tsx @@ -1,3 +1,4 @@ +import { memo } from 'react' import { RichTextContent } from '@/components/ui/rich-text-editor' import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc' import { cn } from '@/lib/shared/utils' @@ -7,7 +8,7 @@ interface PortalWelcomeCardProps { welcomeCard: PortalWelcomeCardData | undefined } -export function PortalWelcomeCard({ welcomeCard }: PortalWelcomeCardProps) { +function PortalWelcomeCardImpl({ welcomeCard }: PortalWelcomeCardProps) { if (!welcomeCard?.enabled) return null const trimmedTitle = welcomeCard.title.trim() const hasTitle = trimmedTitle.length > 0 @@ -33,3 +34,8 @@ export function PortalWelcomeCard({ welcomeCard }: PortalWelcomeCardProps) { ) } + +// Body rendering goes through DOMPurify.sanitize and TipTap's +// generateContentHTML, so memoize on the welcomeCard reference to keep +// the admin live-preview cheap on title-only keystrokes. +export const PortalWelcomeCard = memo(PortalWelcomeCardImpl) diff --git a/apps/web/src/routes/admin/settings.help-center.tsx b/apps/web/src/routes/admin/settings.help-center.tsx index 7b8ff45a..62b78883 100644 --- a/apps/web/src/routes/admin/settings.help-center.tsx +++ b/apps/web/src/routes/admin/settings.help-center.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect, useTransition } from 'react' import { createFileRoute, useRouter } from '@tanstack/react-router' import { useSuspenseQuery } from '@tanstack/react-query' -import { BookOpenIcon, ArrowPathIcon } from '@heroicons/react/24/solid' +import { BookOpenIcon } from '@heroicons/react/24/solid' +import { InlineSpinner } from '@/components/admin/settings/inline-spinner' import { BackLink } from '@/components/ui/back-link' import { PageHeader } from '@/components/shared/page-header' import { SettingsCard } from '@/components/admin/settings/settings-card' @@ -24,11 +25,6 @@ export const Route = createFileRoute('/admin/settings/help-center')({ component: HelpCenterSettingsPage, }) -function InlineSpinner({ visible }: { visible: boolean }) { - if (!visible) return null - return -} - function HelpCenterSettingsPage() { const router = useRouter() const helpCenterConfigQuery = useSuspenseQuery(settingsQueries.helpCenterConfig()) diff --git a/apps/web/src/routes/admin/settings.portal-widget.tsx b/apps/web/src/routes/admin/settings.portal-widget.tsx index 798ec3aa..a54dcf73 100644 --- a/apps/web/src/routes/admin/settings.portal-widget.tsx +++ b/apps/web/src/routes/admin/settings.portal-widget.tsx @@ -24,6 +24,7 @@ import { BrandingPreviewPanel, } from '@/components/admin/settings/branding/branding-layout' import { WidgetPreview } from '@/components/admin/settings/widget/widget-preview' +import { InlineSpinner } from '@/components/admin/settings/inline-spinner' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Button } from '@/components/ui/button' @@ -57,11 +58,6 @@ export const Route = createFileRoute('/admin/settings/portal-widget')({ component: PortalWidgetSettingsPage, }) -function InlineSpinner({ visible }: { visible: boolean }) { - if (!visible) return null - return -} - function PortalWidgetSettingsPage() { const widgetConfigQuery = useSuspenseQuery(settingsQueries.widgetConfig()) const widgetSecretQuery = useSuspenseQuery(settingsQueries.widgetSecret()) diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx index 4f828e12..64b92e37 100644 --- a/apps/web/src/routes/admin/settings.portal.tsx +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState, useTransition } from 'react' import { createFileRoute, useRouter } from '@tanstack/react-router' import { useSuspenseQuery } from '@tanstack/react-query' -import { MegaphoneIcon, ArrowPathIcon } from '@heroicons/react/24/solid' +import { MegaphoneIcon } from '@heroicons/react/24/solid' import type { JSONContent } from '@tiptap/react' import { BackLink } from '@/components/ui/back-link' import { PageHeader } from '@/components/shared/page-header' import { SettingsCard } from '@/components/admin/settings/settings-card' +import { InlineSpinner } from '@/components/admin/settings/inline-spinner' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' @@ -36,11 +37,6 @@ export const Route = createFileRoute('/admin/settings/portal')({ component: PortalSettingsPage, }) -function InlineSpinner({ visible }: { visible: boolean }) { - if (!visible) return null - return -} - function getInitialWelcomeCard(config: PortalConfig): PortalWelcomeCardData { return { ...DEFAULT_PORTAL_CONFIG.welcomeCard!, @@ -92,8 +88,8 @@ function PortalSettingsPage() { const isBusy = saving || isPending - async function saveSnapshot(card: PortalWelcomeCardData) { - const seq = ++saveSeqRef.current + async function saveSnapshot(card: PortalWelcomeCardData, claimedSeq?: number) { + const seq = claimedSeq ?? ++saveSeqRef.current setSaving(true) try { await updatePortalConfigFn({ data: { welcomeCard: card } }) @@ -109,8 +105,13 @@ function PortalSettingsPage() { function scheduleSave(card: PortalWelcomeCardData) { hasPendingEditsRef.current = true clearPendingTimer() + // Reserve the seq slot now so an in-flight earlier save (e.g. a toggle + // fired immediately before this debounce armed) can't resolve with a + // matching seq and prematurely clear `hasPendingEditsRef` while the + // typed-but-not-yet-saved value is still queued. + const claimedSeq = ++saveSeqRef.current saveTimerRef.current = setTimeout(() => { - saveSnapshot(card) + saveSnapshot(card, claimedSeq) }, DEBOUNCE_MS) } @@ -133,7 +134,10 @@ function PortalSettingsPage() { scheduleSave({ enabled, title, body: next }) } - const previewCard: PortalWelcomeCardData = { enabled: true, title, body } + const previewCard = useMemo( + () => ({ enabled: true, title, body }), + [title, body] + ) const isPreviewEmpty = !title.trim() && isEmptyTiptapDoc(body) return ( diff --git a/apps/web/src/routes/admin/settings.widget.tsx b/apps/web/src/routes/admin/settings.widget.tsx index ef121e40..eea7e1e4 100644 --- a/apps/web/src/routes/admin/settings.widget.tsx +++ b/apps/web/src/routes/admin/settings.widget.tsx @@ -24,6 +24,7 @@ import { BrandingPreviewPanel, } from '@/components/admin/settings/branding/branding-layout' import { WidgetPreview } from '@/components/admin/settings/widget/widget-preview' +import { InlineSpinner } from '@/components/admin/settings/inline-spinner' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Button } from '@/components/ui/button' @@ -102,11 +103,6 @@ export const Route = createFileRoute('/admin/settings/widget')({ component: WidgetSettingsPage, }) -function InlineSpinner({ visible }: { visible: boolean }) { - if (!visible) return null - return -} - function WidgetSettingsPage() { const widgetConfigQuery = useSuspenseQuery(settingsQueries.widgetConfig()) const widgetSecretQuery = useSuspenseQuery(settingsQueries.widgetSecret()) From 8c5dad2ff7512d1493a134896f978e1eebe478ba Mon Sep 17 00:00:00 2001 From: James Morton Date: Fri, 15 May 2026 15:12:15 +0100 Subject: [PATCH 12/14] fix(portal): keep welcome card draft editable while disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggling the card off disabled the title and message inputs, so admins couldn't edit a stale draft from a previous announcement before turning it back on. The toggle's immediate save then published that stale draft on the public portal the instant they flipped the switch. Leave the fields editable when the card is disabled. The debounced auto-save still persists edits with `enabled: false`, and the public portal hides the card regardless of content while disabled. Flipping on publishes whatever the admin has typed by then, which matches the draft → publish flow they expect. --- apps/web/src/routes/admin/settings.portal.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx index 64b92e37..03dd4f72 100644 --- a/apps/web/src/routes/admin/settings.portal.tsx +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -177,6 +177,9 @@ function PortalSettingsPage() {
+ {/* Title and message stay editable when the card is disabled so + admins can draft the next announcement without it going live + the moment they flip the switch on. */}
@@ -198,7 +201,7 @@ function PortalSettingsPage() { onChange={(json) => handleBodyChange(json)} placeholder="Tell visitors what kind of feedback you'd love to hear…" minHeight="160px" - disabled={!enabled || isBusy} + disabled={isBusy} features={{ headings: true, images: true, From fae7ba9a4d94f7df3b9c872620177d5bf882f0aa Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 16 May 2026 01:35:54 +0100 Subject: [PATCH 13/14] fix(portal): stop welcome-card autosave loop on settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page initialised local state via useMemo+useEffect and re-synced from the server-side config whenever the route invalidated. After each save, router.invalidate refetched the canonical config, the resync effect called setBody(initial.body), and the new body reference fed back into RichTextEditor whose value-sync setContent fired onUpdate → onChange → scheduleSave → another save. Because the server-side sanitizer normalises the body differently than TipTap's editor.getJSON(), the two strings never converged, so the loop never terminated. Drop the resync effect entirely and initialise state once from the loader-warmed query, matching the pattern used by settings.help-center.tsx and settings.portal-widget.tsx — both of which treat local state as the source of truth post-mount and never run into this race. router.invalidate still fires after each save so the cache is refreshed for the next visit, but the live form fields are no longer overwritten mid-edit. --- apps/web/src/routes/admin/settings.portal.tsx | 60 ++++++------------- 1 file changed, 17 insertions(+), 43 deletions(-) diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx index 03dd4f72..6b6f378b 100644 --- a/apps/web/src/routes/admin/settings.portal.tsx +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -37,35 +37,29 @@ export const Route = createFileRoute('/admin/settings/portal')({ component: PortalSettingsPage, }) -function getInitialWelcomeCard(config: PortalConfig): PortalWelcomeCardData { - return { - ...DEFAULT_PORTAL_CONFIG.welcomeCard!, - ...config.welcomeCard, - } -} - function PortalSettingsPage() { const router = useRouter() const portalConfigQuery = useSuspenseQuery(settingsQueries.portalConfig()) const config = portalConfigQuery.data as PortalConfig - const initial = useMemo(() => getInitialWelcomeCard(config), [config]) - const [enabled, setEnabled] = useState(initial.enabled) - const [title, setTitle] = useState(initial.title) - const [body, setBody] = useState(initial.body) + // Same pattern as settings.help-center.tsx / settings.portal-widget.tsx: + // initialize local state once from the loader-warmed query, then treat + // local state as the source of truth post-mount. router.invalidate after + // each save refreshes the cache for the next visit, but we never re-sync + // the live form fields from it — that would race the server-side + // sanitizer's normalisation back into the editor and reopen a save loop. + const [enabled, setEnabled] = useState(config.welcomeCard?.enabled ?? false) + const [title, setTitle] = useState( + config.welcomeCard?.title ?? DEFAULT_PORTAL_CONFIG.welcomeCard!.title + ) + const [body, setBody] = useState( + config.welcomeCard?.body ?? DEFAULT_PORTAL_CONFIG.welcomeCard!.body + ) const [saving, setSaving] = useState(false) const [isPending, startTransition] = useTransition() const { upload: uploadImage } = useImageUpload({ prefix: 'portal-welcome' }) const saveTimerRef = useRef(null) - // Tracks whether the user has unflushed edits. While true, we skip - // remote re-sync so a background refetch doesn't clobber the typed-but- - // not-yet-saved value with the older server copy. - const hasPendingEditsRef = useRef(false) - // Each save bumps this; only the latest save's resolution clears the - // pending flag, so an in-flight save returning while the user is still - // typing doesn't reopen the door to a stale background re-sync. - const saveSeqRef = useRef(0) function clearPendingTimer() { if (saveTimerRef.current) { @@ -76,42 +70,22 @@ function PortalSettingsPage() { useEffect(() => clearPendingTimer, []) - // Re-sync local form state when the server-side config changes (background - // refetch, another admin's edit, router invalidate) — but only if the user - // has nothing in-flight, so we never overwrite their unsaved input. - useEffect(() => { - if (hasPendingEditsRef.current) return - setEnabled(initial.enabled) - setTitle(initial.title) - setBody(initial.body) - }, [initial]) - const isBusy = saving || isPending - async function saveSnapshot(card: PortalWelcomeCardData, claimedSeq?: number) { - const seq = claimedSeq ?? ++saveSeqRef.current + async function saveSnapshot(card: PortalWelcomeCardData) { setSaving(true) try { await updatePortalConfigFn({ data: { welcomeCard: card } }) - if (seq === saveSeqRef.current) { - hasPendingEditsRef.current = false - startTransition(() => router.invalidate()) - } + startTransition(() => router.invalidate()) } finally { - if (seq === saveSeqRef.current) setSaving(false) + setSaving(false) } } function scheduleSave(card: PortalWelcomeCardData) { - hasPendingEditsRef.current = true clearPendingTimer() - // Reserve the seq slot now so an in-flight earlier save (e.g. a toggle - // fired immediately before this debounce armed) can't resolve with a - // matching seq and prematurely clear `hasPendingEditsRef` while the - // typed-but-not-yet-saved value is still queued. - const claimedSeq = ++saveSeqRef.current saveTimerRef.current = setTimeout(() => { - saveSnapshot(card, claimedSeq) + saveSnapshot(card) }, DEBOUNCE_MS) } From 52063d132224da2292f7e34c3a12976d559997ca Mon Sep 17 00:00:00 2001 From: James Morton Date: Sat, 16 May 2026 01:42:34 +0100 Subject: [PATCH 14/14] fix(portal): replace welcome-card autosave with explicit Save button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The autosave flow was producing an infinite save loop. Two interacting causes: 1. After save, router.invalidate refetched the canonical config; the server-side sanitizer normalises the body JSON differently than TipTap's editor.getJSON(), so re-pushing the round-tripped value into the editor re-fired onUpdate → onChange → scheduleSave. 2. saving=true → disabled=true on RichTextEditor → editor.setEditable fires its own onUpdate event (TipTap default), which calls onChange even though the doc content didn't change. The previous attempt at "match help-center.tsx pattern" addressed (1) but not (2), and the live dev-server log still showed updatePortalConfigFn fired repeatedly with no user interaction. Drop autosave entirely. Local state is edited freely; nothing is sent to the server until the user clicks Save. With no scheduleSave-on-change path, neither of the above feedback loops has anywhere to land. --- apps/web/src/routes/admin/settings.portal.tsx | 87 ++++++------------- 1 file changed, 25 insertions(+), 62 deletions(-) diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx index 6b6f378b..861091c6 100644 --- a/apps/web/src/routes/admin/settings.portal.tsx +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, useTransition } from 'react' +import { useMemo, useState, useTransition } from 'react' import { createFileRoute, useRouter } from '@tanstack/react-router' import { useSuspenseQuery } from '@tanstack/react-query' import { MegaphoneIcon } from '@heroicons/react/24/solid' @@ -10,6 +10,7 @@ import { InlineSpinner } from '@/components/admin/settings/inline-spinner' import { Switch } from '@/components/ui/switch' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' import { RichTextEditor } from '@/components/ui/rich-text-editor' import { PortalWelcomeCard } from '@/components/public/feedback/portal-welcome-card' import { settingsQueries } from '@/lib/client/queries/settings' @@ -23,8 +24,6 @@ import type { import type { TiptapContent } from '@/lib/shared/db-types' import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc' -const DEBOUNCE_MS = 800 - export const Route = createFileRoute('/admin/settings/portal')({ loader: async ({ context }) => { const { requireWorkspaceRole } = await import('@/lib/server/functions/workspace-utils') @@ -42,12 +41,10 @@ function PortalSettingsPage() { const portalConfigQuery = useSuspenseQuery(settingsQueries.portalConfig()) const config = portalConfigQuery.data as PortalConfig - // Same pattern as settings.help-center.tsx / settings.portal-widget.tsx: - // initialize local state once from the loader-warmed query, then treat - // local state as the source of truth post-mount. router.invalidate after - // each save refreshes the cache for the next visit, but we never re-sync - // the live form fields from it — that would race the server-side - // sanitizer's normalisation back into the editor and reopen a save loop. + // Initialise once from the loader-warmed query and treat local state as + // authoritative until the user explicitly clicks Save. router.invalidate + // after a successful save refreshes the cache for the next visit, but the + // live form fields never get re-synced from it. const [enabled, setEnabled] = useState(config.welcomeCard?.enabled ?? false) const [title, setTitle] = useState( config.welcomeCard?.title ?? DEFAULT_PORTAL_CONFIG.welcomeCard!.title @@ -59,55 +56,20 @@ function PortalSettingsPage() { const [isPending, startTransition] = useTransition() const { upload: uploadImage } = useImageUpload({ prefix: 'portal-welcome' }) - const saveTimerRef = useRef(null) - - function clearPendingTimer() { - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current) - saveTimerRef.current = null - } - } - - useEffect(() => clearPendingTimer, []) - const isBusy = saving || isPending - async function saveSnapshot(card: PortalWelcomeCardData) { + async function handleSave() { setSaving(true) try { - await updatePortalConfigFn({ data: { welcomeCard: card } }) + await updatePortalConfigFn({ + data: { welcomeCard: { enabled, title, body } }, + }) startTransition(() => router.invalidate()) } finally { setSaving(false) } } - function scheduleSave(card: PortalWelcomeCardData) { - clearPendingTimer() - saveTimerRef.current = setTimeout(() => { - saveSnapshot(card) - }, DEBOUNCE_MS) - } - - function handleEnabledToggle(checked: boolean) { - clearPendingTimer() - setEnabled(checked) - // Toggling is immediate so admins see the public-portal state flip - // without waiting on a debounce. - saveSnapshot({ enabled: checked, title, body }) - } - - function handleTitleChange(value: string) { - setTitle(value) - scheduleSave({ enabled, title: value, body }) - } - - function handleBodyChange(json: JSONContent) { - const next = json as TiptapContent - setBody(next) - scheduleSave({ enabled, title, body: next }) - } - const previewCard = useMemo( () => ({ enabled: true, title, body }), [title, body] @@ -139,16 +101,12 @@ function PortalSettingsPage() { Shown at the top of the portal home page above the post list

-
- - -
+ {/* Title and message stay editable when the card is disabled so @@ -161,10 +119,9 @@ function PortalSettingsPage() { handleTitleChange(e.target.value)} + onChange={(e) => setTitle(e.target.value)} placeholder="Share your product feedback!" maxLength={PORTAL_WELCOME_CARD_TITLE_MAX} - disabled={isBusy} /> @@ -172,10 +129,9 @@ function PortalSettingsPage() { handleBodyChange(json)} + onChange={(json: JSONContent) => setBody(json as TiptapContent)} placeholder="Tell visitors what kind of feedback you'd love to hear…" minHeight="160px" - disabled={isBusy} features={{ headings: true, images: true, @@ -204,6 +160,13 @@ function PortalSettingsPage() { )} + +
+ + +