Merge branch 'req-identity' into 'main'

Nostr identity request

See merge request soapbox-pub/soapbox!3061
environments/review-main-yi2y9f/deployments/4692
Alex Gleason 2024-06-10 00:35:18 +00:00
commit a09327abeb
15 zmienionych plików z 327 dodań i 126 usunięć

Wyświetl plik

@ -39,6 +39,10 @@ const ADMIN_USERS_APPROVE_REQUEST = 'ADMIN_USERS_APPROVE_REQUEST';
const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS'; const ADMIN_USERS_APPROVE_SUCCESS = 'ADMIN_USERS_APPROVE_SUCCESS';
const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL'; const ADMIN_USERS_APPROVE_FAIL = 'ADMIN_USERS_APPROVE_FAIL';
const ADMIN_USERS_REJECT_REQUEST = 'ADMIN_USERS_REJECT_REQUEST';
const ADMIN_USERS_REJECT_SUCCESS = 'ADMIN_USERS_REJECT_SUCCESS';
const ADMIN_USERS_REJECT_FAIL = 'ADMIN_USERS_REJECT_FAIL';
const ADMIN_USERS_DEACTIVATE_REQUEST = 'ADMIN_USERS_DEACTIVATE_REQUEST'; const ADMIN_USERS_DEACTIVATE_REQUEST = 'ADMIN_USERS_DEACTIVATE_REQUEST';
const ADMIN_USERS_DEACTIVATE_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS'; const ADMIN_USERS_DEACTIVATE_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS';
const ADMIN_USERS_DEACTIVATE_FAIL = 'ADMIN_USERS_DEACTIVATE_FAIL'; const ADMIN_USERS_DEACTIVATE_FAIL = 'ADMIN_USERS_DEACTIVATE_FAIL';
@ -266,6 +270,14 @@ const fetchUsers = (filters: string[] = [], page = 1, query?: string | null, pag
} }
}; };
const revokeName = (accountId: string, reportId?: string) =>
(dispatch: AppDispatch, getState: () => RootState) =>
api(getState)
.post(`/api/v1/admin/accounts/${accountId}/action`, {
type: 'revoke_name',
report_id: reportId,
});
const deactivateMastodonUsers = (accountIds: string[], reportId?: string) => const deactivateMastodonUsers = (accountIds: string[], reportId?: string) =>
(dispatch: AppDispatch, getState: () => RootState) => (dispatch: AppDispatch, getState: () => RootState) =>
Promise.all(accountIds.map(accountId => { Promise.all(accountIds.map(accountId => {
@ -309,56 +321,80 @@ const deactivateUsers = (accountIds: string[], reportId?: string) =>
} }
}; };
const deleteUsers = (accountIds: string[]) => const deleteUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = accountIdsToAccts(getState(), accountIds); const nicknames = accountIdsToAccts(getState(), [accountId]);
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds }); dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountId });
return api(getState) return api(getState)
.delete('/api/v1/pleroma/admin/users', { data: { nicknames } }) .delete('/api/v1/pleroma/admin/users', { data: { nicknames } })
.then(({ data: nicknames }) => { .then(({ data: nicknames }) => {
dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds }); dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountId });
}).catch(error => { }).catch(error => {
dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountIds }); dispatch({ type: ADMIN_USERS_DELETE_FAIL, error, accountId });
}); });
}; };
const approveMastodonUsers = (accountIds: string[]) => const approveMastodonUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => (dispatch: AppDispatch, getState: () => RootState) =>
Promise.all(accountIds.map(accountId => { api(getState)
api(getState) .post(`/api/v1/admin/accounts/${accountId}/approve`)
.post(`/api/v1/admin/accounts/${accountId}/approve`) .then(({ data: user }) => {
.then(({ data: user }) => { dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user, accountId });
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users: [user], accountIds: [accountId] }); }).catch(error => {
}).catch(error => { dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId });
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds: [accountId] }); });
});
}));
const approvePleromaUsers = (accountIds: string[]) => const approvePleromaUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = accountIdsToAccts(getState(), accountIds); const nicknames = accountIdsToAccts(getState(), [accountId]);
return api(getState) return api(getState)
.patch('/api/v1/pleroma/admin/users/approve', { nicknames }) .patch('/api/v1/pleroma/admin/users/approve', { nicknames })
.then(({ data: { users } }) => { .then(({ data: { users } }) => {
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds }); dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user: users[0], accountId });
}).catch(error => { }).catch(error => {
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds }); dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId });
}); });
}; };
const approveUsers = (accountIds: string[]) => const rejectMastodonUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) =>
api(getState)
.post(`/api/v1/admin/accounts/${accountId}/reject`)
.then(({ data: user }) => {
dispatch({ type: ADMIN_USERS_REJECT_SUCCESS, user, accountId });
}).catch(error => {
dispatch({ type: ADMIN_USERS_REJECT_FAIL, error, accountId });
});
const approveUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => { (dispatch: AppDispatch, getState: () => RootState) => {
const state = getState(); const state = getState();
const instance = state.instance; const instance = state.instance;
const features = getFeatures(instance); const features = getFeatures(instance);
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds }); dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountId });
if (features.mastodonAdmin) { if (features.mastodonAdmin) {
return dispatch(approveMastodonUsers(accountIds)); return dispatch(approveMastodonUser(accountId));
} else { } else {
return dispatch(approvePleromaUsers(accountIds)); return dispatch(approvePleromaUser(accountId));
}
};
const rejectUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
dispatch({ type: ADMIN_USERS_REJECT_REQUEST, accountId });
if (features.mastodonAdmin) {
return dispatch(rejectMastodonUser(accountId));
} else {
return dispatch(deleteUser(accountId));
} }
}; };
@ -562,6 +598,9 @@ export {
ADMIN_USERS_APPROVE_REQUEST, ADMIN_USERS_APPROVE_REQUEST,
ADMIN_USERS_APPROVE_SUCCESS, ADMIN_USERS_APPROVE_SUCCESS,
ADMIN_USERS_APPROVE_FAIL, ADMIN_USERS_APPROVE_FAIL,
ADMIN_USERS_REJECT_REQUEST,
ADMIN_USERS_REJECT_SUCCESS,
ADMIN_USERS_REJECT_FAIL,
ADMIN_USERS_DEACTIVATE_REQUEST, ADMIN_USERS_DEACTIVATE_REQUEST,
ADMIN_USERS_DEACTIVATE_SUCCESS, ADMIN_USERS_DEACTIVATE_SUCCESS,
ADMIN_USERS_DEACTIVATE_FAIL, ADMIN_USERS_DEACTIVATE_FAIL,
@ -597,8 +636,10 @@ export {
closeReports, closeReports,
fetchUsers, fetchUsers,
deactivateUsers, deactivateUsers,
deleteUsers, deleteUser,
approveUsers, approveUser,
rejectUser,
revokeName,
deleteStatus, deleteStatus,
toggleStatusSensitivity, toggleStatusSensitivity,
tagUsers, tagUsers,

Wyświetl plik

@ -2,7 +2,7 @@ import React from 'react';
import { defineMessages, IntlShape } from 'react-intl'; import { defineMessages, IntlShape } from 'react-intl';
import { fetchAccountByUsername } from 'soapbox/actions/accounts'; import { fetchAccountByUsername } from 'soapbox/actions/accounts';
import { deactivateUsers, deleteUsers, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin'; import { deactivateUsers, deleteUser, deleteStatus, toggleStatusSensitivity } from 'soapbox/actions/admin';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import OutlineBox from 'soapbox/components/outline-box'; import OutlineBox from 'soapbox/components/outline-box';
import { Stack, Text } from 'soapbox/components/ui'; import { Stack, Text } from 'soapbox/components/ui';
@ -102,7 +102,7 @@ const deleteUserModal = (intl: IntlShape, accountId: string, afterConfirm = () =
confirm, confirm,
checkbox, checkbox,
onConfirm: () => { onConfirm: () => {
dispatch(deleteUsers([accountId])).then(() => { dispatch(deleteUser(accountId)).then(() => {
const message = intl.formatMessage(messages.userDeleted, { acct }); const message = intl.formatMessage(messages.userDeleted, { acct });
dispatch(fetchAccountByUsername(acct)); dispatch(fetchAccountByUsername(acct));
toast.success(message); toast.success(message);

Wyświetl plik

@ -71,6 +71,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
}; };
export interface IAccount { export interface IAccount {
acct?: string;
account: AccountSchema; account: AccountSchema;
action?: React.ReactElement; action?: React.ReactElement;
actionAlignment?: 'center' | 'top'; actionAlignment?: 'center' | 'top';
@ -99,6 +100,7 @@ export interface IAccount {
} }
const Account = ({ const Account = ({
acct,
account, account,
actionType, actionType,
action, action,
@ -228,7 +230,7 @@ const Account = ({
<Stack space={withAccountNote || note ? 1 : 0}> <Stack space={withAccountNote || note ? 1 : 0}>
<HStack alignItems='center' space={1}> <HStack alignItems='center' space={1}>
<Text theme='muted' size='sm' direction='ltr' truncate>@{username}</Text> <Text theme='muted' size='sm' direction='ltr' truncate>@{acct ?? username}</Text>
{account.pleroma?.favicon && ( {account.pleroma?.favicon && (
<InstanceFavicon account={account} disabled={!withLinkToProfile} /> <InstanceFavicon account={account} disabled={!withLinkToProfile} />

Wyświetl plik

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { approveUsers, deleteUsers } from 'soapbox/actions/admin'; import { approveUser, rejectUser } from 'soapbox/actions/admin';
import { useAccount } from 'soapbox/api/hooks'; import { useAccount } from 'soapbox/api/hooks';
import Account from 'soapbox/components/account'; import Account from 'soapbox/components/account';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons'; import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
@ -14,18 +14,19 @@ interface IUnapprovedAccount {
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => { const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { account } = useAccount(accountId);
const adminAccount = useAppSelector(state => state.admin.users.get(accountId)); const adminAccount = useAppSelector(state => state.admin.users.get(accountId));
const { account } = useAccount(adminAccount?.account || undefined);
if (!account) return null; if (!adminAccount || !account) return null;
const handleApprove = () => dispatch(approveUsers([account.id])); const handleApprove = () => dispatch(approveUser(adminAccount.id));
const handleReject = () => dispatch(deleteUsers([account.id])); const handleReject = () => dispatch(rejectUser(adminAccount.id));
return ( return (
<Account <Account
key={account.id} key={adminAccount.id}
account={account} account={account}
acct={`${adminAccount.username}@${adminAccount.domain}`}
note={adminAccount?.invite_request || ''} note={adminAccount?.invite_request || ''}
action={( action={(
<AuthorizeRejectButtons <AuthorizeRejectButtons

Wyświetl plik

@ -1,12 +1,12 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { patchMe } from 'soapbox/actions/me'; import { patchMe } from 'soapbox/actions/me';
import List, { ListItem } from 'soapbox/components/list'; import List, { ListItem } from 'soapbox/components/list';
import { Button, Column, Emoji, HStack, Icon, Input, Tooltip } from 'soapbox/components/ui'; import { Button, CardHeader, CardTitle, Column, Emoji, Form, HStack, Icon, Input, Textarea, Tooltip } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context'; import { useApi, useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks';
import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq'; import { adminAccountSchema } from 'soapbox/schemas/admin-account';
import { useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks';
import toast from 'soapbox/toast'; import toast from 'soapbox/toast';
interface IEditIdentity { interface IEditIdentity {
@ -18,6 +18,7 @@ const messages = defineMessages({
unverified: { id: 'edit_profile.fields.nip05_unverified', defaultMessage: 'Name could not be verified and won\'t be used.' }, unverified: { id: 'edit_profile.fields.nip05_unverified', defaultMessage: 'Name could not be verified and won\'t be used.' },
success: { id: 'edit_profile.success', defaultMessage: 'Your profile has been successfully saved!' }, success: { id: 'edit_profile.success', defaultMessage: 'Your profile has been successfully saved!' },
error: { id: 'edit_profile.error', defaultMessage: 'Profile update failed' }, error: { id: 'edit_profile.error', defaultMessage: 'Profile update failed' },
placeholder: { id: 'edit_identity.reason_placeholder', defaultMessage: 'Why do you want this name?' },
}); });
/** EditIdentity component. */ /** EditIdentity component. */
@ -26,77 +27,111 @@ const EditIdentity: React.FC<IEditIdentity> = () => {
const instance = useInstance(); const instance = useInstance();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { account } = useOwnAccount(); const { account } = useOwnAccount();
const { relay, signer } = useNostr(); const { mutate } = useRequestName();
const { data: approvedNames } = useNames();
const { data: pendingNames } = usePendingNames();
const admin = instance.nostr?.pubkey;
const pubkey = account?.nostr?.pubkey;
const [username, setUsername] = useState<string>(''); const [username, setUsername] = useState<string>('');
const [reason, setReason] = useState<string>('');
const { events: labels } = useNostrReq(
(admin && pubkey)
? [{ kinds: [1985], authors: [admin], '#L': ['nip05'], '#p': [pubkey] }]
: [],
);
if (!account) return null; if (!account) return null;
const updateNip05 = async (nip05: string): Promise<void> => { const updateName = async (name: string): Promise<void> => {
if (account.source?.nostr?.nip05 === nip05) return; if (account.source?.nostr?.nip05 === name) return;
try { try {
await dispatch(patchMe({ nip05 })); await dispatch(patchMe({ nip05: name }));
toast.success(intl.formatMessage(messages.success)); toast.success(intl.formatMessage(messages.success));
} catch (e) { } catch (e) {
toast.error(intl.formatMessage(messages.error)); toast.error(intl.formatMessage(messages.error));
} }
}; };
const submit = async () => { const submit = () => {
if (!admin || !signer || !relay) return; const name = `${username}@${instance.domain}`;
mutate({ name, reason });
const event = await signer.signEvent({
kind: 5950,
content: '',
tags: [
['i', `${username}@${instance.domain}`, 'text'],
['p', admin],
],
created_at: Math.floor(Date.now() / 1000),
});
await relay.event(event);
}; };
return ( return (
<Column label={intl.formatMessage(messages.title)}> <Column label={intl.formatMessage(messages.title)}>
<List> <div className='space-y-4'>
{labels.map((label) => { <Form>
const identifier = label.tags.find(([name]) => name === 'l')?.[1]; <UsernameInput value={username} onChange={(e) => setUsername(e.target.value)} />
if (!identifier) return null; <Textarea
name='reason'
placeholder={intl.formatMessage(messages.placeholder)}
maxLength={500}
onChange={(e) => setReason(e.target.value)}
value={reason}
autoGrow
required
/>
<Button theme='accent' onClick={submit}>
<FormattedMessage id='edit_identity.request' defaultMessage='Request' />
</Button>
</Form>
return ( {((approvedNames?.length ?? 0) > 0) && (
<ListItem <>
key={identifier} <CardHeader>
label={ <CardTitle title={<FormattedMessage id='edit_identity.names_title' defaultMessage='Names' />} />
<HStack alignItems='center' space={2}> </CardHeader>
<span>{identifier}</span>
{(account.source?.nostr?.nip05 === identifier && account.acct !== identifier) && ( <List>
<Tooltip text={intl.formatMessage(messages.unverified)}> {approvedNames?.map(({ username, domain }) => {
<div> const identifier = `${username}@${domain}`;
<Emoji className='h-4 w-4' emoji='⚠️' /> if (!identifier) return null;
</div>
</Tooltip> return (
)} <ListItem
</HStack> key={identifier}
} label={
isSelected={account.source?.nostr?.nip05 === identifier} <HStack alignItems='center' space={2}>
onSelect={() => updateNip05(identifier)} <span>{identifier}</span>
/> {(account.source?.nostr?.nip05 === identifier && account.acct !== identifier) && (
); <Tooltip text={intl.formatMessage(messages.unverified)}>
})} <div>
<ListItem label={<UsernameInput value={username} onChange={(e) => setUsername(e.target.value)} />}> <Emoji className='h-4 w-4' emoji='⚠️' />
<Button theme='accent' onClick={submit}>Add</Button> </div>
</ListItem> </Tooltip>
</List> )}
</HStack>
}
isSelected={account.source?.nostr?.nip05 === identifier}
onSelect={() => updateName(identifier)}
/>
);
})}
</List>
</>
)}
{((pendingNames?.length ?? 0) > 0) && (
<>
<CardHeader>
<CardTitle title={<FormattedMessage id='edit_identity.pending_names_title' defaultMessage='Requested Names' />} />
</CardHeader>
<List>
{pendingNames?.map(({ username, domain }) => {
const identifier = `${username}@${domain}`;
if (!identifier) return null;
return (
<ListItem
key={identifier}
label={
<HStack alignItems='center' space={2}>
<span>{identifier}</span>
</HStack>
}
/>
);
})}
</List>
</>
)}
</div>
</Column> </Column>
); );
}; };
@ -119,4 +154,43 @@ const UsernameInput: React.FC<React.ComponentProps<typeof Input>> = (props) => {
); );
}; };
interface NameRequestData {
name: string;
reason?: string;
}
function useRequestName() {
const api = useApi();
return useMutation({
mutationFn: (data: NameRequestData) => api.post('/api/v1/ditto/names', data),
});
}
function useNames() {
const api = useApi();
return useQuery({
queryKey: ['names', 'approved'],
queryFn: async () => {
const { data } = await api.get('/api/v1/ditto/names?approved=true');
return adminAccountSchema.array().parse(data);
},
placeholderData: [],
});
}
function usePendingNames() {
const api = useApi();
return useQuery({
queryKey: ['names', 'pending'],
queryFn: async () => {
const { data } = await api.get('/api/v1/ditto/names?approved=false');
return adminAccountSchema.array().parse(data);
},
placeholderData: [],
});
}
export default EditIdentity; export default EditIdentity;

Wyświetl plik

@ -129,8 +129,8 @@ interface AccountCredentials {
birthday?: string; birthday?: string;
/** Nostr NIP-05 identifier. */ /** Nostr NIP-05 identifier. */
nip05?: string; nip05?: string;
/** /**
* Lightning address. * Lightning address.
* https://github.com/lnurl/luds/blob/luds/16.md * https://github.com/lnurl/luds/blob/luds/16.md
*/ */
lud16?: string; lud16?: string;

Wyświetl plik

@ -1,19 +1,21 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { defineMessages, useIntl, FormattedMessage, IntlShape, MessageDescriptor, defineMessage } from 'react-intl'; import { defineMessages, useIntl, IntlShape, MessageDescriptor, defineMessage, FormattedMessage } from 'react-intl';
import { Link, useHistory } from 'react-router-dom'; import { Link, useHistory } from 'react-router-dom';
import { mentionCompose } from 'soapbox/actions/compose'; import { mentionCompose } from 'soapbox/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions'; import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions';
import { patchMe } from 'soapbox/actions/me';
import { openModal } from 'soapbox/actions/modals'; import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings'; import { getSettings } from 'soapbox/actions/settings';
import { hideStatus, revealStatus } from 'soapbox/actions/statuses'; import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
import Icon from 'soapbox/components/icon'; import Icon from 'soapbox/components/icon';
import { HStack, Text, Emoji } from 'soapbox/components/ui'; import { HStack, Text, Emoji, Button, Stack } from 'soapbox/components/ui';
import AccountContainer from 'soapbox/containers/account-container'; import AccountContainer from 'soapbox/containers/account-container';
import StatusContainer from 'soapbox/containers/status-container'; import StatusContainer from 'soapbox/containers/status-container';
import { HotKeys } from 'soapbox/features/ui/components/hotkeys'; import { HotKeys } from 'soapbox/features/ui/components/hotkeys';
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks'; import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
import { makeGetNotification } from 'soapbox/selectors'; import { makeGetNotification } from 'soapbox/selectors';
import toast from 'soapbox/toast';
import { NotificationType, validType } from 'soapbox/utils/notification'; import { NotificationType, validType } from 'soapbox/utils/notification';
import type { ScrollPosition } from 'soapbox/components/status'; import type { ScrollPosition } from 'soapbox/components/status';
@ -56,6 +58,7 @@ const icons: Record<NotificationType, string> = {
'pleroma:event_reminder': require('@tabler/icons/outline/calendar-time.svg'), 'pleroma:event_reminder': require('@tabler/icons/outline/calendar-time.svg'),
'pleroma:participation_request': require('@tabler/icons/outline/calendar-event.svg'), 'pleroma:participation_request': require('@tabler/icons/outline/calendar-event.svg'),
'pleroma:participation_accepted': require('@tabler/icons/outline/calendar-event.svg'), 'pleroma:participation_accepted': require('@tabler/icons/outline/calendar-event.svg'),
'ditto:name_grant': require('@tabler/icons/outline/user-check.svg'),
}; };
const nameMessage = defineMessage({ const nameMessage = defineMessage({
@ -63,7 +66,7 @@ const nameMessage = defineMessage({
defaultMessage: '{link}{others}', defaultMessage: '{link}{others}',
}); });
const messages: Record<NotificationType, MessageDescriptor> = defineMessages({ const notificationMessages: Record<NotificationType, MessageDescriptor> = defineMessages({
follow: { follow: {
id: 'notification.follow', id: 'notification.follow',
defaultMessage: '{name} followed you', defaultMessage: '{name} followed you',
@ -132,29 +135,32 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
id: 'notification.pleroma:participation_accepted', id: 'notification.pleroma:participation_accepted',
defaultMessage: 'You were accepted to join the event', defaultMessage: 'You were accepted to join the event',
}, },
'ditto:name_grant': {
id: 'notification.ditto:name_grant',
defaultMessage: 'You were granted the name {acct}',
},
});
const messages = defineMessages({
updateNameSuccess: { id: 'notification.update_name_success', defaultMessage: 'Name updated successfully' },
}); });
const buildMessage = ( const buildMessage = (
intl: IntlShape, intl: IntlShape,
type: NotificationType, type: NotificationType,
account: AccountEntity, account: AccountEntity,
totalCount: number | null, acct: string | undefined,
targetName: string, targetName: string,
instanceTitle: string, instanceTitle: string,
): React.ReactNode => { ): React.ReactNode => {
const link = buildLink(account); const link = buildLink(account);
const name = intl.formatMessage(nameMessage, { const name = intl.formatMessage(nameMessage, {
link, link,
others: totalCount && totalCount > 0 ? ( others: '',
<FormattedMessage
id='notification.others'
defaultMessage='+ {count, plural, one {# other} other {# others}}'
values={{ count: totalCount - 1 }}
/>
) : '',
}); });
return intl.formatMessage(messages[type], { return intl.formatMessage(notificationMessages[type], {
acct,
name, name,
targetName, targetName,
instance: instanceTitle, instance: instanceTitle,
@ -274,6 +280,11 @@ const Notification: React.FC<INotification> = (props) => {
} }
}; };
const updateName = async (name: string) => {
await dispatch(patchMe({ nip05: name }));
toast.success(messages.updateNameSuccess);
};
const renderIcon = (): React.ReactNode => { const renderIcon = (): React.ReactNode => {
if (type === 'pleroma:emoji_reaction' && notification.emoji) { if (type === 'pleroma:emoji_reaction' && notification.emoji) {
return ( return (
@ -349,19 +360,32 @@ const Notification: React.FC<INotification> = (props) => {
showGroup={false} showGroup={false}
/> />
) : null; ) : null;
case 'ditto:name_grant':
return (
<Stack className='p-4'>
<Button onClick={() => updateName(notification.name)}>
<FormattedMessage
id='notification.set_name' defaultMessage='Set name to {name}'
values={{ name: notification.name }}
/>
</Button>
</Stack>
);
default: default:
return null; return null;
} }
}; };
const acct = notification.name;
const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : ''; const targetName = notification.target && typeof notification.target === 'object' ? notification.target.acct : '';
const message: React.ReactNode = validType(type) && account && typeof account === 'object' ? buildMessage(intl, type, account, notification.total_count, targetName, instance.title) : null; const message: React.ReactNode = validType(type) && account && typeof account === 'object' ? buildMessage(intl, type, account, acct, targetName, instance.title) : null;
const ariaLabel = validType(type) ? ( const ariaLabel = validType(type) ? (
notificationForScreenReader( notificationForScreenReader(
intl, intl,
intl.formatMessage(messages[type], { intl.formatMessage(notificationMessages[type], {
acct,
name: account && typeof account === 'object' ? account.acct : '', name: account && typeof account === 'object' ? account.acct : '',
targetName, targetName,
}), }),

Wyświetl plik

@ -1,7 +1,7 @@
import React, { ChangeEventHandler, useState } from 'react'; import React, { ChangeEventHandler, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import { setBadges as saveBadges } from 'soapbox/actions/admin'; import { revokeName, setBadges as saveBadges } from 'soapbox/actions/admin';
import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation'; import { deactivateUserModal, deleteUserModal } from 'soapbox/actions/moderation';
import { useAccount } from 'soapbox/api/hooks'; import { useAccount } from 'soapbox/api/hooks';
import { useSuggest, useVerify } from 'soapbox/api/hooks/admin'; import { useSuggest, useVerify } from 'soapbox/api/hooks/admin';
@ -25,6 +25,7 @@ const messages = defineMessages({
userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' }, userSuggested: { id: 'admin.users.user_suggested_message', defaultMessage: '@{acct} was suggested' },
userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' }, userUnsuggested: { id: 'admin.users.user_unsuggested_message', defaultMessage: '@{acct} was unsuggested' },
badgesSaved: { id: 'admin.users.badges_saved_message', defaultMessage: 'Custom badges updated.' }, badgesSaved: { id: 'admin.users.badges_saved_message', defaultMessage: 'Custom badges updated.' },
revokedName: { id: 'admin.users.revoked_name_message', defaultMessage: 'Name revoked.' },
}); });
interface IAccountModerationModal { interface IAccountModerationModal {
@ -88,6 +89,12 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
dispatch(deactivateUserModal(intl, account.id)); dispatch(deactivateUserModal(intl, account.id));
}; };
const handleRevokeName = () => {
dispatch(revokeName(account.id))
.then(() => toast.success(intl.formatMessage(messages.revokedName)))
.catch(() => {});
};
const handleDelete = () => { const handleDelete = () => {
dispatch(deleteUserModal(intl, account.id)); dispatch(deleteUserModal(intl, account.id));
}; };
@ -151,6 +158,13 @@ const AccountModerationModal: React.FC<IAccountModerationModal> = ({ onClose, ac
</List> </List>
<List> <List>
{features.revokeName && (
<ListItem
label={<FormattedMessage id='account_moderation_modal.fields.revoke_name' defaultMessage='Revoke name' />}
onClick={handleRevokeName}
/>
)}
<ListItem <ListItem
label={<FormattedMessage id='account_moderation_modal.fields.deactivate' defaultMessage='Deactivate account' />} label={<FormattedMessage id='account_moderation_modal.fields.deactivate' defaultMessage='Deactivate account' />}
onClick={handleDeactivate} onClick={handleDeactivate}

Wyświetl plik

@ -74,6 +74,7 @@
"account_moderation_modal.fields.badges": "Custom badges", "account_moderation_modal.fields.badges": "Custom badges",
"account_moderation_modal.fields.deactivate": "Deactivate account", "account_moderation_modal.fields.deactivate": "Deactivate account",
"account_moderation_modal.fields.delete": "Delete account", "account_moderation_modal.fields.delete": "Delete account",
"account_moderation_modal.fields.revoke_name": "Revoke name",
"account_moderation_modal.fields.suggested": "Suggested in people to follow", "account_moderation_modal.fields.suggested": "Suggested in people to follow",
"account_moderation_modal.fields.verified": "Verified account", "account_moderation_modal.fields.verified": "Verified account",
"account_moderation_modal.info.id": "ID: {id}", "account_moderation_modal.info.id": "ID: {id}",
@ -183,6 +184,7 @@
"admin.users.actions.promote_to_moderator_message": "@{acct} was promoted to a moderator", "admin.users.actions.promote_to_moderator_message": "@{acct} was promoted to a moderator",
"admin.users.badges_saved_message": "Custom badges updated.", "admin.users.badges_saved_message": "Custom badges updated.",
"admin.users.remove_donor_message": "@{acct} was removed as a donor", "admin.users.remove_donor_message": "@{acct} was removed as a donor",
"admin.users.revoked_name_message": "Name revoked.",
"admin.users.set_donor_message": "@{acct} was set as a donor", "admin.users.set_donor_message": "@{acct} was set as a donor",
"admin.users.user_deactivated_message": "@{acct} was deactivated", "admin.users.user_deactivated_message": "@{acct} was deactivated",
"admin.users.user_deleted_message": "@{acct} was deleted", "admin.users.user_deleted_message": "@{acct} was deleted",
@ -642,6 +644,10 @@
"edit_federation.save": "Save", "edit_federation.save": "Save",
"edit_federation.success": "{host} federation was updated", "edit_federation.success": "{host} federation was updated",
"edit_federation.unlisted": "Force posts unlisted", "edit_federation.unlisted": "Force posts unlisted",
"edit_identity.names_title": "Names",
"edit_identity.pending_names_title": "Requested Names",
"edit_identity.reason_placeholder": "Why do you want this name?",
"edit_identity.request": "Request",
"edit_password.header": "Change Password", "edit_password.header": "Change Password",
"edit_profile.error": "Profile update failed", "edit_profile.error": "Profile update failed",
"edit_profile.fields.accepts_email_list_label": "Subscribe to newsletter", "edit_profile.fields.accepts_email_list_label": "Subscribe to newsletter",
@ -1159,6 +1165,7 @@
"nostr_signup.siwe.action": "Sign in with extension", "nostr_signup.siwe.action": "Sign in with extension",
"nostr_signup.siwe.alt": "Sign in with key", "nostr_signup.siwe.alt": "Sign in with key",
"nostr_signup.siwe.title": "Sign in", "nostr_signup.siwe.title": "Sign in",
"notification.ditto:name_grant": "You were granted the name {acct}",
"notification.favourite": "{name} liked your post", "notification.favourite": "{name} liked your post",
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you", "notification.follow_request": "{name} has requested to follow you",
@ -1168,7 +1175,6 @@
"notification.mentioned": "{name} mentioned you", "notification.mentioned": "{name} mentioned you",
"notification.move": "{name} moved to {targetName}", "notification.move": "{name} moved to {targetName}",
"notification.name": "{link}{others}", "notification.name": "{link}{others}",
"notification.others": "+ {count, plural, one {# other} other {# others}}",
"notification.pleroma:chat_mention": "{name} sent you a message", "notification.pleroma:chat_mention": "{name} sent you a message",
"notification.pleroma:emoji_reaction": "{name} reacted to your post", "notification.pleroma:emoji_reaction": "{name} reacted to your post",
"notification.pleroma:event_reminder": "An event you are participating in starts soon", "notification.pleroma:event_reminder": "An event you are participating in starts soon",
@ -1176,8 +1182,10 @@
"notification.pleroma:participation_request": "{name} wants to join your event", "notification.pleroma:participation_request": "{name} wants to join your event",
"notification.poll": "A poll you have voted in has ended", "notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} reposted your post", "notification.reblog": "{name} reposted your post",
"notification.set_name": "Set name to {name}",
"notification.status": "{name} just posted", "notification.status": "{name} just posted",
"notification.update": "{name} edited a post you interacted with", "notification.update": "{name} edited a post you interacted with",
"notification.update_name_success": "Name updated successfully",
"notification.user_approved": "Welcome to {instance}!", "notification.user_approved": "Welcome to {instance}!",
"notifications.filter.all": "All", "notifications.filter.all": "All",
"notifications.filter.boosts": "Reposts", "notifications.filter.boosts": "Reposts",

Wyświetl plik

@ -19,6 +19,7 @@ export const NotificationRecord = ImmutableRecord({
emoji: null as string | null, // pleroma:emoji_reaction emoji: null as string | null, // pleroma:emoji_reaction
emoji_url: null as string | null, // pleroma:emoji_reaction emoji_url: null as string | null, // pleroma:emoji_reaction
id: '', id: '',
name: '', // ditto:name_grant
status: null as EmbeddedEntity<Status>, status: null as EmbeddedEntity<Status>,
target: null as EmbeddedEntity<Account>, // move target: null as EmbeddedEntity<Account>, // move
type: '', type: '',

Wyświetl plik

@ -19,6 +19,8 @@ import {
ADMIN_USERS_DELETE_SUCCESS, ADMIN_USERS_DELETE_SUCCESS,
ADMIN_USERS_APPROVE_REQUEST, ADMIN_USERS_APPROVE_REQUEST,
ADMIN_USERS_APPROVE_SUCCESS, ADMIN_USERS_APPROVE_SUCCESS,
ADMIN_USERS_REJECT_REQUEST,
ADMIN_USERS_REJECT_SUCCESS,
} from 'soapbox/actions/admin'; } from 'soapbox/actions/admin';
import { normalizeAdminReport, normalizeAdminAccount } from 'soapbox/normalizers'; import { normalizeAdminReport, normalizeAdminAccount } from 'soapbox/normalizers';
import { normalizeId } from 'soapbox/utils/normalizers'; import { normalizeId } from 'soapbox/utils/normalizers';
@ -120,22 +122,18 @@ function importUsers(state: State, users: APIUser[], filters: Filter[], page: nu
}); });
} }
function deleteUsers(state: State, accountIds: string[]): State { function deleteUser(state: State, accountId: string): State {
return state.withMutations(state => { return state.withMutations(state => {
accountIds.forEach(id => { state.update('awaitingApproval', orderedSet => orderedSet.delete(accountId));
state.update('awaitingApproval', orderedSet => orderedSet.delete(id)); state.deleteIn(['users', accountId]);
state.deleteIn(['users', id]);
});
}); });
} }
function approveUsers(state: State, users: APIUser[]): State { function approveUser(state: State, user: APIUser): State {
const normalizedUser = fixUser(user);
return state.withMutations(state => { return state.withMutations(state => {
users.forEach(user => { state.update('awaitingApproval', orderedSet => orderedSet.delete(user.id));
const normalizedUser = fixUser(user); state.setIn(['users', user.id], normalizedUser);
state.update('awaitingApproval', orderedSet => orderedSet.delete(user.id));
state.setIn(['users', user.id], normalizedUser);
});
}); });
} }
@ -207,11 +205,13 @@ export default function admin(state: State = ReducerRecord(), action: AnyAction)
return importUsers(state, action.users, action.filters, action.page); return importUsers(state, action.users, action.filters, action.page);
case ADMIN_USERS_DELETE_REQUEST: case ADMIN_USERS_DELETE_REQUEST:
case ADMIN_USERS_DELETE_SUCCESS: case ADMIN_USERS_DELETE_SUCCESS:
return deleteUsers(state, action.accountIds); case ADMIN_USERS_REJECT_REQUEST:
case ADMIN_USERS_REJECT_SUCCESS:
return deleteUser(state, action.accountId);
case ADMIN_USERS_APPROVE_REQUEST: case ADMIN_USERS_APPROVE_REQUEST:
return state.update('awaitingApproval', set => set.subtract(action.accountIds)); return state.update('awaitingApproval', set => set.remove(action.accountId));
case ADMIN_USERS_APPROVE_SUCCESS: case ADMIN_USERS_APPROVE_SUCCESS:
return approveUsers(state, action.users); return approveUser(state, action.user);
default: default:
return state; return state;
} }

Wyświetl plik

@ -0,0 +1,27 @@
import { z } from 'zod';
import { accountSchema } from './account';
const adminAccountSchema = z.object({
id: z.string(),
account: accountSchema,
username: z.string(),
domain: z.string(),
created_at: z.string().datetime(),
email: z.string().email().nullish().catch(null),
ip: z.string().ip().nullish(),
ips: z.string().ip().array().nullish(),
locale: z.string(),
invite_request: z.string().nullish(),
role: z.string().nullish(),
confirmed: z.boolean().catch(true),
approved: z.boolean().catch(true),
disabled: z.boolean().catch(false),
silenced: z.boolean().catch(false),
suspended: z.boolean().catch(false),
sensitized: z.boolean().catch(false),
});
type AdminAccount = z.infer<typeof adminAccountSchema>;
export { adminAccountSchema, AdminAccount };

Wyświetl plik

@ -10,7 +10,6 @@ const baseNotificationSchema = z.object({
created_at: z.string().datetime().catch(new Date().toUTCString()), created_at: z.string().datetime().catch(new Date().toUTCString()),
id: z.string(), id: z.string(),
type: z.string(), type: z.string(),
total_count: z.number().optional().catch(undefined), // TruthSocial
}); });
const mentionNotificationSchema = baseNotificationSchema.extend({ const mentionNotificationSchema = baseNotificationSchema.extend({
@ -82,6 +81,11 @@ const participationAcceptedNotificationSchema = baseNotificationSchema.extend({
status: statusSchema, status: statusSchema,
}); });
const nameGrantNotificationSchema = baseNotificationSchema.extend({
type: z.literal('ditto:name_grant'),
name: z.string(),
});
const notificationSchema = z.discriminatedUnion('type', [ const notificationSchema = z.discriminatedUnion('type', [
mentionNotificationSchema, mentionNotificationSchema,
statusNotificationSchema, statusNotificationSchema,
@ -97,6 +101,7 @@ const notificationSchema = z.discriminatedUnion('type', [
eventReminderNotificationSchema, eventReminderNotificationSchema,
participationRequestNotificationSchema, participationRequestNotificationSchema,
participationAcceptedNotificationSchema, participationAcceptedNotificationSchema,
nameGrantNotificationSchema,
]); ]);
type Notification = z.infer<typeof notificationSchema>; type Notification = z.infer<typeof notificationSchema>;

Wyświetl plik

@ -944,6 +944,9 @@ const getInstanceFeatures = (instance: Instance) => {
*/ */
resetPassword: v.software === PLEROMA, resetPassword: v.software === PLEROMA,
/** Admin can revoke the user's identity (without deleting their account). */
revokeName: v.software === DITTO,
/** /**
* Ability to post statuses in Markdown, BBCode, and HTML. * Ability to post statuses in Markdown, BBCode, and HTML.
* @see POST /api/v1/statuses * @see POST /api/v1/statuses

Wyświetl plik

@ -17,6 +17,7 @@ const NOTIFICATION_TYPES = [
'pleroma:event_reminder', 'pleroma:event_reminder',
'pleroma:participation_request', 'pleroma:participation_request',
'pleroma:participation_accepted', 'pleroma:participation_accepted',
'ditto:name_grant',
] as const; ] as const;
/** Notification types to exclude from the "All" filter by default. */ /** Notification types to exclude from the "All" filter by default. */