Add one-click login if possible (#1043)

This commit is contained in:
Erik Michelson 2021-03-09 23:00:14 +01:00 committed by GitHub
parent a6c80ac1f0
commit 6d2dde477c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 216 additions and 53 deletions

View file

@ -85,7 +85,8 @@ SPDX-License-Identifier: CC-BY-SA-4.0
- The dark-mode is also applied to the read-only-view and can be toggled from there.
- Access tokens for the CLI and 3rd-party-clients can be managed in the user profile.
- Change editor font to "Fira Code"
- Note tags can be set as yaml-array in frontmatter
- Note tags can be set as yaml-array in frontmatter.
- If only one external login provider is configured, the sign-in button will directly link to it.
---

View file

@ -6,6 +6,7 @@
describe('Autocompletion', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
cy.get('.CodeMirror')
.click()

View file

@ -8,6 +8,7 @@ import { banner } from '../support/config'
describe('Banner', () => {
beforeEach(() => {
cy.loadConfig()
cy.visit('/')
expect(localStorage.getItem('bannerTimeStamp')).to.be.null
})

View file

@ -6,6 +6,7 @@
describe('Diagram codeblock ', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -9,6 +9,7 @@ import { branding } from '../support/config'
const title = 'This is a test title'
describe('Document Title', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
cy.get('.btn.active.btn-outline-secondary > i.fa-columns')
.should('exist')

View file

@ -5,6 +5,10 @@
*/
describe('Editor mode from URL parameter is used', () => {
beforeEach(() => {
cy.loadConfig()
})
it('mode view', () => {
cy.visitTestEditor('view')
cy.get('.splitter.left')

View file

@ -9,6 +9,7 @@ describe('Export', () => {
const testContent = `---\ntitle: ${ testTitle }\n---\nThis is some test content`
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
cy.codemirrorFill(testContent)
})

View file

@ -8,6 +8,7 @@ const imageUrl = 'http://example.com/non-existing.png'
describe('File upload', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -6,6 +6,7 @@
describe('Help Dialog', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -12,6 +12,7 @@ const findHljsCodeBlock = () => {
describe('Code', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -6,6 +6,7 @@
describe('History', () => {
beforeEach(() => {
cy.loadConfig()
cy.visit('/history')
})

View file

@ -6,6 +6,7 @@
describe('Import markdown file', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -7,6 +7,7 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
describe('Intro page', () => {
beforeEach(() => {
cy.loadConfig()
cy.intercept('/intro.md', 'test content')
cy.visit('/')
})

View file

@ -8,6 +8,7 @@ import { languages } from '../fixtures/languages'
describe('Languages', () => {
beforeEach(() => {
cy.loadConfig()
cy.visit('/')
})

View file

@ -8,6 +8,7 @@ import '../support/index'
describe('Links Intro', () => {
beforeEach(() => {
cy.loadConfig()
cy.visit('/')
})
@ -53,13 +54,6 @@ describe('Links Intro', () => {
cy.url()
.should('include', '/new')
})
it('Sign In', () => {
cy.get('.btn-success.btn-sm')
.click()
cy.url()
.should('include', '/login')
})
})
describe('Menu Buttons logged in', () => {
@ -83,7 +77,7 @@ describe('Links Intro', () => {
.should('include', '/features')
})
it('Features', () => {
it('Profile', () => {
cy.get('a.dropdown-item > i.fa-user')
.click()
cy.url()

View file

@ -6,6 +6,7 @@
describe('Link gets replaced with embedding: ', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -10,6 +10,7 @@ describe('The status bar text length info', () => {
const tooMuchTestContent = `${ dangerTestContent }a`
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -6,6 +6,7 @@
describe('profile page', () => {
beforeEach(() => {
cy.loadConfig()
cy.intercept({
url: '/api/v2/tokens',
method: 'GET'

View file

@ -6,6 +6,7 @@
describe('Quote extra tags', function () {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -6,6 +6,7 @@
describe('Short code gets replaced or rendered: ', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -0,0 +1,114 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
const authProvidersDisabled = {
facebook: false,
github: false,
twitter: false,
gitlab: false,
dropbox: false,
ldap: false,
google: false,
saml: false,
oauth2: false,
internal: false,
openid: false
}
const initLoggedOutTestWithCustomAuthProviders = (cy: Cypress.cy, enabledProviders: Partial<typeof authProvidersDisabled>) => {
cy.loadConfig({
authProviders: {
...authProvidersDisabled,
...enabledProviders
}
})
cy.visit('/')
cy.logout()
}
describe('When logged-in, ', () => {
it('sign-in button is hidden', () => {
cy.loadConfig()
cy.visit('/')
cy.get('[data-cy=sign-in-button]')
.should('not.exist')
})
})
describe('When logged-out ', () => {
describe('and no auth-provider is enabled, ', () => {
it('sign-in button is hidden', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {})
cy.get('[data-cy=sign-in-button]')
.should('not.exist')
})
})
describe('and an interactive auth-provider is enabled, ', () => {
it('sign-in button points to login route: internal', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {
internal: true
})
cy.get('[data-cy=sign-in-button]')
.should('be.visible')
.should('have.attr', 'href', '/login')
})
it('sign-in button points to login route: ldap', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {
ldap: true
})
cy.get('[data-cy=sign-in-button]')
.should('be.visible')
.should('have.attr', 'href', '/login')
})
it('sign-in button points to login route: openid', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {
openid: true
})
cy.get('[data-cy=sign-in-button]')
.should('be.visible')
.should('have.attr', 'href', '/login')
})
})
describe('and only one one-click auth-provider is enabled, ', () => {
it('sign-in button points to auth-provider', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {
saml: true
})
cy.get('[data-cy=sign-in-button]')
.should('be.visible')
// The absolute URL is used because it is defined as API base URL absolute.
.should('have.attr', 'href', 'http://127.0.0.1:3001/api/v2/auth/saml')
})
})
describe('and multiple one-click auth-providers are enabled, ', () => {
it('sign-in button points to login route', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {
saml: true,
github: true
})
cy.get('[data-cy=sign-in-button]')
.should('be.visible')
.should('have.attr', 'href', '/login')
})
})
describe('and one-click- as well as interactive auth-providers are enabled, ', () => {
it('sign-in button points to login route', () => {
initLoggedOutTestWithCustomAuthProviders(cy, {
saml: true,
internal: true
})
cy.get('[data-cy=sign-in-button]')
.should('be.visible')
.should('have.attr', 'href', '/login')
})
})
})

View file

@ -9,6 +9,7 @@ describe('Toolbar Buttons', () => {
const testLink = 'http://hedgedoc.org'
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
cy.get('.CodeMirror')

View file

@ -6,6 +6,7 @@
describe('YAML Array for deprecated syntax of document tags in frontmatter', () => {
beforeEach(() => {
cy.loadConfig()
cy.visitTestEditor()
})

View file

@ -4,6 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare namespace Cypress {
interface Chainable {
loadConfig(): Chainable<Window>
}
}
export const banner = {
text: 'This is the mock banner call',
timestamp: '2020-05-22T20:46:08.962Z'
@ -14,47 +20,54 @@ export const branding = {
logo: '/img/acme.png'
}
beforeEach(() => {
cy.intercept('/api/v2/config', {
export const authProviders = {
facebook: true,
github: true,
twitter: true,
gitlab: true,
dropbox: true,
ldap: true,
google: true,
saml: true,
oauth2: true,
internal: true,
openid: true
}
export const config = {
allowAnonymous: true,
authProviders: authProviders,
branding: branding,
banner: banner,
customAuthNames: {
ldap: 'FooBar',
oauth2: 'Olaf2',
saml: 'aufSAMLn.de'
},
maxDocumentLength: 200,
specialLinks: {
privacy: 'https://example.com/privacy',
termsOfUse: 'https://example.com/termsOfUse',
imprint: 'https://example.com/imprint'
},
plantumlServer: 'http://mock-plantuml.local',
version: {
version: 'mock',
sourceCodeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
issueTrackerUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
},
'iframeCommunication': {
'editorOrigin': 'http://127.0.0.1:3001',
'rendererOrigin': 'http://127.0.0.1:3001'
}
}
Cypress.Commands.add('loadConfig', (additionalConfig?: Partial<typeof config>) => {
return cy.intercept('/api/v2/config', {
statusCode: 200,
body: {
allowAnonymous: true,
authProviders: {
facebook: true,
github: true,
twitter: true,
gitlab: true,
dropbox: true,
ldap: true,
google: true,
saml: true,
oauth2: true,
email: true,
openid: true
},
branding: branding,
banner: banner,
customAuthNames: {
ldap: 'FooBar',
oauth2: 'Olaf2',
saml: 'aufSAMLn.de'
},
maxDocumentLength: 200,
specialLinks: {
privacy: 'https://example.com/privacy',
termsOfUse: 'https://example.com/termsOfUse',
imprint: 'https://example.com/imprint'
},
plantumlServer: 'http://mock-plantuml.local',
version: {
version: 'mock',
sourceCodeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
issueTrackerUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
},
'iframeCommunication': {
'editorOrigin': 'http://127.0.0.1:3001',
'rendererOrigin': 'http://127.0.0.1:3001'
}
...config,
...additionalConfig
}
})
})

View file

@ -7,6 +7,8 @@
import { RegisterError } from '../../components/register-page/register-page'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
export const INTERACTIVE_LOGIN_METHODS = ['internal', 'ldap', 'openid']
export const doInternalLogin = async (username: string, password: string): Promise<void> => {
const response = await fetch(getApiUrl() + '/auth/internal', {
...defaultFetchConfig,

View file

@ -4,7 +4,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import equal from 'fast-deep-equal'
import React, { useMemo } from 'react'
import { Button } from 'react-bootstrap'
import { ButtonProps } from 'react-bootstrap/Button'
import { Trans, useTranslation } from 'react-i18next'
@ -12,16 +13,31 @@ import { useSelector } from 'react-redux'
import { LinkContainer } from 'react-router-bootstrap'
import { ApplicationState } from '../../../redux'
import { ShowIf } from '../../common/show-if/show-if'
import { getApiUrl } from '../../../api/utils'
import { INTERACTIVE_LOGIN_METHODS } from '../../../api/auth'
export type SignInButtonProps = Omit<ButtonProps, 'href'>
export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => {
const { t } = useTranslation()
const anyAuthProviderActive = useSelector((state: ApplicationState) => Object.values(state.config.authProviders)
.includes(true))
const authProviders = useSelector((state: ApplicationState) => state.config.authProviders, equal)
const authEnabled = useMemo(() => Object.values(authProviders).includes(true), [authProviders])
const loginLink = useMemo(() => {
const activeProviders = Object.entries(authProviders)
.filter((entry: [string, boolean]) => entry[1])
.map(entry => entry[0])
const activeOneClickProviders = activeProviders.filter(entry => !INTERACTIVE_LOGIN_METHODS.includes(entry))
if (activeProviders.length === 1 && activeOneClickProviders.length === 1) {
return `${ getApiUrl() }/auth/${ activeOneClickProviders[0] }`
}
return '/login'
}, [authProviders])
return (
<ShowIf condition={ anyAuthProviderActive }>
<LinkContainer to="/login" title={ t('login.signIn') }>
<ShowIf condition={ authEnabled }>
<LinkContainer to={ loginLink } title={ t('login.signIn') }>
<Button
data-cy={ 'sign-in-button' }
variant={ variant || 'success' }