feat: implement basic login mode (#1543)

fixes #1542
This commit is contained in:
Nolan Lawson 2019-09-26 05:28:52 -07:00 committed by GitHub
parent 2ada968439
commit 4ddf47f3da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 209 additions and 94 deletions

View file

@ -8,14 +8,17 @@ import { updateCustomEmojiForInstance } from './emoji'
import { database } from '../_database/database' import { database } from '../_database/database'
import { DOMAIN_BLOCKS } from '../_static/blocks' import { DOMAIN_BLOCKS } from '../_static/blocks'
const REDIRECT_URI = process.browser && `${location.origin}/settings/instances/add`
function createKnownError (message) { function createKnownError (message) {
const err = new Error(message) const err = new Error(message)
err.knownError = true err.knownError = true
return err return err
} }
function getRedirectUri () {
const { copyPasteMode } = store.get()
return copyPasteMode ? 'urn:ietf:wg:oauth:2.0:oob' : `${location.origin}/settings/instances/add`
}
async function redirectToOauth () { async function redirectToOauth () {
let { instanceNameInSearch, loggedInInstances } = store.get() let { instanceNameInSearch, loggedInInstances } = store.get()
instanceNameInSearch = instanceNameInSearch.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase() instanceNameInSearch = instanceNameInSearch.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase()
@ -26,7 +29,8 @@ async function redirectToOauth () {
if (DOMAIN_BLOCKS.some(domain => new RegExp(`(?:\\.|^)${domain}$`, 'i').test(instanceHostname))) { if (DOMAIN_BLOCKS.some(domain => new RegExp(`(?:\\.|^)${domain}$`, 'i').test(instanceHostname))) {
throw createKnownError('This service is blocked') throw createKnownError('This service is blocked')
} }
const registrationPromise = registerApplication(instanceNameInSearch, REDIRECT_URI) const redirectUri = getRedirectUri()
const registrationPromise = registerApplication(instanceNameInSearch, redirectUri)
const instanceInfo = await getInstanceInfo(instanceNameInSearch) const instanceInfo = await getInstanceInfo(instanceNameInSearch)
await database.setInstanceInfo(instanceNameInSearch, instanceInfo) // cache for later await database.setInstanceInfo(instanceNameInSearch, instanceInfo) // cache for later
const instanceData = await registrationPromise const instanceData = await registrationPromise
@ -38,11 +42,16 @@ async function redirectToOauth () {
const oauthUrl = generateAuthLink( const oauthUrl = generateAuthLink(
instanceNameInSearch, instanceNameInSearch,
instanceData.client_id, instanceData.client_id,
REDIRECT_URI redirectUri
) )
// setTimeout to allow the browser to *actually* save the localStorage data (fixes Safari bug apparently) // setTimeout to allow the browser to *actually* save the localStorage data (fixes Safari bug apparently)
const { copyPasteMode } = store.get()
setTimeout(() => { setTimeout(() => {
document.location.href = oauthUrl if (copyPasteMode) {
window.open(oauthUrl, '_blank', 'noopener')
} else {
document.location.href = oauthUrl
}
}, 200) }, 200)
} }
@ -72,12 +81,13 @@ export async function logInToInstance () {
async function registerNewInstance (code) { async function registerNewInstance (code) {
const { currentRegisteredInstanceName, currentRegisteredInstance } = store.get() const { currentRegisteredInstanceName, currentRegisteredInstance } = store.get()
const redirectUri = getRedirectUri()
const instanceData = await getAccessTokenFromAuthCode( const instanceData = await getAccessTokenFromAuthCode(
currentRegisteredInstanceName, currentRegisteredInstanceName,
currentRegisteredInstance.client_id, currentRegisteredInstance.client_id,
currentRegisteredInstance.client_secret, currentRegisteredInstance.client_secret,
code, code,
REDIRECT_URI redirectUri
) )
const { loggedInInstances, loggedInInstancesInOrder, instanceThemes } = store.get() const { loggedInInstances, loggedInInstancesInOrder, instanceThemes } = store.get()
instanceThemes[currentRegisteredInstanceName] = DEFAULT_THEME instanceThemes[currentRegisteredInstanceName] = DEFAULT_THEME
@ -92,7 +102,8 @@ async function registerNewInstance (code) {
loggedInInstances: loggedInInstances, loggedInInstances: loggedInInstances,
currentInstance: currentRegisteredInstanceName, currentInstance: currentRegisteredInstanceName,
loggedInInstancesInOrder: loggedInInstancesInOrder, loggedInInstancesInOrder: loggedInInstancesInOrder,
instanceThemes: instanceThemes instanceThemes: instanceThemes,
copyPasteMode: false
}) })
store.save() store.save()
const { enableGrayscale } = store.get() const { enableGrayscale } = store.get()
@ -113,3 +124,16 @@ export async function handleOauthCode (code) {
store.set({ logInToInstanceLoading: false }) store.set({ logInToInstanceLoading: false })
} }
} }
export async function handleCopyPasteOauthCode (code) {
const { currentRegisteredInstanceName, currentRegisteredInstance } = store.get()
if (!currentRegisteredInstanceName || !currentRegisteredInstance) {
store.set({
logInToInstanceError: 'You must log in to an instance first.',
logInToInstanceErrorForText: '',
instanceNameInSearch: ''
})
} else {
await handleOauthCode(code)
}
}

View file

@ -0,0 +1,40 @@
<aside class="info-aside {className}">
<SvgIcon href="#fa-info-circle" className="aside-icon" />
<span>
<slot></slot>
</span>
</aside>
<style>
.info-aside {
font-size: 1.2em;
color: var(--deemphasized-text-color);
display: flex;
align-items: center;
}
:global(.info-aside a) {
text-decoration: underline;
color: var(--deemphasized-text-color);
}
:global(.info-aside span) {
flex: 1;
}
:global(.aside-icon) {
fill: var(--deemphasized-text-color);
width: 18px;
height: 18px;
margin: 0 10px 0 5px;
min-width: 18px;
}
</style>
<script>
import SvgIcon from './SvgIcon.html'
export default {
data: () => ({
className: ''
}),
components: {
SvgIcon
}
}
</script>

View file

@ -1,65 +1,83 @@
<SettingsLayout page='settings/instances/add' label="Add instance"> <SettingsLayout page='settings/instances/add' label="Add instance">
<h1 id="add-an-instance-h1">Add instance</h1> <h1 id="add-an-instance-h1">Add instance</h1>
<form class="add-new-instance" on:submit='onSubmit(event)' aria-labelledby="add-an-instance-h1"> <div class="add-new-instance">
<form on:submit='onSubmitInstance(event)' aria-labelledby="add-an-instance-h1">
{#if !hasIndexedDB || !hasLocalStorage} {#if !hasIndexedDB || !hasLocalStorage}
<div class="form-error form-error-user-error" role="alert"> <div class="form-error form-error-user-error" role="alert">
It seems Pinafore cannot store data locally. Is your browser in private mode It seems Pinafore cannot store data locally. Is your browser in private mode
or blocking cookies? Pinafore stores all data locally, and requires LocalStorage and or blocking cookies? Pinafore stores all data locally, and requires LocalStorage and
IndexedDB to work correctly. IndexedDB to work correctly.
</div> </div>
{/if}
{#if $logInToInstanceError && $logInToInstanceErrorForText === $instanceNameInSearch}
<div class="form-error form-error-user-error" role="alert">
Error: {$logInToInstanceError}
</div>
{/if}
<noscript>
<div class="form-error" role="alert">
You must enable JavaScript to log in.
</div>
</noscript>
<label for="instanceInput">Instance:</label>
<input type="text" inputmode="url" id="instanceInput"
bind:value='$instanceNameInSearch' placeholder="Enter instance name" required
>
<button class="primary" type="submit" id="submitButton"
disabled={!$instanceNameInSearch || $logInToInstanceLoading}>
Log in
</button>
</form>
{#if $copyPasteMode }
<form aria-label="Enter code" on:submit="onSubmitOauth(event)">
<label for="oauthCodeInput">Code:</label>
<input type="text" id="oauthCodeInput"
bind:value='oauthCode' placeholder="Enter code" required
>
<button class="primary" type="submit" id="submitOauthButton"
disabled={!oauthCode}>
Submit
</button>
</form>
{/if} {/if}
</div>
{#if $logInToInstanceError && $logInToInstanceErrorForText === $instanceNameInSearch}
<div class="form-error form-error-user-error" role="alert">
Error: {$logInToInstanceError}
</div>
{/if}
<noscript>
<div class="form-error" role="alert">
You must enable JavaScript to log in.
</div>
</noscript>
<label class="add-new-instance-label" for="instanceInput">Instance:</label>
<input class="add-new-instance-input" type="text" inputmode="url" id="instanceInput"
bind:value='$instanceNameInSearch' placeholder="Enter instance name" required
>
<button class="primary add-new-instance-button" type="submit" id="submitButton"
disabled={!$instanceNameInSearch || $logInToInstanceLoading}>
Log in
</button>
</form>
{#if !$isUserLoggedIn} {#if !$isUserLoggedIn}
<p>
Don't have an
<Tooltip
text="instance"
tooltipText="An instance is your Mastodon home server, such as mastodon.social or cybre.space."
/>
?
<ExternalLink href="https://joinmastodon.org">Join Mastodon!</ExternalLink>
</p>
{/if}
<p> <p>
Don't have an {#if $copyPasteMode}
<Tooltip Switch back to
text="instance" {:else}
tooltipText="An instance is your Mastodon home server, such as mastodon.social or cybre.space." Trouble logging in? Switch to
/> {/if}
? <button on:click="onCopyPasteModeButtonClick()"
<ExternalLink href="https://joinmastodon.org">Join Mastodon!</ExternalLink> class="copy-paste-mode-button"
aria-pressed={$copyPasteMode}>
{$copyPasteMode ? 'regular' : 'basic'} login mode
</button>.
</p> </p>
{#if $copyPasteMode}
<InfoAside className="add-new-instance-aside">
In basic login mode, click "log in" to open a new window. Then copy the code and paste it above.
</InfoAside>
{/if} {/if}
</SettingsLayout> </SettingsLayout>
<style> <style>
.form-error {
border: 2px solid red;
border-radius: 2px;
padding: 10px;
font-size: 1.3em;
margin: 5px;
background-color: var(--main-bg);
}
.add-new-instance-input {
min-width: 70%;
max-width: 100%;
background-color: var(--input-bg);
}
.add-new-instance { .add-new-instance {
background: var(--form-bg); background: var(--form-bg);
padding: 5px 10px 15px; padding: 5px 10px 15px;
@ -68,13 +86,41 @@
border-radius: 4px; border-radius: 4px;
} }
.add-new-instance-label, .add-new-instance-input, .add-new-instance-button { .form-error {
border: 2px solid red;
border-radius: 2px;
padding: 10px;
font-size: 1.3em;
margin: 5px;
background-color: var(--main-bg);
}
input {
min-width: 70%;
max-width: 100%;
background-color: var(--input-bg);
}
label, input, button, :global(.add-new-instance-aside) {
display: block; display: block;
margin: 20px 5px; margin: 20px 5px;
} }
button.copy-paste-mode-button {
margin: 0;
padding: 0;
display: inline-block;
background: none;
border: none;
font-size: 1em;
color: var(--anchor-text);
}
button.copy-paste-mode-button:hover {
text-decoration: underline;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.add-new-instance-input { input {
min-width: 95%; min-width: 95%;
} }
} }
@ -83,10 +129,11 @@
<script> <script>
import SettingsLayout from '../../../_components/settings/SettingsLayout.html' import SettingsLayout from '../../../_components/settings/SettingsLayout.html'
import { store } from '../../../_store/store' import { store } from '../../../_store/store'
import { logInToInstance, handleOauthCode } from '../../../_actions/addInstance' import { logInToInstance, handleOauthCode, handleCopyPasteOauthCode } from '../../../_actions/addInstance'
import ExternalLink from '../../../_components/ExternalLink.html' import ExternalLink from '../../../_components/ExternalLink.html'
import { testHasIndexedDB, testHasLocalStorage } from '../../../_utils/testStorage' import { testHasIndexedDB, testHasLocalStorage } from '../../../_utils/testStorage'
import Tooltip from '../../../_components/Tooltip.html' import Tooltip from '../../../_components/Tooltip.html'
import InfoAside from '../../../_components/InfoAside.html'
export default { export default {
async oncreate () { async oncreate () {
@ -102,18 +149,30 @@
components: { components: {
SettingsLayout, SettingsLayout,
ExternalLink, ExternalLink,
Tooltip Tooltip,
InfoAside
}, },
store: () => store, store: () => store,
data: () => ({ data: () => ({
hasIndexedDB: true, hasIndexedDB: true,
hasLocalStorage: true hasLocalStorage: true,
oauthCode: ''
}), }),
methods: { methods: {
onSubmit (event) { onSubmitInstance (event) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
logInToInstance() logInToInstance()
},
onSubmitOauth (event) {
event.preventDefault()
event.stopPropagation()
handleCopyPasteOauthCode(this.get().oauthCode)
},
onCopyPasteModeButtonClick () {
const { copyPasteMode } = this.store.get()
console.log('copyPasteMode', copyPasteMode)
this.store.set({ copyPasteMode: !copyPasteMode })
} }
} }
} }

View file

@ -44,13 +44,10 @@
</label> </label>
</form> </form>
<aside> <InfoAside className="wellness-aside">
<SvgIcon href="#fa-info-circle" className="aside-icon" /> You can filter or disable notifications in the
<span> <a rel="prefetch" href="/settings/instances{$currentInstance ? '/' + $currentInstance : ''}">instance settings</a>.
You can filter or disable notifications in the </InfoAside>
<a rel="prefetch" href="/settings/instances{$currentInstance ? '/' + $currentInstance : ''}">instance settings</a>.
</span>
</aside>
<h2>UI</h2> <h2>UI</h2>
@ -78,26 +75,8 @@
display: block; display: block;
padding: 5px 0; padding: 5px 0;
} }
aside { :global(.wellness-aside) {
font-size: 1.2em;
margin: 20px 10px 0px 10px; margin: 20px 10px 0px 10px;
color: var(--deemphasized-text-color);
display: flex;
align-items: center;
}
aside a {
text-decoration: underline;
color: var(--deemphasized-text-color);
}
aside span {
flex: 1;
}
:global(.aside-icon) {
fill: var(--deemphasized-text-color);
width: 18px;
height: 18px;
margin: 0 10px 0 5px;
min-width: 18px;
} }
@media (max-width: 240px) { @media (max-width: 240px) {
@ -110,7 +89,7 @@
import SettingsLayout from '../../_components/settings/SettingsLayout.html' import SettingsLayout from '../../_components/settings/SettingsLayout.html'
import { store } from '../../_store/store' import { store } from '../../_store/store'
import ExternalLink from '../../_components/ExternalLink.html' import ExternalLink from '../../_components/ExternalLink.html'
import SvgIcon from '../../_components/SvgIcon.html' import InfoAside from '../../_components/InfoAside.html'
export default { export default {
oncreate () { oncreate () {
@ -119,7 +98,7 @@
components: { components: {
SettingsLayout, SettingsLayout,
ExternalLink, ExternalLink,
SvgIcon InfoAside
}, },
methods: { methods: {
flushChangesToCheckAll () { flushChangesToCheckAll () {

View file

@ -1,7 +1,7 @@
import { Selector as $ } from 'testcafe' import { Selector as $ } from 'testcafe'
import { import {
addInstanceButton, addInstanceButton,
authorizeInput, confirmationDialogOKButton, authorizeInput, confirmationDialogOKButton, copyPasteModeButton,
emailInput, emailInput,
formError, formError,
getFirstVisibleStatus, getNthStatus, getOpacity, getFirstVisibleStatus, getNthStatus, getOpacity,
@ -9,10 +9,10 @@ import {
homeNavButton, homeNavButton,
instanceInput, instanceInput,
logInToInstanceLink, logInToInstanceLink,
mastodonLogInButton, mastodonLogInButton, oauthCodeInput,
passwordInput, reload, passwordInput, reload,
settingsButton, settingsButton,
sleep sleep, submitOauthButton
} from '../utils' } from '../utils'
import { loginAsFoobar } from '../roles' import { loginAsFoobar } from '../roles'
@ -96,3 +96,13 @@ test('Logs in, refreshes, then logs out', async t => {
.click(homeNavButton) .click(homeNavButton)
.expect(getOpacity('.hidden-from-ssr')()).eql('1') .expect(getOpacity('.hidden-from-ssr')()).eql('1')
}) })
test('Shows error when entering only oauth code in basic mode', async t => {
await t
.click(logInToInstanceLink)
.click(copyPasteModeButton)
.typeText(oauthCodeInput, 'blahblahblah')
.click(submitOauthButton)
.expect(formError.exists).ok()
.expect(formError.innerText).contains('You must log in to an instance first')
})

View file

@ -29,6 +29,8 @@ export const emailInput = $('input#user_email')
export const passwordInput = $('input#user_password') export const passwordInput = $('input#user_password')
export const authorizeInput = $('button[type=submit]:not(.negative)') export const authorizeInput = $('button[type=submit]:not(.negative)')
export const logInToInstanceLink = $('a[href="/settings/instances/add"]') export const logInToInstanceLink = $('a[href="/settings/instances/add"]')
export const copyPasteModeButton = $('.copy-paste-mode-button')
export const oauthCodeInput = $('#oauthCodeInput')
export const searchInput = $('.search-input') export const searchInput = $('.search-input')
export const postStatusButton = $('.compose-box-button') export const postStatusButton = $('.compose-box-button')
export const showMoreButton = $('.more-items-header button') export const showMoreButton = $('.more-items-header button')
@ -39,6 +41,7 @@ export const accountProfileFollowButton = $('.account-profile .account-profile-f
export const goBackButton = $('.dynamic-page-go-back') export const goBackButton = $('.dynamic-page-go-back')
export const accountProfileMoreOptionsButton = $('.account-profile-more-options button') export const accountProfileMoreOptionsButton = $('.account-profile-more-options button')
export const addInstanceButton = $('#submitButton') export const addInstanceButton = $('#submitButton')
export const submitOauthButton = $('#submitOauthButton')
export const mastodonLogInButton = $('button[type="submit"]') export const mastodonLogInButton = $('button[type="submit"]')
export const followsButton = $('.account-profile-details > *:nth-child(2)') export const followsButton = $('.account-profile-details > *:nth-child(2)')
export const followersButton = $('.account-profile-details > *:nth-child(3)') export const followersButton = $('.account-profile-details > *:nth-child(3)')