From 57efcaf35c4e053722b75bdf620ad4f280ab6bb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 26 Jul 2024 15:56:13 +0200 Subject: [PATCH 1/2] GoToSocial: Support account security settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/security.ts | 65 +++++++++++++++++++++++++++++------------ src/locales/pl.json | 2 +- src/utils/features.ts | 5 +++- 3 files changed, 51 insertions(+), 21 deletions(-) diff --git a/src/actions/security.ts b/src/actions/security.ts index b3cb0ebcc..dcc26c22d 100644 --- a/src/actions/security.ts +++ b/src/actions/security.ts @@ -4,12 +4,12 @@ * @see module:soapbox/actions/auth */ +import api from 'soapbox/api'; import toast from 'soapbox/toast'; import { getLoggedInAccount } from 'soapbox/utils/auth'; +import { GOTOSOCIAL, parseVersion } from 'soapbox/utils/features'; import { normalizeUsername } from 'soapbox/utils/input'; -import api from '../api'; - import { AUTH_LOGGED_OUT, messages } from './auth'; import type { AppDispatch, RootState } from 'soapbox/store'; @@ -69,20 +69,39 @@ const revokeOAuthTokenById = (id: number) => const changePassword = (oldPassword: string, newPassword: string, confirmation: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHANGE_PASSWORD_REQUEST }); - return api(getState)('/api/pleroma/change_password', { - method: 'POST', - body: JSON.stringify({ - password: oldPassword, - new_password: newPassword, - new_password_confirmation: confirmation, - }), - }).then(response => { - if (response.json.error) throw response.json.error; // This endpoint returns HTTP 200 even on failure - dispatch({ type: CHANGE_PASSWORD_SUCCESS, response }); - }).catch(error => { - dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true }); - throw error; - }); + const state = getState(); + const instance = state.instance; + const v = parseVersion(instance.version); + + if (v.software === GOTOSOCIAL) { + return api(getState)('/api/v1/user/password_change', { + method: 'POST', + body: JSON.stringify({ + old_password: oldPassword, + new_password: newPassword, + }), + }).then(response => { + dispatch({ type: CHANGE_PASSWORD_SUCCESS, response }); + }).catch(error => { + dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true }); + throw error; + }); + } else { + return api(getState)('/api/pleroma/change_password', { + method: 'POST', + body: JSON.stringify({ + password: oldPassword, + new_password: newPassword, + new_password_confirmation: confirmation, + }), + }).then(response => { + if (response.json.error) throw response.json.error; // This endpoint returns HTTP 200 even on failure + dispatch({ type: CHANGE_PASSWORD_SUCCESS, response }); + }).catch(error => { + dispatch({ type: CHANGE_PASSWORD_FAIL, error, skipAlert: true }); + throw error; + }); + } }; const resetPassword = (usernameOrEmail: string) => @@ -110,10 +129,14 @@ const resetPassword = (usernameOrEmail: string) => const changeEmail = (email: string, password: string) => (dispatch: AppDispatch, getState: () => RootState) => { dispatch({ type: CHANGE_EMAIL_REQUEST, email }); - return api(getState)('/api/pleroma/change_email', { + const state = getState(); + const instance = state.instance; + const v = parseVersion(instance.version); + + return api(getState)(v.software === GOTOSOCIAL ? '/api/v1/user/email_change' : '/api/pleroma/change_email', { method: 'POST', body: JSON.stringify({ - email, + [v.software === GOTOSOCIAL ? 'new_email' : 'email']: email, password, }), }).then(response => { @@ -127,10 +150,14 @@ const changeEmail = (email: string, password: string) => const deleteAccount = (password: string) => (dispatch: AppDispatch, getState: () => RootState) => { + dispatch({ type: CHANGE_PASSWORD_REQUEST }); const account = getLoggedInAccount(getState()); + const state = getState(); + const instance = state.instance; + const v = parseVersion(instance.version); dispatch({ type: DELETE_ACCOUNT_REQUEST }); - return api(getState)('/api/pleroma/delete_account', { + return api(getState)(v.software === GOTOSOCIAL ? '/api/v1/accounts/delete' : '/api/pleroma/delete_account', { method: 'POST', body: JSON.stringify({ password }), }).then(response => { diff --git a/src/locales/pl.json b/src/locales/pl.json index 1f016e857..b4665a717 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1301,7 +1301,7 @@ "security.fields.new_password.label": "Nowe hasło", "security.fields.old_password.label": "Obecne hasło", "security.fields.password.label": "Hasło", - "security.fields.password_confirmation.label": "Obecne hasło (ponownie)", + "security.fields.password_confirmation.label": "Nowe hasło (ponownie)", "security.headers.delete": "Usuń konto", "security.headers.tokens": "Sesje", "security.qr.fail": "Nie udało się uzyskać klucza konfiguracyjnego", diff --git a/src/utils/features.ts b/src/utils/features.ts index b50d67979..464685990 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -877,8 +877,11 @@ const getInstanceFeatures = (instance: Instance) => { * @see POST /api/pleroma/change_password * @see POST /api/pleroma/change_email * @see POST /api/pleroma/delete_account + * @see POST /api/v1/user/email_change + * @see POST /api/v1/user/password_change + * @see POST /api/v1/accounts/delete_account */ - security: v.software === PLEROMA, + security: any([v.software === PLEROMA, v.software === GOTOSOCIAL]), /** * Ability to manage account sessions. From f476e743c285d071696ab7b2f69a9bb9e76ad14f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 26 Jul 2024 21:55:17 +0200 Subject: [PATCH 2/2] Support Pleroma/GoToSocial-specific status visibilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/auth.ts | 6 +- src/actions/compose.ts | 12 ++- src/components/status-action-bar.tsx | 4 +- .../auth-login/components/login-page.tsx | 2 +- .../compose/components/compose-form.tsx | 2 +- .../compose/components/privacy-dropdown.tsx | 91 +++++++++++++++---- src/features/compose/components/warning.tsx | 2 +- .../compose/containers/warning-container.tsx | 2 +- .../status/components/detailed-status.tsx | 2 +- src/locales/en.json | 5 + src/normalizers/status.ts | 2 +- src/reducers/compose.ts | 6 +- src/utils/features.ts | 20 +++- 13 files changed, 125 insertions(+), 31 deletions(-) diff --git a/src/actions/auth.ts b/src/actions/auth.ts index 4b5a7bd47..7d6856451 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -76,10 +76,10 @@ const getAuthApp = () => const createAuthApp = () => (dispatch: AppDispatch, getState: () => RootState) => { const params = { - client_name: sourceCode.displayName, + client_name: sourceCode.displayName, redirect_uris: 'urn:ietf:wg:oauth:2.0:oob', - scopes: getScopes(getState()), - website: sourceCode.homepage, + scopes: getScopes(getState()), + website: sourceCode.homepage, }; return dispatch(createApp(params)).then((app: Record) => diff --git a/src/actions/compose.ts b/src/actions/compose.ts index c09eb40df..5808dc345 100644 --- a/src/actions/compose.ts +++ b/src/actions/compose.ts @@ -65,7 +65,7 @@ const COMPOSE_LANGUAGE_CHANGE = 'COMPOSE_LANGUAGE_CHANGE' as const; const COMPOSE_MODIFIED_LANGUAGE_CHANGE = 'COMPOSE_MODIFIED_LANGUAGE_CHANGE' as const; const COMPOSE_LANGUAGE_ADD = 'COMPOSE_LANGUAGE_ADD' as const; const COMPOSE_LANGUAGE_DELETE = 'COMPOSE_LANGUAGE_DELETE' as const; -const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE' as const; +const COMPOSE_FEDERATED_CHANGE = 'COMPOSE_FEDERATED_CHANGE' as const; const COMPOSE_EMOJI_INSERT = 'COMPOSE_EMOJI_INSERT' as const; @@ -387,6 +387,7 @@ const submitCompose = (composeId: string, opts: SubmitComposeOpts = {}) => scheduled_at: compose.schedule, language: compose.language || compose.suggested_language, to, + federated: compose.federated, }; if (compose.language && compose.textMap.size) { @@ -937,6 +938,11 @@ const addSuggestedLanguage = (composeId: string, language: string) => ({ language, }); +const changeComposeFederated = (composeId: string) => ({ + type: COMPOSE_FEDERATED_CHANGE, + id: composeId, +}); + type ComposeAction = ComposeSetStatusAction | ReturnType @@ -989,6 +995,7 @@ type ComposeAction = | ReturnType | ReturnType | ReturnType + | ReturnType export { COMPOSE_CHANGE, @@ -1022,7 +1029,6 @@ export { COMPOSE_MODIFIED_LANGUAGE_CHANGE, COMPOSE_LANGUAGE_ADD, COMPOSE_LANGUAGE_DELETE, - COMPOSE_LISTABILITY_CHANGE, COMPOSE_EMOJI_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, COMPOSE_UPLOAD_CHANGE_SUCCESS, @@ -1043,6 +1049,7 @@ export { COMPOSE_CHANGE_MEDIA_ORDER, COMPOSE_ADD_SUGGESTED_QUOTE, COMPOSE_ADD_SUGGESTED_LANGUAGE, + COMPOSE_FEDERATED_CHANGE, setComposeToStatus, changeCompose, replyCompose, @@ -1104,5 +1111,6 @@ export { changeMediaOrder, addSuggestedQuote, addSuggestedLanguage, + changeComposeFederated, type ComposeAction, }; diff --git a/src/components/status-action-bar.tsx b/src/components/status-action-bar.tsx index c10826dbe..a6788616e 100644 --- a/src/components/status-action-bar.tsx +++ b/src/components/status-action-bar.tsx @@ -463,7 +463,7 @@ const StatusActionBar: React.FC = ({ icon: status.pinned ? require('@tabler/icons/outline/pinned-off.svg') : require('@tabler/icons/outline/pin.svg'), }); } else { - if (status.visibility === 'private') { + if (status.visibility === 'private' || status.visibility === 'mutuals_only') { menu.push({ text: intl.formatMessage(status.reblogged ? messages.cancel_reblog_private : messages.reblog_private), action: handleReblogClick, @@ -656,7 +656,7 @@ const StatusActionBar: React.FC = ({ if (status.visibility === 'direct') { reblogIcon = require('@tabler/icons/outline/mail.svg'); - } else if (status.visibility === 'private') { + } else if (status.visibility === 'private' || status.visibility === 'mutuals_only') { reblogIcon = require('@tabler/icons/outline/lock.svg'); } diff --git a/src/features/auth-login/components/login-page.tsx b/src/features/auth-login/components/login-page.tsx index b9704000b..987f37527 100644 --- a/src/features/auth-login/components/login-page.tsx +++ b/src/features/auth-login/components/login-page.tsx @@ -78,7 +78,7 @@ const LoginPage = () => { -
+
diff --git a/src/features/compose/components/compose-form.tsx b/src/features/compose/components/compose-form.tsx index 513727960..0129cfe5f 100644 --- a/src/features/compose/components/compose-form.tsx +++ b/src/features/compose/components/compose-form.tsx @@ -229,7 +229,7 @@ const ComposeForm = ({ id, shouldCondense, autoFocus, clickab } else if (privacy === 'direct') { publishIcon = require('@tabler/icons/outline/mail.svg'); publishText = intl.formatMessage(messages.message); - } else if (privacy === 'private') { + } else if (privacy === 'private' || privacy === 'mutuals_only') { publishIcon = require('@tabler/icons/outline/lock.svg'); publishText = intl.formatMessage(messages.publish); } else { diff --git a/src/features/compose/components/privacy-dropdown.tsx b/src/features/compose/components/privacy-dropdown.tsx index 9a5a2ecd1..19a0e3637 100644 --- a/src/features/compose/components/privacy-dropdown.tsx +++ b/src/features/compose/components/privacy-dropdown.tsx @@ -1,17 +1,18 @@ import clsx from 'clsx'; import { supportsPassiveEvents } from 'detect-passive-events'; import React, { useState, useRef, useEffect } from 'react'; -import { useIntl, defineMessages } from 'react-intl'; +import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { spring } from 'react-motion'; // @ts-ignore import Overlay from 'react-overlays/lib/Overlay'; -import { changeComposeVisibility } from 'soapbox/actions/compose'; +import { changeComposeFederated, changeComposeVisibility } from 'soapbox/actions/compose'; import { closeModal, openModal } from 'soapbox/actions/modals'; import Icon from 'soapbox/components/icon'; -import { Button } from 'soapbox/components/ui'; -import { useAppDispatch, useCompose } from 'soapbox/hooks'; +import { Button, Toggle } from 'soapbox/components/ui'; +import { useAppDispatch, useCompose, useFeatures, useInstance } from 'soapbox/hooks'; import { userTouching } from 'soapbox/is-mobile'; +import { GOTOSOCIAL, parseVersion, PLEROMA } from 'soapbox/utils/features'; import Motion from '../../ui/util/optional-motion'; @@ -22,13 +23,26 @@ const messages = defineMessages({ unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not post to public timelines' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, + mutuals_only_short: { id: 'privacy.mutuals_only.short', defaultMessage: 'Mutuals-only' }, + mutuals_only_long: { id: 'privacy.mutuals_only.long', defaultMessage: 'Post to mutually followed users only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, + local_short: { id: 'privacy.local.short', defaultMessage: 'Local-only' }, + local_long: { id: 'privacy.local.long', defaultMessage: 'Only visible on your instance' }, + change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust post privacy' }, + local: { id: 'privacy.local', defaultMessage: '{privacy} (local-only)' }, }); const listenerOptions = supportsPassiveEvents ? { passive: true } : false; +interface Option { + icon: string; + value: string; + text: string; + meta: string; +} + interface IPrivacyDropdownMenu { style?: React.CSSProperties; items: any[]; @@ -37,9 +51,14 @@ interface IPrivacyDropdownMenu { onClose: () => void; onChange: (value: string | null) => void; unavailable?: boolean; + showFederated?: boolean; + federated?: boolean; + onChangeFederated: () => void; } -const PrivacyDropdownMenu: React.FC = ({ style, items, placement, value, onClose, onChange }) => { +const PrivacyDropdownMenu: React.FC = ({ + style, items, placement, value, onClose, onChange, showFederated, federated, onChangeFederated, +}) => { const node = useRef(null); const focusedItem = useRef(null); @@ -52,9 +71,7 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla }; const handleKeyDown: React.KeyboardEventHandler = e => { - const value = e.currentTarget.getAttribute('data-index'); - const index = items.findIndex(item => item.value === value); - let element: ChildNode | null | undefined = null; + const index = [...e.currentTarget.parentElement!.children].indexOf(e.currentTarget); let element: ChildNode | null | undefined = null; switch (e.key) { case 'Escape': @@ -86,7 +103,8 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla if (element) { (element as HTMLElement).focus(); - onChange((element as HTMLElement).getAttribute('data-index')); + const value = (element as HTMLElement).getAttribute('data-index'); + if (value !== 'local_switch') onChange(value); e.preventDefault(); e.stopPropagation(); } @@ -97,8 +115,11 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla e.preventDefault(); - onClose(); - onChange(value); + if (value === 'local_switch') onChangeFederated(); + else { + onClose(); + onChange(value); + } }; useEffect(() => { @@ -143,7 +164,7 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla onKeyDown={handleKeyDown} onClick={handleClick} className={clsx( - 'flex cursor-pointer p-2.5 text-sm text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800', + 'flex cursor-pointer items-center p-2.5 text-gray-700 hover:bg-gray-100 black:hover:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800', { 'bg-gray-100 dark:bg-gray-800 black:bg-gray-900 hover:bg-gray-200 dark:hover:bg-gray-700': active }, )} aria-selected={active} @@ -154,16 +175,41 @@ const PrivacyDropdownMenu: React.FC = ({ style, items, pla
- {item.text} + {item.text} {item.meta}
); })} + {showFederated && ( +
+
+ +
+ +
+ + + + +
+ + +
+ )} )} @@ -181,6 +227,10 @@ const PrivacyDropdown: React.FC = ({ const intl = useIntl(); const node = useRef(null); const activeElement = useRef(null); + const instance = useInstance(); + const features = useFeatures(); + + const v = parseVersion(instance.version); const compose = useCompose(composeId); @@ -194,11 +244,15 @@ const PrivacyDropdown: React.FC = ({ { icon: require('@tabler/icons/outline/world.svg'), value: 'public', text: intl.formatMessage(messages.public_short), meta: intl.formatMessage(messages.public_long) }, { icon: require('@tabler/icons/outline/lock-open.svg'), value: 'unlisted', text: intl.formatMessage(messages.unlisted_short), meta: intl.formatMessage(messages.unlisted_long) }, { icon: require('@tabler/icons/outline/lock.svg'), value: 'private', text: intl.formatMessage(messages.private_short), meta: intl.formatMessage(messages.private_long) }, + features.mutualsOnlyStatuses ? { icon: require('@tabler/icons/outline/users-group.svg'), value: 'mutuals_only', text: intl.formatMessage(messages.mutuals_only_short), meta: intl.formatMessage(messages.mutuals_only_long) } : undefined, { icon: require('@tabler/icons/outline/mail.svg'), value: 'direct', text: intl.formatMessage(messages.direct_short), meta: intl.formatMessage(messages.direct_long) }, - ]; + features.localOnlyStatuses && v.software === PLEROMA ? { icon: require('@tabler/icons/outline/affiliate.svg'), value: 'local', text: intl.formatMessage(messages.local_short), meta: intl.formatMessage(messages.local_long) } : undefined, + ].filter((option): option is Option => !!option); const onChange = (value: string | null) => value && dispatch(changeComposeVisibility(composeId, value)); + const onChangeFederated = () => dispatch(changeComposeFederated(composeId)); + const onModalOpen = (props: Record) => dispatch(openModal('ACTIONS', props)); const onModalClose = () => dispatch(closeModal('ACTIONS')); @@ -280,7 +334,9 @@ const PrivacyDropdown: React.FC = ({