From d3105031f8ec15fbfb5ac0341d5201e230946adf Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 18 Sep 2018 16:45:58 +0200 Subject: [PATCH] Redesign forms, verify link ownership with rel="me" (#8703) * Verify link ownership with rel="me" * Add explanation about verification to UI * Perform link verifications * Add click-to-copy widget for verification HTML * Redesign edit profile page * Redesign forms * Improve responsive design of settings pages * Restore landing page sign-up form * Fix typo * Support tags, add spec * Fix links not being verified on first discovery and passive updates --- app/helpers/home_helper.rb | 8 + .../features/account/components/header.js | 15 +- app/javascript/packs/public.js | 27 ++ app/javascript/styles/mastodon/accounts.scss | 14 + app/javascript/styles/mastodon/admin.scss | 88 ++--- app/javascript/styles/mastodon/basics.scss | 7 + app/javascript/styles/mastodon/boost.scss | 7 - .../styles/mastodon/components.scss | 9 +- .../styles/mastodon/containers.scss | 8 + app/javascript/styles/mastodon/forms.scss | 367 ++++++++++++------ app/lib/activitypub/activity/update.rb | 1 + app/models/account.rb | 56 ++- app/serializers/rest/account_serializer.rb | 4 + .../activitypub/process_account_service.rb | 5 + app/services/fetch_link_card_service.rb | 2 +- app/services/update_account_service.rb | 5 +- app/services/verify_link_service.rb | 32 ++ app/views/about/_registration.html.haml | 11 +- app/views/accounts/_bio.html.haml | 7 +- app/views/admin/change_emails/show.html.haml | 11 +- app/views/admin/custom_emojis/new.html.haml | 5 +- app/views/admin/domain_blocks/new.html.haml | 13 +- .../admin/email_domain_blocks/new.html.haml | 3 +- app/views/admin/settings/edit.html.haml | 69 ++-- .../confirmations/finish_signup.html.haml | 3 +- app/views/auth/confirmations/new.html.haml | 3 +- app/views/auth/passwords/edit.html.haml | 6 +- app/views/auth/passwords/new.html.haml | 3 +- app/views/auth/registrations/edit.html.haml | 20 +- app/views/auth/registrations/new.html.haml | 20 +- app/views/auth/sessions/new.html.haml | 12 +- app/views/auth/sessions/two_factor.html.haml | 3 +- app/views/filters/_fields.html.haml | 12 +- app/views/invites/_form.html.haml | 8 +- app/views/invites/index.html.haml | 2 +- .../settings/applications/_fields.html.haml | 6 +- app/views/settings/imports/show.html.haml | 4 +- app/views/settings/preferences/show.html.haml | 34 +- app/views/settings/profiles/show.html.haml | 53 ++- .../confirmations/new.html.haml | 3 +- .../two_factor_authentications/show.html.haml | 2 +- app/workers/verify_account_links_worker.rb | 20 + config/initializers/simple_form.rb | 29 +- config/locales/en.yml | 6 +- config/locales/simple_form.en.yml | 3 + spec/services/verify_link_service_spec.rb | 51 +++ 46 files changed, 764 insertions(+), 313 deletions(-) create mode 100644 app/services/verify_link_service.rb create mode 100644 app/workers/verify_account_links_worker.rb create mode 100644 spec/services/verify_link_service_spec.rb diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb index 0157785fa7..ba7c443c22 100644 --- a/app/helpers/home_helper.rb +++ b/app/helpers/home_helper.rb @@ -48,4 +48,12 @@ module HomeHelper '1+' end end + + def custom_field_classes(field) + if field.verified? + 'verified' + else + 'emojify' + end + end end diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index e49758b049..11ae589053 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -15,8 +15,18 @@ const messages = defineMessages({ requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' }, + linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' }, }); +const dateFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric', + hour12: false, + hour: '2-digit', + minute: '2-digit', +}; + class Avatar extends ImmutablePureComponent { static propTypes = { @@ -163,7 +173,10 @@ class Header extends ImmutablePureComponent { {fields.map((pair, i) => (
-
+ +
+ {pair.get('verified_at') && } +
))} diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index c83e2889a0..22a8643d9c 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -68,6 +68,7 @@ function main() { }); const reactComponents = document.querySelectorAll('[data-component]'); + if (reactComponents.length > 0) { import(/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container') .then(({ default: MediaContainer }) => { @@ -80,6 +81,7 @@ function main() { } const parallaxComponents = document.querySelectorAll('.parallax'); + if (parallaxComponents.length > 0 ) { new Rellax('.parallax', { speed: -1 }); } @@ -87,6 +89,7 @@ function main() { const history = createHistory(); const detailedStatuses = document.querySelectorAll('.public-layout .detailed-status'); const location = history.location; + if (detailedStatuses.length === 1 && (!location.state || !location.state.scrolledToDetailedStatus)) { detailedStatuses[0].scrollIntoView(); history.replace(location.pathname, { ...location.state, scrolledToDetailedStatus: true }); @@ -175,6 +178,30 @@ function main() { lock.style.display = 'none'; } }); + + delegate(document, '.input-copy input', 'click', ({ target }) => { + target.select(); + }); + + delegate(document, '.input-copy button', 'click', ({ target }) => { + const input = target.parentNode.querySelector('input'); + + input.focus(); + input.select(); + + try { + if (document.execCommand('copy')) { + input.blur(); + target.parentNode.classList.add('copied'); + + setTimeout(() => { + target.parentNode.classList.remove('copied'); + }, 700); + } + } catch (err) { + console.error(err); + } + }); } loadPolyfills().then(main).catch(error => { diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index c27bc0df3f..06effbdb2e 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -265,6 +265,20 @@ } } + .verified { + border: 1px solid rgba($valid-value-color, 0.5); + background: rgba($valid-value-color, 0.25); + + a { + color: $valid-value-color; + font-weight: 500; + } + + &__mark { + color: $valid-value-color; + } + } + dl:last-child { border-bottom: 0; } diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 7d359921ab..9dfd89dc26 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1,3 +1,5 @@ +$no-columns-breakpoint: 600px; + .admin-wrapper { display: flex; justify-content: center; @@ -24,12 +26,22 @@ height: 100px; } + @media screen and (max-width: $no-columns-breakpoint) { + & > a:first-child { + display: none; + } + } + ul { list-style: none; border-radius: 4px 0 0 4px; overflow: hidden; margin-bottom: 20px; + @media screen and (max-width: $no-columns-breakpoint) { + margin-bottom: 0; + } + a { display: block; padding: 15px; @@ -62,19 +74,23 @@ a { border: 0; padding: 15px 35px; - - &.selected { - color: $primary-text-color; - background-color: $ui-highlight-color; - border-bottom: 0; - border-radius: 0; - - &:hover { - background-color: lighten($ui-highlight-color, 5%); - } - } } } + + .simple-navigation-active-leaf a { + color: $primary-text-color; + background-color: $ui-highlight-color; + border-bottom: 0; + border-radius: 0; + + &:hover { + background-color: lighten($ui-highlight-color, 5%); + } + } + } + + & > ul > .simple-navigation-active-leaf a { + border-radius: 4px 0 0 4px; } } @@ -89,11 +105,19 @@ padding-top: 60px; padding-left: 25px; + @media screen and (max-width: $no-columns-breakpoint) { + max-width: none; + padding: 15px; + padding-top: 30px; + } + h2 { color: $secondary-text-color; font-size: 24px; line-height: 28px; font-weight: 400; + padding-bottom: 40px; + border-bottom: 1px solid lighten($ui-base-color, 8%); margin-bottom: 40px; } @@ -108,7 +132,7 @@ h4 { text-transform: uppercase; font-size: 13px; - font-weight: 500; + font-weight: 700; color: $darker-text-color; padding-bottom: 8px; margin-bottom: 8px; @@ -122,6 +146,11 @@ font-weight: 400; } + .fields-group h6 { + color: $primary-text-color; + font-weight: 500; + } + & > p { font-size: 14px; line-height: 18px; @@ -172,30 +201,7 @@ } } - .simple_form { - max-width: 400px; - - &.edit_user, - &.new_form_admin_settings, - &.new_form_two_factor_confirmation, - &.new_form_delete_confirmation, - &.new_import, - &.new_domain_block, - &.edit_domain_block { - max-width: none; - } - - .form_two_factor_confirmation_code, - .form_delete_confirmation_password { - max-width: 400px; - } - - .actions { - max-width: 400px; - } - } - - @media screen and (max-width: 600px) { + @media screen and (max-width: $no-columns-breakpoint) { display: block; overflow-y: auto; -webkit-overflow-scrolling: touch; @@ -209,16 +215,8 @@ .sidebar { width: 100%; - padding: 10px 0; + padding: 0; height: auto; - - .logo { - margin: 20px auto; - } - } - - .content { - padding-top: 20px; } } } diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss index 7a6a1c4909..3bbb31e6e3 100644 --- a/app/javascript/styles/mastodon/basics.scss +++ b/app/javascript/styles/mastodon/basics.scss @@ -1,3 +1,10 @@ +@function hex-color($color) { + @if type-of($color) == 'color' { + $color: str-slice(ie-hex-str($color), 4); + } + @return '%23' + unquote($color) +} + body { font-family: 'mastodon-font-sans-serif', sans-serif; background: darken($ui-base-color, 8%); diff --git a/app/javascript/styles/mastodon/boost.scss b/app/javascript/styles/mastodon/boost.scss index 8e11cb5960..5a6d6ae406 100644 --- a/app/javascript/styles/mastodon/boost.scss +++ b/app/javascript/styles/mastodon/boost.scss @@ -1,10 +1,3 @@ -@function hex-color($color) { - @if type-of($color) == 'color' { - $color: str-slice(ie-hex-str($color), 4); - } - @return '%23' + unquote($color) -} - button.icon-button i.fa-retweet { background-image: url("data:image/svg+xml;utf8,"); diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 048563336f..9421523bd9 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5363,9 +5363,11 @@ noscript { overflow: hidden; margin: 20px -10px -20px; border-bottom: 0; + border-top: 0; dl { - border-top: 1px solid lighten($ui-base-color, 8%); + border-top: 1px solid lighten($ui-base-color, 4%); + border-bottom: 0; display: flex; } @@ -5392,6 +5394,11 @@ noscript { flex: 1 1 auto; color: $primary-text-color; background: $ui-base-color; + + &.verified { + border: 1px solid rgba($valid-value-color, 0.5); + background: rgba($valid-value-color, 0.25); + } } } diff --git a/app/javascript/styles/mastodon/containers.scss b/app/javascript/styles/mastodon/containers.scss index 2c2ce5d3b8..47582f3237 100644 --- a/app/javascript/styles/mastodon/containers.scss +++ b/app/javascript/styles/mastodon/containers.scss @@ -718,6 +718,14 @@ a { color: lighten($ui-highlight-color, 8%); } + + dl:first-child .verified { + border-radius: 0 4px 0 0; + } + + .verified a { + color: $valid-value-color; + } } .account__header__content { diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index 144b4a519c..cbd3de94c8 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -1,3 +1,5 @@ +$no-columns-breakpoint: 600px; + code { font-family: 'mastodon-font-monospace', monospace; font-weight: 400; @@ -13,6 +15,60 @@ code { .input { margin-bottom: 15px; overflow: hidden; + + &.hidden { + margin: 0; + } + + &.radio_buttons { + .radio { + margin-bottom: 15px; + + &:last-child { + margin-bottom: 0; + } + } + + .radio > label { + position: relative; + padding-left: 28px; + + input { + position: absolute; + top: -2px; + left: 0; + } + } + } + + &.boolean { + position: relative; + margin-bottom: 0; + + .label_input > label { + font-family: inherit; + font-size: 14px; + padding-top: 5px; + color: $primary-text-color; + display: block; + width: auto; + } + + .label_input, + .hint { + padding-left: 28px; + } + + .label_input__wrapper { + position: static; + } + + label.checkbox { + position: absolute; + top: 2px; + left: 0; + } + } } .row { @@ -27,9 +83,22 @@ code { } } + .hint { + color: $darker-text-color; + + a { + color: $highlight-text-color; + } + + code { + border-radius: 3px; + padding: 0.2em 0.4em; + background: darken($ui-base-color, 12%); + } + } + span.hint { display: block; - color: $darker-text-color; font-size: 12px; margin-top: 4px; } @@ -44,17 +113,6 @@ code { line-height: 18px; margin-top: 15px; margin-bottom: 0; - color: $darker-text-color; - - a { - color: $highlight-text-color; - } - } - - code { - border-radius: 3px; - padding: 0.2em 0.4em; - background: darken($ui-base-color, 12%); } } @@ -72,87 +130,60 @@ code { } } - .label_input { - display: flex; + .input.with_floating_label { + .label_input { + display: flex; - label { - flex: 0 0 auto; + & > label { + font-family: inherit; + font-size: 14px; + color: $primary-text-color; + font-weight: 500; + min-width: 150px; + flex: 0 0 auto; + } + + input, + select { + flex: 1 1 auto; + } } - input { - flex: 1 1 auto; + &.select .hint { + margin-top: 6px; + margin-left: 150px; } } .input.with_label { - padding: 15px 0; - margin-bottom: 0; - - .label_input { - flex-wrap: wrap; - align-items: flex-start; - } - - &.file .label_input { - flex-wrap: nowrap; - } - - &.select .label_input { - align-items: initial; - } - .label_input > label { font-family: inherit; - font-size: 16px; + font-size: 14px; color: $primary-text-color; display: block; - padding-top: 5px; - margin-bottom: 5px; - flex: 1; - min-width: 150px; + margin-bottom: 8px; word-wrap: break-word; + font-weight: 500; + } - &.select { - flex: 0; - } - - & ~ * { - margin-left: 10px; - } + .hint { + margin-top: 6px; } ul { flex: 390px; } - - &.boolean { - padding: initial; - margin-bottom: initial; - - .label_input > label { - font-family: inherit; - font-size: 14px; - color: $primary-text-color; - display: block; - width: auto; - } - - label.checkbox { - position: relative; - padding-left: 25px; - flex: 1 1 auto; - } - } } .input.with_block_label { - padding-top: 15px; + max-width: none; & > label { font-family: inherit; font-size: 16px; color: $primary-text-color; display: block; + font-weight: 500; padding-top: 5px; } @@ -165,8 +196,59 @@ code { } } + .required abbr { + text-decoration: none; + color: lighten($error-value-color, 12%); + } + .fields-group { margin-bottom: 25px; + + .input:last-child { + margin-bottom: 0; + } + } + + .fields-row { + display: flex; + margin: 0 -10px; + padding-top: 5px; + margin-bottom: 25px; + + .input { + max-width: none; + } + + &__column { + box-sizing: border-box; + padding: 0 10px; + flex: 1 1 auto; + min-height: 1px; + + &-6 { + max-width: 50%; + } + } + + .fields-group:last-child, + .fields-row__column.fields-group { + margin-bottom: 0; + } + + @media screen and (max-width: $no-columns-breakpoint) { + display: block; + margin-bottom: 0; + + &__column { + max-width: none; + } + + .fields-group:last-child, + .fields-row__column.fields-group, + .fields-row__column { + margin-bottom: 25px; + } + } } .input.radio_buttons .radio label { @@ -178,36 +260,6 @@ code { width: auto; } - .input.boolean { - margin-bottom: 5px; - - label { - font-family: inherit; - font-size: 14px; - color: $primary-text-color; - display: block; - width: auto; - } - - label.checkbox { - position: relative; - padding-left: 25px; - flex: 1 1 auto; - } - - input[type=checkbox] { - position: absolute; - left: 0; - top: 5px; - margin: 0; - } - - .hint { - padding-left: 25px; - margin-left: 0; - } - } - .check_boxes { .checkbox { label { @@ -236,12 +288,7 @@ code { input[type=email], input[type=password], textarea { - background: transparent; box-sizing: border-box; - border: 0; - border-bottom: 2px solid $ui-primary-color; - border-radius: 2px 2px 0 0; - padding: 7px 4px; font-size: 16px; color: $primary-text-color; display: block; @@ -249,23 +296,31 @@ code { outline: 0; font-family: inherit; resize: vertical; + background: darken($ui-base-color, 10%); + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + padding: 10px; &:invalid { box-shadow: none; } &:focus:invalid { - border-bottom-color: lighten($error-red, 12%); + border-color: lighten($error-red, 12%); } &:required:valid { - border-bottom-color: $valid-value-color; + border-color: $valid-value-color; + } + + &:hover { + border-color: darken($ui-base-color, 20%); } &:active, &:focus { - border-bottom-color: $highlight-text-color; - background: rgba($base-overlay-background, 0.1); + border-color: $highlight-text-color; + background: darken($ui-base-color, 8%); } } @@ -349,22 +404,32 @@ code { } select { + appearance: none; + box-sizing: border-box; font-size: 16px; - max-height: 29px; + color: $primary-text-color; + display: block; + width: 100%; + outline: 0; + font-family: inherit; + resize: vertical; + background: darken($ui-base-color, 10%) url("data:image/svg+xml;utf8,") no-repeat right 8px center / auto 16px; + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + padding: 10px; + height: 41px; } - .input-with-append { - position: relative; - - .input input { - padding-right: 142px; + .label_input { + &__wrapper { + position: relative; } - .append { + &__append { position: absolute; - right: 0; - top: 0; - padding: 7px 4px; + right: 1px; + top: 1px; + padding: 10px; padding-bottom: 9px; font-size: 16px; color: $dark-text-color; @@ -383,7 +448,7 @@ code { right: 0; bottom: 1px; width: 5px; - background-image: linear-gradient(to right, rgba($ui-base-color, 0), $ui-base-color); + background-image: linear-gradient(to right, rgba(darken($ui-base-color, 10%), 0), darken($ui-base-color, 10%)); } } } @@ -459,6 +524,30 @@ code { } } +.quick-nav { + list-style: none; + margin-bottom: 25px; + font-size: 14px; + + li { + display: inline-block; + margin-right: 10px; + } + + a { + color: $highlight-text-color; + text-transform: uppercase; + text-decoration: none; + font-weight: 700; + + &:hover, + &:focus, + &:active { + color: lighten($highlight-text-color, 8%); + } + } +} + .oauth-prompt, .follow-prompt { margin-bottom: 30px; @@ -632,3 +721,49 @@ code { font-family: 'mastodon-font-monospace', monospace; } } + +.input-copy { + background: darken($ui-base-color, 10%); + border: 1px solid darken($ui-base-color, 14%); + border-radius: 4px; + display: flex; + align-items: center; + padding-right: 4px; + position: relative; + top: 1px; + transition: border-color 300ms linear; + + &__wrapper { + flex: 1 1 auto; + } + + input[type=text] { + background: transparent; + border: 0; + padding: 10px; + font-size: 14px; + font-family: 'mastodon-font-monospace', monospace; + } + + button { + flex: 0 0 auto; + margin: 4px; + text-transform: none; + font-weight: 400; + font-size: 14px; + padding: 7px 18px; + padding-bottom: 6px; + width: auto; + transition: background 300ms linear; + } + + &.copied { + border-color: $valid-value-color; + transition: none; + + button { + background: $valid-value-color; + transition: none; + } + } +} diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb index 6eebc3b5c4..67dae3f813 100644 --- a/app/lib/activitypub/activity/update.rb +++ b/app/lib/activitypub/activity/update.rb @@ -11,6 +11,7 @@ class ActivityPub::Activity::Update < ActivityPub::Activity def update_account return if @account.uri != object_uri + ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true) end end diff --git a/app/models/account.rb b/app/models/account.rb index 440a731e3d..979e0b12c6 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -223,11 +223,19 @@ class Account < ApplicationRecord end def fields_attributes=(attributes) - fields = [] + fields = [] + old_fields = self[:fields] || [] if attributes.is_a?(Hash) attributes.each_value do |attr| next if attr[:name].blank? + + previous = old_fields.find { |item| item['value'] == attr[:value] } + + if previous && previous['verified_at'].present? + attr[:verified_at] = previous['verified_at'] + end + fields << attr end end @@ -235,13 +243,18 @@ class Account < ApplicationRecord self[:fields] = fields end - def build_fields - return if fields.size >= 4 + DEFAULT_FIELDS_SIZE = 4 - raw_fields = self[:fields] || [] - add_fields = 4 - raw_fields.size - add_fields.times { raw_fields << { name: '', value: '' } } - self.fields = raw_fields + def build_fields + return if fields.size >= DEFAULT_FIELDS_SIZE + + tmp = self[:fields] || [] + + (DEFAULT_FIELDS_SIZE - tmp.size).times do + tmp << { name: '', value: '' } + end + + self.fields = tmp end def magic_key @@ -294,17 +307,32 @@ class Account < ApplicationRecord end class Field < ActiveModelSerializers::Model - attributes :name, :value, :account, :errors + attributes :name, :value, :verified_at, :account, :errors - def initialize(account, attr) - @account = account - @name = attr['name'].strip[0, 255] - @value = attr['value'].strip[0, 255] - @errors = {} + def initialize(account, attributes) + @account = account + @attributes = attributes + @name = attributes['name'].strip[0, 255] + @value = attributes['value'].strip[0, 255] + @verified_at = attributes['verified_at']&.to_datetime + @errors = {} + end + + def verified? + verified_at.present? + end + + def verifiable? + value.present? && /\A#{FetchLinkCardService::URL_PATTERN}\z/ =~ value + end + + def mark_verified! + @verified_at = Time.now.utc + @attributes['verified_at'] = @verified_at end def to_h - { name: @name, value: @value } + { name: @name, value: @value, verified_at: @verified_at } end end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 3a724aa7c2..d84b48afb1 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -13,6 +13,10 @@ class REST::AccountSerializer < ActiveModel::Serializer class FieldSerializer < ActiveModel::Serializer attributes :name, :value + attribute :verified_at, if: :verifiable? + + delegate :verifiable?, to: :object + def value Formatter.instance.format_field(object.account, object.value) end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 670a0e4d67..c77858f1df 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -34,6 +34,7 @@ class ActivityPub::ProcessAccountService < BaseService after_protocol_change! if protocol_changed? after_key_change! if key_changed? && !@options[:signed_with_known_key] check_featured_collection! if @account.featured_collection_url.present? + check_links! unless @account.fields.empty? @account rescue Oj::ParseError @@ -99,6 +100,10 @@ class ActivityPub::ProcessAccountService < BaseService ActivityPub::SynchronizeFeaturedCollectionWorker.perform_async(@account.id) end + def check_links! + VerifyAccountLinksWorker.perform_async(@account.id) + end + def actor_type if @json['type'].is_a?(Array) @json['type'].find { |type| ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES.include?(type) } diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index ea94e2491c..4169c685bd 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -29,7 +29,7 @@ class FetchLinkCardService < BaseService end attach_card if @card&.persisted? - rescue HTTP::Error, Addressable::URI::InvalidURIError, Mastodon::LengthValidationError => e + rescue HTTP::Error, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e Rails.logger.debug "Error fetching link #{@url}: #{e}" nil end diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb index 09ea377e73..362d80c9e4 100644 --- a/app/services/update_account_service.rb +++ b/app/services/update_account_service.rb @@ -2,11 +2,14 @@ class UpdateAccountService < BaseService def call(account, params, raise_error: false) - was_locked = account.locked + was_locked = account.locked update_method = raise_error ? :update! : :update + account.send(update_method, params).tap do |ret| next unless ret + authorize_all_follow_requests(account) if was_locked && !account.locked + VerifyAccountLinksWorker.perform_async(@account.id) if account.fields_changed? end end diff --git a/app/services/verify_link_service.rb b/app/services/verify_link_service.rb new file mode 100644 index 0000000000..9da7f93c39 --- /dev/null +++ b/app/services/verify_link_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class VerifyLinkService < BaseService + def call(field) + @link_back = ActivityPub::TagManager.instance.url_for(field.account) + @url = field.value + + perform_request! + + return unless link_back_present? + + field.mark_verified! + field.account.save! + rescue HTTP::Error, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError => e + Rails.logger.debug "Error fetching link #{@url}: #{e}" + nil + end + + private + + def perform_request! + @body = Request.new(:get, @url).add_headers('Accept' => 'text/html').perform do |res| + res.code != 200 ? nil : res.body_with_limit + end + end + + def link_back_present? + return false if @body.empty? + + Nokogiri::HTML(@body).xpath('//a[@rel="me"]|//link[@rel="me"]').any? { |link| link['href'] == @link_back } + end +end diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml index 6ca1d71290..ee4f8fe2e6 100644 --- a/app/views/about/_registration.html.haml +++ b/app/views/about/_registration.html.haml @@ -1,13 +1,10 @@ = simple_form_for(new_user, url: user_registration_path) do |f| = f.simple_fields_for :account do |account_fields| - .input-with-append - = account_fields.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off' } - .append - = "@#{site_hostname}" + = account_fields.input :username, wrapper: :with_label, autofocus: true, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username') }, append: "@#{site_hostname}", hint: false - = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' } - = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } + = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false + = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false + = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false .actions = f.button :button, t('auth.register'), type: :submit, class: 'button button-primary' diff --git a/app/views/accounts/_bio.html.haml b/app/views/accounts/_bio.html.haml index 4e674beff1..2ea34a0485 100644 --- a/app/views/accounts/_bio.html.haml +++ b/app/views/accounts/_bio.html.haml @@ -4,8 +4,11 @@ - account.fields.each do |field| %dl %dt.emojify{ title: field.name }= Formatter.instance.format_field(account, field.name, custom_emojify: true) - %dd.emojify{ title: field.value }= Formatter.instance.format_field(account, field.value, custom_emojify: true) - + %dd{ title: field.value, class: custom_field_classes(field) } + - if field.verified? + %span.verified__mark{ title: t('accounts.link_verified_on', date: l(field.verified_at)) } + = fa_icon 'check' + = Formatter.instance.format_field(account, field.value, custom_emojify: true) = account_badge(account) - if account.note.present? diff --git a/app/views/admin/change_emails/show.html.haml b/app/views/admin/change_emails/show.html.haml index a661b1ad61..6febef9b1a 100644 --- a/app/views/admin/change_emails/show.html.haml +++ b/app/views/admin/change_emails/show.html.haml @@ -2,6 +2,11 @@ = t('admin.accounts.change_email.title', username: @account.acct) = simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f| - = f.input :email, wrapper: :with_label, disabled: true, label: t('admin.accounts.change_email.current_email') - = f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email') - = f.button :submit, class: "button", value: t('admin.accounts.change_email.submit') + .fields-group + = f.input :email, wrapper: :with_label, disabled: true, label: t('admin.accounts.change_email.current_email') + + .fields-group + = f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email') + + .actions + = f.button :submit, class: "button", value: t('admin.accounts.change_email.submit') diff --git a/app/views/admin/custom_emojis/new.html.haml b/app/views/admin/custom_emojis/new.html.haml index 672afe435f..e15a07cb81 100644 --- a/app/views/admin/custom_emojis/new.html.haml +++ b/app/views/admin/custom_emojis/new.html.haml @@ -5,8 +5,9 @@ = render 'shared/error_messages', object: @custom_emoji .fields-group - = f.input :shortcode, placeholder: t('admin.custom_emojis.shortcode'), hint: t('admin.custom_emojis.shortcode_hint') - = f.input :image, input_html: { accept: 'image/png' }, hint: t('admin.custom_emojis.image_hint') + = f.input :shortcode, wrapper: :with_label, label: t('admin.custom_emojis.shortcode'), hint: t('admin.custom_emojis.shortcode_hint') + .fields-group + = f.input :image, wrapper: :with_label, input_html: { accept: 'image/png' }, hint: t('admin.custom_emojis.image_hint') .actions = f.button :button, t('admin.custom_emojis.upload'), type: :submit diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 2b8b24e233..f0eb217ebe 100644 --- a/app/views/admin/domain_blocks/new.html.haml +++ b/app/views/admin/domain_blocks/new.html.haml @@ -7,14 +7,15 @@ = simple_form_for @domain_block, url: admin_domain_blocks_path do |f| = render 'shared/error_messages', object: @domain_block - %p.hint= t('.hint') + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), hint: t('.hint'), required: true - = f.input :domain, placeholder: t('admin.domain_blocks.domain') - = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| t(".severity.#{type}") } + .fields-row__column.fields-row__column-6.fields-group + = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| t(".severity.#{type}") }, hint: t('.severity.desc_html') - %p.hint= t('.severity.desc_html') - - = f.input :reject_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_media'), hint: I18n.t('admin.domain_blocks.reject_media_hint') + .fields-group + = f.input :reject_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_media'), hint: I18n.t('admin.domain_blocks.reject_media_hint') .actions = f.button :button, t('.create'), type: :submit diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml index bcae867d95..f372fa5120 100644 --- a/app/views/admin/email_domain_blocks/new.html.haml +++ b/app/views/admin/email_domain_blocks/new.html.haml @@ -4,7 +4,8 @@ = simple_form_for @email_domain_block, url: admin_email_domain_blocks_path do |f| = render 'shared/error_messages', object: @email_domain_block - = f.input :domain, placeholder: t('admin.email_domain_blocks.domain') + .fields-group + = f.input :domain, wrapper: :with_label, label: t('admin.email_domain_blocks.domain') .actions = f.button :button, t('.create'), type: :submit diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index f40edc35a5..c5a606693b 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -2,24 +2,37 @@ = t('admin.settings.title') = simple_form_for @admin_settings, url: admin_settings_path, html: { method: :patch } do |f| - .actions.actions--top - = f.button :button, t('generic.save_changes'), type: :submit .fields-group - = f.input :site_title, placeholder: t('admin.settings.site_title') - = f.input :site_short_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_short_description.title'), hint: t('admin.settings.site_short_description.desc_html'), input_html: { rows: 2 } - = f.input :site_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description.title'), hint: t('admin.settings.site_description.desc_html'), input_html: { rows: 4 } - = f.input :site_contact_username, placeholder: t('admin.settings.contact_information.username') - = f.input :site_contact_email, placeholder: t('admin.settings.contact_information.email') - - %hr/ + = f.input :site_title, wrapper: :with_label, label: t('admin.settings.site_title') .fields-group = f.input :theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false - = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html') - = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html') - %hr/ + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :site_contact_username, wrapper: :with_label, label: t('admin.settings.contact_information.username') + .fields-row__column.fields-row__column-6.fields-group + = f.input :site_contact_email, wrapper: :with_label, label: t('admin.settings.contact_information.email') + + .fields-group + = f.input :site_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description.title'), hint: t('admin.settings.site_description.desc_html'), input_html: { rows: 4 } + + .fields-group + = f.input :site_short_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_short_description.title'), hint: t('admin.settings.site_short_description.desc_html'), input_html: { rows: 2 } + + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html') + .fields-row__column.fields-row__column-6.fields-group + = f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html') + + %hr.spacer/ + + .fields-group + = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html') + + %hr.spacer/ .fields-group = f.input :timeline_preview, as: :boolean, wrapper: :with_label, label: t('admin.settings.timeline_preview.title'), hint: t('admin.settings.timeline_preview.desc_html') @@ -36,27 +49,6 @@ .fields-group = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html') - .fields-group - = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } - - %hr/ - - .fields-group - = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, as: :radio_buttons, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - - %hr/ - - .fields-group - = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } - = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } - = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }, label: t('admin.settings.custom_css.title'), hint: t('admin.settings.custom_css.desc_html') - %hr/ - - .fields-group - = f.input :bootstrap_timeline_accounts, wrapper: :with_block_label, label: t('admin.settings.bootstrap_timeline_accounts.title'), hint: t('admin.settings.bootstrap_timeline_accounts.desc_html') - - %hr/ - .fields-group = f.input :activity_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.activity_api_enabled.title'), hint: t('admin.settings.activity_api_enabled.desc_html') @@ -66,5 +58,16 @@ .fields-group = f.input :preview_sensitive_media, as: :boolean, wrapper: :with_label, label: t('admin.settings.preview_sensitive_media.title'), hint: t('admin.settings.preview_sensitive_media.desc_html') + %hr.spacer/ + + .fields-group + = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + + .fields-group + = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } + = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } + = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } + = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }, label: t('admin.settings.custom_css.title'), hint: t('admin.settings.custom_css.desc_html') + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/auth/confirmations/finish_signup.html.haml b/app/views/auth/confirmations/finish_signup.html.haml index 4b5161d6b0..9d09b74e16 100644 --- a/app/views/auth/confirmations/finish_signup.html.haml +++ b/app/views/auth/confirmations/finish_signup.html.haml @@ -8,7 +8,8 @@ = msg %br - = f.input :email + .fields-group + = f.input :email, wrapper: :with_label, required: true, hint: false .actions = f.submit t('auth.confirm_email'), class: 'button' diff --git a/app/views/auth/confirmations/new.html.haml b/app/views/auth/confirmations/new.html.haml index 07ca4a7da9..4a1bedaa42 100644 --- a/app/views/auth/confirmations/new.html.haml +++ b/app/views/auth/confirmations/new.html.haml @@ -4,7 +4,8 @@ = simple_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| = render 'shared/error_messages', object: resource - = f.input :email, autofocus: true, required: true, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + .fields-group + = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false .actions = f.button :button, t('auth.resend_confirmation'), type: :submit diff --git a/app/views/auth/passwords/edit.html.haml b/app/views/auth/passwords/edit.html.haml index 53d1769d62..383d44f00f 100644 --- a/app/views/auth/passwords/edit.html.haml +++ b/app/views/auth/passwords/edit.html.haml @@ -7,8 +7,10 @@ - if !use_seamless_external_login? || resource.encrypted_password.present? = f.input :reset_password_token, as: :hidden - = f.input :password, autofocus: true, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + .fields-group + = f.input :password, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' }, required: true + .fields-group + = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' }, required: true .actions = f.button :button, t('auth.set_new_password'), type: :submit diff --git a/app/views/auth/passwords/new.html.haml b/app/views/auth/passwords/new.html.haml index f4d22031a5..bae5b24ba8 100644 --- a/app/views/auth/passwords/new.html.haml +++ b/app/views/auth/passwords/new.html.haml @@ -4,7 +4,8 @@ = simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| = render 'shared/error_messages', object: resource - = f.input :email, autofocus: true, required: true, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } + .fields-group + = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false .actions = f.button :button, t('auth.reset_password'), type: :submit diff --git a/app/views/auth/registrations/edit.html.haml b/app/views/auth/registrations/edit.html.haml index 05fc7df313..7fc08a23b8 100644 --- a/app/views/auth/registrations/edit.html.haml +++ b/app/views/auth/registrations/edit.html.haml @@ -1,24 +1,32 @@ - content_for :page_title do = t('auth.security') -%h4= t('auth.change_password') = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: 'auth_edit' }) do |f| = render 'shared/error_messages', object: resource - if !use_seamless_external_login? || resource.encrypted_password.present? - = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } - = f.input :password, placeholder: t('simple_form.labels.defaults.new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } - = f.input :current_password, placeholder: t('simple_form.labels.defaults.current_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' } + .fields-group + = f.input :email, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, required: true, hint: false + + .fields-group + = f.input :password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.new_password'), :autocomplete => 'off' }, hint: false + + .fields-group + = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_new_password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_new_password'), :autocomplete => 'off' } + + .fields-group + = f.input :current_password, wrapper: :with_label, input_html: { 'aria-label' => t('simple_form.labels.defaults.current_password'), :autocomplete => 'off' }, required: true .actions = f.button :button, t('generic.save_changes'), type: :submit - else %p.hint= t('users.seamless_external_login') +%hr.spacer/ + = render 'sessions' - if open_deletion? - + %hr.spacer/ %h4= t('auth.delete_account') %p.muted-hint= t('auth.delete_account_html', path: settings_delete_path) diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index 200ed42de1..72ce8e531f 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -13,18 +13,22 @@ = render 'application/card', account: @invite.user.account = f.simple_fields_for :account do |ff| - .input-with-append - = ff.input :username, autofocus: true, placeholder: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off' } - .append - = "@#{site_hostname}" + .fields-group + = ff.input :username, wrapper: :with_label, autofocus: true, label: t('simple_form.labels.defaults.username'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off' }, append: "@#{site_hostname}", hint: t('simple_form.hints.defaults.username', domain: site_hostname) + + .fields-group + = f.input :email, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' } + + .fields-group + = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } + .fields-group + = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } - = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' } - = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } - = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } = f.input :invite_code, as: :hidden + %p.hint= t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path) + .actions = f.button :button, t('auth.register'), type: :submit - %p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path) .form-footer= render 'auth/shared/links' diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml index 0c9f9d5fe8..ceb1694084 100644 --- a/app/views/auth/sessions/new.html.haml +++ b/app/views/auth/sessions/new.html.haml @@ -5,11 +5,13 @@ = render partial: 'shared/og' = simple_form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| - - if use_seamless_external_login? - = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.username_or_email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') } - - else - = f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') } - = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' } + .fields-group + - if use_seamless_external_login? + = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false + - else + = f.input :email, autofocus: true, wrapper: :with_label, label: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false + .fields-group + = f.input :password, wrapper: :with_label, label: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, hint: false .actions = f.button :button, t('auth.login'), type: :submit diff --git a/app/views/auth/sessions/two_factor.html.haml b/app/views/auth/sessions/two_factor.html.haml index 1af3193ae0..4e6bbd7a95 100644 --- a/app/views/auth/sessions/two_factor.html.haml +++ b/app/views/auth/sessions/two_factor.html.haml @@ -4,7 +4,8 @@ = simple_form_for(resource, as: resource_name, url: session_path(resource_name), method: :post) do |f| %p.hint{ style: 'margin-bottom: 25px' }= t('simple_form.hints.sessions.otp') - = f.input :otp_attempt, type: :number, placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, required: true, autofocus: true + .fields-group + = f.input :otp_attempt, type: :number, wrapper: :with_label, label: t('simple_form.labels.defaults.otp_attempt'), input_html: { 'aria-label' => t('simple_form.labels.defaults.otp_attempt'), :autocomplete => 'off' }, autofocus: true .actions = f.button :button, t('auth.login'), type: :submit diff --git a/app/views/filters/_fields.html.haml b/app/views/filters/_fields.html.haml index a5a3f03375..fb94a07fce 100644 --- a/app/views/filters/_fields.html.haml +++ b/app/views/filters/_fields.html.haml @@ -1,14 +1,16 @@ -.fields-group - = f.input :phrase, as: :string, wrapper: :with_block_label +.fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :phrase, as: :string, wrapper: :with_label, hint: false + .fields-row__column.fields-row__column-6.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') .fields-group = f.input :context, wrapper: :with_block_label, collection: CustomFilter::VALID_CONTEXTS, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', label_method: lambda { |context| I18n.t("filters.contexts.#{context}") }, include_blank: false +%hr.spacer/ + .fields-group = f.input :irreversible, wrapper: :with_label .fields-group = f.input :whole_word, wrapper: :with_label - -.fields-group - = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml index 42a107bb2c..3a2a5ef0e1 100644 --- a/app/views/invites/_form.html.haml +++ b/app/views/invites/_form.html.haml @@ -1,9 +1,11 @@ = simple_form_for(@invite, url: controller.is_a?(Admin::InvitesController) ? admin_invites_path : invites_path) do |f| = render 'shared/error_messages', object: @invite - .fields-group - = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') - = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') + .fields-row__column.fields-row__column-6.fields-group + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') .fields-group = f.input :autofollow, wrapper: :with_label diff --git a/app/views/invites/index.html.haml b/app/views/invites/index.html.haml index f4c5047fa0..fb827f6e6d 100644 --- a/app/views/invites/index.html.haml +++ b/app/views/invites/index.html.haml @@ -6,7 +6,7 @@ = render 'form' - %hr/ + %hr.spacer/ %table.table %thead diff --git a/app/views/settings/applications/_fields.html.haml b/app/views/settings/applications/_fields.html.haml index db90df3491..6a2863b200 100644 --- a/app/views/settings/applications/_fields.html.haml +++ b/app/views/settings/applications/_fields.html.haml @@ -1,6 +1,8 @@ .fields-group - = f.input :name, placeholder: t('activerecord.attributes.doorkeeper/application.name') - = f.input :website, placeholder: t('activerecord.attributes.doorkeeper/application.website') + = f.input :name, wrapper: :with_label, label: t('activerecord.attributes.doorkeeper/application.name') + +.fields-group + = f.input :website, wrapper: :with_label, label: t('activerecord.attributes.doorkeeper/application.website') .fields-group = f.input :redirect_uri, wrapper: :with_block_label, label: t('activerecord.attributes.doorkeeper/application.redirect_uri'), hint: t('doorkeeper.applications.help.redirect_uri') diff --git a/app/views/settings/imports/show.html.haml b/app/views/settings/imports/show.html.haml index 2b43cb1349..4512fc714d 100644 --- a/app/views/settings/imports/show.html.haml +++ b/app/views/settings/imports/show.html.haml @@ -2,10 +2,8 @@ = t('settings.import') = simple_form_for @import, url: settings_import_path do |f| - %p.hint= t('imports.preface') - .field-group - = f.input :type, collection: Import.types.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + = f.input :type, collection: Import.types.keys, wrapper: :with_block_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }, hint: t('imports.preface') .field-group = f.input :data, wrapper: :with_block_label, hint: t('simple_form.hints.imports.data') diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 43430069f6..c79ecf6d54 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -1,29 +1,32 @@ - content_for :page_title do = t('settings.preferences') +%ul.quick-nav + %li= link_to t('preferences.languages'), '#settings_languages' + %li= link_to t('preferences.publishing'), '#settings_publishing' + %li= link_to t('preferences.other'), '#settings_other' + %li= link_to t('preferences.web'), '#settings_web' + = simple_form_for current_user, url: settings_preferences_path, html: { method: :put } do |f| = render 'shared/error_messages', object: current_user - .actions.actions--top - = f.button :button, t('generic.save_changes'), type: :submit - - %h4= t 'preferences.languages' + .fields-row#settings_languages + .fields-group.fields-row__column.fields-row__column-6 + = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale + .fields-group.fields-row__column.fields-row__column-6 + = f.input :setting_default_language, collection: [nil] + filterable_languages.sort, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.language_detection') : human_locale(locale) }, required: false, include_blank: false .fields-group - = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, selected: I18n.locale - - = f.input :setting_default_language, collection: [nil] + filterable_languages.sort, wrapper: :with_label, label_method: lambda { |locale| locale.nil? ? I18n.t('statuses.language_detection') : human_locale(locale) }, required: false, include_blank: false - = f.input :chosen_languages, collection: filterable_languages.sort, wrapper: :with_block_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) }, required: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' - %h4= t 'preferences.publishing' + %hr#settings_publishing/ .fields-group - = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_floating_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' = f.input :setting_default_sensitive, as: :boolean, wrapper: :with_label - %h4= t 'preferences.other' + %hr#settings_other/ .fields-group = f.input :setting_noindex, as: :boolean, wrapper: :with_label @@ -31,12 +34,13 @@ .fields-group = f.input :setting_hide_network, as: :boolean, wrapper: :with_label - %h4= t 'preferences.web' + %hr#settings_web/ + + - if Themes.instance.names.size > 1 + .fields-group + = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_block_label, include_blank: false .fields-group - - if Themes.instance.names.size > 1 - = f.input :setting_theme, collection: Themes.instance.names, label_method: lambda { |theme| I18n.t("themes.#{theme}", default: theme) }, wrapper: :with_label, include_blank: false - = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 9518bcb610..42e3840a1b 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -4,16 +4,21 @@ = simple_form_for @account, url: settings_profile_path, html: { method: :put } do |f| = render 'shared/error_messages', object: @account - .fields-group - = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name'), hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe - = f.input :note, placeholder: t('simple_form.labels.defaults.note'), hint: t('simple_form.hints.defaults.note', count: 160 - @account.note.size).html_safe + .fields-row + .fields-row__column.fields-group.fields-row__column-6 + = f.input :display_name, wrapper: :with_label, hint: t('simple_form.hints.defaults.display_name', count: 30 - @account.display_name.size).html_safe + = f.input :note, wrapper: :with_label, hint: t('simple_form.hints.defaults.note', count: 160 - @account.note.size).html_safe - = render 'application/card', account: @account + .fields-row + .fields-row__column.fields-row__column-6 + = render 'application/card', account: @account - .fields-group - = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT)) + .fields-row__column.fields-group.fields-row__column-6 + = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT)) - = f.input :header, wrapper: :with_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header', dimensions: '1500x500', size: number_to_human_size(AccountHeader::LIMIT)) + = f.input :header, wrapper: :with_label, input_html: { accept: AccountHeader::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.header', dimensions: '1500x500', size: number_to_human_size(AccountHeader::LIMIT)) + + %hr.spacer/ .fields-group = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked') @@ -21,15 +26,27 @@ .fields-group = f.input :bot, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.bot') - .fields-group - .input.with_block_label - %label= t('simple_form.labels.defaults.fields') - %span.hint= t('simple_form.hints.defaults.fields') + %hr.spacer/ - = f.simple_fields_for :fields do |fields_f| - .row - = fields_f.input :name, placeholder: t('simple_form.labels.account.fields.name') - = fields_f.input :value, placeholder: t('simple_form.labels.account.fields.value') + .fields-row + .fields-row__column.fields-group.fields-row__column-6 + .input.with_block_label + %label= t('simple_form.labels.defaults.fields') + %span.hint= t('simple_form.hints.defaults.fields') + + = f.simple_fields_for :fields do |fields_f| + .row + = fields_f.input :name, placeholder: t('simple_form.labels.account.fields.name') + = fields_f.input :value, placeholder: t('simple_form.labels.account.fields.value') + + .fields-row__column.fields-group.fields-row__column-6 + %h6= t('verification.verification') + %p.hint= t('verification.explanation_html') + + .input-copy + .input-copy__wrapper + %input{ type: :text, maxlength: '999', spellcheck: 'false', readonly: 'true', value: link_to('Mastodon', ActivityPub::TagManager.instance.url_for(@account), rel: 'me').to_str } + %button{ type: :button }= t('generic.copy') .actions = f.button :button, t('generic.save_changes'), type: :submit @@ -38,3 +55,9 @@ %h6= t('auth.migrate_account') %p.muted-hint= t('auth.migrate_account_html', path: settings_migration_path) + +- if open_deletion? + %hr.spacer/ + + %h6= t('auth.delete_account') + %p.muted-hint= t('auth.delete_account_html', path: settings_delete_path) diff --git a/app/views/settings/two_factor_authentication/confirmations/new.html.haml b/app/views/settings/two_factor_authentication/confirmations/new.html.haml index fd4a3e7683..e641552991 100644 --- a/app/views/settings/two_factor_authentication/confirmations/new.html.haml +++ b/app/views/settings/two_factor_authentication/confirmations/new.html.haml @@ -11,7 +11,8 @@ %p.hint= t('two_factor_authentication.manual_instructions') %samp.qr-alternative__code= current_user.otp_secret.scan(/.{4}/).join(' ') - = f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' } + .fields-group + = f.input :code, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true .actions = f.button :button, t('two_factor_authentication.enable'), type: :submit diff --git a/app/views/settings/two_factor_authentications/show.html.haml b/app/views/settings/two_factor_authentications/show.html.haml index 67a64a0468..259bcd1ef3 100644 --- a/app/views/settings/two_factor_authentications/show.html.haml +++ b/app/views/settings/two_factor_authentications/show.html.haml @@ -10,7 +10,7 @@ %hr/ = simple_form_for @confirmation, url: settings_two_factor_authentication_path, method: :delete do |f| - = f.input :code, hint: t('two_factor_authentication.code_hint'), placeholder: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' } + = f.input :code, wrapper: :with_label, hint: t('two_factor_authentication.code_hint'), label: t('simple_form.labels.defaults.otp_attempt'), input_html: { :autocomplete => 'off' }, required: true .actions = f.button :button, t('two_factor_authentication.disable'), type: :submit diff --git a/app/workers/verify_account_links_worker.rb b/app/workers/verify_account_links_worker.rb new file mode 100644 index 0000000000..901498583e --- /dev/null +++ b/app/workers/verify_account_links_worker.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class VerifyAccountLinksWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull', retry: false, unique: :until_executed + + def perform(account_id) + account = Account.find(account_id) + + account.fields.each do |field| + next unless !field.verified? && field.verifiable? + VerifyLinkService.new.call(field) + end + + account.save! if account.changed? + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb index e413cdb448..386ede654c 100644 --- a/config/initializers/simple_form.rb +++ b/config/initializers/simple_form.rb @@ -1,4 +1,15 @@ # Use this setup block to configure all options available in SimpleForm. + +module AppendComponent + def append(wrapper_options = nil) + @append ||= begin + options[:append].to_s.html_safe if options[:append].present? + end + end +end + +SimpleForm.include_component(AppendComponent) + SimpleForm.setup do |config| # Wrappers are used by the form builder to generate a # complete input. You can remove any component from the @@ -52,6 +63,22 @@ SimpleForm.setup do |config| config.wrappers :with_label, class: [:input, :with_label], hint_class: :field_with_hint, error_class: :field_with_errors do |b| b.use :html5 + + b.wrapper tag: :div, class: :label_input do |ba| + ba.use :label + + ba.wrapper tag: :div, class: :label_input__wrapper do |bb| + bb.use :input + bb.optional :append, wrap_with: { tag: :div, class: 'label_input__append' } + end + end + + b.use :hint, wrap_with: { tag: :span, class: :hint } + b.use :error, wrap_with: { tag: :span, class: :error } + end + + config.wrappers :with_floating_label, class: [:input, :with_floating_label], hint_class: :field_with_hint, error_class: :field_with_errors do |b| + b.use :html5 b.use :label_input, wrap_with: { tag: :div, class: :label_input } b.use :hint, wrap_with: { tag: :span, class: :hint } b.use :error, wrap_with: { tag: :span, class: :error } @@ -111,7 +138,7 @@ SimpleForm.setup do |config| # config.item_wrapper_class = nil # How the label text should be generated altogether with the required text. - # config.label_text = lambda { |label, required, explicit_label| "#{required} #{label}" } + config.label_text = lambda { |label, required, explicit_label| "#{label} #{required}" } # You can define the class to use on all labels. Default is nil. # config.label_class = nil diff --git a/config/locales/en.yml b/config/locales/en.yml index 5cbb790ddb..f883b17a19 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -48,6 +48,7 @@ en: other: Followers following: Following joined: Joined %{date} + link_verified_on: Ownership of this link was checked on %{date} media: Media moved_html: "%{name} has moved to %{new_profile_link}:" network_hidden: This information is not available @@ -460,7 +461,7 @@ en: warning: Be very careful with this data. Never share it with anyone! your_token: Your access token auth: - agreement_html: By signing up you agree to follow the rules of the instance and our terms of service. + agreement_html: By clicking "Sign up" below you agree to follow the rules of the instance and our terms of service. change_password: Password confirm_email: Confirm email delete_account: Delete account @@ -921,3 +922,6 @@ en: otp_lost_help_html: If you lost access to both, you may get in touch with %{email} seamless_external_login: You are logged in via an external service, so password and e-mail settings are not available. signed_in_as: 'Signed in as:' + verification: + explanation_html: 'You can verify yourself as the owner of the links in your profile metadata. For that, the linked website must contain a link back to your Mastodon profile. The link back must have a rel="me" attribute. The text content of the link does not matter. Here is an example:' + verification: Verification diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 2e4cb1effd..c2e5729977 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -11,6 +11,7 @@ en: display_name: one: 1 character left other: %{count} characters left + email: You will be sent a confirmation e-mail fields: You can have up to 4 items displayed as a table on your profile header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px inbox_url: Copy the URL from the frontpage of the relay you want to use @@ -20,12 +21,14 @@ en: note: one: 1 character left other: %{count} characters left + password: Use at least 8 characters phrase: Will be matched regardless of casing in text or content warning of a toot scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones. setting_default_language: The language of your toots can be detected automatically, but it's not always accurate setting_hide_network: Who you follow and who follows you will not be shown on your profile setting_noindex: Affects your public profile and status pages setting_theme: Affects how Mastodon looks when you're logged in from any device. + username: Your username will be unique on %{domain} whole_word: When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word imports: data: CSV file exported from another Mastodon instance diff --git a/spec/services/verify_link_service_spec.rb b/spec/services/verify_link_service_spec.rb new file mode 100644 index 0000000000..8ce867e9d4 --- /dev/null +++ b/spec/services/verify_link_service_spec.rb @@ -0,0 +1,51 @@ +require 'rails_helper' + +RSpec.describe VerifyLinkService, type: :service do + subject { described_class.new } + + let(:account) { Fabricate(:account, username: 'alice') } + let(:field) { Account::Field.new(account, 'name' => 'Website', 'value' => 'http://example.com') } + + before do + stub_request(:get, 'http://example.com').to_return(status: 200, body: html) + subject.call(field) + end + + context 'when a link contains an back' do + let(:html) do + <<-HTML + + + Follow me on Mastodon + + HTML + end + + it 'marks the field as verified' do + expect(field.verified?).to be true + end + end + + context 'when a link contains a back' do + let(:html) do + <<-HTML + + + + + HTML + end + + it 'marks the field as verified' do + expect(field.verified?).to be true + end + end + + context 'when a link does not contain a link back' do + let(:html) { '' } + + it 'marks the field as verified' do + expect(field.verified?).to be false + end + end +end