Merge branch 'req-identity' into 'main'

Nostr identity request

See merge request soapbox-pub/soapbox!3061
environments/review-main-yi2y9f/deployments/4692
Alex Gleason 3 months ago
commit a09327abeb

@ -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_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_SUCCESS = 'ADMIN_USERS_DEACTIVATE_SUCCESS';
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) =>
(dispatch: AppDispatch, getState: () => RootState) =>
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) => {
const nicknames = accountIdsToAccts(getState(), accountIds);
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountIds });
const nicknames = accountIdsToAccts(getState(), [accountId]);
dispatch({ type: ADMIN_USERS_DELETE_REQUEST, accountId });
return api(getState)
.delete('/api/v1/pleroma/admin/users', { data: { nicknames } })
.then(({ data: nicknames }) => {
dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountIds });
dispatch({ type: ADMIN_USERS_DELETE_SUCCESS, nicknames, accountId });
}).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) =>
Promise.all(accountIds.map(accountId => {
api(getState)
.post(`/api/v1/admin/accounts/${accountId}/approve`)
.then(({ data: user }) => {
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users: [user], accountIds: [accountId] });
}).catch(error => {
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountIds: [accountId] });
});
}));
api(getState)
.post(`/api/v1/admin/accounts/${accountId}/approve`)
.then(({ data: user }) => {
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user, accountId });
}).catch(error => {
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId });
});
const approvePleromaUsers = (accountIds: string[]) =>
const approvePleromaUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const nicknames = accountIdsToAccts(getState(), accountIds);
const nicknames = accountIdsToAccts(getState(), [accountId]);
return api(getState)
.patch('/api/v1/pleroma/admin/users/approve', { nicknames })
.then(({ data: { users } }) => {
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, users, accountIds });
dispatch({ type: ADMIN_USERS_APPROVE_SUCCESS, user: users[0], accountId });
}).catch(error => {
dispatch({ type: ADMIN_USERS_APPROVE_FAIL, error, accountId });
});
};
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_APPROVE_FAIL, error, accountIds });
dispatch({ type: ADMIN_USERS_REJECT_FAIL, error, accountId });
});
const approveUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountId });
if (features.mastodonAdmin) {
return dispatch(approveMastodonUser(accountId));
} else {
return dispatch(approvePleromaUser(accountId));
}
};
const approveUsers = (accountIds: string[]) =>
const rejectUser = (accountId: string) =>
(dispatch: AppDispatch, getState: () => RootState) => {
const state = getState();
const instance = state.instance;
const features = getFeatures(instance);
dispatch({ type: ADMIN_USERS_APPROVE_REQUEST, accountIds });
dispatch({ type: ADMIN_USERS_REJECT_REQUEST, accountId });
if (features.mastodonAdmin) {
return dispatch(approveMastodonUsers(accountIds));
return dispatch(rejectMastodonUser(accountId));
} else {
return dispatch(approvePleromaUsers(accountIds));
return dispatch(deleteUser(accountId));
}
};
@ -562,6 +598,9 @@ export {
ADMIN_USERS_APPROVE_REQUEST,
ADMIN_USERS_APPROVE_SUCCESS,
ADMIN_USERS_APPROVE_FAIL,
ADMIN_USERS_REJECT_REQUEST,
ADMIN_USERS_REJECT_SUCCESS,
ADMIN_USERS_REJECT_FAIL,
ADMIN_USERS_DEACTIVATE_REQUEST,
ADMIN_USERS_DEACTIVATE_SUCCESS,
ADMIN_USERS_DEACTIVATE_FAIL,
@ -597,8 +636,10 @@ export {
closeReports,
fetchUsers,
deactivateUsers,
deleteUsers,
approveUsers,
deleteUser,
approveUser,
rejectUser,
revokeName,
deleteStatus,
toggleStatusSensitivity,
tagUsers,

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

@ -71,6 +71,7 @@ const ProfilePopper: React.FC<IProfilePopper> = ({ condition, wrapper, children
};
export interface IAccount {
acct?: string;
account: AccountSchema;
action?: React.ReactElement;
actionAlignment?: 'center' | 'top';
@ -99,6 +100,7 @@ export interface IAccount {
}
const Account = ({
acct,
account,
actionType,
action,
@ -228,7 +230,7 @@ const Account = ({
<Stack space={withAccountNote || note ? 1 : 0}>
<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 && (
<InstanceFavicon account={account} disabled={!withLinkToProfile} />

@ -1,6 +1,6 @@
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 Account from 'soapbox/components/account';
import { AuthorizeRejectButtons } from 'soapbox/components/authorize-reject-buttons';
@ -14,18 +14,19 @@ interface IUnapprovedAccount {
const UnapprovedAccount: React.FC<IUnapprovedAccount> = ({ accountId }) => {
const dispatch = useAppDispatch();
const { account } = useAccount(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 handleReject = () => dispatch(deleteUsers([account.id]));
const handleApprove = () => dispatch(approveUser(adminAccount.id));
const handleReject = () => dispatch(rejectUser(adminAccount.id));
return (
<Account
key={account.id}
key={adminAccount.id}
account={account}
acct={`${adminAccount.username}@${adminAccount.domain}`}
note={adminAccount?.invite_request || ''}
action={(
<AuthorizeRejectButtons

@ -1,12 +1,12 @@
import { useMutation, useQuery } from '@tanstack/react-query';
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 List, { ListItem } from 'soapbox/components/list';
import { Button, Column, Emoji, HStack, Icon, Input, Tooltip } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq';
import { useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks';
import { Button, CardHeader, CardTitle, Column, Emoji, Form, HStack, Icon, Input, Textarea, Tooltip } from 'soapbox/components/ui';
import { useApi, useAppDispatch, useInstance, useOwnAccount } from 'soapbox/hooks';
import { adminAccountSchema } from 'soapbox/schemas/admin-account';
import toast from 'soapbox/toast';
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.' },
success: { id: 'edit_profile.success', defaultMessage: 'Your profile has been successfully saved!' },
error: { id: 'edit_profile.error', defaultMessage: 'Profile update failed' },
placeholder: { id: 'edit_identity.reason_placeholder', defaultMessage: 'Why do you want this name?' },
});
/** EditIdentity component. */
@ -26,77 +27,111 @@ const EditIdentity: React.FC<IEditIdentity> = () => {
const instance = useInstance();
const dispatch = useAppDispatch();
const { account } = useOwnAccount();
const { relay, signer } = useNostr();
const { mutate } = useRequestName();
const admin = instance.nostr?.pubkey;
const pubkey = account?.nostr?.pubkey;
const [username, setUsername] = useState<string>('');
const { data: approvedNames } = useNames();
const { data: pendingNames } = usePendingNames();
const { events: labels } = useNostrReq(
(admin && pubkey)
? [{ kinds: [1985], authors: [admin], '#L': ['nip05'], '#p': [pubkey] }]
: [],
);
const [username, setUsername] = useState<string>('');
const [reason, setReason] = useState<string>('');
if (!account) return null;
const updateNip05 = async (nip05: string): Promise<void> => {
if (account.source?.nostr?.nip05 === nip05) return;
const updateName = async (name: string): Promise<void> => {
if (account.source?.nostr?.nip05 === name) return;
try {
await dispatch(patchMe({ nip05 }));
await dispatch(patchMe({ nip05: name }));
toast.success(intl.formatMessage(messages.success));
} catch (e) {
toast.error(intl.formatMessage(messages.error));
}
};
const submit = async () => {
if (!admin || !signer || !relay) return;
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);
const submit = () => {
const name = `${username}@${instance.domain}`;
mutate({ name, reason });
};
return (
<Column label={intl.formatMessage(messages.title)}>
<List>
{labels.map((label) => {
const identifier = label.tags.find(([name]) => name === 'l')?.[1];
if (!identifier) return null;
return (
<ListItem
key={identifier}
label={
<HStack alignItems='center' space={2}>
<span>{identifier}</span>
{(account.source?.nostr?.nip05 === identifier && account.acct !== identifier) && (
<Tooltip text={intl.formatMessage(messages.unverified)}>
<div>
<Emoji className='h-4 w-4' emoji='⚠️' />
</div>
</Tooltip>
)}
</HStack>
}
isSelected={account.source?.nostr?.nip05 === identifier}
onSelect={() => updateNip05(identifier)}
/>
);
})}
<ListItem label={<UsernameInput value={username} onChange={(e) => setUsername(e.target.value)} />}>
<Button theme='accent' onClick={submit}>Add</Button>
</ListItem>
</List>
<div className='space-y-4'>
<Form>
<UsernameInput value={username} onChange={(e) => setUsername(e.target.value)} />
<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>
{((approvedNames?.length ?? 0) > 0) && (
<>
<CardHeader>
<CardTitle title={<FormattedMessage id='edit_identity.names_title' defaultMessage='Names' />} />
</CardHeader>
<List>
{approvedNames?.map(({ username, domain }) => {
const identifier = `${username}@${domain}`;
if (!identifier) return null;
return (
<ListItem
key={identifier}
label={
<HStack alignItems='center' space={2}>
<span>{identifier}</span>
{(account.source?.nostr?.nip05 === identifier && account.acct !== identifier) && (
<Tooltip text={intl.formatMessage(messages.unverified)}>
<div>
<Emoji className='h-4 w-4' emoji='⚠️' />
</div>
</Tooltip>
)}
</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>
);
};
@ -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;

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

@ -1,19 +1,21 @@
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 { mentionCompose } from 'soapbox/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'soapbox/actions/interactions';
import { patchMe } from 'soapbox/actions/me';
import { openModal } from 'soapbox/actions/modals';
import { getSettings } from 'soapbox/actions/settings';
import { hideStatus, revealStatus } from 'soapbox/actions/statuses';
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 StatusContainer from 'soapbox/containers/status-container';
import { HotKeys } from 'soapbox/features/ui/components/hotkeys';
import { useAppDispatch, useAppSelector, useInstance } from 'soapbox/hooks';
import { makeGetNotification } from 'soapbox/selectors';
import toast from 'soapbox/toast';
import { NotificationType, validType } from 'soapbox/utils/notification';
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:participation_request': 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({
@ -63,7 +66,7 @@ const nameMessage = defineMessage({
defaultMessage: '{link}{others}',
});
const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
const notificationMessages: Record<NotificationType, MessageDescriptor> = defineMessages({
follow: {
id: 'notification.follow',
defaultMessage: '{name} followed you',
@ -132,29 +135,32 @@ const messages: Record<NotificationType, MessageDescriptor> = defineMessages({
id: 'notification.pleroma:participation_accepted',
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 = (
intl: IntlShape,
type: NotificationType,
account: AccountEntity,
totalCount: number | null,
acct: string | undefined,
targetName: string,
instanceTitle: string,
): React.ReactNode => {
const link = buildLink(account);
const name = intl.formatMessage(nameMessage, {
link,
others: totalCount && totalCount > 0 ? (
<FormattedMessage
id='notification.others'
defaultMessage='+ {count, plural, one {# other} other {# others}}'
values={{ count: totalCount - 1 }}
/>
) : '',
others: '',
});
return intl.formatMessage(messages[type], {
return intl.formatMessage(notificationMessages[type], {
acct,
name,
targetName,
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 => {
if (type === 'pleroma:emoji_reaction' && notification.emoji) {
return (
@ -349,19 +360,32 @@ const Notification: React.FC<INotification> = (props) => {
showGroup={false}
/>
) : 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:
return null;
}
};
const acct = notification.name;
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) ? (
notificationForScreenReader(
intl,
intl.formatMessage(messages[type], {
intl.formatMessage(notificationMessages[type], {
acct,
name: account && typeof account === 'object' ? account.acct : '',
targetName,
}),

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

@ -74,6 +74,7 @@
"account_moderation_modal.fields.badges": "Custom badges",
"account_moderation_modal.fields.deactivate": "Deactivate 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.verified": "Verified account",
"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.badges_saved_message": "Custom badges updated.",
"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.user_deactivated_message": "@{acct} was deactivated",
"admin.users.user_deleted_message": "@{acct} was deleted",
@ -642,6 +644,10 @@
"edit_federation.save": "Save",
"edit_federation.success": "{host} federation was updated",
"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_profile.error": "Profile update failed",
"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.alt": "Sign in with key",
"nostr_signup.siwe.title": "Sign in",
"notification.ditto:name_grant": "You were granted the name {acct}",
"notification.favourite": "{name} liked your post",
"notification.follow": "{name} followed you",
"notification.follow_request": "{name} has requested to follow you",
@ -1168,7 +1175,6 @@
"notification.mentioned": "{name} mentioned you",
"notification.move": "{name} moved to {targetName}",
"notification.name": "{link}{others}",
"notification.others": "+ {count, plural, one {# other} other {# others}}",
"notification.pleroma:chat_mention": "{name} sent you a message",
"notification.pleroma:emoji_reaction": "{name} reacted to your post",
"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.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} reposted your post",
"notification.set_name": "Set name to {name}",
"notification.status": "{name} just posted",
"notification.update": "{name} edited a post you interacted with",
"notification.update_name_success": "Name updated successfully",
"notification.user_approved": "Welcome to {instance}!",
"notifications.filter.all": "All",
"notifications.filter.boosts": "Reposts",

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

@ -19,6 +19,8 @@ import {
ADMIN_USERS_DELETE_SUCCESS,
ADMIN_USERS_APPROVE_REQUEST,
ADMIN_USERS_APPROVE_SUCCESS,
ADMIN_USERS_REJECT_REQUEST,
ADMIN_USERS_REJECT_SUCCESS,
} from 'soapbox/actions/admin';
import { normalizeAdminReport, normalizeAdminAccount } from 'soapbox/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 => {
accountIds.forEach(id => {
state.update('awaitingApproval', orderedSet => orderedSet.delete(id));
state.deleteIn(['users', id]);
});
state.update('awaitingApproval', orderedSet => orderedSet.delete(accountId));
state.deleteIn(['users', accountId]);
});
}
function approveUsers(state: State, users: APIUser[]): State {
function approveUser(state: State, user: APIUser): State {
const normalizedUser = fixUser(user);
return state.withMutations(state => {
users.forEach(user => {
const normalizedUser = fixUser(user);
state.update('awaitingApproval', orderedSet => orderedSet.delete(user.id));
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);
case ADMIN_USERS_DELETE_REQUEST:
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:
return state.update('awaitingApproval', set => set.subtract(action.accountIds));
return state.update('awaitingApproval', set => set.remove(action.accountId));
case ADMIN_USERS_APPROVE_SUCCESS:
return approveUsers(state, action.users);
return approveUser(state, action.user);
default:
return state;
}

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

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

@ -944,6 +944,9 @@ const getInstanceFeatures = (instance: Instance) => {
*/
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.
* @see POST /api/v1/statuses

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

Loading…
Cancel
Save