0
0
Fork 0

Add terms of service (#33055)

This commit is contained in:
Eugen Rochko 2024-12-09 11:04:46 +01:00 committed by GitHub
parent 7a2a345c08
commit 30aa0df88c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 1456 additions and 238 deletions

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Admin::TermsOfService::DistributionsController < Admin::BaseController
before_action :set_terms_of_service
def create
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
private
def set_terms_of_service
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Admin::TermsOfService::DraftsController < Admin::BaseController
before_action :set_terms_of_service
def show
authorize :terms_of_service, :create?
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)
log_action(:publish, @terms_of_service) if @terms_of_service.published?
redirect_to @terms_of_service.published? ? admin_terms_of_service_index_path : admin_terms_of_service_draft_path
else
render :show
end
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.draft.first || TermsOfService.new(text: current_terms_of_service&.text)
end
def current_terms_of_service
TermsOfService.live.first
end
def resource_params
params.require(:terms_of_service).permit(:text, :changelog)
end
end

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
class Admin::TermsOfService::GeneratesController < Admin::BaseController
before_action :set_instance_presenter
def show
authorize :terms_of_service, :create?
@generator = TermsOfService::Generator.new(
domain: @instance_presenter.domain,
admin_email: @instance_presenter.contact.email
)
end
def create
authorize :terms_of_service, :create?
@generator = TermsOfService::Generator.new(resource_params)
if @generator.valid?
TermsOfService.create!(text: @generator.render)
redirect_to admin_terms_of_service_draft_path
else
render :show
end
end
private
def set_instance_presenter
@instance_presenter = InstancePresenter.new
end
def resource_params
params.require(:terms_of_service_generator).permit(*TermsOfService::Generator::VARIABLES)
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Admin::TermsOfService::HistoriesController < Admin::BaseController
def show
authorize :terms_of_service, :index?
@terms_of_service = TermsOfService.published.all
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Admin::TermsOfService::PreviewsController < Admin::BaseController
before_action :set_terms_of_service
def show
authorize @terms_of_service, :distribute?
@user_count = @terms_of_service.scope_for_notification.count
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class Admin::TermsOfService::TestsController < Admin::BaseController
before_action :set_terms_of_service
def create
authorize @terms_of_service, :distribute?
UserMailer.terms_of_service_changed(current_user, @terms_of_service).deliver_later!
redirect_to admin_terms_of_service_preview_path(@terms_of_service)
end
private
def set_terms_of_service
@terms_of_service = TermsOfService.find(params[:terms_of_service_id])
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Admin::TermsOfServiceController < Admin::BaseController
def index
authorize :terms_of_service, :index?
@terms_of_service = TermsOfService.live.first
end
end

View file

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

View file

@ -0,0 +1,11 @@
# frozen_string_literal: true
class TermsOfServiceController < ApplicationController
include WebAppControllerConcern
skip_before_action :require_functional!
def show
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
end
end

View file

@ -64,6 +64,10 @@ module FormattingHelper
end
end
def markdown(text)
Redcarpet::Markdown.new(Redcarpet::Render::HTML, escape_html: true, no_images: true).render(text).html_safe # rubocop:disable Rails/OutputSafety
end
private
def wrapped_status_content_format(status)

View file

@ -0,0 +1,11 @@
import { apiRequestGet } from 'mastodon/api';
import type {
ApiTermsOfServiceJSON,
ApiPrivacyPolicyJSON,
} from 'mastodon/api_types/instance';
export const apiGetTermsOfService = () =>
apiRequestGet<ApiTermsOfServiceJSON>('v1/instance/terms_of_service');
export const apiGetPrivacyPolicy = () =>
apiRequestGet<ApiPrivacyPolicyJSON>('v1/instance/privacy_policy');

View file

@ -0,0 +1,9 @@
export interface ApiTermsOfServiceJSON {
updated_at: string;
content: string;
}
export interface ApiPrivacyPolicyJSON {
updated_at: string;
content: string;
}

View file

@ -18,7 +18,7 @@ import Column from 'mastodon/components/column';
import { Icon } from 'mastodon/components/icon';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { Skeleton } from 'mastodon/components/skeleton';
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' },

View file

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

View file

@ -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 (
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3><FormattedMessage id='privacy_policy.title' defaultMessage='Privacy Policy' /></h3>
<p><FormattedMessage id='privacy_policy.last_updated' defaultMessage='Last updated {date}' values={{ date: isLoading ? <Skeleton width='10ch' /> : <FormattedDate value={lastUpdated} year='numeric' month='short' day='2-digit' /> }} /></p>
</div>
<div
className='privacy-policy__body prose'
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);
}
}
export default injectIntl(PrivacyPolicy);

View file

@ -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<ApiPrivacyPolicyJSON>();
const [loading, setLoading] = useState(true);
useEffect(() => {
apiGetPrivacyPolicy()
.then((data) => {
setResponse(data);
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, []);
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.title)}
>
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3>
<FormattedMessage
id='privacy_policy.title'
defaultMessage='Privacy Policy'
/>
</h3>
<p>
<FormattedMessage
id='privacy_policy.last_updated'
defaultMessage='Last updated {date}'
values={{
date: loading ? (
<Skeleton width='10ch' />
) : (
<FormattedDate
value={response?.updated_at}
year='numeric'
month='short'
day='2-digit'
/>
),
}}
/>
</p>
</div>
{response && (
<div
className='privacy-policy__body prose'
dangerouslySetInnerHTML={{ __html: response.content }}
/>
)}
</div>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default PrivacyPolicy;

View file

@ -0,0 +1,95 @@
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';
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
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<ApiTermsOfServiceJSON>();
const [loading, setLoading] = useState(true);
useEffect(() => {
apiGetTermsOfService()
.then((data) => {
setResponse(data);
setLoading(false);
return '';
})
.catch(() => {
setLoading(false);
});
}, []);
if (!loading && !response) {
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
}
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.title)}
>
<div className='scrollable privacy-policy'>
<div className='column-title'>
<h3>
<FormattedMessage
id='terms_of_service.title'
defaultMessage='Terms of Service'
/>
</h3>
<p>
<FormattedMessage
id='privacy_policy.last_updated'
defaultMessage='Last updated {date}'
values={{
date: loading ? (
<Skeleton width='10ch' />
) : (
<FormattedDate
value={response?.updated_at}
year='numeric'
month='short'
day='2-digit'
/>
),
}}
/>
</p>
</div>
{response && (
<div
className='privacy-policy__body prose'
dangerouslySetInnerHTML={{ __html: response.content }}
/>
)}
</div>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='all' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default TermsOfService;

View file

@ -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,

View file

@ -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 = <span aria-hidden>{' · '}</span>;
return (
<div className='link-footer'>
<p>
<strong>{domain}</strong>:
{' '}
<Link to='/about' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.about' defaultMessage='About' /></Link>
{statusPageUrl && (
<>
{DividingCircle}
<a href={statusPageUrl} target='_blank' rel='noopener'><FormattedMessage id='footer.status' defaultMessage='Status' /></a>
</>
)}
{canInvite && (
<>
{DividingCircle}
<a href='/invites' target='_blank'><FormattedMessage id='footer.invite' defaultMessage='Invite people' /></a>
</>
)}
{canProfileDirectory && (
<>
{DividingCircle}
<Link to='/directory'><FormattedMessage id='footer.directory' defaultMessage='Profiles directory' /></Link>
</>
)}
{DividingCircle}
<Link to='/privacy-policy' target={multiColumn ? '_blank' : undefined}><FormattedMessage id='footer.privacy_policy' defaultMessage='Privacy policy' /></Link>
</p>
<p>
<strong>Mastodon</strong>:
{' '}
<a href='https://joinmastodon.org' target='_blank'><FormattedMessage id='footer.about' defaultMessage='About' /></a>
{DividingCircle}
<a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='footer.get_app' defaultMessage='Get the app' /></a>
{DividingCircle}
<Link to='/keyboard-shortcuts'><FormattedMessage id='footer.keyboard_shortcuts' defaultMessage='Keyboard shortcuts' /></Link>
{DividingCircle}
<a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='footer.source_code' defaultMessage='View source code' /></a>
{DividingCircle}
<span className='version'>v{version}</span>
</p>
</div>
);
}
}
export default injectIntl(withIdentity(connect(null, mapDispatchToProps)(LinkFooter)));

View file

@ -0,0 +1,105 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import {
domain,
version,
source_url,
statusPageUrl,
profile_directory as canProfileDirectory,
termsOfServiceEnabled,
} from 'mastodon/initial_state';
const DividingCircle: React.FC = () => <span aria-hidden>{' · '}</span>;
export const LinkFooter: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
return (
<div className='link-footer'>
<p>
<strong>{domain}</strong>:{' '}
<Link to='/about' target={multiColumn ? '_blank' : undefined}>
<FormattedMessage id='footer.about' defaultMessage='About' />
</Link>
{statusPageUrl && (
<>
<DividingCircle />
<a href={statusPageUrl} target='_blank' rel='noopener noreferrer'>
<FormattedMessage id='footer.status' defaultMessage='Status' />
</a>
</>
)}
{canProfileDirectory && (
<>
<DividingCircle />
<Link to='/directory'>
<FormattedMessage
id='footer.directory'
defaultMessage='Profiles directory'
/>
</Link>
</>
)}
<DividingCircle />
<Link
to='/privacy-policy'
target={multiColumn ? '_blank' : undefined}
rel='privacy-policy'
>
<FormattedMessage
id='footer.privacy_policy'
defaultMessage='Privacy policy'
/>
</Link>
{termsOfServiceEnabled && (
<>
<DividingCircle />
<Link
to='/terms-of-service'
target={multiColumn ? '_blank' : undefined}
rel='terms-of-service'
>
<FormattedMessage
id='footer.terms_of_service'
defaultMessage='Terms of service'
/>
</Link>
</>
)}
</p>
<p>
<strong>Mastodon</strong>:{' '}
<a href='https://joinmastodon.org' target='_blank' rel='noreferrer'>
<FormattedMessage id='footer.about' defaultMessage='About' />
</a>
<DividingCircle />
<a
href='https://joinmastodon.org/apps'
target='_blank'
rel='noreferrer'
>
<FormattedMessage id='footer.get_app' defaultMessage='Get the app' />
</a>
<DividingCircle />
<Link to='/keyboard-shortcuts'>
<FormattedMessage
id='footer.keyboard_shortcuts'
defaultMessage='Keyboard shortcuts'
/>
</Link>
<DividingCircle />
<a href={source_url} rel='noopener noreferrer' target='_blank'>
<FormattedMessage
id='footer.source_code'
defaultMessage='View source code'
/>
</a>
<DividingCircle />
<span className='version'>v{version}</span>
</p>
</div>
);
};

View file

@ -71,6 +71,7 @@ import {
Explore,
About,
PrivacyPolicy,
TermsOfService,
} from './util/async-components';
import { ColumnsContextProvider } from './util/columns_context';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
@ -198,6 +199,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/about' component={About} content={children} />
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path='/terms-of-service' component={TermsOfService} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
<Redirect from='/timelines/public' to='/public' exact />

View file

@ -198,6 +198,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');
}

View file

@ -43,6 +43,8 @@
* @property {boolean=} use_pending_items
* @property {string} version
* @property {string} sso_redirect
* @property {string} status_page_url
* @property {boolean} terms_of_service_enabled
*/
/**
@ -115,10 +117,9 @@ 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');
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
/**
* @returns {string | undefined}
*/

View file

@ -359,11 +359,11 @@
"footer.about": "About",
"footer.directory": "Profiles directory",
"footer.get_app": "Get the app",
"footer.invite": "Invite people",
"footer.keyboard_shortcuts": "Keyboard shortcuts",
"footer.privacy_policy": "Privacy policy",
"footer.source_code": "View source code",
"footer.status": "Status",
"footer.terms_of_service": "Terms of service",
"generic.saved": "Saved",
"getting_started.heading": "Getting started",
"hashtag.admin_moderation": "Open moderation interface for #{name}",
@ -857,6 +857,7 @@
"subscribed_languages.target": "Change subscribed languages for {target}",
"tabs_bar.home": "Home",
"tabs_bar.notifications": "Notifications",
"terms_of_service.title": "Terms of Service",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
"time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",

View file

@ -173,7 +173,9 @@ table + p {
}
.email-prose {
p {
p,
ul,
ol {
color: #17063b;
font-size: 14px;
line-height: 20px;

View file

@ -253,6 +253,10 @@ $content-width: 840px;
.time-period {
padding: 0 10px;
}
.back-link {
margin-bottom: 0;
}
}
h2 small {
@ -1940,3 +1944,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;
}
}
}

View file

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

View file

@ -57,6 +57,7 @@ class Admin::ActionLogFilter
enable_relay: { target_type: 'Relay', action: 'enable' }.freeze,
memorialize_account: { target_type: 'Account', action: 'memorialize' }.freeze,
promote_user: { target_type: 'User', action: 'promote' }.freeze,
publish_terms_of_service: { target_type: 'TermsOfService', action: 'publish' }.freeze,
remove_avatar_user: { target_type: 'User', action: 'remove_avatar' }.freeze,
reopen_report: { target_type: 'Report', action: 'reopen' }.freeze,
resend_user: { target_type: 'User', action: 'resend' }.freeze,

View file

@ -0,0 +1,34 @@
# 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
validates :changelog, presence: true, if: -> { published? }
def published?
published_at.present?
end
def notification_sent?
notification_sent_at.present?
end
def scope_for_notification
User.confirmed.joins(:account).merge(Account.without_suspended).where(created_at: (..published_at))
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class TermsOfService::Generator
include ActiveModel::Model
TEMPLATE = Rails.root.join('config', 'templates', 'terms-of-service.md').read
VARIABLES = %i(
admin_email
arbitration_address
arbitration_website
dmca_address
dmca_email
domain
jurisdiction
).freeze
attr_accessor(*VARIABLES)
validates(*VARIABLES, presence: true)
def render
format(TEMPLATE, VARIABLES.index_with { |key| public_send(key) })
end
end

View file

@ -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.notification_sent? && role.can?(:manage_settings)
end
def update?
!record.published? && role.can?(:manage_settings)
end
def destroy?
!record.published? && role.can?(:manage_settings)
end
end

View file

@ -109,6 +109,7 @@ class InitialStateSerializer < ActiveModel::Serializer
trends_as_landing_page: Setting.trends_as_landing_page,
trends_enabled: Setting.trends,
version: instance_presenter.version,
terms_of_service_enabled: TermsOfService.live.exists?,
}
end

View file

@ -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')]), admin_terms_of_service_draft_path
primary.item :previous, safe_join([material_symbol('history'), t('admin.terms_of_service.history')]), admin_terms_of_service_history_path

View file

@ -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: 'admin/terms_of_service/links'
= simple_form_for @terms_of_service, url: admin_terms_of_service_draft_path, method: :put 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, class: 'button button-secondary'
= form.button :button, t('admin.terms_of_service.publish'), type: :submit, name: :action_type, value: :publish

View file

@ -0,0 +1,41 @@
- content_for :page_title do
= t('admin.terms_of_service.generates.title')
- content_for :heading_actions do
.back-link
= link_to admin_terms_of_service_index_path do
= material_symbol 'chevron_left'
= t('admin.terms_of_service.back')
%p.lead= t('admin.terms_of_service.generates.explanation_html')
%p.lead= t('admin.terms_of_service.generates.chance_to_review_html')
%hr.spacer/
= simple_form_for @generator, url: admin_terms_of_service_generate_path, method: :post do |form|
= render 'shared/error_messages', object: @generator
.fields-group
= form.input :domain, wrapper: :with_label
.fields-group
= form.input :jurisdiction, wrapper: :with_label
.fields-group
= form.input :admin_email, wrapper: :with_label
.fields-group
= form.input :dmca_email, wrapper: :with_label
.fields-group
= form.input :dmca_address, wrapper: :with_label
.fields-group
= form.input :arbitration_address, wrapper: :with_label
.fields-group
= form.input :arbitration_website, wrapper: :with_label
.actions
= form.button :button, t('admin.terms_of_service.generates.action'), type: :submit

View file

@ -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: 'admin/terms_of_service/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(terms_of_service.changelog)

View file

@ -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: 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.notification_sent?
%span
= t('admin.terms_of_service.notified_on_html', date: 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'), admin_terms_of_service_preview_path(@terms_of_service), class: 'link-button'
.admin__terms-of-service__container__body
.prose
= markdown(@terms_of_service.text)
%hr.spacer/
%h3= t('admin.terms_of_service.changelog')
.prose
= markdown(@terms_of_service.changelog)
- else
%p.lead= t('admin.terms_of_service.no_terms_of_service_html')
.content__heading__actions
= link_to t('admin.terms_of_service.create'), admin_terms_of_service_draft_path, class: 'button'
= link_to t('admin.terms_of_service.generate'), admin_terms_of_service_generate_path, class: 'button button-secondary'

View file

@ -0,0 +1,20 @@
- content_for :page_title do
= t('admin.terms_of_service.preview.title')
- content_for :heading_actions do
.back-link
= link_to admin_terms_of_service_index_path do
= material_symbol 'chevron_left'
= t('admin.terms_of_service.back')
%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(@terms_of_service.changelog)
%hr.spacer/
.content__heading__actions
= link_to t('admin.terms_of_service.preview.send_preview', email: current_user.email), admin_terms_of_service_test_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)), admin_terms_of_service_distribution_path(@terms_of_service), method: :post, class: 'button', data: { confirm: t('admin.reports.are_you_sure') }

View file

@ -72,7 +72,7 @@
.fields-group
= f.input :agreement,
as: :boolean,
label: t('auth.privacy_policy_agreement_html', rules_path: about_more_path, privacy_policy_path: privacy_policy_path),
label: t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path),
required: false,
wrapper: :with_label

View file

@ -0,0 +1,6 @@
- content_for :page_title, t('terms_of_service.title')
- content_for :header_tags do
= render partial: 'shared/og'
= render 'shared/web_app'

View file

@ -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(@terms_of_service.changelog)
%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)

View file

@ -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) %>

View file

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