40
packages/pl-fe/src/features/admin/components/admin-tabs.tsx
Normal file
40
packages/pl-fe/src/features/admin/components/admin-tabs.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
import { useRouteMatch } from 'react-router-dom';
|
||||
|
||||
import { Tabs } from 'soapbox/components/ui';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
dashboard: { id: 'admin_nav.dashboard', defaultMessage: 'Dashboard' },
|
||||
reports: { id: 'admin_nav.reports', defaultMessage: 'Reports' },
|
||||
waitlist: { id: 'admin_nav.awaiting_approval', defaultMessage: 'Waitlist' },
|
||||
});
|
||||
|
||||
const AdminTabs: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const match = useRouteMatch();
|
||||
|
||||
const approvalCount = useAppSelector(state => state.admin.awaitingApproval.count());
|
||||
const reportsCount = useAppSelector(state => state.admin.openReports.count());
|
||||
|
||||
const tabs = [{
|
||||
name: '/soapbox/admin',
|
||||
text: intl.formatMessage(messages.dashboard),
|
||||
to: '/soapbox/admin',
|
||||
}, {
|
||||
name: '/soapbox/admin/reports',
|
||||
text: intl.formatMessage(messages.reports),
|
||||
to: '/soapbox/admin/reports',
|
||||
count: reportsCount,
|
||||
}, {
|
||||
name: '/soapbox/admin/approval',
|
||||
text: intl.formatMessage(messages.waitlist),
|
||||
to: '/soapbox/admin/approval',
|
||||
count: approvalCount,
|
||||
}];
|
||||
|
||||
return <Tabs items={tabs} activeItem={match.path} />;
|
||||
};
|
||||
|
||||
export { AdminTabs as default };
|
||||
56
packages/pl-fe/src/features/admin/components/dashcounter.tsx
Normal file
56
packages/pl-fe/src/features/admin/components/dashcounter.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
import { FormattedNumber } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Text } from 'soapbox/components/ui';
|
||||
import { isNumber } from 'soapbox/utils/numbers';
|
||||
|
||||
interface IDashCounter {
|
||||
count: number | undefined;
|
||||
label: React.ReactNode;
|
||||
to?: string;
|
||||
percent?: boolean;
|
||||
}
|
||||
|
||||
/** Displays a (potentially clickable) dashboard statistic. */
|
||||
const DashCounter: React.FC<IDashCounter> = ({ count, label, to = '#', percent = false }) => {
|
||||
|
||||
if (!isNumber(count)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className='flex cursor-pointer flex-col items-center space-y-2 rounded bg-gray-200 p-4 transition-transform hover:-translate-y-1 dark:bg-gray-800'
|
||||
to={to}
|
||||
>
|
||||
<Text align='center' size='2xl' weight='medium'>
|
||||
<FormattedNumber
|
||||
value={count}
|
||||
style={percent ? 'unit' : undefined}
|
||||
unit={percent ? 'percent' : undefined}
|
||||
numberingSystem='latn'
|
||||
/>
|
||||
</Text>
|
||||
<Text align='center'>
|
||||
{label}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface IDashCounters {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Wrapper container for dash counters. */
|
||||
const DashCounters: React.FC<IDashCounters> = ({ children }) => (
|
||||
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export {
|
||||
DashCounter,
|
||||
DashCounters,
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
import { OrderedSet as ImmutableOrderedSet } from 'immutable';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { fetchUsers } from 'soapbox/actions/admin';
|
||||
import { Widget } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'admin.latest_accounts_panel.title', defaultMessage: 'Latest Accounts' },
|
||||
expand: { id: 'admin.latest_accounts_panel.more', defaultMessage: 'Click to see {count, plural, one {# account} other {# accounts}}' },
|
||||
});
|
||||
|
||||
interface ILatestAccountsPanel {
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const LatestAccountsPanel: React.FC<ILatestAccountsPanel> = ({ limit = 5 }) => {
|
||||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const dispatch = useAppDispatch();
|
||||
const accountIds = useAppSelector<ImmutableOrderedSet<string>>((state) => state.admin.get('latestUsers').take(limit));
|
||||
|
||||
const [total, setTotal] = useState<number | undefined>(accountIds.size);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUsers({
|
||||
origin: 'local',
|
||||
status: 'active',
|
||||
limit,
|
||||
})).then((value) => {
|
||||
setTotal(value.total);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAction = () => {
|
||||
history.push('/soapbox/admin/users');
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget
|
||||
title={intl.formatMessage(messages.title)}
|
||||
onActionClick={handleAction}
|
||||
actionTitle={intl.formatMessage(messages.expand, { count: total })}
|
||||
>
|
||||
{accountIds.take(limit).map((account) => (
|
||||
<AccountContainer key={account} id={account} withRelationship={false} withDate />
|
||||
))}
|
||||
</Widget>
|
||||
);
|
||||
};
|
||||
|
||||
export { LatestAccountsPanel as default };
|
||||
@ -0,0 +1,74 @@
|
||||
import { Instance } from 'pl-api';
|
||||
import React from 'react';
|
||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { updateConfig } from 'soapbox/actions/admin';
|
||||
import { RadioGroup, RadioItem } from 'soapbox/components/radio';
|
||||
import { useAppDispatch, useInstance } from 'soapbox/hooks';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
type RegistrationMode = 'open' | 'approval' | 'closed';
|
||||
|
||||
const messages = defineMessages({
|
||||
saved: { id: 'admin.dashboard.settings_saved', defaultMessage: 'Settings saved!' },
|
||||
});
|
||||
|
||||
const generateConfig = (mode: RegistrationMode) => {
|
||||
const configMap = {
|
||||
open: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', false] }],
|
||||
approval: [{ tuple: [':registrations_open', true] }, { tuple: [':account_approval_required', true] }],
|
||||
closed: [{ tuple: [':registrations_open', false] }],
|
||||
};
|
||||
|
||||
return [{
|
||||
group: ':pleroma',
|
||||
key: ':instance',
|
||||
value: configMap[mode],
|
||||
}];
|
||||
};
|
||||
|
||||
const modeFromInstance = ({ registrations }: Instance): RegistrationMode => {
|
||||
if (registrations.approval_required && registrations.enabled) return 'approval';
|
||||
return registrations.enabled ? 'open' : 'closed';
|
||||
};
|
||||
|
||||
/** Allows changing the registration mode of the instance, eg "open", "closed", "approval" */
|
||||
const RegistrationModePicker: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const instance = useInstance();
|
||||
|
||||
const mode = modeFromInstance(instance);
|
||||
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
const config = generateConfig(e.target.value as RegistrationMode);
|
||||
dispatch(updateConfig(config)).then(() => {
|
||||
toast.success(intl.formatMessage(messages.saved));
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioGroup onChange={onChange}>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.open_label' defaultMessage='Open' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.open_hint' defaultMessage='Anyone can join.' />}
|
||||
checked={mode === 'open'}
|
||||
value='open'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.approval_label' defaultMessage='Approval Required' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.approval_hint' defaultMessage='Users can sign up, but their account only gets activated when an admin approves it.' />}
|
||||
checked={mode === 'approval'}
|
||||
value='approval'
|
||||
/>
|
||||
<RadioItem
|
||||
label={<FormattedMessage id='admin.dashboard.registration_mode.closed_label' defaultMessage='Closed' />}
|
||||
hint={<FormattedMessage id='admin.dashboard.registration_mode.closed_hint' defaultMessage='Nobody can sign up. You can still invite people.' />}
|
||||
checked={mode === 'closed'}
|
||||
value='closed'
|
||||
/>
|
||||
</RadioGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export { RegistrationModePicker as default };
|
||||
@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useIntl, defineMessages } from 'react-intl';
|
||||
|
||||
import { deleteStatusModal } from 'soapbox/actions/moderation';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import StatusContent from 'soapbox/components/status-content';
|
||||
import StatusMedia from 'soapbox/components/status-media';
|
||||
import { HStack, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import type { SelectedStatus } from 'soapbox/selectors';
|
||||
|
||||
const messages = defineMessages({
|
||||
viewStatus: { id: 'admin.reports.actions.view_status', defaultMessage: 'View post' },
|
||||
deleteStatus: { id: 'admin.statuses.actions.delete_status', defaultMessage: 'Delete post' },
|
||||
});
|
||||
|
||||
interface IReportStatus {
|
||||
status: SelectedStatus;
|
||||
}
|
||||
|
||||
const ReportStatus: React.FC<IReportStatus> = ({ status }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleDeleteStatus = () => {
|
||||
dispatch(deleteStatusModal(intl, status.id));
|
||||
};
|
||||
|
||||
const makeMenu = () => {
|
||||
const acct = status.account.acct;
|
||||
|
||||
return [{
|
||||
text: intl.formatMessage(messages.viewStatus, { acct: `@${acct}` }),
|
||||
to: `/@${acct}/posts/${status.id}`,
|
||||
icon: require('@tabler/icons/outline/pencil.svg'),
|
||||
}, {
|
||||
text: intl.formatMessage(messages.deleteStatus, { acct: `@${acct}` }),
|
||||
action: handleDeleteStatus,
|
||||
icon: require('@tabler/icons/outline/trash.svg'),
|
||||
destructive: true,
|
||||
}];
|
||||
};
|
||||
|
||||
const menu = makeMenu();
|
||||
|
||||
return (
|
||||
<HStack space={2} alignItems='start'>
|
||||
<Stack space={2} className='overflow-hidden' grow>
|
||||
<StatusContent status={status} />
|
||||
<StatusMedia status={status} showMedia />
|
||||
</Stack>
|
||||
|
||||
<div className='flex-none'>
|
||||
<DropdownMenu
|
||||
items={menu}
|
||||
src={require('@tabler/icons/outline/dots-vertical.svg')}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export { ReportStatus as default };
|
||||
159
packages/pl-fe/src/features/admin/components/report.tsx
Normal file
159
packages/pl-fe/src/features/admin/components/report.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useIntl, FormattedMessage, defineMessages } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { closeReport } from 'soapbox/actions/admin';
|
||||
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
|
||||
import DropdownMenu from 'soapbox/components/dropdown-menu';
|
||||
import HoverRefWrapper from 'soapbox/components/hover-ref-wrapper';
|
||||
import { Accordion, Avatar, Button, Stack, HStack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
import { makeGetReport } from 'soapbox/selectors';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import ReportStatus from './report-status';
|
||||
|
||||
const messages = defineMessages({
|
||||
reportClosed: { id: 'admin.reports.report_closed_message', defaultMessage: 'Report on @{name} was closed' },
|
||||
deactivateUser: { id: 'admin.users.actions.deactivate_user', defaultMessage: 'Deactivate @{name}' },
|
||||
deleteUser: { id: 'admin.users.actions.delete_user', defaultMessage: 'Delete @{name}' },
|
||||
});
|
||||
|
||||
interface IReport {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const Report: React.FC<IReport> = ({ id }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getReport = useCallback(makeGetReport(), []);
|
||||
|
||||
const report = useAppSelector((state) => getReport(state, id));
|
||||
|
||||
const [accordionExpanded, setAccordionExpanded] = useState(false);
|
||||
|
||||
if (!report) return null;
|
||||
|
||||
const account = report.account;
|
||||
const targetAccount = report.target_account!;
|
||||
|
||||
const makeMenu = () => [{
|
||||
text: intl.formatMessage(messages.deactivateUser, { name: targetAccount.username }),
|
||||
action: handleDeactivateUser,
|
||||
icon: require('@tabler/icons/outline/hourglass-empty.svg'),
|
||||
}, {
|
||||
text: intl.formatMessage(messages.deleteUser, { name: targetAccount.username }),
|
||||
action: handleDeleteUser,
|
||||
icon: require('@tabler/icons/outline/trash.svg'),
|
||||
destructive: true,
|
||||
}];
|
||||
|
||||
const handleCloseReport = () => {
|
||||
dispatch(closeReport(report.id)).then(() => {
|
||||
const message = intl.formatMessage(messages.reportClosed, { name: targetAccount.username as string });
|
||||
toast.success(message);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
const handleDeactivateUser = () => {
|
||||
const accountId = targetAccount.id;
|
||||
dispatch(deactivateUserModal(intl, accountId!, () => handleCloseReport()));
|
||||
};
|
||||
|
||||
const handleDeleteUser = () => {
|
||||
const accountId = targetAccount.id as string;
|
||||
dispatch(deleteUserModal(intl, accountId!, () => handleCloseReport()));
|
||||
};
|
||||
|
||||
const handleAccordionToggle = (setting: boolean) => {
|
||||
setAccordionExpanded(setting);
|
||||
};
|
||||
|
||||
const menu = makeMenu();
|
||||
const statuses = report.statuses;
|
||||
const statusCount = statuses.length;
|
||||
const acct = targetAccount.acct;
|
||||
const reporterAcct = account?.acct;
|
||||
|
||||
return (
|
||||
<HStack space={3} className='p-3' key={report.id}>
|
||||
<HoverRefWrapper accountId={targetAccount.id} inline>
|
||||
<Link to={`/@${acct}`} title={acct}>
|
||||
<Avatar
|
||||
src={targetAccount.avatar}
|
||||
alt={targetAccount.avatar_description}
|
||||
size={32}
|
||||
className='overflow-hidden'
|
||||
/>
|
||||
</Link>
|
||||
</HoverRefWrapper>
|
||||
|
||||
<Stack space={3} className='overflow-hidden' grow>
|
||||
<Text tag='h4' weight='bold'>
|
||||
<FormattedMessage
|
||||
id='admin.reports.report_title'
|
||||
defaultMessage='Report on {acct}'
|
||||
values={{ acct: (
|
||||
<HoverRefWrapper accountId={targetAccount.id} inline>
|
||||
<Link to={`/@${acct}`} title={acct}>@{acct}</Link>
|
||||
</HoverRefWrapper>
|
||||
) }}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
{statusCount > 0 && (
|
||||
<Accordion
|
||||
headline={`Reported posts (${statusCount})`}
|
||||
expanded={accordionExpanded}
|
||||
onToggle={handleAccordionToggle}
|
||||
>
|
||||
<Stack space={4}>
|
||||
{statuses.map(status => (
|
||||
<ReportStatus
|
||||
key={status.id}
|
||||
status={status}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Accordion>
|
||||
)}
|
||||
|
||||
<Stack>
|
||||
{!!report.comment && report.comment.length > 0 && (
|
||||
<Text
|
||||
tag='blockquote'
|
||||
dangerouslySetInnerHTML={{ __html: report.comment }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!!account && (
|
||||
<HStack space={1}>
|
||||
<Text theme='muted' tag='span'>—</Text>
|
||||
|
||||
<HoverRefWrapper accountId={account.id} inline>
|
||||
<Link
|
||||
to={`/@${reporterAcct}`}
|
||||
title={reporterAcct}
|
||||
className='text-primary-600 hover:underline dark:text-accent-blue'
|
||||
>
|
||||
@{reporterAcct}
|
||||
</Link>
|
||||
</HoverRefWrapper>
|
||||
</HStack>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<HStack space={2} alignItems='start' className='flex-none'>
|
||||
<Button onClick={handleCloseReport}>
|
||||
<FormattedMessage id='admin.reports.actions.close' defaultMessage='Close' />
|
||||
</Button>
|
||||
|
||||
<DropdownMenu items={menu} src={require('@tabler/icons/outline/dots-vertical.svg')} />
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export { Report as default };
|
||||
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
|
||||
import { approveUser, deleteUser } from 'soapbox/actions/admin';
|
||||
import { useAccount } from 'soapbox/api/hooks';
|
||||
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
|
||||
import { Stack, HStack, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
interface IUnapprovedAccount {
|
||||
accountId: string;
|
||||
}
|
||||
|
||||
/** Displays an unapproved account for moderation purposes. */
|
||||
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { account } = useAccount(accountId);
|
||||
const adminAccount = useAppSelector(state => state.admin.users.get(accountId));
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
const handleApprove = () => dispatch(approveUser(account.id));
|
||||
const handleReject = () => dispatch(deleteUser(account.id));
|
||||
|
||||
return (
|
||||
<HStack space={4} justifyContent='between'>
|
||||
<Stack space={1}>
|
||||
<Text weight='semibold'>
|
||||
@{account.acct}
|
||||
</Text>
|
||||
<Text tag='blockquote' size='sm'>
|
||||
{adminAccount?.invite_request || ''}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack justifyContent='center'>
|
||||
<AuthorizeRejectButtons
|
||||
onAuthorize={handleApprove}
|
||||
onReject={handleReject}
|
||||
countdown={3000}
|
||||
/>
|
||||
</Stack>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export { UnapprovedAccount as default };
|
||||
Reference in New Issue
Block a user