Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .github/renovate.json5
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,19 @@
"ghost/admin/lib/koenig-editor/package.json"
],
"packageRules": [
// Always require dashboard approval for major updates
// This was largely to avoid the noise of major updates which were ESM only
// The idea was to check and accept major updates if they were NOT ESM
// But this hasn't been workable with our capacity
// Plus, ESM-only is an edge case in the grand scheme of dependencies
{
"description": "Require dashboard approval for major updates",
"matchUpdateTypes": [
"major"
],
"dependencyDashboardApproval": true
},

// Group NQL packages separately from other TryGhost packages
{
"groupName": "NQL packages",
Expand Down
12 changes: 9 additions & 3 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export type Member = {
email?: string;
avatar_image?: string;
can_comment?: boolean;
commenting?: {
disabled: boolean;
disabled_reason?: string;
disabled_until?: string;
};
};

export interface MembersResponseType {
Expand All @@ -22,12 +27,13 @@ export const useBrowseMembers = createQuery<MembersResponseType>({

export const useDisableMemberCommenting = createMutation<
MembersResponseType,
{id: string; reason: string}
{id: string; reason: string; hideComments?: boolean}
>({
method: 'POST',
path: ({id}) => `/members/${id}/commenting/disable`,
body: ({reason}) => ({
reason
body: ({reason, hideComments}) => ({
reason,
hide_comments: hideComments
}),
invalidateQueries: {
dataType: 'CommentsResponseType'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ const features: Feature[] = [{
title: 'Disable Member Commenting',
description: 'Allow staff to disable commenting for individual members',
flag: 'disableMemberCommenting'
}, {
title: 'Hide Comments When Disabling',
description: 'Show option to hide all previous comments when disabling commenting for a member',
flag: 'disableMemberCommentingHideComments'
}, {
title: 'Sniper Links',
description: 'Enable mail app links on signup/signin',
Expand Down
15 changes: 13 additions & 2 deletions apps/portal/src/components/pages/magic-link-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import SniperLinkButton from '../common/sniper-link-button';
import AppContext from '../../app-context';
import {ReactComponent as EnvelopeIcon} from '../../images/icons/envelope.svg';
import {t} from '../../utils/i18n';
import {isInviteOnly} from '../../utils/helpers';

export const MagicLinkStyles = `
.gh-portal-icon-envelope {
Expand Down Expand Up @@ -97,7 +98,8 @@ export default class MagicLinkPage extends React.Component {
return {
signin: {
withOTC: t('An email has been sent to {submittedEmailOrInbox}. Click the link inside or enter your code below.', {submittedEmailOrInbox}),
withoutOTC: t('A login link has been sent to your inbox. If it doesn\'t arrive in 3 minutes, be sure to check your spam folder.')
withoutOTC: t('A login link has been sent to your inbox. If it doesn\'t arrive in 3 minutes, be sure to check your spam folder.'),
withoutOTCInviteOnly: t('If you have an account, a sign in link will be sent to you shortly. Please check your inbox and spam folder.')
},
signup: t('To complete signup, click the confirmation link in your inbox. If it doesn\'t arrive within 3 minutes, check your spam folder!')
};
Expand All @@ -119,7 +121,16 @@ export default class MagicLinkPage extends React.Component {
return descriptionConfig.signup;
}

return otcRef ? descriptionConfig.signin.withOTC : descriptionConfig.signin.withoutOTC;
if (otcRef) {
return descriptionConfig.signin.withOTC;
}

const {site} = this.context;
if (isInviteOnly({site})) {
return descriptionConfig.signin.withoutOTCInviteOnly;
}

return descriptionConfig.signin.withoutOTC;
}

renderFormHeader() {
Expand Down
4 changes: 2 additions & 2 deletions apps/portal/src/utils/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export function chooseBestErrorMessage(error, alreadyTranslatedDefaultMessage) {
if (specialMessages.length === 0) {
// This formatting is intentionally weird. It causes the i18n-parser to pick these strings up.
// Do not redefine this t. It's a local function and needs to stay that way.
t('No member exists with this e-mail address. Please sign up first.');
t('No member exists with this e-mail address.');
t('No member exists with this email address. Please sign up first.');
t('No member exists with this email address.');
t('This site is invite-only, contact the owner for access.');
t('Unable to initiate checkout session');
t('This site is not accepting payments at the moment.');
Expand Down
4 changes: 2 additions & 2 deletions apps/portal/test/data-attributes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -934,15 +934,15 @@ describe('Portal Data attributes:', () => {
})
.mockResolvedValueOnce({
ok: false,
json: async () => ({errors: [{message: 'No member exists with this e-mail address. Please sign up first.'}]}),
json: async () => ({errors: [{message: 'No member exists with this email address. Please sign up first.'}]}),
status: 400
});

await formSubmitHandler({event, form, errorEl, siteUrl, submitHandler});

expect(window.fetch).toHaveBeenCalledTimes(2);
expect(form.classList.add).toHaveBeenCalledWith('error');
expect(errorEl.innerText).toBe('No member exists with this e-mail address. Please sign up first.');
expect(errorEl.innerText).toBe('No member exists with this email address. Please sign up first.');
});
});
});
2 changes: 2 additions & 0 deletions apps/posts/src/views/comments/comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const Comments: React.FC = () => {
const {data: configData} = useBrowseConfig();
const commentPermalinksEnabled = configData?.config?.labs?.commentPermalinks === true;
const disableMemberCommentingEnabled = configData?.config?.labs?.disableMemberCommenting === true;
const hideCommentsEnabled = configData?.config?.labs?.disableMemberCommentingHideComments === true;

const handleAddFilter = useCallback((field: string, value: string, operator: string = 'is') => {
setFilters((prevFilters) => {
Expand Down Expand Up @@ -87,6 +88,7 @@ const Comments: React.FC = () => {
disableMemberCommentingEnabled={disableMemberCommentingEnabled}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
hideCommentsEnabled={hideCommentsEnabled}
isFetchingNextPage={isFetchingNextPage}
isLoading={isFetching && !isFetchingNextPage}
items={data?.comments ?? []}
Expand Down
30 changes: 27 additions & 3 deletions apps/posts/src/views/comments/components/comments-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AlertDialogTitle,
Badge,
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
Expand All @@ -19,6 +20,7 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Label,
LucideIcon,
Tooltip,
TooltipContent,
Expand Down Expand Up @@ -135,7 +137,8 @@ function CommentsList({
onAddFilter,
isLoading,
commentPermalinksEnabled,
disableMemberCommentingEnabled
disableMemberCommentingEnabled,
hideCommentsEnabled
}: {
items: Comment[];
totalItems: number;
Expand All @@ -146,6 +149,7 @@ function CommentsList({
isLoading?: boolean;
commentPermalinksEnabled?: boolean;
disableMemberCommentingEnabled?: boolean;
hideCommentsEnabled?: boolean;
}) {
const parentRef = useRef<HTMLDivElement>(null);

Expand All @@ -168,6 +172,7 @@ function CommentsList({
const {mutate: enableCommenting} = useEnableMemberCommenting();
const [commentToDelete, setCommentToDelete] = useState<Comment | null>(null);
const [memberToDisable, setMemberToDisable] = useState<{member: Comment['member']; commentId: string} | null>(null);
const [hideComments, setHideComments] = useState(false);

const confirmDelete = () => {
if (commentToDelete) {
Expand All @@ -180,9 +185,11 @@ function CommentsList({
if (memberToDisable?.member?.id) {
disableCommenting({
id: memberToDisable.member.id,
reason: `Disabled from comment ${memberToDisable.commentId}`
reason: `Disabled from comment ${memberToDisable.commentId}`,
...(hideCommentsEnabled && {hideComments})
});
setMemberToDisable(null);
setHideComments(false);
}
};

Expand Down Expand Up @@ -450,6 +457,7 @@ function CommentsList({
<Dialog open={!!memberToDisable} onOpenChange={(open) => {
if (!open) {
setMemberToDisable(null);
setHideComments(false);
}
}}>
<DialogContent>
Expand All @@ -461,8 +469,24 @@ function CommentsList({
</DialogDescription>
</DialogHeader>

{hideCommentsEnabled && (
<div className="flex items-center gap-2 py-2">
<Checkbox
checked={hideComments}
id="hide-comments"
onCheckedChange={checked => setHideComments(checked === true)}
/>
<Label htmlFor="hide-comments">
Hide all previous comments
</Label>
</div>
)}

<DialogFooter>
<Button variant="outline" onClick={() => setMemberToDisable(null)}>
<Button variant="outline" onClick={() => {
setMemberToDisable(null);
setHideComments(false);
}}>
Cancel
</Button>
<Button onClick={confirmDisableCommenting}>
Expand Down
2 changes: 2 additions & 0 deletions e2e/helpers/pages/admin/comments/comments-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class CommentsPage extends AdminPage {
readonly disableCommentsButton: Locator;
readonly cancelButton: Locator;
readonly commentingDisabledIndicator = (row: Locator) => row.getByTestId('commenting-disabled-indicator');
readonly hideCommentsCheckbox: Locator;

constructor(page: Page) {
super(page);
Expand All @@ -28,6 +29,7 @@ export class CommentsPage extends AdminPage {
this.disableCommentsModalTitle = this.disableCommentsModal.getByRole('heading', {name: 'Disable comments'});
this.disableCommentsButton = page.getByRole('button', {name: 'Disable comments'});
this.cancelButton = this.disableCommentsModal.getByRole('button', {name: 'Cancel'});
this.hideCommentsCheckbox = page.getByRole('checkbox', {name: 'Hide all previous comments'});
}

async waitForComments(): Promise<void> {
Expand Down
73 changes: 73 additions & 0 deletions e2e/tests/admin/comments/disable-commenting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,79 @@ test.describe('Ghost Admin - Disable Commenting', () => {
await expect(commentsPage.disableCommentingMenuItem).toBeVisible();
});

test.describe('with hide comments enabled', () => {
test.use({labs: {disableMemberCommenting: true, disableMemberCommentingHideComments: true}});

test('hide comments checkbox appears in modal', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create();
await commentFactory.create({
post_id: post.id,
member_id: member.id,
html: '<p>Test comment for hide checkbox</p>'
});

const commentsPage = new CommentsPage(page);
await commentsPage.goto();
await commentsPage.waitForComments();

const commentRow = commentsPage.getCommentRowByText('Test comment for hide checkbox');
await commentsPage.openMoreMenu(commentRow);
await commentsPage.clickDisableCommenting();

await expect(commentsPage.hideCommentsCheckbox).toBeVisible();
});

test('disabling with hide comments checked marks comments as hidden', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create();
await commentFactory.create({
post_id: post.id,
member_id: member.id,
html: '<p>Comment that should be hidden</p>'
});

const commentsPage = new CommentsPage(page);
await commentsPage.goto();
await commentsPage.waitForComments();

const commentRow = commentsPage.getCommentRowByText('Comment that should be hidden');
await expect(commentRow).toBeVisible();
await expect(commentRow.getByText('Hidden', {exact: true})).toBeHidden();

await commentsPage.openMoreMenu(commentRow);
await commentsPage.clickDisableCommenting();
await commentsPage.hideCommentsCheckbox.check();
await commentsPage.confirmDisableCommenting();

await expect(commentRow.getByText('Hidden', {exact: true})).toBeVisible();
await expect(commentsPage.commentingDisabledIndicator(commentRow)).toBeVisible();
});

test('disabling without hide comments checked keeps comments visible', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create();
await commentFactory.create({
post_id: post.id,
member_id: member.id,
html: '<p>Comment that should stay visible</p>'
});

const commentsPage = new CommentsPage(page);
await commentsPage.goto();
await commentsPage.waitForComments();

const commentRow = commentsPage.getCommentRowByText('Comment that should stay visible');
await commentsPage.openMoreMenu(commentRow);
await commentsPage.clickDisableCommenting();
await commentsPage.confirmDisableCommenting();

await expect(commentsPage.disableCommentsModal).toBeHidden();
await expect(commentRow).toBeVisible();
await expect(commentsPage.commentingDisabledIndicator(commentRow)).toBeVisible();
});
});

test.describe('disable/enable commenting flow', () => {
test('members can comment by default - no disabled indicator shown', async ({page}) => {
const post = await postFactory.create({status: 'published'});
Expand Down
10 changes: 10 additions & 0 deletions ghost/admin/app/components/member/newsletter-preference.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@

<a class="midgrey" href="https://ghost.org/help/disabled-emails" target="_blank" rel="noopener noreferrer">Learn more</a>
</p>

<button
type="button"
class="gh-btn gh-btn-primary gh-btn-icon"
{{on "click" this.reEnableEmail}}
disabled={{this.isReEnabling}}
data-test-button="reenable-email"
>
<span>Re-enable email</span>
</button>
</div>
{{else}}
<div class="gh-member-newsletter-footer middarkgrey">
Expand Down
33 changes: 33 additions & 0 deletions ghost/admin/app/components/member/newsletter-preference.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import Component from '@glimmer/component';
import moment from 'moment-timezone';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';

export default class MembersNewsletterPreference extends Component {
@service ajax;
@service notifications;
@service ghostPaths;
@service store;

@tracked filterValue;
@tracked isReEnabling = false;

constructor(...args) {
super(...args);
Expand Down Expand Up @@ -58,4 +65,30 @@ export default class MembersNewsletterPreference extends Component {
}
this.args.setMemberNewsletters(updatedNewsletters);
}

@action
async reEnableEmail() {
this.isReEnabling = true;

try {
const url = `${this.ghostPaths.url.api('members', this.args.member.id)}suppression`;
await this.ajax.delete(url);

// Refresh the member data to get updated suppression status
await this.store.findRecord('member', this.args.member.id, {
reload: true,
include: 'tiers'
});

this.notifications.showNotification('Email re-enabled successfully', {
type: 'success'
});
} catch (error) {
this.notifications.showAlert('Failed to re-enable email. Please try again.', {
type: 'error'
});
} finally {
this.isReEnabling = false;
}
}
}
Loading
Loading