Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
* - Auto-bump when workspace flips off while a cell sits on Anonymous
* - Save payload preserves `moderation` round-trip (passthrough only —
* editing moderation lives in `<BoardModerationForm>`)
* - Replies switch reads/writes `access.replyPolicy` and round-trips
* every other access key
*
* The mutation, segments, and portalConfig queries are mocked. The
* portalConfig mock is mutable so tests can flip workspace flags between
Expand Down Expand Up @@ -500,6 +502,101 @@ describe('<BoardAccessForm> save', () => {
})
})

// ---------------------------------------------------------------------------
// Replies (access.replyPolicy)
// ---------------------------------------------------------------------------

describe('<BoardAccessForm> reply policy', () => {
const REPLY_LABEL = 'Only the post author and team members can reply'

function replySwitch() {
return screen.getByRole('switch', { name: REPLY_LABEL })
}

it('renders the Replies switch off when access.replyPolicy is absent', () => {
renderForm(PUBLIC_ACCESS)
expect(replySwitch()).toHaveAttribute('data-state', 'unchecked')
})

it("renders the Replies switch off for an explicit replyPolicy: 'anyone'", () => {
renderForm({ ...PUBLIC_ACCESS, replyPolicy: 'anyone' })
expect(replySwitch()).toHaveAttribute('data-state', 'unchecked')
})

it("renders the Replies switch on for replyPolicy: 'author-only'", () => {
renderForm({ ...PUBLIC_ACCESS, replyPolicy: 'author-only' })
expect(replySwitch()).toHaveAttribute('data-state', 'checked')
})

it('toggling the switch marks the form dirty and surfaces the save dock', () => {
renderForm(PUBLIC_ACCESS)
expect(
screen.getByRole('region', { name: /save changes/i }).getAttribute('data-dirty')
).toBeNull()
fireEvent.click(replySwitch())
expect(screen.getByRole('region', { name: /save changes/i }).getAttribute('data-dirty')).toBe(
'true'
)
expect(replySwitch()).toHaveAttribute('data-state', 'checked')
})

it("saves replyPolicy: 'author-only' while every other access key is unchanged", async () => {
const access: BoardAccess = {
view: 'anonymous',
vote: 'authenticated',
comment: 'authenticated',
submit: 'segments',
segments: { view: [], vote: [], comment: [], submit: ['seg_alpha'] },
moderation: { anonPosts: 'on', signedPosts: 'inherit', comments: 'off' },
}
renderForm(access)
fireEvent.click(replySwitch())
fireEvent.click(screen.getByRole('button', { name: /save changes/i }))
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith({
boardId: BOARD_ID,
access: { ...access, replyPolicy: 'author-only' },
})
)
})

it("switching off writes an explicit replyPolicy: 'anyone'", async () => {
const access: BoardAccess = { ...PUBLIC_ACCESS, replyPolicy: 'author-only' }
renderForm(access)
fireEvent.click(replySwitch())
expect(replySwitch()).toHaveAttribute('data-state', 'unchecked')
fireEvent.click(screen.getByRole('button', { name: /save changes/i }))
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith({
boardId: BOARD_ID,
access: { ...access, replyPolicy: 'anyone' },
})
)
})

it('saving a tier change preserves an existing author-only replyPolicy', async () => {
const access: BoardAccess = { ...PUBLIC_ACCESS, replyPolicy: 'author-only' }
renderForm(access)
// Edit the matrix only — the reply switch is untouched.
clickTierCell('Comment', 'Team only')
fireEvent.click(screen.getByRole('button', { name: /save changes/i }))
await waitFor(() =>
expect(mutate).toHaveBeenCalledWith({
boardId: BOARD_ID,
access: { ...access, comment: 'team' },
})
)
})

it('Discard restores the original reply policy', () => {
renderForm({ ...PUBLIC_ACCESS, replyPolicy: 'author-only' })
fireEvent.click(replySwitch())
expect(replySwitch()).toHaveAttribute('data-state', 'unchecked')
fireEvent.click(screen.getByRole('button', { name: /discard/i }))
expect(replySwitch()).toHaveAttribute('data-state', 'checked')
})
})

describe('PRESET_META ↔ accessForPreset agreement (round-trip guard)', () => {
// The UI preset tiles (PRESET_META, which drives deriveActivePreset) and
// the server/optimistic source of truth (accessForPreset) must encode the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useForm } from 'react-hook-form'
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
ChatBubbleLeftEllipsisIcon,
ChatBubbleLeftIcon,
CheckIcon,
ChevronDownIcon,
Expand All @@ -29,6 +30,7 @@ import {
UsersIcon,
} from '@heroicons/react/24/solid'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { BoardSettingsSaveDock } from './board-settings-save-dock'
import { FormError } from '@/components/shared/form-error'
import { useUpdateBoardAccess } from '@/lib/client/mutations'
Expand All @@ -41,6 +43,7 @@ import {
type AccessTier,
type BoardAccess,
DEFAULT_BOARD_ACCESS,
resolveReplyPolicy,
} from '@/lib/shared/db-types'
import { accessForPreset } from '@/lib/shared/schemas/boards'

Expand All @@ -61,6 +64,9 @@ import { accessForPreset } from '@/lib/shared/schemas/boards'
* ceiling: when off, the `anonymous` cell on vote/comment/submit is
* disabled (striped + globe icon) and an effect auto-bumps any cell
* currently on `anonymous` up to `authenticated`.
* - A "Replies" switch below the matrix edits `access.replyPolicy`
* (absent/`anyone` vs `author-only`). It shares this form's dirty
* state and save dock — the Access tab has exactly one of each.
*
* The persisted shape is `BoardAccess` (see @/lib/shared/db-types).
*/
Expand Down Expand Up @@ -358,12 +364,25 @@ export function BoardAccessForm({ board }: BoardAccessFormProps) {
[form]
)

// `replyPolicy` is an optional key on BoardAccess (absent == 'anyone'), so
// it may be missing from the form's defaults. Writing it explicitly on
// toggle keeps the saved payload unambiguous in both directions.
const handleReplyPolicyChange = useCallback(
(authorOnly: boolean) => {
form.setValue('replyPolicy', authorOnly ? 'author-only' : 'anyone', { shouldDirty: true })
},
[form]
)

const onSubmit = useCallback(
(next: FormShape) => {
if (segsError) return
mutation.mutate({ boardId: board.id, access: next })
// Spread the server-side access under the form values so any key this
// form doesn't edit (moderation, and anything added later) round-trips
// verbatim instead of being dropped by a partial save.
mutation.mutate({ boardId: board.id, access: { ...board.access, ...next } })
},
[board.id, mutation, segsError]
[board.id, board.access, mutation, segsError]
)

const handleDiscard = useCallback(() => {
Expand Down Expand Up @@ -437,6 +456,14 @@ export function BoardAccessForm({ board }: BoardAccessFormProps) {
)}
</div>

<div className="space-y-4">
<span className="text-sm font-semibold">Replies</span>
<ReplyPolicyRow
authorOnly={resolveReplyPolicy(values) === 'author-only'}
onChange={handleReplyPolicyChange}
/>
</div>

<p className="flex items-center gap-2 text-xs text-muted-foreground">
<ShieldCheckIcon className="h-3 w-3" />
Team members and admins always have full access — they bypass these rules.
Expand All @@ -453,6 +480,51 @@ export function BoardAccessForm({ board }: BoardAccessFormProps) {
)
}

// ─── Replies (author-only) row ───────────────────────────────────────

interface ReplyPolicyRowProps {
authorOnly: boolean
onChange: (authorOnly: boolean) => void
}

const REPLY_POLICY_LABEL = 'Only the post author and team members can reply'

/**
* `access.replyPolicy` toggle. It sits beside the matrix rather than in it
* because it is not a tier: the Comment row still decides who may reply at
* all, and this narrows that set per post. Rendered inside the access form so
* it shares one dirty state and one save dock with the matrix.
*/
function ReplyPolicyRow({ authorOnly, onChange }: ReplyPolicyRowProps) {
return (
<div className="flex flex-col gap-3 rounded-lg border bg-muted/20 px-4 py-3.5 sm:flex-row sm:items-center">
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border bg-muted/40 text-muted-foreground">
<ChatBubbleLeftEllipsisIcon className="h-3.5 w-3.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{REPLY_POLICY_LABEL}</span>
{authorOnly && (
<span className="rounded border border-primary/30 bg-primary/10 px-1.5 py-px text-xs font-semibold uppercase tracking-wider text-primary">
On
</span>
)}
</div>
<div className="mt-0.5 text-xs leading-snug text-muted-foreground">
Anyone the access tiers allow can still view and open posts, but each post&apos;s thread
stays between its author and your team.
</div>
</div>
<Switch
checked={authorOnly}
onCheckedChange={onChange}
aria-label={REPLY_POLICY_LABEL}
className="shrink-0 sm:ml-3"
/>
</div>
)
}

// ─── Preset cards row ────────────────────────────────────────────────

interface PresetGridProps {
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/components/public/auth-comments-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useAuthBroadcast } from '@/lib/client/hooks/use-auth-broadcast'
import { useEnsureAnonSession } from '@/lib/client/hooks/use-ensure-anon-session'
import { useCreateComment } from '@/lib/client/mutations'
import type { PublicCommentView } from '@/lib/client/queries/portal-detail'
import type { ReplyPolicy } from '@/lib/shared/db-types'
import type { PostCommentId, PostId, PrincipalId } from '@quackback/ids'
import { resolveCommentingState } from '@/components/public/comment-permission'

Expand All @@ -15,6 +16,11 @@ interface AuthCommentsSectionProps {
comments: PublicCommentView[]
/** Server-determined: user is authenticated member who can comment */
allowCommenting?: boolean
/**
* Server-reported board reply rule. Only used to explain a denial; the
* decision itself already lives in `allowCommenting`. Undefined = 'anyone'.
*/
replyPolicy?: ReplyPolicy
user?: { name: string | null; email: string; principalId?: PrincipalId }
/** Message to show when comments are locked (overrides "Sign in to comment") */
lockedMessage?: string
Expand Down Expand Up @@ -65,6 +71,7 @@ export function AuthCommentsSection({
postId,
comments,
allowCommenting: serverAllowCommenting = false,
replyPolicy,
user: serverUser,
lockedMessage,
pinnedCommentId,
Expand Down Expand Up @@ -156,6 +163,7 @@ export function AuthCommentsSection({
comments={comments}
allowCommenting={allowCommenting}
noAccess={noAccess}
replyPolicy={replyPolicy}
user={userData}
teamBadgeLogoUrl={settings?.brandingData?.logoUrl ?? undefined}
teamBadgeLabel={settings?.brandingData?.name ?? settings?.name ?? undefined}
Expand Down
30 changes: 28 additions & 2 deletions apps/web/src/components/public/comment-thread.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useIntl } from 'react-intl'
import { FormattedMessage, useIntl } from 'react-intl'
import {
ArrowRightIcon,
ArrowUturnLeftIcon,
Expand Down Expand Up @@ -33,7 +33,7 @@ import { CommentForm, type CreateCommentMutation } from './comment-form'
import { RichTextEditor } from '@/components/ui/rich-text-editor'
import { COMMENT_EDITOR_FEATURES } from './comment-editor-features'
import { commentMarkdownToTiptapJson } from '@/lib/server/markdown-tiptap'
import type { TiptapContent } from '@/lib/shared/db-types'
import type { ReplyPolicy, TiptapContent } from '@/lib/shared/db-types'
import type { PostCommentId, PostId, PrincipalId } from '@quackback/ids'
import { InlineModerationActions } from '@/components/shared/inline-moderation-actions'
import { useApproveComment, useRejectComment } from '@/lib/client/mutations/moderation'
Expand Down Expand Up @@ -113,6 +113,12 @@ interface CommentThreadProps {
* authn): show "You don't have access" instead of a sign-in prompt.
*/
noAccess?: boolean
/**
* The board's reply rule, as reported by the server. `'author-only'` narrows
* the `noAccess` notice from the generic tier denial to "this thread belongs
* to its author". Undefined (admin mode, legacy payloads) means `'anyone'`.
*/
replyPolicy?: ReplyPolicy
user?: { name: string | null; email: string; principalId?: PrincipalId }
/** Logo URL for the team badge (from branding settings) */
teamBadgeLogoUrl?: string
Expand Down Expand Up @@ -166,6 +172,7 @@ export function CommentThread({
comments,
allowCommenting = true,
noAccess = false,
replyPolicy,
user,
teamBadgeLogoUrl,
teamBadgeLabel,
Expand Down Expand Up @@ -226,6 +233,25 @@ export function CommentThread({
)
}

// Signed in but denied because the board only lets each post's own author
// (and the team) reply. Name that rule instead of the generic tier denial —
// the viewer's account is fine, this thread just isn't theirs. Signed-out
// viewers fall through to the sign-in CTA below: they may yet sign in as
// the author.
if (noAccess && replyPolicy === 'author-only') {
return (
<div className="flex items-center justify-center gap-3 py-4 px-4 bg-muted/30 [border-radius:var(--radius)] border border-border/30">
<LockClosedIcon className="h-4 w-4 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
<FormattedMessage
id="portal.commentThread.authorOnlyReplies"
defaultMessage="Only the post author and team members can reply on this board"
/>
</p>
</div>
)
}

// Signed in but denied by the board's comment tier (segments/team) — an
// authorization failure, not authentication. State it; no sign-in affordance.
if (noAccess) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ export function CommentsSection({
// Use server-detected team membership when not in explicit admin mode
const effectiveIsTeamMember = isTeamMember ?? data?.isTeamMember ?? false

// Why the composer is closed on an author-only board. Undefined in admin mode
// (the query is disabled there), which the thread reads as 'anyone' — admin
// commenting is unaffected by the board's reply rule.
const replyPolicy = data?.replyPolicy

return (
<div
className="p-6 animate-in fade-in duration-200 fill-mode-backwards"
Expand All @@ -168,6 +173,7 @@ export function CommentsSection({
postId={postId}
comments={comments}
allowCommenting={allowCommenting}
replyPolicy={replyPolicy}
user={adminUser ?? data?.user}
lockedMessage={lockedMessage}
pinnedCommentId={pinnedCommentId}
Expand Down
19 changes: 14 additions & 5 deletions apps/web/src/components/widget/widget-post-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,22 @@ export function WidgetPostDetail({ postId, statuses }: WidgetPostDetailProps) {
</div>

{/* Identified viewer denied by the board's comment tier (segments/team)
— authorization, not auth: state it, no form or login prompt. */}
— authorization, not auth: state it, no form or login prompt. An
author-only board names that rule instead: the account is fine,
this thread just isn't the viewer's. */}
{!post.isCommentsLocked && commentNoAccess && (
<p className="text-xs text-muted-foreground/70 mb-3">
<FormattedMessage
id="widget.postDetail.commentNoAccess"
defaultMessage="You don't have access to comment on this board"
/>
{post.replyPolicy === 'author-only' ? (
<FormattedMessage
id="widget.postDetail.authorOnlyReplies"
defaultMessage="Only the post author and team members can reply on this board"
/>
) : (
<FormattedMessage
id="widget.postDetail.commentNoAccess"
defaultMessage="You don't have access to comment on this board"
/>
)}
</p>
)}

Expand Down
Loading