diff --git a/app/controllers/admin/roles_controller.rb b/app/controllers/admin/roles_controller.rb index 2f9af8a6fc..238d75bf79 100644 --- a/app/controllers/admin/roles_controller.rb +++ b/app/controllers/admin/roles_controller.rb @@ -62,7 +62,7 @@ module Admin def resource_params params - .expect(user_role: [:name, :color, :highlighted, :position, permissions_as_keys: []]) + .expect(user_role: [:name, :color, :highlighted, :position, :require_2fa, permissions_as_keys: []]) end end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f6d3ce35ab..a19fcc7a0a 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -61,19 +61,25 @@ class ApplicationController < ActionController::Base return if request.referer.blank? redirect_uri = URI(request.referer) - return if redirect_uri.path.start_with?('/auth') + return if redirect_uri.path.start_with?('/auth', '/settings/two_factor_authentication', '/settings/otp_authentication') stored_url = redirect_uri.to_s if redirect_uri.host == request.host && redirect_uri.port == request.port store_location_for(:user, stored_url) end + def mfa_setup_path(path_params = {}) + settings_two_factor_authentication_methods_path(path_params) + end + def require_functional! return if current_user.functional? respond_to do |format| format.any do - if current_user.confirmed? + if current_user.missing_2fa? + redirect_to mfa_setup_path + elsif current_user.confirmed? redirect_to edit_user_registration_path else redirect_to auth_setup_path @@ -85,6 +91,8 @@ class ApplicationController < ActionController::Base render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 elsif !current_user.approved? render json: { error: 'Your login is currently pending approval' }, status: 403 + elsif current_user.missing_2fa? + render json: { error: 'Your account requires two-factor authentication' }, status: 403 elsif !current_user.functional? render json: { error: 'Your login is currently disabled' }, status: 403 end diff --git a/app/controllers/concerns/challengable_concern.rb b/app/controllers/concerns/challengable_concern.rb index 7fbc469bdf..bd97037da6 100644 --- a/app/controllers/concerns/challengable_concern.rb +++ b/app/controllers/concerns/challengable_concern.rb @@ -42,7 +42,7 @@ module ChallengableConcern end def render_challenge - render 'auth/challenges/new', layout: 'auth' + render 'auth/challenges/new', layout: params[:oauth] ? 'modal' : 'auth' end def challenge_passed? diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 75f0b42e83..8b3b41e72f 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -24,4 +24,8 @@ class OAuth::AuthorizationsController < Doorkeeper::AuthorizationsController def truthy_param?(key) ActiveModel::Type::Boolean.new.cast(params[key]) end + + def mfa_setup_path + super({ oauth: true }) + end end diff --git a/app/controllers/settings/two_factor_authentication/base_controller.rb b/app/controllers/settings/two_factor_authentication/base_controller.rb new file mode 100644 index 0000000000..8770f927e7 --- /dev/null +++ b/app/controllers/settings/two_factor_authentication/base_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Settings + module TwoFactorAuthentication + class BaseController < ::Settings::BaseController + layout -> { truthy_param?(:oauth) ? 'modal' : 'admin' } + end + end +end diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index eae990e79b..61e2aef5a8 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -4,12 +4,15 @@ module Settings module TwoFactorAuthentication class ConfirmationsController < BaseController include ChallengableConcern + include Devise::Controllers::StoreLocation skip_before_action :require_functional! before_action :require_challenge! before_action :ensure_otp_secret + helper_method :return_to_app_url + def new prepare_two_factor_form end @@ -37,6 +40,10 @@ module Settings private + def return_to_app_url + stored_location_for(:user) + end + def confirmation_params params.expect(form_two_factor_confirmation: [:otp_attempt]) end diff --git a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb index ca8d46afe4..5460448d99 100644 --- a/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb +++ b/app/controllers/settings/two_factor_authentication/otp_authentication_controller.rb @@ -17,7 +17,7 @@ module Settings def create session[:new_otp_secret] = User.generate_otp_secret - redirect_to new_settings_two_factor_authentication_confirmation_path + redirect_to new_settings_two_factor_authentication_confirmation_path(params.permit(:oauth)) end private diff --git a/app/controllers/settings/two_factor_authentication_methods_controller.rb b/app/controllers/settings/two_factor_authentication_methods_controller.rb index a6d5c1fe2d..49579b3677 100644 --- a/app/controllers/settings/two_factor_authentication_methods_controller.rb +++ b/app/controllers/settings/two_factor_authentication_methods_controller.rb @@ -22,7 +22,7 @@ module Settings private def require_otp_enabled - redirect_to settings_otp_authentication_path unless current_user.otp_enabled? + redirect_to settings_otp_authentication_path(params.permit(:oauth)) unless current_user.otp_enabled? end end end diff --git a/app/helpers/json_ld_helper.rb b/app/helpers/json_ld_helper.rb index 804cc72d70..3ab00819d8 100644 --- a/app/helpers/json_ld_helper.rb +++ b/app/helpers/json_ld_helper.rb @@ -70,6 +70,10 @@ module JsonLdHelper !json.nil? && equals_or_includes?(json['@context'], ActivityPub::TagManager::CONTEXT) end + def supported_security_context?(json) + !json.nil? && equals_or_includes?(json['@context'], 'https://w3id.org/security/v1') + end + def unsupported_uri_scheme?(uri) uri.nil? || !uri.start_with?('http://', 'https://') end diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index 437f597314..60df9abc53 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -6,7 +6,10 @@ import { fetchRelationships } from './accounts'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { unreblog, reblog } from './interactions_typed'; import { openModal } from './modal'; -import { timelineExpandPinnedFromStatus } from './timelines_typed'; +import { + insertPinnedStatusIntoTimelines, + removePinnedStatusFromTimelines, +} from './timelines_typed'; export const REBLOGS_EXPAND_REQUEST = 'REBLOGS_EXPAND_REQUEST'; export const REBLOGS_EXPAND_SUCCESS = 'REBLOGS_EXPAND_SUCCESS'; @@ -369,7 +372,7 @@ export function pin(status) { api().post(`/api/v1/statuses/${status.get('id')}/pin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(pinSuccess(status)); - dispatch(timelineExpandPinnedFromStatus(status)); + dispatch(insertPinnedStatusIntoTimelines(status)); }).catch(error => { dispatch(pinFail(status, error)); }); @@ -408,7 +411,7 @@ export function unpin (status) { api().post(`/api/v1/statuses/${status.get('id')}/unpin`).then(response => { dispatch(importFetchedStatus(response.data)); dispatch(unpinSuccess(status)); - dispatch(timelineExpandPinnedFromStatus(status)); + dispatch(removePinnedStatusFromTimelines(status)); }).catch(error => { dispatch(unpinFail(status, error)); }); diff --git a/app/javascript/mastodon/actions/timelines_typed.ts b/app/javascript/mastodon/actions/timelines_typed.ts index f07b1274e2..9816561779 100644 --- a/app/javascript/mastodon/actions/timelines_typed.ts +++ b/app/javascript/mastodon/actions/timelines_typed.ts @@ -7,8 +7,8 @@ import type { Status } from '../models/status'; import { createAppThunk } from '../store/typed_functions'; import { - expandAccountFeaturedTimeline, expandTimeline, + insertIntoTimeline, TIMELINE_NON_STATUS_MARKERS, } from './timelines'; @@ -173,9 +173,13 @@ export function parseTimelineKey(key: string): TimelineParams | null { return null; } -export function isTimelineKeyPinned(key: string) { +export function isTimelineKeyPinned(key: string, accountId?: string) { const parsedKey = parseTimelineKey(key); - return parsedKey?.type === 'account' && parsedKey.pinned; + const isPinned = parsedKey?.type === 'account' && parsedKey.pinned; + if (!accountId || !isPinned) { + return isPinned; + } + return parsedKey.userId === accountId; } export function isNonStatusId(value: unknown) { @@ -199,52 +203,71 @@ export const timelineDelete = createAction<{ reblogOf: string | null; }>('timelines/delete'); -export const timelineExpandPinnedFromStatus = createAppThunk( +export const timelineDeleteStatus = createAction<{ + statusId: string; + timelineKey: string; +}>('timelines/deleteStatus'); + +export const insertPinnedStatusIntoTimelines = createAppThunk( (status: Status, { dispatch, getState }) => { - const accountId = status.getIn(['account', 'id']) as string; - if (!accountId) { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { return; } - // Verify that any of the relevant timelines are actually expanded before dispatching, to avoid unnecessary API calls. + const tags = + ( + status.get('tags') as + | ImmutableList> // We only care about the tag name. + | undefined + ) + ?.map((tag) => tag.get('name') as string) + .toArray() ?? []; + const timelines = getState().timelines as ImmutableMap; - if (!timelines.some((_, key) => key.startsWith(`account:${accountId}:`))) { - return; - } - - void dispatch( - expandTimelineByParams({ - type: 'account', - userId: accountId, - pinned: true, - }), - ); - void dispatch(expandAccountFeaturedTimeline(accountId)); - - // Iterate over tags and clear those too. - const tags = status.get('tags') as - | ImmutableList> // We only care about the tag name. - | undefined; - if (!tags) { - return; - } - tags.forEach((tag) => { - const tagName = tag.get('name'); - if (!tagName) { - return; + const accountTimelines = timelines.filter((_, key) => { + if (!key.startsWith(`account:${currentAccountId}:`)) { + return false; + } + const parsed = parseTimelineKey(key); + const isPinned = parsed?.type === 'account' && parsed.pinned; + if (!isPinned) { + return false; } - void dispatch( - expandTimelineByParams({ - type: 'account', - userId: accountId, - pinned: true, - tagged: tagName, - }), - ); - void dispatch( - expandAccountFeaturedTimeline(accountId, { tagged: tagName }), - ); + return !parsed.tagged || tags.includes(parsed.tagged); + }); + + accountTimelines.forEach((_, key) => { + dispatch(insertIntoTimeline(key, status.get('id') as string, 0)); + }); + }, +); + +export const removePinnedStatusFromTimelines = createAppThunk( + (status: Status, { dispatch, getState }) => { + const currentAccountId = getState().meta.get('me', null) as string | null; + if (!currentAccountId) { + return; + } + + const statusId = status.get('id') as string; + const timelines = getState().timelines as ImmutableMap< + string, + ImmutableMap<'items' | 'pendingItems', ImmutableList> + >; + + timelines.forEach((timeline, key) => { + if (!isTimelineKeyPinned(key, currentAccountId)) { + return; + } + + if ( + timeline.get('items')?.includes(statusId) || + timeline.get('pendingItems')?.includes(statusId) + ) { + dispatch(timelineDeleteStatus({ statusId, timelineKey: key })); + } }); }, ); diff --git a/app/javascript/mastodon/components/exit_animation_wrapper.tsx b/app/javascript/mastodon/components/exit_animation_wrapper.tsx index dba7d3e92c..4339068565 100644 --- a/app/javascript/mastodon/components/exit_animation_wrapper.tsx +++ b/app/javascript/mastodon/components/exit_animation_wrapper.tsx @@ -26,7 +26,7 @@ export const ExitAnimationWrapper: React.FC<{ * Render prop that provides the nested component with the `delayedIsActive` flag */ children: (delayedIsActive: boolean) => React.ReactNode; -}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => { +}> = ({ isActive, delayMs = 500, withEntryDelay, children }) => { const [delayedIsActive, setDelayedIsActive] = useState( isActive && !withEntryDelay, ); diff --git a/app/javascript/mastodon/components/form_fields/text_area_field.tsx b/app/javascript/mastodon/components/form_fields/text_area_field.tsx index bbde89574f..6cbced9faf 100644 --- a/app/javascript/mastodon/components/form_fields/text_area_field.tsx +++ b/app/javascript/mastodon/components/form_fields/text_area_field.tsx @@ -1,5 +1,5 @@ import type { ComponentPropsWithoutRef } from 'react'; -import { forwardRef } from 'react'; +import { forwardRef, useCallback } from 'react'; import classNames from 'classnames'; @@ -36,12 +36,26 @@ TextAreaField.displayName = 'TextAreaField'; export const TextArea = forwardRef< HTMLTextAreaElement, ComponentPropsWithoutRef<'textarea'> ->(({ className, ...otherProps }, ref) => ( -