diff --git a/app/controllers/admin/terms_of_service_controller.rb b/app/controllers/admin/terms_of_service_controller.rb new file mode 100644 index 0000000000..901a00cfd4 --- /dev/null +++ b/app/controllers/admin/terms_of_service_controller.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class Admin::TermsOfServiceController < Admin::BaseController + before_action :set_terms_of_service, except: [:index, :draft, :history, :create] + before_action :set_markdown, only: [:index, :history, :preview] + + def index + authorize :terms_of_service, :index? + + @terms_of_service = TermsOfService.live.first + end + + def draft + authorize :terms_of_service, :create? + + @terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: TermsOfService.live.first&.text) + end + + def history + authorize :terms_of_service, :index? + + @terms_of_service = TermsOfService.published.all + end + + def create + authorize :terms_of_service, :create? + + @terms_of_service = TermsOfService.new(resource_params) + + @terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish' + + if @terms_of_service.save + redirect_to @terms_of_service.published ? admin_terms_of_service_index_path : draft_admin_terms_of_service_index_path + else + render :draft + end + end + + def update + authorize @terms_of_service, :update? + + @terms_of_service.published_at = Time.now.utc if params[:action_type] == 'publish' + + if @terms_of_service.update(resource_params) + redirect_to @terms_of_service.published ? admin_terms_of_service_index_path : draft_admin_terms_of_service_index_path + else + render :draft + end + end + + def preview + authorize @terms_of_service, :distribute? + + @user_count = @terms_of_service.scope_for_notification.count + end + + def send_preview + authorize @terms_of_service, :distribute? + UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later! + redirect_to preview_admin_terms_of_service_path(@terms_of_service) + end + + def send_to_all + authorize @terms_of_service, :distribute? + @terms_of_service.touch(:notification_sent_at) + Admin::DistributeTermsOfServiceNotificationWorker.perform_async(@terms_of_service.id) + redirect_to admin_terms_of_service_index_path + end + + def destroy + authorize @terms_of_service, :destroy? + @terms_of_service.destroy + redirect_to admin_terms_of_service_index_path + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.find(params[:id]) + end + + def set_markdown + @markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true) + end + + def resource_params + params.require(:terms_of_service).permit(:text, :changelog) + end +end diff --git a/app/controllers/api/v1/instances/terms_of_services_controller.rb b/app/controllers/api/v1/instances/terms_of_services_controller.rb new file mode 100644 index 0000000000..e9e8e8ef55 --- /dev/null +++ b/app/controllers/api/v1/instances/terms_of_services_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Api::V1::Instances::TermsOfServicesController < Api::V1::Instances::BaseController + before_action :set_terms_of_service + + def show + cache_even_if_authenticated! + render json: @terms_of_service, serializer: REST::PrivacyPolicySerializer + end + + private + + def set_terms_of_service + @terms_of_service = TermsOfService.live.first! + end +end diff --git a/app/javascript/mastodon/api/instance.ts b/app/javascript/mastodon/api/instance.ts new file mode 100644 index 0000000000..ec9146fb34 --- /dev/null +++ b/app/javascript/mastodon/api/instance.ts @@ -0,0 +1,11 @@ +import { apiRequestGet } from 'mastodon/api'; +import type { + ApiTermsOfServiceJSON, + ApiPrivacyPolicyJSON, +} from 'mastodon/api_types/instance'; + +export const apiGetTermsOfService = () => + apiRequestGet('v1/instance/terms_of_service'); + +export const apiGetPrivacyPolicy = () => + apiRequestGet('v1/instance/privacy_policy'); diff --git a/app/javascript/mastodon/api_types/instance.ts b/app/javascript/mastodon/api_types/instance.ts new file mode 100644 index 0000000000..ead9774515 --- /dev/null +++ b/app/javascript/mastodon/api_types/instance.ts @@ -0,0 +1,9 @@ +export interface ApiTermsOfServiceJSON { + updated_at: string; + content: string; +} + +export interface ApiPrivacyPolicyJSON { + updated_at: string; + content: string; +} diff --git a/app/javascript/mastodon/features/about/index.jsx b/app/javascript/mastodon/features/about/index.jsx index 65a36520d6..e7d357f4c6 100644 --- a/app/javascript/mastodon/features/about/index.jsx +++ b/app/javascript/mastodon/features/about/index.jsx @@ -18,7 +18,7 @@ import { Icon } from 'mastodon/components/icon'; import { ServerHeroImage } from 'mastodon/components/server_hero_image'; import { Skeleton } from 'mastodon/components/skeleton'; import Account from 'mastodon/containers/account_container'; -import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import { LinkFooter} from 'mastodon/features/ui/components/link_footer'; const messages = defineMessages({ title: { id: 'column.about', defaultMessage: 'About' }, diff --git a/app/javascript/mastodon/features/getting_started/index.jsx b/app/javascript/mastodon/features/getting_started/index.jsx index 8d26115dfa..ece06953ea 100644 --- a/app/javascript/mastodon/features/getting_started/index.jsx +++ b/app/javascript/mastodon/features/getting_started/index.jsx @@ -25,7 +25,7 @@ import StarIcon from '@/material-icons/400-24px/star.svg?react'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import Column from 'mastodon/components/column'; import ColumnHeader from 'mastodon/components/column_header'; -import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import { LinkFooter } from 'mastodon/features/ui/components/link_footer'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions'; diff --git a/app/javascript/mastodon/features/privacy_policy/index.jsx b/app/javascript/mastodon/features/privacy_policy/index.jsx deleted file mode 100644 index d420546e4f..0000000000 --- a/app/javascript/mastodon/features/privacy_policy/index.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage, FormattedDate, injectIntl, defineMessages } from 'react-intl'; - -import { Helmet } from 'react-helmet'; - -import api from 'mastodon/api'; -import Column from 'mastodon/components/column'; -import { Skeleton } from 'mastodon/components/skeleton'; - -const messages = defineMessages({ - title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' }, -}); - -class PrivacyPolicy extends PureComponent { - - static propTypes = { - intl: PropTypes.object, - multiColumn: PropTypes.bool, - }; - - state = { - content: null, - lastUpdated: null, - isLoading: true, - }; - - componentDidMount () { - api().get('/api/v1/instance/privacy_policy').then(({ data }) => { - this.setState({ content: data.content, lastUpdated: data.updated_at, isLoading: false }); - }).catch(() => { - this.setState({ isLoading: false }); - }); - } - - render () { - const { intl, multiColumn } = this.props; - const { isLoading, content, lastUpdated } = this.state; - - return ( - -
-
-

-

: }} />

-
- -
-
- - - {intl.formatMessage(messages.title)} - - - - ); - } - -} - -export default injectIntl(PrivacyPolicy); diff --git a/app/javascript/mastodon/features/privacy_policy/index.tsx b/app/javascript/mastodon/features/privacy_policy/index.tsx new file mode 100644 index 0000000000..79314347ec --- /dev/null +++ b/app/javascript/mastodon/features/privacy_policy/index.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react'; + +import { + FormattedMessage, + FormattedDate, + useIntl, + defineMessages, +} from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { apiGetPrivacyPolicy } from 'mastodon/api/instance'; +import type { ApiPrivacyPolicyJSON } from 'mastodon/api_types/instance'; +import Column from 'mastodon/components/column'; +import { Skeleton } from 'mastodon/components/skeleton'; + +const messages = defineMessages({ + title: { id: 'privacy_policy.title', defaultMessage: 'Privacy Policy' }, +}); + +const PrivacyPolicy: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const [response, setResponse] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + apiGetPrivacyPolicy() + .then((data) => { + setResponse(data); + setLoading(false); + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, []); + + return ( + +
+
+

+ +

+

+ + ) : ( + + ), + }} + /> +

+
+ + {response && ( +
+ )} +
+ + + {intl.formatMessage(messages.title)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default PrivacyPolicy; diff --git a/app/javascript/mastodon/features/terms_of_service/index.tsx b/app/javascript/mastodon/features/terms_of_service/index.tsx new file mode 100644 index 0000000000..843530430b --- /dev/null +++ b/app/javascript/mastodon/features/terms_of_service/index.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react'; + +import { + FormattedMessage, + FormattedDate, + useIntl, + defineMessages, +} from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import { apiGetTermsOfService } from 'mastodon/api/instance'; +import type { ApiTermsOfServiceJSON } from 'mastodon/api_types/instance'; +import Column from 'mastodon/components/column'; +import { Skeleton } from 'mastodon/components/skeleton'; + +const messages = defineMessages({ + title: { id: 'terms_of_service.title', defaultMessage: 'Terms Of Service' }, +}); + +const TermsOfService: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + const intl = useIntl(); + const [response, setResponse] = useState(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + apiGetTermsOfService() + .then((data) => { + setResponse(data); + setLoading(false); + return ''; + }) + .catch(() => { + setLoading(false); + }); + }, []); + + return ( + +
+
+

+ +

+

+ + ) : ( + + ), + }} + /> +

+
+ + {response && ( +
+ )} +
+ + + {intl.formatMessage(messages.title)} + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export default TermsOfService; diff --git a/app/javascript/mastodon/features/ui/components/compose_panel.jsx b/app/javascript/mastodon/features/ui/components/compose_panel.jsx index 18321cbe63..b085b2dc2a 100644 --- a/app/javascript/mastodon/features/ui/components/compose_panel.jsx +++ b/app/javascript/mastodon/features/ui/components/compose_panel.jsx @@ -7,10 +7,9 @@ import { changeComposing, mountCompose, unmountCompose } from 'mastodon/actions/ import ServerBanner from 'mastodon/components/server_banner'; import ComposeFormContainer from 'mastodon/features/compose/containers/compose_form_container'; import SearchContainer from 'mastodon/features/compose/containers/search_container'; +import { LinkFooter } from 'mastodon/features/ui/components/link_footer'; import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import LinkFooter from './link_footer'; - class ComposePanel extends PureComponent { static propTypes = { identity: identityContextPropShape, diff --git a/app/javascript/mastodon/features/ui/components/link_footer.jsx b/app/javascript/mastodon/features/ui/components/link_footer.jsx deleted file mode 100644 index 49b21c2e48..0000000000 --- a/app/javascript/mastodon/features/ui/components/link_footer.jsx +++ /dev/null @@ -1,95 +0,0 @@ -import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; - -import { FormattedMessage, injectIntl } from 'react-intl'; - -import { Link } from 'react-router-dom'; - -import { connect } from 'react-redux'; - -import { openModal } from 'mastodon/actions/modal'; -import { identityContextPropShape, withIdentity } from 'mastodon/identity_context'; -import { domain, version, source_url, statusPageUrl, profile_directory as profileDirectory } from 'mastodon/initial_state'; -import { PERMISSION_INVITE_USERS } from 'mastodon/permissions'; - -const mapDispatchToProps = (dispatch) => ({ - onLogout () { - dispatch(openModal({ modalType: 'CONFIRM_LOG_OUT' })); - - }, -}); - -class LinkFooter extends PureComponent { - static propTypes = { - identity: identityContextPropShape, - multiColumn: PropTypes.bool, - onLogout: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - }; - - handleLogoutClick = e => { - e.preventDefault(); - e.stopPropagation(); - - this.props.onLogout(); - - return false; - }; - - render () { - const { signedIn, permissions } = this.props.identity; - const { multiColumn } = this.props; - - const canInvite = signedIn && ((permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS); - const canProfileDirectory = profileDirectory; - - const DividingCircle = {' · '}; - - return ( -
-

- {domain}: - {' '} - - {statusPageUrl && ( - <> - {DividingCircle} - - - )} - {canInvite && ( - <> - {DividingCircle} - - - )} - {canProfileDirectory && ( - <> - {DividingCircle} - - - )} - {DividingCircle} - -

- -

- Mastodon: - {' '} - - {DividingCircle} - - {DividingCircle} - - {DividingCircle} - - {DividingCircle} - v{version} -

-
- ); - } - -} - -export default injectIntl(withIdentity(connect(null, mapDispatchToProps)(LinkFooter))); diff --git a/app/javascript/mastodon/features/ui/components/link_footer.tsx b/app/javascript/mastodon/features/ui/components/link_footer.tsx new file mode 100644 index 0000000000..28b361a7e4 --- /dev/null +++ b/app/javascript/mastodon/features/ui/components/link_footer.tsx @@ -0,0 +1,95 @@ +import { FormattedMessage } from 'react-intl'; + +import { Link } from 'react-router-dom'; + +import { + domain, + version, + source_url, + statusPageUrl, + profile_directory as canProfileDirectory, +} from 'mastodon/initial_state'; + +const DividingCircle: React.FC = () => {' · '}; + +export const LinkFooter: React.FC<{ + multiColumn: boolean; +}> = ({ multiColumn }) => { + return ( +
+

+ {domain}:{' '} + + + + {statusPageUrl && ( + <> + + + + + + )} + {canProfileDirectory && ( + <> + + + + + + )} + + + + + + + + +

+ +

+ Mastodon:{' '} + + + + + + + + + + + + + + + + + v{version} +

+
+ ); +}; diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index daa4585ead..f2e28932ee 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -70,6 +70,7 @@ import { Onboarding, About, PrivacyPolicy, + TermsOfService, } from './util/async-components'; import { ColumnsContextProvider } from './util/columns_context'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; @@ -197,6 +198,7 @@ class SwitchingColumnsArea extends PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 5a85c856d2..bff27bd0ec 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -202,6 +202,10 @@ export function PrivacyPolicy () { return import(/*webpackChunkName: "features/privacy_policy" */'../../privacy_policy'); } +export function TermsOfService () { + return import(/*webpackChunkName: "features/terms_of_service" */'../../terms_of_service'); +} + export function NotificationRequests () { return import(/*webpackChunkName: "features/notifications/requests" */'../../notifications/requests'); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 60b35cb31a..2610d001de 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -43,6 +43,7 @@ * @property {boolean=} use_pending_items * @property {string} version * @property {string} sso_redirect + * @property {string} status_page_url */ /** @@ -115,7 +116,6 @@ export const usePendingItems = getMeta('use_pending_items'); export const version = getMeta('version'); export const languages = initialState?.languages; export const criticalUpdatesPending = initialState?.critical_updates_pending; -// @ts-expect-error export const statusPageUrl = getMeta('status_page_url'); export const sso_redirect = getMeta('sso_redirect'); diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss index f46160889a..1f3310877a 100644 --- a/app/javascript/styles/mailer.scss +++ b/app/javascript/styles/mailer.scss @@ -173,7 +173,9 @@ table + p { } .email-prose { - p { + p, + ul, + ol { color: #17063b; font-size: 14px; line-height: 20px; diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index b5f8570ae2..68d258bba0 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1940,3 +1940,76 @@ a.sparkline { } } } + +.admin { + &__terms-of-service { + &__container { + background: var(--surface-background-color); + border-radius: 8px; + border: 1px solid var(--background-border-color); + overflow: hidden; + + &__header { + padding: 16px; + font-size: 14px; + line-height: 20px; + color: $secondary-text-color; + display: flex; + align-items: center; + gap: 12px; + } + + &__body { + background: var(--background-color); + padding: 16px; + overflow-y: scroll; + height: 30vh; + } + } + + &__history { + & > li { + border-bottom: 1px solid var(--background-border-color); + + &:last-child { + border-bottom: 0; + } + } + + &__item { + padding: 16px 0; + padding-bottom: 8px; + + h5 { + font-size: 14px; + line-height: 20px; + font-weight: 600; + margin-bottom: 16px; + } + } + } + } +} + +.dot-indicator { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 500; + + &__indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: $dark-text-color; + } + + &.success { + color: $valid-value-color; + + .dot-indicator__indicator { + background-color: $valid-value-color; + } + } +} diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 5c9e5c96d9..b02c462217 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -209,6 +209,16 @@ class UserMailer < Devise::Mailer end end + def terms_of_service_changed(user, terms_of_service) + @resource = user + @terms_of_service = terms_of_service + @markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true) + + I18n.with_locale(locale) do + mail subject: default_i18n_subject + end + end + private def default_devise_subject diff --git a/app/models/terms_of_service.rb b/app/models/terms_of_service.rb new file mode 100644 index 0000000000..00f36d39e1 --- /dev/null +++ b/app/models/terms_of_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: terms_of_services +# +# id :bigint(8) not null, primary key +# changelog :text default(""), not null +# notification_sent_at :datetime +# published_at :datetime +# text :text default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class TermsOfService < ApplicationRecord + scope :published, -> { where.not(published_at: nil).order(published_at: :desc) } + scope :live, -> { published.limit(1) } + scope :draft, -> { where(published_at: nil).order(id: :desc).limit(1) } + + validates :text, presence: true + + def published? + published_at.present? + end + + def notified? + notification_sent_at.present? + end + + def scope_for_notification + User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at)) + end +end diff --git a/app/policies/terms_of_service_policy.rb b/app/policies/terms_of_service_policy.rb new file mode 100644 index 0000000000..0cbdb6a2e1 --- /dev/null +++ b/app/policies/terms_of_service_policy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class TermsOfServicePolicy < ApplicationPolicy + def index? + role.can?(:manage_settings) + end + + def create? + role.can?(:manage_settings) + end + + def distribute? + record.published? && !record.notified? && role.can?(:manage_settings) + end + + def update? + !record.published? && role.can?(:manage_settings) + end + + def destroy? + !record.published? && role.can?(:manage_settings) + end +end diff --git a/app/views/admin/terms_of_service/_links.html.haml b/app/views/admin/terms_of_service/_links.html.haml new file mode 100644 index 0000000000..933da89792 --- /dev/null +++ b/app/views/admin/terms_of_service/_links.html.haml @@ -0,0 +1,6 @@ +.content__heading__tabs + = render_navigation renderer: :links do |primary| + :ruby + primary.item :current, safe_join([material_symbol('description'), t('admin.terms_of_service.current')]), admin_terms_of_service_index_path + primary.item :draft, safe_join([material_symbol('description'), t('admin.terms_of_service.draft')]), draft_admin_terms_of_service_index_path + primary.item :previous, safe_join([material_symbol('history'), t('admin.terms_of_service.history')]), history_admin_terms_of_service_index_path diff --git a/app/views/admin/terms_of_service/draft.html.haml b/app/views/admin/terms_of_service/draft.html.haml new file mode 100644 index 0000000000..ef0cd76d1d --- /dev/null +++ b/app/views/admin/terms_of_service/draft.html.haml @@ -0,0 +1,19 @@ +- content_for :page_title do + = t('admin.terms_of_service.title') + +- content_for :heading do + %h2= t('admin.terms_of_service.title') + = render partial: 'links' + += simple_form_for @terms_of_service, url: @terms_of_service.new_record? ? admin_terms_of_service_index_path : admin_terms_of_service_path(@terms_of_service) do |form| + = render 'shared/error_messages', object: @terms_of_service + + .fields-group + = form.input :text, wrapper: :with_block_label, input_html: { rows: 8 } + + .fields-group + = form.input :changelog, wrapper: :with_block_label, input_html: { rows: 8 } + + .actions + = form.button :button, t('admin.terms_of_service.save_draft'), type: :submit, name: :action_type, value: :save_draft + = form.button :button, t('admin.terms_of_service.publish'), type: :submit, name: :action_type, value: :publish, class: 'negative' diff --git a/app/views/admin/terms_of_service/history.html.haml b/app/views/admin/terms_of_service/history.html.haml new file mode 100644 index 0000000000..e817f0be40 --- /dev/null +++ b/app/views/admin/terms_of_service/history.html.haml @@ -0,0 +1,16 @@ +- content_for :page_title do + = t('admin.terms_of_service.history') + +- content_for :heading do + %h2= t('admin.terms_of_service.title') + = render partial: 'links' + +- if @terms_of_service.empty? + %p= t('admin.terms_of_service.no_history') +- else + %ol.admin__terms-of-service__history + - @terms_of_service.each do |terms_of_service| + %li + .admin__terms-of-service__history__item + %h5= l(terms_of_service.published_at) + .prose= @markdown.render(terms_of_service.changelog).html_safe diff --git a/app/views/admin/terms_of_service/index.html.haml b/app/views/admin/terms_of_service/index.html.haml new file mode 100644 index 0000000000..d6f86c7d34 --- /dev/null +++ b/app/views/admin/terms_of_service/index.html.haml @@ -0,0 +1,39 @@ +- content_for :page_title do + = t('admin.terms_of_service.title') + +- content_for :heading do + %h2= t('admin.terms_of_service.title') + = render partial: 'links' + +- if @terms_of_service.present? + .admin__terms-of-service__container + .admin__terms-of-service__container__header + .dot-indicator.success + .dot-indicator__indicator + %span= t('admin.terms_of_service.live') + · + %span + = t('admin.terms_of_service.published_on_html', date: content_tag(:time, l(@terms_of_service.published_at.to_date), class: 'formatted', date: @terms_of_service.published_at.to_date.iso8601)) + · + - if @terms_of_service.notified? + %span + = t('admin.terms_of_service.notified_on_html', date: content_tag(:time, l(@terms_of_service.notification_sent_at.to_date), class: 'formatted', date: @terms_of_service.notification_sent_at.to_date.iso8601)) + - else + = link_to t('admin.terms_of_service.notify_users'), preview_admin_terms_of_service_path(@terms_of_service), class: 'link-button' + + .admin__terms-of-service__container__body + .prose + = @markdown.render(@terms_of_service.text).html_safe + + %hr.spacer/ + + %h3= t('admin.terms_of_service.changelog') + + .prose + = @markdown.render(@terms_of_service.changelog).html_safe +- else + %p.lead= t('admin.terms_of_service.no_terms_of_service_html') + + .content__heading__actions + = link_to t('admin.terms_of_service.create'), draft_admin_terms_of_service_index_path, class: 'button' + = link_to t('admin.terms_of_service.generate'), draft_admin_terms_of_service_index_path, class: 'button disabled' diff --git a/app/views/admin/terms_of_service/preview.html.haml b/app/views/admin/terms_of_service/preview.html.haml new file mode 100644 index 0000000000..f03ce76c2e --- /dev/null +++ b/app/views/admin/terms_of_service/preview.html.haml @@ -0,0 +1,14 @@ +- content_for :page_title do + = t('admin.terms_of_service.preview.title') + +%p.lead + = t('admin.terms_of_service.preview.explanation_html', count: @user_count, display_count: number_with_delimiter(@user_count), date: l(@terms_of_service.published_at.to_date)) + +.prose + = @markdown.render(@terms_of_service.changelog).html_safe + +%hr.spacer/ + +.content__heading__actions + = link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), send_preview_admin_terms_of_service_path(@terms_of_service), method: :post, class: 'button button-secondary' + = link_to t('admin.terms_of_service.preview.send_to_all', count: @user_count, display_count: number_with_delimiter(@user_count)), send_to_all_admin_terms_of_service_path(@terms_of_service), method: :post, class: 'button' diff --git a/app/views/user_mailer/terms_of_service_changed.html.haml b/app/views/user_mailer/terms_of_service_changed.html.haml new file mode 100644 index 0000000000..50ee089c20 --- /dev/null +++ b/app/views/user_mailer/terms_of_service_changed.html.haml @@ -0,0 +1,17 @@ += content_for :heading do + = render 'application/mailer/heading', + image_url: frontend_asset_url('images/mailer-new/heading/user.png'), + subtitle: t('user_mailer.terms_of_service_changed.subtitle', domain: site_hostname), + title: t('user_mailer.terms_of_service_changed.title') +%table.email-w-full{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-body-padding-td + %table.email-inner-card-table{ cellspacing: 0, cellpadding: 0, border: 0, role: 'presentation' } + %tr + %td.email-inner-card-td.email-prose + %p= t('user_mailer.terms_of_service_changed.description_html', path: terms_of_service_url, domain: site_hostname) + %p + %strong= t('user_mailer.terms_of_service_changed.changelog') + = @markdown.render(@terms_of_service.changelog).html_safe + %p= t('user_mailer.terms_of_service_changed.agreement', domain: site_hostname) + %p= t('user_mailer.terms_of_service_changed.sign_off', domain: site_hostname) diff --git a/app/views/user_mailer/terms_of_service_changed.text.erb b/app/views/user_mailer/terms_of_service_changed.text.erb new file mode 100644 index 0000000000..8416572f0a --- /dev/null +++ b/app/views/user_mailer/terms_of_service_changed.text.erb @@ -0,0 +1,14 @@ +<%= t('user_mailer.terms_of_service_changed.title') %> + +=== + +<%= t('user_mailer.terms_of_service_changed.description', domain: site_hostname) %> + +=> <%= terms_of_service_url %> + +<%= t('user_mailer.terms_of_service_changed.changelog') %> + +<%= @terms_of_service.changelog %> +<%= t('user_mailer.terms_of_service_changed.agreement', domain: site_hostname) %> + +<%= t('user_mailer.terms_of_service_changed.sign_off', domain: site_hostname) %> diff --git a/app/workers/admin/distribute_terms_of_service_notification_worker.rb b/app/workers/admin/distribute_terms_of_service_notification_worker.rb new file mode 100644 index 0000000000..7370ee87e8 --- /dev/null +++ b/app/workers/admin/distribute_terms_of_service_notification_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class Admin::DistributeTermsOfServiceNotificationWorker + include Sidekiq::Worker + + def perform(terms_of_service_id) + terms_of_service = TermsOfService.find(terms_of_service_id) + + terms_of_service.scope_for_notification.find_each do |user| + UserMailer.terms_of_service_changed(user, terms_of_service).deliver_later! + end + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 2971fe1f25..7ae64e077e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -925,6 +925,31 @@ en: search: Search title: Hashtags updated_msg: Hashtag settings updated successfully + terms_of_service: + changelog: What's changed + create: Create from blank slate + current: Current + description_html: Manage the terms of service that users agree to. This is a legal document, and any changes must be communicated to the users. + draft: Draft + edit: Start new draft + generate: Generate from template + history: History + live: Live + no_history: There are no recorded changes of the terms of service yet. + no_terms_of_service_html: You don't currently have any terms of service configured. Unlike the privacy policy, terms of service are optional for online services, however, they are meant to protect you from potential liabilities in disputes with your users. + notified_on_html: Users notified on %{date} + notify_users: Notify users + preview: + explanation_html: 'The e-mail will be sent to %{display_count} users who have signed up before %{date}. The following text will be included in the e-mail:' + send_preview: Send preview to %{email} + send_to_all: + one: Send %{display_count} e-mail + other: Send %{display_count} e-mails + title: Preview terms of service notification + publish: Publish + published_on_html: Published on %{date} + save_draft: Save draft + title: Terms Of Service title: Administration trends: allow: Allow @@ -1900,6 +1925,15 @@ en: further_actions_html: If this wasn't you, we recommend that you %{action} immediately and enable two-factor authentication to keep your account secure. subject: Your account has been accessed from a new IP address title: A new sign-in + terms_of_service_changed: + agreement: By continuing to use %{domain}, you are agreeing to these terms. If you disagree with the updated terms, you may terminate your agreement with %{domain} at any time by deleting your account. + changelog: 'At a glance, here is what this update means for you:' + description: 'You are receiving this e-mail because we''re making some changes to our terms of service at %{domain}. We encourage you to review the updated terms in full here:' + description_html: You are receiving this e-mail because we're making some changes to our terms of service at %{domain}. We encourage you to review the updated terms in full here. + sign_off: The %{domain} team + subject: Updates to our terms of service + subtitle: The terms of service of %{domain} are changing + title: Important update warning: appeal: Submit an appeal appeal_description: If you believe this is an error, you can submit an appeal to the staff of %{instance}. diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index f451c780b0..8f5c1a353e 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -129,6 +129,9 @@ en: show_application: You will always be able to see which app published your post regardless. tag: name: You can only change the casing of the letters, for example, to make it more readable + terms_of_service: + changelog: Can be structured with Markdown syntax. + text: Can be structured with Markdown syntax. user: chosen_languages: When checked, only posts in selected languages will be displayed in public timelines role: The role controls which permissions the user has. @@ -317,6 +320,9 @@ en: name: Hashtag trendable: Allow this hashtag to appear under trends usable: Allow posts to use this hashtag locally + terms_of_service: + changelog: What's changed? + text: Terms Of Service user: role: Role time_zone: Time zone diff --git a/config/routes.rb b/config/routes.rb index 0f4df757da..564864c0f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -234,8 +234,9 @@ Rails.application.routes.draw do get '/about', to: 'about#show' get '/about/more', to: redirect('/about') - get '/privacy-policy', to: 'privacy#show', as: :privacy_policy - get '/terms', to: redirect('/privacy-policy') + get '/privacy-policy', to: 'privacy#show', as: :privacy_policy + get '/terms-of-service', to: 'privacy#show', as: :terms_of_service + get '/terms', to: redirect('/terms-of-service') match '/', via: [:post, :put, :patch, :delete], to: 'application#raise_not_found', format: false match '*unmatched_route', via: :all, to: 'application#raise_not_found', format: false diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 2afe570236..c2fd02e125 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -33,6 +33,19 @@ namespace :admin do resources :action_logs, only: [:index] resources :warning_presets, except: [:new, :show] + resources :terms_of_service, only: [:index, :create, :update, :destroy] do + collection do + get :draft + get :history + end + + member do + get :preview + post :send_preview + post :send_to_all + end + end + resources :announcements, except: [:show] do member do post :publish diff --git a/config/routes/api.rb b/config/routes/api.rb index 86e41a2abe..34a267b35d 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -116,6 +116,7 @@ namespace :api, format: false do resources :rules, only: [:index] resources :domain_blocks, only: [:index] resource :privacy_policy, only: [:show] + resource :terms_of_service, only: [:show] resource :extended_description, only: [:show] resource :translation_languages, only: [:show] resource :languages, only: [:show] diff --git a/db/migrate/20241123224956_create_terms_of_services.rb b/db/migrate/20241123224956_create_terms_of_services.rb new file mode 100644 index 0000000000..dda2b0647c --- /dev/null +++ b/db/migrate/20241123224956_create_terms_of_services.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateTermsOfServices < ActiveRecord::Migration[7.2] + def change + create_table :terms_of_services do |t| + t.text :text, null: false, default: '' + t.text :changelog, null: false, default: '' + t.datetime :published_at + t.datetime :notification_sent_at + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 30b16a8a82..de7d84cc78 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do +ActiveRecord::Schema[7.2].define(version: 2024_11_23_224956) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -191,8 +191,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do t.boolean "hide_collections" t.integer "avatar_storage_schema_version" t.integer "header_storage_schema_version" - t.datetime "sensitized_at", precision: nil t.integer "suspension_origin" + t.datetime "sensitized_at", precision: nil t.boolean "trendable" t.datetime "reviewed_at", precision: nil t.datetime "requested_review_at", precision: nil @@ -556,12 +556,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do end create_table "ip_blocks", force: :cascade do |t| - t.inet "ip", default: "0.0.0.0", null: false - t.integer "severity", default: 0, null: false - t.datetime "expires_at", precision: nil - t.text "comment", default: "", null: false t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false + t.datetime "expires_at", precision: nil + t.inet "ip", default: "0.0.0.0", null: false + t.integer "severity", default: 0, null: false + t.text "comment", default: "", null: false t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true end @@ -1080,6 +1080,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do t.index ["tag_id"], name: "index_tag_follows_on_tag_id" end + create_table "tag_trends", force: :cascade do |t| + t.bigint "tag_id", null: false + t.float "score", default: 0.0, null: false + t.integer "rank", default: 0, null: false + t.boolean "allowed", default: false, null: false + t.string "language" + t.index ["tag_id", "language"], name: "index_tag_trends_on_tag_id_and_language", unique: true + end + create_table "tags", force: :cascade do |t| t.string "name", default: "", null: false t.datetime "created_at", precision: nil, null: false @@ -1096,6 +1105,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do t.index "lower((name)::text) text_pattern_ops", name: "index_tags_on_name_lower_btree", unique: true end + create_table "terms_of_services", force: :cascade do |t| + t.text "text", default: "", null: false + t.text "changelog", default: "", null: false + t.datetime "published_at" + t.datetime "notification_sent_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "tombstones", force: :cascade do |t| t.bigint "account_id" t.string "uri", null: false @@ -1343,6 +1361,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade add_foreign_key "tag_follows", "accounts", on_delete: :cascade add_foreign_key "tag_follows", "tags", on_delete: :cascade + add_foreign_key "tag_trends", "tags", on_delete: :cascade add_foreign_key "tombstones", "accounts", on_delete: :cascade add_foreign_key "user_invite_requests", "users", on_delete: :cascade add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade @@ -1380,9 +1399,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true create_view "user_ips", sql_definition: <<-SQL - SELECT user_id, - ip, - max(used_at) AS used_at + SELECT t0.user_id, + t0.ip, + max(t0.used_at) AS used_at FROM ( SELECT users.id AS user_id, users.sign_up_ip AS ip, users.created_at AS used_at @@ -1399,7 +1418,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do login_activities.created_at FROM login_activities WHERE (login_activities.success = true)) t0 - GROUP BY user_id, ip; + GROUP BY t0.user_id, t0.ip; SQL create_view "account_summaries", materialized: true, sql_definition: <<-SQL SELECT accounts.id AS account_id, @@ -1420,9 +1439,9 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true create_view "global_follow_recommendations", materialized: true, sql_definition: <<-SQL - SELECT account_id, - sum(rank) AS rank, - array_agg(reason) AS reason + SELECT t0.account_id, + sum(t0.rank) AS rank, + array_agg(t0.reason) AS reason FROM ( SELECT account_summaries.account_id, ((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank, 'most_followed'::text AS reason @@ -1446,8 +1465,8 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_04_082851) do WHERE (follow_recommendation_suppressions.account_id = statuses.account_id))))) GROUP BY account_summaries.account_id HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0 - GROUP BY account_id - ORDER BY (sum(rank)) DESC; + GROUP BY t0.account_id + ORDER BY (sum(t0.rank)) DESC; SQL add_index "global_follow_recommendations", ["account_id"], name: "index_global_follow_recommendations_on_account_id", unique: true diff --git a/spec/fabricators/terms_of_service_fabricator.rb b/spec/fabricators/terms_of_service_fabricator.rb new file mode 100644 index 0000000000..f32d269578 --- /dev/null +++ b/spec/fabricators/terms_of_service_fabricator.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +Fabricator(:terms_of_service) do + text 'MyText' + changelog 'MyText' + notification_sent_at '2024-11-23 23:49:56' +end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 2722538e1a..e677a24df2 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -98,4 +98,9 @@ class UserMailerPreview < ActionMailer::Preview def failed_2fa UserMailer.failed_2fa(User.first, '127.0.0.1', 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:75.0) Gecko/20100101 Firefox/75.0', Time.now.utc) end + + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/terms_of_service_changed + def terms_of_service_changed + UserMailer.terms_of_service_changed(User.first, TermsOfService.live.first) + end end diff --git a/spec/models/terms_of_service_spec.rb b/spec/models/terms_of_service_spec.rb new file mode 100644 index 0000000000..a4b2f536af --- /dev/null +++ b/spec/models/terms_of_service_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe TermsOfService do + pending "add some examples to (or delete) #{__FILE__}" +end