about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2018-09-18 16:45:58 +0200
committerGitHub <noreply@github.com>2018-09-18 16:45:58 +0200
commitf4d549d30081478b1fe2bde9d340262e132bb891 (patch)
treed920447e62986f1ec47ce88084cf58a7abcad312
parentf8b54d229f70cb726511bcd35e1440618e487672 (diff)
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 <link> tags, add spec

* Fix links not being verified on first discovery and passive updates
-rw-r--r--app/helpers/home_helper.rb8
-rw-r--r--app/javascript/mastodon/features/account/components/header.js15
-rw-r--r--app/javascript/packs/public.js27
-rw-r--r--app/javascript/styles/mastodon/accounts.scss14
-rw-r--r--app/javascript/styles/mastodon/admin.scss84
-rw-r--r--app/javascript/styles/mastodon/basics.scss7
-rw-r--r--app/javascript/styles/mastodon/boost.scss7
-rw-r--r--app/javascript/styles/mastodon/components.scss9
-rw-r--r--app/javascript/styles/mastodon/containers.scss8
-rw-r--r--app/javascript/styles/mastodon/forms.scss361
-rw-r--r--app/lib/activitypub/activity/update.rb1
-rw-r--r--app/models/account.rb54
-rw-r--r--app/serializers/rest/account_serializer.rb4
-rw-r--r--app/services/activitypub/process_account_service.rb5
-rw-r--r--app/services/fetch_link_card_service.rb2
-rw-r--r--app/services/update_account_service.rb5
-rw-r--r--app/services/verify_link_service.rb32
-rw-r--r--app/views/about/_registration.html.haml11
-rw-r--r--app/views/accounts/_bio.html.haml7
-rw-r--r--app/views/admin/change_emails/show.html.haml11
-rw-r--r--app/views/admin/custom_emojis/new.html.haml5
-rw-r--r--app/views/admin/domain_blocks/new.html.haml13
-rw-r--r--app/views/admin/email_domain_blocks/new.html.haml3
-rw-r--r--app/views/admin/settings/edit.html.haml63
-rw-r--r--app/views/auth/confirmations/finish_signup.html.haml3
-rw-r--r--app/views/auth/confirmations/new.html.haml3
-rw-r--r--app/views/auth/passwords/edit.html.haml6
-rw-r--r--app/views/auth/passwords/new.html.haml3
-rw-r--r--app/views/auth/registrations/edit.html.haml20
-rw-r--r--app/views/auth/registrations/new.html.haml22
-rw-r--r--app/views/auth/sessions/new.html.haml12
-rw-r--r--app/views/auth/sessions/two_factor.html.haml3
-rw-r--r--app/views/filters/_fields.html.haml12
-rw-r--r--app/views/invites/_form.html.haml8
-rw-r--r--app/views/invites/index.html.haml2
-rw-r--r--app/views/settings/applications/_fields.html.haml6
-rw-r--r--app/views/settings/imports/show.html.haml4
-rw-r--r--app/views/settings/preferences/show.html.haml34
-rw-r--r--app/views/settings/profiles/show.html.haml53
-rw-r--r--app/views/settings/two_factor_authentication/confirmations/new.html.haml3
-rw-r--r--app/views/settings/two_factor_authentications/show.html.haml2
-rw-r--r--app/workers/verify_account_links_worker.rb20
-rw-r--r--config/initializers/simple_form.rb29
-rw-r--r--config/locales/en.yml6
-rw-r--r--config/locales/simple_form.en.yml3
-rw-r--r--spec/services/verify_link_service_spec.rb51
46 files changed, 756 insertions, 305 deletions
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
index 0157785fa..ba7c443c2 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 e49758b04..11ae58905 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) => (
                 <dl key={i}>
                   <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
-                  <dd dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} title={pair.get('value_plain')} />
+
+                  <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
+                    {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><i className='fa fa-check verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
+                  </dd>
                 </dl>
               ))}
             </div>
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index c83e2889a..22a8643d9 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 c27bc0df3..06effbdb2 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 7d359921a..9dfd89dc2 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,20 +74,24 @@
         a {
           border: 0;
           padding: 15px 35px;
+        }
+      }
 
-          &.selected {
-            color: $primary-text-color;
-            background-color: $ui-highlight-color;
-            border-bottom: 0;
-            border-radius: 0;
+      .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%);
-            }
-          }
+        &:hover {
+          background-color: lighten($ui-highlight-color, 5%);
         }
       }
     }
+
+    & > ul > .simple-navigation-active-leaf a {
+      border-radius: 4px 0 0 4px;
+    }
   }
 
   .content-wrapper {
@@ -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 7a6a1c490..3bbb31e6e 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 8e11cb596..5a6d6ae40 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,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='209'><path d='M4.97 3.16c-.1.03-.17.1-.22.18L.8 8.24c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77L5.5 3.35c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.02-2.4.02H7.1l2.32 2.85.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($action-button-color)}' stroke-width='0'/><path d='M7.78 19.66c-.24.02-.44.25-.44.5v2.46h-.06c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v4.47c0 4.26-.56 3.62 3.65 3.62H8.5l-1.3-1.06c-.1-.08-.18-.2-.2-.3-.02-.17.06-.35.2-.45l1.33-1.1H7.28c-.44 0-.72-.3-.72-.7v-4.48c0-.44.28-.72.72-.72h.06v2.5c0 .38.54.63.82.38l4.9-3.93c.25-.18.25-.6 0-.78l-4.9-3.92c-.1-.1-.24-.14-.38-.12zm9.34 2.93c-.54-.02-1.3.02-2.4.02h-1.25l1.3 1.07c.1.07.18.2.2.33.02.16-.06.3-.2.4l-1.33 1.1h1.28c.42 0 .72.28.72.72v4.47c0 .42-.3.72-.72.72h-.1v-2.47c0-.3-.3-.53-.6-.47-.07 0-.14.05-.2.1l-4.9 3.93c-.26.18-.26.6 0 .78l4.9 3.92c.27.25.82 0 .8-.38v-2.5h.1c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.15.4-3.62-1.25-3.66zM10.34 38.66c-.24.02-.44.25-.43.5v2.47H7.3c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.47c0 3.66-.23 3.7 2.34 3.66l-1.34-1.1c-.1-.08-.18-.2-.2-.3 0-.17.07-.35.2-.45l1.96-1.6c-.03-.06-.04-.13-.04-.2v-4.48c0-.44.28-.72.72-.72H9.9v2.5c0 .36.5.6.8.38l4.93-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.08-.23-.13-.36-.12zm5.63 2.93l1.34 1.1c.1.07.18.2.2.33.02.16-.03.3-.16.4l-1.96 1.6c.02.07.06.13.06.22v4.47c0 .42-.3.72-.72.72h-2.66v-2.47c0-.3-.3-.53-.6-.47-.06.02-.12.05-.18.1l-4.94 3.93c-.24.18-.24.6 0 .78l4.94 3.92c.28.22.78-.02.78-.38v-2.5h2.66c4.27 0 3.65.67 3.65-3.62v-4.47c0-3.66.34-3.7-2.4-3.66zM13.06 57.66c-.23.03-.4.26-.4.5v2.47H7.28c-1.08 0-1.86-.04-2.4-.04-1.64 0-1.25.43-1.25 3.65v4.87l2.93-2.37v-2.5c0-.44.28-.72.72-.72h5.38v2.5c0 .36.5.6.78.38l4.94-3.93c.24-.18.24-.6 0-.78l-4.94-3.92c-.1-.1-.24-.14-.38-.12zm5.3 6.15l-2.92 2.4v2.52c0 .42-.3.72-.72.72h-5.4v-2.47c0-.3-.32-.53-.6-.47-.07.02-.13.05-.2.1L3.6 70.52c-.25.18-.25.6 0 .78l4.93 3.92c.28.22.78-.02.78-.38v-2.5h5.42c4.27 0 3.65.67 3.65-3.62v-4.47-.44zM19.25 78.8c-.1.03-.2.1-.28.17l-.9.9c-.44-.3-1.36-.25-3.35-.25H7.28c-1.08 0-1.86-.03-2.4-.03-1.64 0-1.25.43-1.25 3.65v.7l2.93.3v-1c0-.44.28-.72.72-.72h7.44c.2 0 .37.08.5.2l-1.8 1.8c-.25.26-.08.76.27.8l6.27.7c.28.03.56-.25.53-.53l-.7-6.25c0-.27-.3-.48-.55-.44zm-17.2 6.1c-.2.07-.36.3-.33.54l.7 6.25c.02.36.58.55.83.27l.8-.8c.02 0 .04-.02.04 0 .46.24 1.37.17 3.18.17h7.44c4.27 0 3.65.67 3.65-3.62v-.75l-2.93-.3v1.05c0 .42-.3.72-.72.72H7.28c-.15 0-.3-.03-.4-.1L8.8 86.4c.3-.24.1-.8-.27-.84l-6.28-.65h-.2zM4.88 98.6c-1.33 0-1.34.48-1.3 2.3l1.14-1.37c.08-.1.22-.17.34-.2.16 0 .34.08.44.2l1.66 2.03c.04 0 .07-.03.12-.03h7.44c.34 0 .57.2.65.5h-2.43c-.34.05-.53.52-.3.78l3.92 4.95c.18.24.6.24.78 0l3.94-4.94c.22-.27-.02-.76-.37-.77H18.4c.02-3.9.6-3.4-3.66-3.4H7.28c-1.08 0-1.86-.04-2.4-.04zm.15 2.46c-.1.03-.2.1-.28.2l-3.94 4.9c-.2.28.03.77.4.78H3.6c-.02 3.94-.45 3.4 3.66 3.4h7.44c3.65 0 3.74.3 3.7-2.25l-1.1 1.34c-.1.1-.2.17-.32.2-.16 0-.34-.08-.44-.2l-1.65-2.03c-.06.02-.1.04-.18.04H7.28c-.35 0-.57-.2-.66-.5h2.44c.37 0 .63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.23-.47-.2zM4.88 117.6c-1.16 0-1.3.3-1.3 1.56l1.14-1.38c.08-.1.22-.14.34-.16.16 0 .34.04.44.16l2.22 2.75h7c.42 0 .72.28.72.72v.53h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-.53c0-4.2.72-3.63-3.66-3.63H7.28c-1.08 0-1.86-.03-2.4-.03zm.1 1.74c-.1.03-.17.1-.23.16L.8 124.44c-.2.28.03.77.4.78H3.6v.5c0 4.26-.55 3.62 3.66 3.62h7.44c1.03 0 1.74.02 2.28 0-.16.02-.34-.03-.44-.15l-2.22-2.76H7.28c-.44 0-.72-.3-.72-.72v-.5h2.5c.37.02.63-.5.4-.78L5.5 119.5c-.12-.15-.34-.22-.53-.16zm12.02 10c1.2-.02 1.4-.25 1.4-1.53l-1.1 1.36c-.07.1-.17.17-.3.18zM5.94 136.6l2.37 2.93h6.42c.42 0 .72.28.72.72v1.25h-2.6c-.3.1-.43.54-.2.78l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.25c0-4.2.72-3.63-3.66-3.63H7.28c-.6 0-.92-.02-1.34-.03zm-1.72.06c-.4.08-.54.3-.6.75l.6-.74zm.84.93c-.12 0-.24.08-.3.18l-3.95 4.9c-.24.3 0 .83.4.82H3.6v1.22c0 4.26-.55 3.62 3.66 3.62h7.44c.63 0 .97.02 1.4.03l-2.37-2.93H7.28c-.44 0-.72-.3-.72-.72v-1.22h2.5c.4.04.67-.53.4-.8l-3.96-4.92c-.1-.13-.27-.2-.44-.2zm13.28 10.03l-.56.7c.36-.07.5-.3.56-.7zM17.13 155.6c-.55-.02-1.32.03-2.4.03h-8.2l2.38 2.9h5.82c.42 0 .72.28.72.72v1.97H12.9c-.32.06-.48.52-.28.78l3.94 4.94c.2.23.6.22.78-.03l3.94-4.9c.22-.28-.02-.77-.37-.78H18.4v-1.97c0-3.15.4-3.62-1.25-3.66zm-12.1.28c-.1.02-.2.1-.28.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v1.96c0 4.26-.55 3.62 3.66 3.62h8.24l-2.36-2.9H7.28c-.44 0-.72-.3-.72-.72v-1.97h2.5c.37.02.63-.5.4-.78l-3.96-4.9c-.1-.15-.3-.22-.47-.2zM5.13 174.5c-.15 0-.3.07-.38.2L.8 179.6c-.24.27 0 .82.4.8H3.6v2.32c0 4.26-.55 3.62 3.66 3.62h7.94l-2.35-2.9h-5.6c-.43 0-.7-.3-.7-.72v-2.3h2.5c.38.03.66-.54.4-.83l-3.97-4.9c-.1-.13-.23-.2-.38-.2zm12 .1c-.55-.02-1.32.03-2.4.03H6.83l2.35 2.9h5.52c.42 0 .72.28.72.72v2.34h-2.6c-.3.1-.43.53-.2.78l3.92 4.9c.18.24.6.24.78 0l3.94-4.9c.22-.3-.02-.78-.37-.8H18.4v-2.33c0-3.15.4-3.62-1.25-3.66zM4.97 193.16c-.1.03-.17.1-.22.18l-3.94 4.9c-.2.3.03.78.4.8H3.6v2.68c0 4.26-.55 3.62 3.66 3.62h7.66l-2.3-2.84c-.03-.02-.03-.04-.05-.06H7.27c-.44 0-.72-.3-.72-.72v-2.7h2.5c.37.03.63-.48.4-.77l-3.96-4.9c-.12-.17-.34-.25-.53-.2zm12.16.43c-.55-.02-1.32.03-2.4.03H7.1l2.32 2.84.03.06h5.25c.42 0 .72.28.72.72v2.7h-2.5c-.36.02-.56.54-.3.8l3.92 4.9c.18.25.6.25.78 0l3.94-4.9c.26-.28 0-.83-.37-.8H18.4v-2.7c0-3.15.4-3.62-1.25-3.66z' fill='#{hex-color($highlight-text-color)}' stroke-width='0'/></svg>");
 
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 048563336..9421523bd 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 2c2ce5d3b..47582f323 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 144b4a519..cbd3de94c 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,49 +196,70 @@ code {
     }
   }
 
+  .required abbr {
+    text-decoration: none;
+    color: lighten($error-value-color, 12%);
+  }
+
   .fields-group {
     margin-bottom: 25px;
-  }
 
-  .input.radio_buttons .radio label {
-    margin-bottom: 5px;
-    font-family: inherit;
-    font-size: 14px;
-    color: $primary-text-color;
-    display: block;
-    width: auto;
+    .input:last-child {
+      margin-bottom: 0;
+    }
   }
 
-  .input.boolean {
-    margin-bottom: 5px;
+  .fields-row {
+    display: flex;
+    margin: 0 -10px;
+    padding-top: 5px;
+    margin-bottom: 25px;
 
-    label {
-      font-family: inherit;
-      font-size: 14px;
-      color: $primary-text-color;
-      display: block;
-      width: auto;
+    .input {
+      max-width: none;
     }
 
-    label.checkbox {
-      position: relative;
-      padding-left: 25px;
+    &__column {
+      box-sizing: border-box;
+      padding: 0 10px;
       flex: 1 1 auto;
+      min-height: 1px;
+
+      &-6 {
+        max-width: 50%;
+      }
     }
 
-    input[type=checkbox] {
-      position: absolute;
-      left: 0;
-      top: 5px;
-      margin: 0;
+    .fields-group:last-child,
+    .fields-row__column.fields-group {
+      margin-bottom: 0;
     }
 
-    .hint {
-      padding-left: 25px;
-      margin-left: 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 {
+    margin-bottom: 5px;
+    font-family: inherit;
+    font-size: 14px;
+    color: $primary-text-color;
+    display: block;
+    width: auto;
+  }
+
   .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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 12%))}'/></svg>") 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 6eebc3b5c..67dae3f81 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 440a731e3..979e0b12c 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
 
+  DEFAULT_FIELDS_SIZE = 4
+
   def build_fields
-    return if fields.size >= 4
+    return if fields.size >= DEFAULT_FIELDS_SIZE
+
+    tmp = self[:fields] || []
+
+    (DEFAULT_FIELDS_SIZE - tmp.size).times do
+      tmp << { name: '', value: '' }
+    end
 
-    raw_fields = self[:fields] || []
-    add_fields = 4 - raw_fields.size
-    add_fields.times { raw_fields << { name: '', value: '' } }
-    self.fields = raw_fields
+    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, 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 initialize(account, attr)
-      @account = account
-      @name    = attr['name'].strip[0, 255]
-      @value   = attr['value'].strip[0, 255]
-      @errors  = {}
+    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 3a724aa7c..d84b48afb 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 670a0e4d6..c77858f1d 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 ea94e2491..4169c685b 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 09ea377e7..362d80c9e 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 000000000..9da7f93c3
--- /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 6ca1d7129..ee4f8fe2e 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 4e674beff..2ea34a048 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 a661b1ad6..6febef9b1 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 672afe435..e15a07cb8 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 2b8b24e23..f0eb217eb 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 bcae867d9..f372fa512 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 f40edc35a..c5a606693 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_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
+
+  .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 }
-    = 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/
+  .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 :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')
+    = 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/
+  %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')
@@ -37,34 +50,24 @@
     = 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/
+    = 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')
 
   .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/
+    = f.input :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html')
 
   .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')
+    = 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/
+  %hr.spacer/
 
   .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')
+    = 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 :peers_api_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.peers_api_enabled.title'), hint: t('admin.settings.peers_api_enabled.desc_html')
-
-  .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')
+    = 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 4b5161d6b..9d09b74e1 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 07ca4a7da..4a1bedaa4 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 53d1769d6..383d44f00 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 f4d22031a..bae5b24ba 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 05fc7df31..7fc08a23b 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 200ed42de..72ce8e531 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}"
-
-  = 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' }
+    .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 :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 0c9f9d5fe..ceb169408 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 1af3193ae..4e6bbd7a9 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 a5a3f0337..fb94a07fc 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 42a107bb2..3a2a5ef0e 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 f4c5047fa..fb827f6e6 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 db90df349..6a2863b20 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 2b43cb134..4512fc714 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 43430069f..c79ecf6d5 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/
 
-  .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
+  - 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
     = 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 9518bcb61..42e3840a1 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/
+
+  .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')
+        = 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 fd4a3e768..e64155299 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 67a64a046..259bcd1ef 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 000000000..901498583
--- /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 e413cdb44..386ede654 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 5cbb790dd..f883b17a1 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 <a href="%{rules_path}">the rules of the instance</a> and <a href="%{terms_path}">our terms of service</a>.
+    agreement_html: By clicking "Sign up" below you agree to follow <a href="%{rules_path}">the rules of the instance</a> and <a href="%{terms_path}">our terms of service</a>.
     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 <strong>verify yourself as the owner of the links in your profile metadata</strong>. For that, the linked website must contain a link back to your Mastodon profile. The link back <strong>must</strong> have a <code>rel="me"</code> 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 2e4cb1eff..c2e572997 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -11,6 +11,7 @@ en:
         display_name:
           one: <span class="name-counter">1</span> character left
           other: <span class="name-counter">%{count}</span> 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: <span class="note-counter">1</span> character left
           other: <span class="note-counter">%{count}</span> 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 000000000..8ce867e9d
--- /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 <a> back' do
+    let(:html) do
+      <<-HTML
+        <!doctype html>
+        <body>
+          <a href="#{ActivityPub::TagManager.instance.url_for(account)}" rel="me">Follow me on Mastodon</a>
+        </body>
+      HTML
+    end
+
+    it 'marks the field as verified' do
+      expect(field.verified?).to be true
+    end
+  end
+
+  context 'when a link contains a <link> back' do
+    let(:html) do
+      <<-HTML
+        <!doctype html>
+        <head>
+          <link type="text/html" href="#{ActivityPub::TagManager.instance.url_for(account)}" rel="me" />
+        </head>
+      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