diff --git a/src/actions/admin.ts b/src/actions/admin.ts index 433cd1e00..c5eb1150a 100644 --- a/src/actions/admin.ts +++ b/src/actions/admin.ts @@ -55,10 +55,6 @@ const ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST = 'ADMIN_STATUS_TOGGLE_SENSITIVITY const ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS'; const ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL = 'ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL'; -const ADMIN_LOG_FETCH_REQUEST = 'ADMIN_LOG_FETCH_REQUEST'; -const ADMIN_LOG_FETCH_SUCCESS = 'ADMIN_LOG_FETCH_SUCCESS'; -const ADMIN_LOG_FETCH_FAIL = 'ADMIN_LOG_FETCH_FAIL'; - const ADMIN_USERS_TAG_REQUEST = 'ADMIN_USERS_TAG_REQUEST'; const ADMIN_USERS_TAG_SUCCESS = 'ADMIN_USERS_TAG_SUCCESS'; const ADMIN_USERS_TAG_FAIL = 'ADMIN_USERS_TAG_FAIL'; @@ -423,19 +419,6 @@ const toggleStatusSensitivity = (id: string, sensitive: boolean) => }); }; -const fetchModerationLog = (params?: Record) => - (dispatch: AppDispatch, getState: () => RootState) => { - dispatch({ type: ADMIN_LOG_FETCH_REQUEST }); - return api(getState) - .get('/api/v1/pleroma/admin/moderation_log', { params }) - .then(({ data }) => { - dispatch({ type: ADMIN_LOG_FETCH_SUCCESS, items: data.items, total: data.total }); - return data; - }).catch(error => { - dispatch({ type: ADMIN_LOG_FETCH_FAIL, error }); - }); - }; - const tagUsers = (accountIds: string[], tags: string[]) => (dispatch: AppDispatch, getState: () => RootState) => { const nicknames = accountIdsToAccts(getState(), accountIds); @@ -707,9 +690,6 @@ export { ADMIN_STATUS_TOGGLE_SENSITIVITY_REQUEST, ADMIN_STATUS_TOGGLE_SENSITIVITY_SUCCESS, ADMIN_STATUS_TOGGLE_SENSITIVITY_FAIL, - ADMIN_LOG_FETCH_REQUEST, - ADMIN_LOG_FETCH_SUCCESS, - ADMIN_LOG_FETCH_FAIL, ADMIN_USERS_TAG_REQUEST, ADMIN_USERS_TAG_SUCCESS, ADMIN_USERS_TAG_FAIL, @@ -757,7 +737,6 @@ export { approveUsers, deleteStatus, toggleStatusSensitivity, - fetchModerationLog, tagUsers, untagUsers, setTags, diff --git a/src/api/hooks/admin/index.ts b/src/api/hooks/admin/index.ts index 23f815c8a..0ca5e7514 100644 --- a/src/api/hooks/admin/index.ts +++ b/src/api/hooks/admin/index.ts @@ -1,6 +1,6 @@ -export { useCreateDomain, type CreateDomainParams } from './useCreateDomain'; -export { useDeleteDomain } from './useDeleteDomain'; export { useDomains } from './useDomains'; +export { useModerationLog } from './useModerationLog'; +export { useRelays } from './useRelays'; +export { useRules } from './useRules'; export { useSuggest } from './useSuggest'; -export { useUpdateDomain } from './useUpdateDomain'; export { useVerify } from './useVerify'; \ No newline at end of file diff --git a/src/api/hooks/admin/useDeleteDomain.ts b/src/api/hooks/admin/useDeleteDomain.ts index 3975209e0..05aeb70e8 100644 --- a/src/api/hooks/admin/useDeleteDomain.ts +++ b/src/api/hooks/admin/useDeleteDomain.ts @@ -2,11 +2,6 @@ import { Entities } from 'soapbox/entity-store/entities'; import { useDeleteEntity } from 'soapbox/entity-store/hooks'; import { useApi } from 'soapbox/hooks'; -interface DeleteDomainParams { - domain: string; - public: boolean; -} - const useDeleteDomain = () => { const api = useApi(); @@ -23,4 +18,4 @@ const useDeleteDomain = () => { }; }; -export { useDeleteDomain, type DeleteDomainParams }; +export { useDeleteDomain }; diff --git a/src/api/hooks/admin/useDomains.ts b/src/api/hooks/admin/useDomains.ts index 998edca46..aed6afe7c 100644 --- a/src/api/hooks/admin/useDomains.ts +++ b/src/api/hooks/admin/useDomains.ts @@ -1,8 +1,21 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useApi } from 'soapbox/hooks'; +import { queryClient } from 'soapbox/queries/client'; import { domainSchema, type Domain } from 'soapbox/schemas'; +import type { AxiosResponse } from 'axios'; + +interface CreateDomainParams { + domain: string; + public: boolean; +} + +interface UpdateDomainParams { + id: string; + public: boolean; +} + const useDomains = () => { const api = useApi(); @@ -14,12 +27,56 @@ const useDomains = () => { }; const result = useQuery>({ - queryKey: ['domains'], + queryKey: ['admin', 'domains'], queryFn: getDomains, placeholderData: [], }); - return result; + const { + mutate: createDomain, + isPending: isCreating, + } = useMutation({ + mutationFn: (params: CreateDomainParams) => api.post('/api/v1/pleroma/admin/domains', params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray) => + [...prevResult, domainSchema.parse(data)], + ), + }); + + const { + mutate: updateDomain, + isPending: isUpdating, + } = useMutation({ + mutationFn: ({ id, ...params }: UpdateDomainParams) => api.patch(`/api/v1/pleroma/admin/domains/${id}`, params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray) => + prevResult.map((domain) => domain.id === data.id ? domainSchema.parse(data) : domain), + ), + }); + + const { + mutate: deleteDomain, + isPending: isDeleting, + } = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/pleroma/admin/domains/${id}`), + retry: false, + onSuccess: (_, id) => + queryClient.setQueryData(['admin', 'domains'], (prevResult: ReadonlyArray) => + prevResult.filter(({ id: domainId }) => domainId !== id), + ), + }); + + return { + ...result, + createDomain, + isCreating, + updateDomain, + isUpdating, + deleteDomain, + isDeleting, + }; }; export { useDomains }; diff --git a/src/api/hooks/admin/useModerationLog.ts b/src/api/hooks/admin/useModerationLog.ts new file mode 100644 index 000000000..e87d0b1c3 --- /dev/null +++ b/src/api/hooks/admin/useModerationLog.ts @@ -0,0 +1,42 @@ +import { useInfiniteQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks'; +import { moderationLogEntrySchema, type ModerationLogEntry } from 'soapbox/schemas'; + +interface ModerationLogResult { + items: ModerationLogEntry[]; + total: number; +} + +const flattenPages = (pages?: ModerationLogResult[]): ModerationLogEntry[] => (pages || []).map(({ items }) => items).flat(); + +const useModerationLog = () => { + const api = useApi(); + + const getModerationLog = async (page: number): Promise => { + const { data } = await api.get('/api/v1/pleroma/admin/moderation_log', { params: { page } }); + + const normalizedData = data.items.map((domain) => moderationLogEntrySchema.parse(domain)); + + return { + items: normalizedData, + total: data.total, + }; + }; + + const queryInfo = useInfiniteQuery({ + queryKey: ['admin', 'moderation_log'], + queryFn: ({ pageParam }) => getModerationLog(pageParam), + initialPageParam: 1, + getNextPageParam: (page, allPages) => flattenPages(allPages)!.length >= page.total ? undefined : allPages.length + 1, + }); + + const data = flattenPages(queryInfo.data?.pages); + + return { + ...queryInfo, + data, + }; +}; + +export { useModerationLog }; diff --git a/src/api/hooks/admin/useRelays.ts b/src/api/hooks/admin/useRelays.ts new file mode 100644 index 000000000..ff1d1ecce --- /dev/null +++ b/src/api/hooks/admin/useRelays.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks'; +import { queryClient } from 'soapbox/queries/client'; +import { relaySchema, type Relay } from 'soapbox/schemas'; + +import type { AxiosResponse } from 'axios'; + +const useRelays = () => { + const api = useApi(); + + const getRelays = async () => { + const { data } = await api.get<{ relays: Relay[] }>('/api/v1/pleroma/admin/relay'); + + const normalizedData = data.relays?.map((relay) => relaySchema.parse(relay)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'relays'], + queryFn: getRelays, + placeholderData: [], + }); + + const { + mutate: followRelay, + isPending: isPendingFollow, + } = useMutation({ + mutationFn: (relayUrl: string) => api.post('/api/v1/pleroma/admin/relays', { relay_url: relayUrl }), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'relays'], (prevResult: ReadonlyArray) => + [...prevResult, relaySchema.parse(data)], + ), + }); + + const { + mutate: unfollowRelay, + isPending: isPendingUnfollow, + } = useMutation({ + mutationFn: (relayUrl: string) => api.delete('/api/v1/pleroma/admin/relays', { + data: { relay_url: relayUrl }, + }), + retry: false, + onSuccess: (_, relayUrl) => + queryClient.setQueryData(['admin', 'relays'], (prevResult: ReadonlyArray) => + prevResult.filter(({ actor }) => actor !== relayUrl), + ), + }); + + return { + ...result, + followRelay, + isPendingFollow, + unfollowRelay, + isPendingUnfollow, + }; +}; + +export { useRelays }; diff --git a/src/api/hooks/admin/useRules.ts b/src/api/hooks/admin/useRules.ts new file mode 100644 index 000000000..87971e8fb --- /dev/null +++ b/src/api/hooks/admin/useRules.ts @@ -0,0 +1,85 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { useApi } from 'soapbox/hooks'; +import { queryClient } from 'soapbox/queries/client'; +import { adminRuleSchema, type AdminRule } from 'soapbox/schemas'; + +import type { AxiosResponse } from 'axios'; + +interface CreateRuleParams { + priority?: number; + text: string; + hint?: string; +} + +interface UpdateRuleParams { + id: string; + priority?: number; + text?: string; + hint?: string; +} + +const useRules = () => { + const api = useApi(); + + const getRules = async () => { + const { data } = await api.get('/api/v1/pleroma/admin/rules'); + + const normalizedData = data.map((rule) => adminRuleSchema.parse(rule)); + return normalizedData; + }; + + const result = useQuery>({ + queryKey: ['admin', 'rules'], + queryFn: getRules, + placeholderData: [], + }); + + const { + mutate: createRule, + isPending: isCreating, + } = useMutation({ + mutationFn: (params: CreateRuleParams) => api.post('/api/v1/pleroma/admin/rules', params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + [...prevResult, adminRuleSchema.parse(data)], + ), + }); + + const { + mutate: updateRule, + isPending: isUpdating, + } = useMutation({ + mutationFn: ({ id, ...params }: UpdateRuleParams) => api.patch(`/api/v1/pleroma/admin/rules/${id}`, params), + retry: false, + onSuccess: ({ data }: AxiosResponse) => + queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + prevResult.map((rule) => rule.id === data.id ? adminRuleSchema.parse(data) : rule), + ), + }); + + const { + mutate: deleteRule, + isPending: isDeleting, + } = useMutation({ + mutationFn: (id: string) => api.delete(`/api/v1/pleroma/admin/rules/${id}`), + retry: false, + onSuccess: (_, id) => + queryClient.setQueryData(['admin', 'rules'], (prevResult: ReadonlyArray) => + prevResult.filter(({ id: ruleId }) => ruleId !== id), + ), + }); + + return { + ...result, + createRule, + isCreating, + updateRule, + isUpdating, + deleteRule, + isDeleting, + }; +}; + +export { useRules }; diff --git a/src/api/index.ts b/src/api/index.ts index f71676145..dda1fa508 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -4,7 +4,7 @@ * @module soapbox/api */ -import axios, { AxiosInstance, AxiosResponse } from 'axios'; +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; import LinkHeader from 'http-link-header'; import { createSelector } from 'reselect'; diff --git a/src/entity-store/entities.ts b/src/entity-store/entities.ts index 97ef4d9ac..13cdf4eaa 100644 --- a/src/entity-store/entities.ts +++ b/src/entity-store/entities.ts @@ -11,7 +11,9 @@ enum Entities { GROUP_TAGS = 'GroupTags', PATRON_USERS = 'PatronUsers', RELATIONSHIPS = 'Relationships', - STATUSES = 'Statuses' + RELAYS = 'Relays', + RULES = 'Rules', + STATUSES = 'Statuses', } interface EntityTypes { @@ -24,6 +26,8 @@ interface EntityTypes { [Entities.GROUP_TAGS]: Schemas.GroupTag; [Entities.PATRON_USERS]: Schemas.PatronUser; [Entities.RELATIONSHIPS]: Schemas.Relationship; + [Entities.RELAYS]: Schemas.Relay; + [Entities.RULES]: Schemas.AdminRule; [Entities.STATUSES]: Schemas.Status; } diff --git a/src/features/admin/domains.tsx b/src/features/admin/domains.tsx index 28f360e81..e2455f3f8 100644 --- a/src/features/admin/domains.tsx +++ b/src/features/admin/domains.tsx @@ -1,8 +1,8 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; import { openModal } from 'soapbox/actions/modals'; -import { useDeleteDomain, useDomains } from 'soapbox/api/hooks/admin'; +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'; @@ -30,23 +30,23 @@ const Domain: React.FC = ({ domain }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - const { mutate: deleteDomain } = useDeleteDomain(); - const { refetch } = useDomains(); + const { deleteDomain } = useDomains(); const handleEditDomain = (domain: DomainEntity) => () => { dispatch(openModal('EDIT_DOMAIN', { domainId: domain.id })); }; - const handleDeleteDomain = (id: string) => () => { + const handleDeleteDomain = () => () => { dispatch(openModal('CONFIRM', { heading: intl.formatMessage(messages.deleteHeading), message: intl.formatMessage(messages.deleteMessage), confirm: intl.formatMessage(messages.deleteConfirm), onConfirm: () => { - deleteDomain(domain.id).then(() => { - toast.success(messages.domainDeleteSuccess); - refetch(); - }).catch(() => {}); + deleteDomain(domain.id, { + onSuccess: () => { + toast.success(messages.domainDeleteSuccess); + }, + }); }, })); }; @@ -90,7 +90,7 @@ const Domain: React.FC = ({ domain }) => { - @@ -103,12 +103,16 @@ const Domains: React.FC = () => { const intl = useIntl(); const dispatch = useAppDispatch(); - const { data: domains, isFetching } = useDomains(); + const { data: domains, isFetching, refetch } = useDomains(); const handleCreateDomain = () => { dispatch(openModal('EDIT_DOMAIN')); }; + useEffect(() => { + if (!isFetching) refetch(); + }, []); + const emptyMessage = ; return ( diff --git a/src/features/admin/moderation-log.tsx b/src/features/admin/moderation-log.tsx index 004d87997..57a80f83f 100644 --- a/src/features/admin/moderation-log.tsx +++ b/src/features/admin/moderation-log.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { defineMessages, FormattedDate, useIntl } from 'react-intl'; -import { fetchModerationLog } from 'soapbox/actions/admin'; +import { useModerationLog } from 'soapbox/api/hooks/admin'; import ScrollableList from 'soapbox/components/scrollable-list'; import { Column, Stack, Text } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector } from 'soapbox/hooks'; -import { AdminLog } from 'soapbox/types/entities'; + +import type { ModerationLogEntry } from 'soapbox/schemas'; const messages = defineMessages({ heading: { id: 'column.admin.moderation_log', defaultMessage: 'Moderation Log' }, @@ -14,37 +14,18 @@ const messages = defineMessages({ const ModerationLog = () => { const intl = useIntl(); - const dispatch = useAppDispatch(); - - const items = useAppSelector((state) => { - return state.admin_log.index.map((i) => state.admin_log.items.get(String(i))); - }); - - const hasMore = useAppSelector((state) => state.admin_log.total - state.admin_log.index.count() > 0); - const [isLoading, setIsLoading] = useState(true); - const [lastPage, setLastPage] = useState(0); + const { + data, + hasNextPage, + isLoading, + fetchNextPage, + } = useModerationLog(); - const showLoading = isLoading && items.count() === 0; - - useEffect(() => { - dispatch(fetchModerationLog()) - .then(() => { - setIsLoading(false); - setLastPage(1); - }) - .catch(() => { }); - }, []); + const showLoading = isLoading && data.length === 0; const handleLoadMore = () => { - const page = lastPage + 1; - - setIsLoading(true); - dispatch(fetchModerationLog({ page })) - .then(() => { - setIsLoading(false); - setLastPage(page); - }).catch(() => { }); + fetchNextPage(); }; return ( @@ -54,11 +35,11 @@ const ModerationLog = () => { showLoading={showLoading} scrollKey='moderation-log' emptyMessage={intl.formatMessage(messages.emptyMessage)} - hasMore={hasMore} + hasMore={hasNextPage} onLoadMore={handleLoadMore} listClassName='divide-y divide-solid divide-gray-200 dark:divide-gray-800' > - {items.map(item => item && ( + {data.map(item => item && ( ))} @@ -67,7 +48,7 @@ const ModerationLog = () => { }; interface ILogItem { - log: AdminLog; + log: ModerationLogEntry; } const LogItem: React.FC = ({ log }) => { diff --git a/src/features/admin/relays.tsx b/src/features/admin/relays.tsx new file mode 100644 index 000000000..e3d06a9ba --- /dev/null +++ b/src/features/admin/relays.tsx @@ -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 = ({ relay }) => { + const { unfollowRelay } = useRelays(); + + const handleDeleteRelay = () => () => { + unfollowRelay(relay.actor, { + onSuccess: () => { + toast.success(messages.relayDeleteSuccess); + }, + }); + }; + + return ( +
+ + + + + + + {' '} + {relay.actor} + + {relay.followed_back && ( + + + + )} + + + + + +
+ ); +}; + +const NewRelayForm: React.FC = () => { + const intl = useIntl(); + + const name = useTextField(); + + const { followRelay, isPendingFollow } = useRelays(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + followRelay(name.value, { + onSuccess() { + toast.success(messages.createSuccess); + }, + onError() { + toast.error(messages.createFail); + }, + }); + }; + + const label = intl.formatMessage(messages.label); + + return ( +
+ + + + + +
+ ); +}; + +const Relays: React.FC = () => { + const intl = useIntl(); + + const { data: relays, isFetching } = useRelays(); + + const emptyMessage = ; + + return ( + + + + + {relays && ( + + {relays.map((relay) => ( + + ))} + + )} + + + ); +}; + +export default Relays; diff --git a/src/features/admin/rules.tsx b/src/features/admin/rules.tsx new file mode 100644 index 000000000..e4b9754e5 --- /dev/null +++ b/src/features/admin/rules.tsx @@ -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 = ({ 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 ( +
+ + {rule.text} + {rule.hint} + {rule.priority !== null && ( + + + + + {' '} + {rule.priority} + + )} + + + + + +
+ ); +}; + +const Rules: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const { data, isLoading } = useRules(); + + const handleCreateRule = () => { + dispatch(openModal('EDIT_RULE')); + }; + + const emptyMessage = ; + + return ( + + + + + {data!.map((rule) => ( + + ))} + + + + ); +}; + +export default Rules; diff --git a/src/features/admin/tabs/dashboard.tsx b/src/features/admin/tabs/dashboard.tsx index 903fc9f82..bec02694d 100644 --- a/src/features/admin/tabs/dashboard.tsx +++ b/src/features/admin/tabs/dashboard.tsx @@ -100,6 +100,13 @@ const Dashboard: React.FC = () => { /> )} + {features.adminRules && ( + } + /> + )} + {features.domains && ( { return (
- +