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/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/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' 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..1f6a4edb --- /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.' }], + }, + ], +} + +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/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({
+ + 0 + const hasBody = !isEmptyTiptapDoc(welcomeCard.body) + if (!hasTitle && !hasBody) return null + + return ( +
+ {hasTitle && ( +

+ {trimmedTitle} +

+ )} + {hasBody && ( + + )} +
+ ) +} + +// 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/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..5fb0b84c --- /dev/null +++ b/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { ValidationError } from '@/lib/shared/errors' +import { mergeWelcomeCard, normalizeWelcomeCardInput, publicWelcomeCard } from '../settings.helpers' +import { DEFAULT_PORTAL_CONFIG } from '../settings.types' + +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' }) + }) +}) + +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/__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.helpers.ts b/apps/web/src/lib/server/domains/settings/settings.helpers.ts index 187a0b51..baeaedbc 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,12 @@ 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 { + DEFAULT_PORTAL_CONFIG, + PORTAL_WELCOME_CARD_TITLE_MAX, + type PortalWelcomeCard, +} from './settings.types' export type SettingsRecord = NonNullable>> @@ -72,3 +78,61 @@ export async function invalidateSettingsCache(): Promise { console.log(`[domain:settings] Invalidating settings cache`) 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 + * 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..d6142782 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,9 @@ import { requireSettings, wrapDbError, invalidateSettingsCache, + normalizeWelcomeCardInput, + mergeWelcomeCard, + publicWelcomeCard, } from './settings.helpers' async function getConfiguredAuthTypes(): Promise> { @@ -600,9 +603,16 @@ export async function getPortalConfig(): Promise { export async function updatePortalConfig(input: UpdatePortalConfigInput): Promise { console.log(`[domain:settings] updatePortalConfig`) try { + 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, input 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) { @@ -612,21 +622,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) { @@ -741,10 +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 }), + ...(welcome && { welcomeCard: welcome }), } } catch (error) { console.error(`[domain:settings] getPublicPortalConfig failed:`, error) @@ -821,11 +840,15 @@ export async function getTenantSettings(): Promise { oauth: filteredAuthOAuth, openSignup: authConfig.openSignup, }, - publicPortalConfig: { - oauth: filteredPortalOAuth, - features: portalConfig.features, - ...(portalCustomNames && { customProviderNames: portalCustomNames }), - }, + 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/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/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({ diff --git a/apps/web/src/lib/shared/types/settings.ts b/apps/web/src/lib/shared/types/settings.ts index d02ea11c..33b71da2 100644 --- a/apps/web/src/lib/shared/types/settings.ts +++ b/apps/web/src/lib/shared/types/settings.ts @@ -11,6 +11,8 @@ export type { PortalAuthMethods, + PortalConfig, + PortalWelcomeCard, TenantSettings, HelpCenterConfig, AuthConfig, @@ -24,4 +26,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' 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..a1310084 --- /dev/null +++ b/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts @@ -0,0 +1,37 @@ +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((child) => isEmptyNode(child, 0)) +} + +// 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((child) => isEmptyNode(child, depth + 1)) + } + 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 +} diff --git a/apps/web/src/routes/_portal/index.tsx b/apps/web/src/routes/_portal/index.tsx index 3af4a2a8..099ac29d 100644 --- a/apps/web/src/routes/_portal/index.tsx +++ b/apps/web/src/routes/_portal/index.tsx @@ -66,12 +66,15 @@ export const Route = createFileRoute('/_portal/')({ org.publicPortalConfig?.features?.anonymousVoting ?? DEFAULT_PORTAL_CONFIG.features.anonymousVoting + const welcomeCard = org.publicPortalConfig?.welcomeCard + return { org, baseUrl: context.baseUrl ?? '', isEmpty: portalData.boards.length === 0, session, anonymousVotingEnabled, + welcomeCard, } }, head: ({ loaderData }) => { @@ -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} />
) 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 new file mode 100644 index 00000000..861091c6 --- /dev/null +++ b/apps/web/src/routes/admin/settings.portal.tsx @@ -0,0 +1,174 @@ +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' +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' +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' +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' +import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc' + +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 PortalSettingsPage() { + const router = useRouter() + const portalConfigQuery = useSuspenseQuery(settingsQueries.portalConfig()) + const config = portalConfigQuery.data as PortalConfig + + // 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 + ) + 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 isBusy = saving || isPending + + async function handleSave() { + setSaving(true) + try { + await updatePortalConfigFn({ + data: { welcomeCard: { enabled, title, body } }, + }) + startTransition(() => router.invalidate()) + } finally { + setSaving(false) + } + } + + const previewCard = useMemo( + () => ({ enabled: true, title, body }), + [title, body] + ) + const isPreviewEmpty = !title.trim() && isEmptyTiptapDoc(body) + + return ( +
+
+ Settings +
+ + + +
+
+
+ +

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

+
+ +
+ + {/* 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. */} +
+ + setTitle(e.target.value)} + placeholder="Share your product feedback!" + maxLength={PORTAL_WELCOME_CARD_TITLE_MAX} + /> +
+ +
+ + setBody(json as TiptapContent)} + placeholder="Tell visitors what kind of feedback you'd love to hear…" + minHeight="160px" + features={{ + headings: true, + images: true, + codeBlocks: true, + taskLists: true, + blockquotes: true, + tables: true, + dividers: true, + bubbleMenu: true, + slashMenu: true, + embeds: true, + }} + onImageUpload={uploadImage} + /> +
+ +
+

Preview

+
+ {isPreviewEmpty ? ( +

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

+ ) : ( + + )} +
+
+ +
+ + +
+
+
+
+ ) +} 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())