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
6 changes: 6 additions & 0 deletions biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
"!.vscode"
]
},
"css": {
"formatter": {
"enabled": true,
"indentStyle": "tab"
}
},
"linter": {
"rules": {
"recommended": false,
Expand Down
134 changes: 134 additions & 0 deletions client/components/BanUserDialog/BanUserDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import type { CommunityBan } from 'server/models';
import type { BanReason, SerializedModel } from 'types';

import React, { useCallback, useState } from 'react';

import { Button, Callout, Classes, Dialog, HTMLSelect, Intent, TextArea } from '@blueprintjs/core';

import { apiFetch } from 'client/utils/apiFetch';
import { moderationReasonLabels } from 'utils/moderationReasons';

const reasons = (Object.entries(moderationReasonLabels) as [BanReason, string][]).map(
([value, label]) => ({ value, label }),
);

type Props = {
isOpen: boolean;
onClose: () => void;
userId: string;
communityId: string;
threadCommentId?: string | null;
userName?: string;
onBanned?: (banData: SerializedModel<CommunityBan>) => void;
};

const BanUserDialog = (props: Props) => {
const { isOpen, onClose, userId, communityId, threadCommentId, userName, onBanned } = props;
const [reason, setReason] = useState<BanReason>('spam-content');
const [reasonText, setReasonText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSubmit = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const banData = await apiFetch.post('/api/communityBans', {
userId,
communityId,
reason,
reasonText: reasonText.trim() || null,
sourceThreadCommentId: threadCommentId ?? null,
});
onBanned?.(banData);
onClose();
} catch (err: any) {
setError(err?.message ?? 'Failed to ban user');
} finally {
setIsLoading(false);
}
}, [userId, communityId, reason, reasonText, threadCommentId, onBanned, onClose]);

return (
<Dialog
isOpen={isOpen}
onClose={onClose}
title={`Ban user${userName ? `: ${userName}` : ''}`}
style={{ width: 520 }}
>
<div className={Classes.DIALOG_BODY}>
<Callout intent={Intent.WARNING} style={{ marginBottom: 20 }}>
<p style={{ margin: '0 0 8px' }}>
<strong>This will ban the user from your community.</strong> They will not
be able to perform any actions, including creating Pubs, posting
discussions, or editing content.
</p>
<p style={{ margin: '0 0 8px' }}>
All of their existing discussions and comments will be hidden from other
users. You can reverse this action at any time from the Members settings.
</p>
<p style={{ margin: 0, fontSize: 13, opacity: 0.85 }}>
The user will <strong>not</strong> be notified of this action.
</p>
</Callout>

<div style={{ marginBottom: 14 }}>
<label htmlFor="flag-reason" style={{ fontWeight: 600, fontSize: 14 }}>
Reason
</label>
<HTMLSelect
id="flag-reason"
value={reason}
onChange={(e) => setReason(e.target.value as BanReason)}
fill
style={{ marginTop: 4 }}
>
{reasons.map((r) => (
<option key={r.value} value={r.value}>
{r.label}
</option>
))}
</HTMLSelect>
</div>

<div style={{ marginBottom: 8 }}>
<label htmlFor="flag-reason-text" style={{ fontWeight: 600, fontSize: 14 }}>
Details (optional)
</label>
<p style={{ margin: '2px 0 6px', fontSize: 13, color: '#666' }}>
This message will be sent to the PubPub team and helps us identify patterns
of abuse across the platform.
</p>
<TextArea
id="flag-reason-text"
value={reasonText}
onChange={(e) => setReasonText(e.target.value)}
fill
rows={3}
placeholder="Provide additional context about why this user is being banned..."
style={{ marginTop: 0 }}
/>
</div>

{error && (
<div style={{ color: 'var(--pt-intent-danger)', marginTop: 8, fontSize: 13 }}>
{error}
</div>
)}
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Cancel" onClick={onClose} disabled={isLoading} />
<Button
text="Ban user"
intent={Intent.DANGER}
onClick={handleSubmit}
loading={isLoading}
/>
</div>
</div>
</Dialog>
);
};

export default BanUserDialog;
12 changes: 12 additions & 0 deletions client/components/SpamStatusMenu/SpamStatusMenu.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
.spam-menu {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;

& button {
flex-grow: 1;
width: 100%;
justify-content: flex-start;
}
}
52 changes: 17 additions & 35 deletions client/components/SpamStatusMenu/SpamStatusMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import type { SpamAction } from 'client/containers/SuperAdminDashboard/UserSpam/MarkSpamStatusButton';
import type { SpamStatus } from 'types';

import React, { useCallback, useState } from 'react';

import UserSpamActions from 'client/containers/SuperAdminDashboard/UserSpam/UserSpamActions';
import { apiFetch } from 'client/utils/apiFetch';
import { Icon } from 'components';
import { MenuButton, MenuItem, MenuItemDivider } from 'components/Menu';
import { MenuButton } from 'components/Menu';

import './SpamStatusMenu.scss';

type Props = {
userId: string;
Expand All @@ -13,64 +17,42 @@ type Props = {
small?: boolean;
};

const statusLabels: Record<SpamStatus, { label: string; icon: string }> = {
'confirmed-spam': { label: 'Mark as spam', icon: 'cross' },
'confirmed-not-spam': { label: 'Mark as not spam', icon: 'tick' },
unreviewed: { label: 'Mark as unreviewed', icon: 'undo' },
};

const SpamStatusMenu = (props: Props) => {
const { userId, currentStatus = null, onStatusChanged, small = true } = props;
const [isLoading, setIsLoading] = useState(false);

const handleSetStatus = useCallback(
async (status: SpamStatus) => {
const handleAction = useCallback(
async (action: SpamAction) => {
setIsLoading(true);

try {
await apiFetch.put('/api/spamTags/user', { status, userId });
onStatusChanged?.(status);
if (action === 'remove') {
await apiFetch.delete('/api/spamTags/user', { userId });
} else {
await apiFetch.put('/api/spamTags/user', { status: action, userId });
}
onStatusChanged?.(action === 'remove' ? null : action);
} finally {
setIsLoading(false);
}
},
[userId, onStatusChanged],
);

const handleRemoveTag = useCallback(async () => {
setIsLoading(true);
try {
await apiFetch.delete('/api/spamTags/user', { userId });
onStatusChanged?.(null);
} finally {
setIsLoading(false);
}
}, [userId, onStatusChanged]);

return (
<MenuButton
aria-label="Spam actions"
buttonContent="Spam"
className="spam-menu"
buttonProps={{
icon: <Icon icon="shield" iconSize={small ? 12 : 14} />,
minimal: true,
small,
loading: isLoading,
className: 'spam-button',
}}
>
{(Object.keys(statusLabels) as SpamStatus[]).map((status) => (
<MenuItem
key={status}
text={statusLabels[status].label}
icon={status === currentStatus ? 'tick' : 'blank'}
onClick={() => handleSetStatus(status)}
/>
))}
{currentStatus && (
<>
<MenuItemDivider />
<MenuItem text="Remove spam tag" icon="trash" onClick={handleRemoveTag} />
</>
)}
<UserSpamActions userId={userId} status={currentStatus} handleAction={handleAction} />
</MenuButton>
);
};
Expand Down
1 change: 1 addition & 0 deletions client/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export { default as AttributionEditor } from './AttributionEditor/AttributionEdi
export { default as Avatar } from './Avatar/Avatar';
export { default as Avatars } from './Avatars/Avatars';
export { default as Banner } from './Banner/Banner';
export { default as BanUserDialog } from './BanUserDialog/BanUserDialog';
export { default as Byline } from './Byline/Byline';
export { default as ClickToCopyButton } from './ClickToCopyButton/ClickToCopyButton';
export { ClientOnlyContext, default as ClientOnly } from './ClientOnly/ClientOnly';
Expand Down
21 changes: 18 additions & 3 deletions client/containers/DashboardActivity/ActivityFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import classNames from 'classnames';
import { Checkbox } from 'reakit/Checkbox';

import { Icon, type IconName } from 'components';
import { usePageContext } from 'utils/hooks';

import './activityFilters.scss';

Expand All @@ -26,6 +27,7 @@ const filterLabels: Record<ActivityFilter, FilterLabel> = {
pub: { label: 'Pubs', icon: 'pubDoc' },
page: { label: 'Pages', icon: 'page-layout' },
member: { label: 'Members', icon: 'people' },
moderation: { label: 'Moderation', icon: 'flag' },
review: { label: 'Reviews', icon: 'social-media' },
discussion: { label: 'Discussions', icon: 'chat' },
pubEdge: { label: 'Connections', icon: 'layout-auto' },
Expand All @@ -47,18 +49,29 @@ const filtersByScopeKind = {
'discussion',
'submission',
]),
communityAdmin: sortedFilters([
'community',
'collection',
'pub',
'page',
'member',
'moderation',
'review',
'discussion',
'submission',
]),
collection: sortedFilters(['pub', 'member', 'review', 'discussion', 'submission']),
pub: sortedFilters(['member', 'review', 'discussion', 'pubEdge', 'submission']),
};

const getFiltersForScope = (scope: ScopeId) => {
const getFiltersForScope = (scope: ScopeId, canAdminCommunity: boolean) => {
if ('pubId' in scope && scope.pubId) {
return filtersByScopeKind.pub;
}
if ('collectionId' in scope && scope.collectionId) {
return filtersByScopeKind.collection;
}
return filtersByScopeKind.community;
return canAdminCommunity ? filtersByScopeKind.communityAdmin : filtersByScopeKind.community;
};

const toggleFilterInclusion = (currentFilters: ActivityFilter[], toggleFilter: ActivityFilter) => {
Expand All @@ -70,6 +83,8 @@ const toggleFilterInclusion = (currentFilters: ActivityFilter[], toggleFilter: A

const ActivityFilters = (props: Props) => {
const { activeFilters, onUpdateActiveFilters, scope } = props;
const { scopeData } = usePageContext();
const { canAdminCommunity } = scopeData.activePermissions;

return (
<div
Expand All @@ -79,7 +94,7 @@ const ActivityFilters = (props: Props) => {
)}
>
<div className="label">Filter by</div>
{getFiltersForScope(scope).map((filter) => {
{getFiltersForScope(scope, canAdminCommunity).map((filter) => {
const { label, icon } = filterLabels[filter];
return (
<Checkbox
Expand Down
Loading