Switch to workspace

Signed-off-by: marcin mikołajczak <git@mkljczk.pl>
This commit is contained in:
marcin mikołajczak
2024-08-28 12:46:03 +02:00
parent 694abcb489
commit 4d5690d0c1
1318 changed files with 12005 additions and 11618 deletions

View 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 };

View 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 };

View 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,
};

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View 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'>&mdash;</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 };

View File

@@ -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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };