* 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 updatesmaster
@@ -48,4 +48,12 @@ module HomeHelper | |||
'1+' | |||
end | |||
end | |||
def custom_field_classes(field) | |||
if field.verified? | |||
'verified' | |||
else | |||
'emojify' | |||
end | |||
end | |||
end |
@@ -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> | |||
@@ -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 => { | |||
@@ -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; | |||
} | |||
@@ -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; | |||
} | |||
} | |||
} | |||
@@ -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%); | |||
@@ -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); | |||
} | |||
} | |||
} | |||
@@ -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 { | |||
@@ -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; | |||
} | |||
} | |||
} |
@@ -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 |
@@ -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 | |||
@@ -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 | |||
@@ -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) } | |||
@@ -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 | |||
@@ -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 | |||
@@ -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 |
@@ -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' | |||
@@ -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? | |||
@@ -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') |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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' |
@@ -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 | |||
@@ -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 | |||
@@ -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 | |||
@@ -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) |
@@ -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' |
@@ -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 | |||
@@ -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 | |||
@@ -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') |
@@ -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 | |||
@@ -6,7 +6,7 @@ | |||
= render 'form' | |||
%hr/ | |||
%hr.spacer/ | |||
%table.table | |||
%thead | |||
@@ -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') | |||
@@ -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') | |||
@@ -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 | |||
@@ -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) |
@@ -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 |
@@ -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 | |||
@@ -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 |
@@ -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 | |||
@@ -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 |
@@ -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 | |||
@@ -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 |