128
packages/pl-fe/src/features/admin/announcements.tsx
Normal file
128
packages/pl-fe/src/features/admin/announcements.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { FormattedDate, FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { useAnnouncements } from 'soapbox/api/hooks/admin/useAnnouncements';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { AdminAnnouncement } from 'soapbox/schemas';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.announcements', defaultMessage: 'Announcements' },
|
||||
deleteConfirm: { id: 'confirmations.admin.delete_announcement.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.admin.delete_announcement.heading', defaultMessage: 'Delete announcement' },
|
||||
deleteMessage: { id: 'confirmations.admin.delete_announcement.message', defaultMessage: 'Are you sure you want to delete the announcement?' },
|
||||
deleteSuccess: { id: 'admin.edit_announcement.deleted', defaultMessage: 'Announcement deleted' },
|
||||
});
|
||||
|
||||
interface IAnnouncement {
|
||||
announcement: AdminAnnouncement;
|
||||
}
|
||||
|
||||
const Announcement: React.FC<IAnnouncement> = ({ announcement }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { deleteAnnouncement } = useAnnouncements();
|
||||
|
||||
const handleEditAnnouncement = () => {
|
||||
dispatch(openModal('EDIT_ANNOUNCEMENT', { announcement }));
|
||||
};
|
||||
|
||||
const handleDeleteAnnouncement = () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => deleteAnnouncement(announcement.id, {
|
||||
onSuccess: () => toast.success(messages.deleteSuccess),
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={announcement.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||
<Stack space={2}>
|
||||
<Text dangerouslySetInnerHTML={{ __html: announcement.contentHtml }} />
|
||||
{(announcement.starts_at || announcement.ends_at || announcement.all_day) && (
|
||||
<HStack space={2} wrap>
|
||||
{announcement.starts_at && (
|
||||
<Text size='sm'>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.announcements.starts_at' defaultMessage='Starts at:' />
|
||||
</Text>
|
||||
{' '}
|
||||
<FormattedDate value={announcement.starts_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||
</Text>
|
||||
)}
|
||||
{announcement.ends_at && (
|
||||
<Text size='sm'>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.announcements.ends_at' defaultMessage='Ends at:' />
|
||||
</Text>
|
||||
{' '}
|
||||
<FormattedDate value={announcement.ends_at} year='2-digit' month='short' day='2-digit' weekday='short' />
|
||||
</Text>
|
||||
)}
|
||||
{announcement.all_day && (
|
||||
<Text weight='medium' size='sm'>
|
||||
<FormattedMessage id='admin.announcements.all_day' defaultMessage='All day' />
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
)}
|
||||
<HStack justifyContent='end' space={2}>
|
||||
<Button theme='primary' onClick={handleEditAnnouncement}>
|
||||
<FormattedMessage id='admin.announcements.edit' defaultMessage='Edit' />
|
||||
</Button>
|
||||
<Button theme='primary' onClick={handleDeleteAnnouncement}>
|
||||
<FormattedMessage id='admin.announcements.delete' defaultMessage='Delete' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Announcements: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: announcements, isLoading } = useAnnouncements();
|
||||
|
||||
const handleCreateAnnouncement = () => {
|
||||
dispatch(openModal('EDIT_ANNOUNCEMENT'));
|
||||
};
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.admin.announcements' defaultMessage='There are no announcements yet.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Stack className='gap-4'>
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end'
|
||||
icon={require('@tabler/icons/outline/plus.svg')}
|
||||
onClick={handleCreateAnnouncement}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='admin.announcements.action' defaultMessage='Create announcement' />
|
||||
</Button>
|
||||
<ScrollableList
|
||||
scrollKey='announcements'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading && !announcements?.length}
|
||||
>
|
||||
{announcements!.map((announcement) => (
|
||||
<Announcement key={announcement.id} announcement={announcement} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { Announcements as default };
|
||||
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 };
|
||||
148
packages/pl-fe/src/features/admin/domains.tsx
Normal file
148
packages/pl-fe/src/features/admin/domains.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { useDomains } from 'soapbox/api/hooks/admin';
|
||||
import { dateFormatOptions } from 'soapbox/components/relative-timestamp';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import Indicator from '../developers/components/indicator';
|
||||
|
||||
import type { Domain as DomainEntity } from 'soapbox/schemas';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.domains', defaultMessage: 'Domains' },
|
||||
deleteConfirm: { id: 'confirmations.admin.delete_domain.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.admin.delete_domain.heading', defaultMessage: 'Delete domain' },
|
||||
deleteMessage: { id: 'confirmations.admin.delete_domain.message', defaultMessage: 'Are you sure you want to delete the domain?' },
|
||||
domainDeleteSuccess: { id: 'admin.edit_domain.deleted', defaultMessage: 'Domain deleted' },
|
||||
domainLastChecked: { id: 'admin.domains.resolve.last_checked', defaultMessage: 'Last checked: {date}' },
|
||||
});
|
||||
|
||||
interface IDomain {
|
||||
domain: DomainEntity;
|
||||
}
|
||||
|
||||
const Domain: React.FC<IDomain> = ({ domain }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { deleteDomain } = useDomains();
|
||||
|
||||
const handleEditDomain = (domain: DomainEntity) => () => {
|
||||
dispatch(openModal('EDIT_DOMAIN', { domainId: domain.id }));
|
||||
};
|
||||
|
||||
const handleDeleteDomain = () => () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => {
|
||||
deleteDomain(domain.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(messages.domainDeleteSuccess);
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const domainState = domain.last_checked_at ? (domain.resolves ? 'active' : 'error') : 'pending';
|
||||
const domainStateLabel = {
|
||||
active: <FormattedMessage id='admin.domains.resolve.success_label' defaultMessage='Resolves correctly' />,
|
||||
error: <FormattedMessage id='admin.domains.resolve.fail_label' defaultMessage='Not resolving' />,
|
||||
pending: <FormattedMessage id='admin.domains.resolve.pending_label' defaultMessage='Pending resolve check' />,
|
||||
}[domainState];
|
||||
const domainStateTitle = domain.last_checked_at ? intl.formatMessage(messages.domainLastChecked, {
|
||||
date: intl.formatDate(domain.last_checked_at, dateFormatOptions),
|
||||
}) : undefined;
|
||||
|
||||
return (
|
||||
<div key={domain.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||
<Stack space={2}>
|
||||
<HStack alignItems='center' space={4} wrap>
|
||||
<Text size='sm'>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.domains.name' defaultMessage='Domain:' />
|
||||
</Text>
|
||||
{' '}
|
||||
{domain.domain}
|
||||
</Text>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
{domain.public ? (
|
||||
<FormattedMessage id='admin.domains.public' defaultMessage='Public' />
|
||||
) : (
|
||||
<FormattedMessage id='admin.domains.private' defaultMessage='Private' />
|
||||
)}
|
||||
</Text>
|
||||
<HStack alignItems='center' space={2} title={domainStateTitle}>
|
||||
<Indicator state={domainState} />
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
{domainStateLabel}
|
||||
</Text>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack justifyContent='end' space={2}>
|
||||
<Button theme='primary' onClick={handleEditDomain(domain)}>
|
||||
<FormattedMessage id='admin.domains.edit' defaultMessage='Edit' />
|
||||
</Button>
|
||||
<Button theme='primary' onClick={handleDeleteDomain()}>
|
||||
<FormattedMessage id='admin.domains.delete' defaultMessage='Delete' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Domains: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data: domains, isFetching, refetch } = useDomains();
|
||||
|
||||
const handleCreateDomain = () => {
|
||||
dispatch(openModal('EDIT_DOMAIN'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching) refetch();
|
||||
}, []);
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.admin.domains' defaultMessage='There are no domains yet.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Stack className='gap-4'>
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end'
|
||||
icon={require('@tabler/icons/outline/plus.svg')}
|
||||
onClick={handleCreateDomain}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='admin.domains.action' defaultMessage='Create domain' />
|
||||
</Button>
|
||||
{domains && (
|
||||
<ScrollableList
|
||||
scrollKey='domains'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isFetching}
|
||||
showLoading={isFetching && !domains?.length}
|
||||
>
|
||||
{domains.map((domain) => (
|
||||
<Domain key={domain.id} domain={domain} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
)}
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { Domains as default };
|
||||
36
packages/pl-fe/src/features/admin/index.tsx
Normal file
36
packages/pl-fe/src/features/admin/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import { Switch, Route } from 'react-router-dom';
|
||||
|
||||
import { Column } from 'soapbox/components/ui';
|
||||
import { useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import AdminTabs from './components/admin-tabs';
|
||||
import Waitlist from './tabs/awaiting-approval';
|
||||
import Dashboard from './tabs/dashboard';
|
||||
import Reports from './tabs/reports';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.dashboard', defaultMessage: 'Dashboard' },
|
||||
});
|
||||
|
||||
const Admin: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { account } = useOwnAccount();
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)} withHeader={false}>
|
||||
<AdminTabs />
|
||||
|
||||
<Switch>
|
||||
<Route path='/soapbox/admin' exact component={Dashboard} />
|
||||
<Route path='/soapbox/admin/reports' exact component={Reports} />
|
||||
<Route path='/soapbox/admin/approval' exact component={Waitlist} />
|
||||
</Switch>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { Admin as default };
|
||||
72
packages/pl-fe/src/features/admin/moderation-log.tsx
Normal file
72
packages/pl-fe/src/features/admin/moderation-log.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { defineMessages, FormattedDate, useIntl } from 'react-intl';
|
||||
|
||||
import { useModerationLog } from 'soapbox/api/hooks/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column, Stack, Text } from 'soapbox/components/ui';
|
||||
|
||||
import type { ModerationLogEntry } from 'soapbox/schemas';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation log' },
|
||||
emptyMessage: { id: 'admin.moderation_log.empty_message', defaultMessage: 'You have not performed any moderation actions yet. When you do, a history will be shown here.' },
|
||||
});
|
||||
|
||||
const ModerationLog = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
data,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
} = useModerationLog();
|
||||
|
||||
const showLoading = isLoading && data.length === 0;
|
||||
|
||||
const handleLoadMore = () => {
|
||||
fetchNextPage();
|
||||
};
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<ScrollableList
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
scrollKey='moderation-log'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
hasMore={hasNextPage}
|
||||
onLoadMore={handleLoadMore}
|
||||
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||
>
|
||||
{data.map(item => item && (
|
||||
<LogItem key={item.id} log={item} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
interface ILogItem {
|
||||
log: ModerationLogEntry;
|
||||
}
|
||||
|
||||
const LogItem: React.FC<ILogItem> = ({ log }) => (
|
||||
<Stack space={2} className='p-4'>
|
||||
<Text>{log.message}</Text>
|
||||
|
||||
<Text theme='muted' size='xs'>
|
||||
<FormattedDate
|
||||
value={new Date(log.time * 1000)}
|
||||
hour12
|
||||
year='numeric'
|
||||
month='short'
|
||||
day='2-digit'
|
||||
hour='numeric'
|
||||
minute='2-digit'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
export { ModerationLog as default };
|
||||
139
packages/pl-fe/src/features/admin/relays.tsx
Normal file
139
packages/pl-fe/src/features/admin/relays.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { useRelays } from 'soapbox/api/hooks/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, Form, HStack, Input, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useTextField } from 'soapbox/hooks/forms';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
import type { Relay as RelayEntity } from 'soapbox/schemas';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.relays', defaultMessage: 'Instance relays' },
|
||||
relayDeleteSuccess: { id: 'admin.relays.deleted', defaultMessage: 'Relay unfollowed' },
|
||||
label: { id: 'admin.relays.new.url_placeholder', defaultMessage: 'Instance relay URL' },
|
||||
createSuccess: { id: 'admin.relays.add.success', defaultMessage: 'Instance relay followed' },
|
||||
createFail: { id: 'admin.relays.add.fail', defaultMessage: 'Failed to follow the instance relay' },
|
||||
});
|
||||
|
||||
interface IRelay {
|
||||
relay: RelayEntity;
|
||||
}
|
||||
|
||||
const Relay: React.FC<IRelay> = ({ relay }) => {
|
||||
const { unfollowRelay } = useRelays();
|
||||
|
||||
const handleDeleteRelay = () => () => {
|
||||
unfollowRelay(relay.actor, {
|
||||
onSuccess: () => {
|
||||
toast.success(messages.relayDeleteSuccess);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={relay.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||
<Stack space={2}>
|
||||
<HStack alignItems='center' space={4} wrap>
|
||||
<Text size='sm'>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.relays.url' defaultMessage='Instance URL:' />
|
||||
</Text>
|
||||
{' '}
|
||||
{relay.actor}
|
||||
</Text>
|
||||
{relay.followed_back && (
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.relays.followed_back' defaultMessage='Followed back' />
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack justifyContent='end' space={2}>
|
||||
<Button theme='primary' onClick={handleDeleteRelay()}>
|
||||
<FormattedMessage id='admin.relays.unfollow' defaultMessage='Unfollow' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NewRelayForm: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const name = useTextField();
|
||||
|
||||
const { followRelay, isPendingFollow } = useRelays();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<Element>) => {
|
||||
e.preventDefault();
|
||||
followRelay(name.value, {
|
||||
onSuccess() {
|
||||
toast.success(messages.createSuccess);
|
||||
},
|
||||
onError() {
|
||||
toast.error(messages.createFail);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const label = intl.formatMessage(messages.label);
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<HStack space={2} alignItems='center'>
|
||||
<label className='grow'>
|
||||
<span style={{ display: 'none' }}>{label}</span>
|
||||
|
||||
<Input
|
||||
type='text'
|
||||
placeholder={label}
|
||||
disabled={isPendingFollow}
|
||||
{...name}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
disabled={isPendingFollow}
|
||||
onClick={handleSubmit}
|
||||
theme='primary'
|
||||
>
|
||||
<FormattedMessage id='admin.relays.new.follow' defaultMessage='Follow' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
const Relays: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { data: relays, isFetching } = useRelays();
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.admin.relays' defaultMessage='There are no relays followed yet.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Stack className='gap-4'>
|
||||
<NewRelayForm />
|
||||
|
||||
{relays && (
|
||||
<ScrollableList
|
||||
scrollKey='relays'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isFetching}
|
||||
showLoading={isFetching && !relays?.length}
|
||||
>
|
||||
{relays.map((relay) => (
|
||||
<Relay key={relay.id} relay={relay} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
)}
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { Relays as default };
|
||||
111
packages/pl-fe/src/features/admin/rules.tsx
Normal file
111
packages/pl-fe/src/features/admin/rules.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { openModal } from 'soapbox/actions/modals';
|
||||
import { useRules } from 'soapbox/api/hooks/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Button, Column, HStack, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppDispatch } from 'soapbox/hooks';
|
||||
import { AdminRule } from 'soapbox/schemas';
|
||||
import toast from 'soapbox/toast';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.rules', defaultMessage: 'Instance rules' },
|
||||
deleteConfirm: { id: 'confirmations.admin.delete_rule.confirm', defaultMessage: 'Delete' },
|
||||
deleteHeading: { id: 'confirmations.admin.delete_rule.heading', defaultMessage: 'Delete rule' },
|
||||
deleteMessage: { id: 'confirmations.admin.delete_rule.message', defaultMessage: 'Are you sure you want to delete the rule?' },
|
||||
ruleDeleteSuccess: { id: 'admin.edit_rule.deleted', defaultMessage: 'Rule deleted' },
|
||||
});
|
||||
|
||||
interface IRule {
|
||||
rule: AdminRule;
|
||||
}
|
||||
|
||||
const Rule: React.FC<IRule> = ({ rule }) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const { deleteRule } = useRules();
|
||||
|
||||
const handleEditRule = (rule: AdminRule) => () => {
|
||||
dispatch(openModal('EDIT_RULE', { rule }));
|
||||
};
|
||||
|
||||
const handleDeleteRule = (id: string) => () => {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
heading: intl.formatMessage(messages.deleteHeading),
|
||||
message: intl.formatMessage(messages.deleteMessage),
|
||||
confirm: intl.formatMessage(messages.deleteConfirm),
|
||||
onConfirm: () => deleteRule(id, {
|
||||
onSuccess: () => toast.success(messages.ruleDeleteSuccess),
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={rule.id} className='rounded-lg bg-gray-100 p-4 dark:bg-primary-800'>
|
||||
<Stack space={2}>
|
||||
<Text>{rule.text}</Text>
|
||||
<Text tag='span' theme='muted' size='sm'>{rule.hint}</Text>
|
||||
{rule.priority !== null && (
|
||||
<Text size='sm'>
|
||||
<Text tag='span' size='sm' weight='medium'>
|
||||
<FormattedMessage id='admin.rule.priority' defaultMessage='Priority:' />
|
||||
</Text>
|
||||
{' '}
|
||||
{rule.priority}
|
||||
</Text>
|
||||
)}
|
||||
<HStack justifyContent='end' space={2}>
|
||||
<Button theme='primary' onClick={handleEditRule(rule)}>
|
||||
<FormattedMessage id='admin.rules.edit' defaultMessage='Edit' />
|
||||
</Button>
|
||||
<Button theme='primary' onClick={handleDeleteRule(rule.id)}>
|
||||
<FormattedMessage id='admin.rules.delete' defaultMessage='Delete' />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Rules: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { data, isLoading } = useRules();
|
||||
|
||||
const handleCreateRule = () => {
|
||||
dispatch(openModal('EDIT_RULE'));
|
||||
};
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.admin.rules' defaultMessage='There are no instance rules yet.' />;
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Stack className='gap-4'>
|
||||
<Button
|
||||
className='sm:w-fit sm:self-end'
|
||||
icon={require('@tabler/icons/outline/plus.svg')}
|
||||
onClick={handleCreateRule}
|
||||
theme='secondary'
|
||||
block
|
||||
>
|
||||
<FormattedMessage id='admin.rules.action' defaultMessage='Create rule' />
|
||||
</Button>
|
||||
<ScrollableList
|
||||
scrollKey='rules'
|
||||
emptyMessage={emptyMessage}
|
||||
itemClassName='py-3 first:pt-0 last:pb-0'
|
||||
isLoading={isLoading}
|
||||
showLoading={isLoading}
|
||||
>
|
||||
{data!.map((rule) => (
|
||||
<Rule key={rule.id} rule={rule} />
|
||||
))}
|
||||
</ScrollableList>
|
||||
</Stack>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { Rules as default };
|
||||
50
packages/pl-fe/src/features/admin/tabs/awaiting-approval.tsx
Normal file
50
packages/pl-fe/src/features/admin/tabs/awaiting-approval.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchUsers } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import UnapprovedAccount from '../components/unapproved-account';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.awaiting_approval', defaultMessage: 'Awaiting Approval' },
|
||||
emptyMessage: { id: 'admin.awaiting_approval.empty_message', defaultMessage: 'There is nobody waiting for approval. When a new user signs up, you can review them here.' },
|
||||
});
|
||||
|
||||
const AwaitingApproval: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
const accountIds = useAppSelector(state => state.admin.awaitingApproval);
|
||||
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUsers({
|
||||
origin: 'local',
|
||||
status: 'pending',
|
||||
}))
|
||||
.then(() => setLoading(false))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const showLoading = isLoading && accountIds.count() === 0;
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
scrollKey='awaiting-approval'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||
>
|
||||
{accountIds.map(id => (
|
||||
<div key={id} className='px-5 py-4'>
|
||||
<UnapprovedAccount accountId={id} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
||||
export { AwaitingApproval as default };
|
||||
189
packages/pl-fe/src/features/admin/tabs/dashboard.tsx
Normal file
189
packages/pl-fe/src/features/admin/tabs/dashboard.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { getSubscribersCsv, getUnsubscribersCsv, getCombinedCsv } from 'soapbox/actions/admin';
|
||||
import List, { ListItem } from 'soapbox/components/list';
|
||||
import { CardTitle, Icon, IconButton, Stack } from 'soapbox/components/ui';
|
||||
import { useAppDispatch, useOwnAccount, useFeatures, useInstance } from 'soapbox/hooks';
|
||||
import sourceCode from 'soapbox/utils/code';
|
||||
import { download } from 'soapbox/utils/download';
|
||||
|
||||
import { DashCounter, DashCounters } from '../components/dashcounter';
|
||||
import RegistrationModePicker from '../components/registration-mode-picker';
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const instance = useInstance();
|
||||
const features = useFeatures();
|
||||
const { account } = useOwnAccount();
|
||||
|
||||
const handleSubscribersClick: React.MouseEventHandler = e => {
|
||||
dispatch(getSubscribersCsv()).then(({ data }) => {
|
||||
download(data, 'subscribers.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleUnsubscribersClick: React.MouseEventHandler = e => {
|
||||
dispatch(getUnsubscribersCsv()).then(({ data }) => {
|
||||
download(data, 'unsubscribers.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleCombinedClick: React.MouseEventHandler = e => {
|
||||
dispatch(getCombinedCsv()).then(({ data }) => {
|
||||
download(data, 'combined.csv');
|
||||
}).catch(() => {});
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const v = features.version;
|
||||
|
||||
const {
|
||||
user_count: userCount,
|
||||
status_count: statusCount,
|
||||
domain_count: domainCount,
|
||||
} = instance.stats;
|
||||
|
||||
const mau = instance.pleroma.stats.mau;
|
||||
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
|
||||
|
||||
if (!account) return null;
|
||||
|
||||
return (
|
||||
<Stack space={6} className='mt-4'>
|
||||
<DashCounters>
|
||||
<DashCounter
|
||||
count={mau}
|
||||
label={<FormattedMessage id='admin.dashcounters.mau_label' defaultMessage='monthly active users' />}
|
||||
/>
|
||||
<DashCounter
|
||||
to='/soapbox/admin/users'
|
||||
count={userCount}
|
||||
label={<FormattedMessage id='admin.dashcounters.user_count_label' defaultMessage='total users' />}
|
||||
/>
|
||||
<DashCounter
|
||||
count={retention}
|
||||
label={<FormattedMessage id='admin.dashcounters.retention_label' defaultMessage='user retention' />}
|
||||
percent
|
||||
/>
|
||||
<DashCounter
|
||||
to='/timeline/local'
|
||||
count={statusCount}
|
||||
label={<FormattedMessage id='admin.dashcounters.status_count_label' defaultMessage='posts' />}
|
||||
/>
|
||||
<DashCounter
|
||||
count={domainCount}
|
||||
label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
|
||||
/>
|
||||
</DashCounters>
|
||||
|
||||
<List>
|
||||
{account.is_admin && (
|
||||
<ListItem
|
||||
to='/soapbox/config'
|
||||
label={<FormattedMessage id='navigation_bar.soapbox_config' defaultMessage='Soapbox config' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ListItem
|
||||
to='/soapbox/admin/log'
|
||||
label={<FormattedMessage id='column.admin.moderation_log' defaultMessage='Moderation log' />}
|
||||
/>
|
||||
|
||||
{features.adminAnnouncements && (
|
||||
<ListItem
|
||||
to='/soapbox/admin/announcements'
|
||||
label={<FormattedMessage id='column.admin.announcements' defaultMessage='Announcements' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.adminRules && (
|
||||
<ListItem
|
||||
to='/soapbox/admin/rules'
|
||||
label={<FormattedMessage id='column.admin.rules' defaultMessage='Instance rules' />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{features.domains && (
|
||||
<ListItem
|
||||
to='/soapbox/admin/domains'
|
||||
label={<FormattedMessage id='column.admin.domains' defaultMessage='Domains' />}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
|
||||
{account.is_admin && (
|
||||
<>
|
||||
<CardTitle
|
||||
title={<FormattedMessage id='admin.dashboard.registration_mode_label' defaultMessage='Registrations' />}
|
||||
/>
|
||||
|
||||
<RegistrationModePicker />
|
||||
</>
|
||||
)}
|
||||
|
||||
<CardTitle
|
||||
title={<FormattedMessage id='admin.dashwidgets.software_header' defaultMessage='Software' />}
|
||||
/>
|
||||
|
||||
<List>
|
||||
<ListItem label={<FormattedMessage id='admin.software.frontend' defaultMessage='Frontend' />}>
|
||||
<a
|
||||
href={sourceCode.ref ? `${sourceCode.url}/tree/${sourceCode.ref}` : sourceCode.url}
|
||||
className='flex items-center space-x-1 truncate'
|
||||
target='_blank'
|
||||
>
|
||||
<span>{sourceCode.displayName} {sourceCode.version}</span>
|
||||
|
||||
<Icon
|
||||
className='h-4 w-4'
|
||||
src={require('@tabler/icons/outline/external-link.svg')}
|
||||
/>
|
||||
</a>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
|
||||
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
{(features.emailList && account.is_admin) && (
|
||||
<>
|
||||
<CardTitle
|
||||
title={<FormattedMessage id='admin.dashwidgets.email_list_header' defaultMessage='Email list' />}
|
||||
/>
|
||||
|
||||
<List>
|
||||
<ListItem label='subscribers.csv'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/outline/download.svg')}
|
||||
onClick={handleSubscribersClick}
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label='unsubscribers.csv'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/outline/download.svg')}
|
||||
onClick={handleUnsubscribersClick}
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
</ListItem>
|
||||
|
||||
<ListItem label='combined.csv'>
|
||||
<IconButton
|
||||
src={require('@tabler/icons/outline/download.svg')}
|
||||
onClick={handleCombinedClick}
|
||||
iconClassName='h-5 w-5'
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { Dashboard as default };
|
||||
45
packages/pl-fe/src/features/admin/tabs/reports.tsx
Normal file
45
packages/pl-fe/src/features/admin/tabs/reports.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { fetchReports } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { useAppSelector, useAppDispatch } from 'soapbox/hooks';
|
||||
|
||||
import Report from '../components/report';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.reports', defaultMessage: 'Reports' },
|
||||
modlog: { id: 'column.admin.reports.menu.moderation_log', defaultMessage: 'Moderation log' },
|
||||
emptyMessage: { id: 'admin.reports.empty_message', defaultMessage: 'There are no open reports. If a user gets reported, they will show up here.' },
|
||||
});
|
||||
|
||||
const Reports: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
|
||||
const reports = useAppSelector(state => state.admin.openReports.toList());
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchReports())
|
||||
.then(() => setLoading(false))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const showLoading = isLoading && reports.count() === 0;
|
||||
|
||||
return (
|
||||
<ScrollableList
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
scrollKey='admin-reports'
|
||||
emptyMessage={intl.formatMessage(messages.emptyMessage)}
|
||||
listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800'
|
||||
>
|
||||
{reports.map(report => report && <Report id={report} key={report} />)}
|
||||
</ScrollableList>
|
||||
);
|
||||
};
|
||||
|
||||
export { Reports as default };
|
||||
69
packages/pl-fe/src/features/admin/user-index.tsx
Normal file
69
packages/pl-fe/src/features/admin/user-index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import debounce from 'lodash/debounce';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { expandUserIndex, fetchUserIndex, setUserIndexQuery } from 'soapbox/actions/admin';
|
||||
import ScrollableList from 'soapbox/components/scrollable-list';
|
||||
import { Column, Input } from 'soapbox/components/ui';
|
||||
import AccountContainer from 'soapbox/containers/account-container';
|
||||
import { useAppDispatch, useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const messages = defineMessages({
|
||||
heading: { id: 'column.admin.users', defaultMessage: 'Users' },
|
||||
empty: { id: 'admin.user_index.empty', defaultMessage: 'No users found.' },
|
||||
searchPlaceholder: { id: 'admin.user_index.search_input_placeholder', defaultMessage: 'Who are you looking for?' },
|
||||
});
|
||||
|
||||
const UserIndex: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const intl = useIntl();
|
||||
|
||||
const { isLoading, items, total, query, next } = useAppSelector((state) => state.admin_user_index);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!isLoading) dispatch(expandUserIndex());
|
||||
};
|
||||
|
||||
const updateQuery = useCallback(debounce(() => {
|
||||
dispatch(fetchUserIndex());
|
||||
}, 900, { leading: true }), []);
|
||||
|
||||
const handleQueryChange: React.ChangeEventHandler<HTMLInputElement> = e => {
|
||||
dispatch(setUserIndexQuery(e.target.value));
|
||||
updateQuery();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateQuery();
|
||||
}, []);
|
||||
|
||||
const hasMore = items.count() < total && !!next;
|
||||
|
||||
const showLoading = isLoading && items.isEmpty();
|
||||
|
||||
return (
|
||||
<Column label={intl.formatMessage(messages.heading)}>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={handleQueryChange}
|
||||
placeholder={intl.formatMessage(messages.searchPlaceholder)}
|
||||
/>
|
||||
<ScrollableList
|
||||
scrollKey='user-index'
|
||||
hasMore={hasMore}
|
||||
isLoading={isLoading}
|
||||
showLoading={showLoading}
|
||||
onLoadMore={handleLoadMore}
|
||||
emptyMessage={intl.formatMessage(messages.empty)}
|
||||
className='mt-4'
|
||||
itemClassName='pb-4'
|
||||
>
|
||||
{items.map(id =>
|
||||
<AccountContainer key={id} id={id} withDate />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export { UserIndex as default };
|
||||
Reference in New Issue
Block a user