diff --git a/CHANGELOG.md b/CHANGELOG.md index f7da5af2a..db93c73b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. --- diff --git a/cypress/integration/autocompletion.spec.ts b/cypress/integration/autocompletion.spec.ts index 37e79e83a..64b2d869d 100644 --- a/cypress/integration/autocompletion.spec.ts +++ b/cypress/integration/autocompletion.spec.ts @@ -6,6 +6,7 @@ describe('Autocompletion', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() cy.get('.CodeMirror') .click() diff --git a/cypress/integration/banner.spec.ts b/cypress/integration/banner.spec.ts index 6dcbcd34f..bbb136c66 100644 --- a/cypress/integration/banner.spec.ts +++ b/cypress/integration/banner.spec.ts @@ -8,6 +8,7 @@ import { banner } from '../support/config' describe('Banner', () => { beforeEach(() => { + cy.loadConfig() cy.visit('/') expect(localStorage.getItem('bannerTimeStamp')).to.be.null }) diff --git a/cypress/integration/diagrams.spec.ts b/cypress/integration/diagrams.spec.ts index 186810da3..87ad5fb02 100644 --- a/cypress/integration/diagrams.spec.ts +++ b/cypress/integration/diagrams.spec.ts @@ -6,6 +6,7 @@ describe('Diagram codeblock ', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/documentTitle.spec.ts b/cypress/integration/documentTitle.spec.ts index 8d7745708..807332426 100644 --- a/cypress/integration/documentTitle.spec.ts +++ b/cypress/integration/documentTitle.spec.ts @@ -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') diff --git a/cypress/integration/editorMode.spec.ts b/cypress/integration/editorMode.spec.ts index 906b2d583..ae5e5c4a6 100644 --- a/cypress/integration/editorMode.spec.ts +++ b/cypress/integration/editorMode.spec.ts @@ -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') diff --git a/cypress/integration/export.spec.ts b/cypress/integration/export.spec.ts index b76a44160..b93a57f9f 100644 --- a/cypress/integration/export.spec.ts +++ b/cypress/integration/export.spec.ts @@ -9,6 +9,7 @@ describe('Export', () => { const testContent = `---\ntitle: ${ testTitle }\n---\nThis is some test content` beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() cy.codemirrorFill(testContent) }) diff --git a/cypress/integration/fileUpload.spec.ts b/cypress/integration/fileUpload.spec.ts index 7cc136988..f8ea47132 100644 --- a/cypress/integration/fileUpload.spec.ts +++ b/cypress/integration/fileUpload.spec.ts @@ -8,6 +8,7 @@ const imageUrl = 'http://example.com/non-existing.png' describe('File upload', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/helpDialog.spec.ts b/cypress/integration/helpDialog.spec.ts index 1c9c73548..355d9b030 100644 --- a/cypress/integration/helpDialog.spec.ts +++ b/cypress/integration/helpDialog.spec.ts @@ -6,6 +6,7 @@ describe('Help Dialog', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/highlightedCodeBlock.spec.ts b/cypress/integration/highlightedCodeBlock.spec.ts index ecc3463c2..5ea0d6af1 100644 --- a/cypress/integration/highlightedCodeBlock.spec.ts +++ b/cypress/integration/highlightedCodeBlock.spec.ts @@ -12,6 +12,7 @@ const findHljsCodeBlock = () => { describe('Code', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/history.spec.ts b/cypress/integration/history.spec.ts index 5ae951eb8..8711c3dee 100644 --- a/cypress/integration/history.spec.ts +++ b/cypress/integration/history.spec.ts @@ -6,6 +6,7 @@ describe('History', () => { beforeEach(() => { + cy.loadConfig() cy.visit('/history') }) diff --git a/cypress/integration/import.spec.ts b/cypress/integration/import.spec.ts index df75f90de..1c0812337 100644 --- a/cypress/integration/import.spec.ts +++ b/cypress/integration/import.spec.ts @@ -6,6 +6,7 @@ describe('Import markdown file', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/intro.spec.ts b/cypress/integration/intro.spec.ts index a3517d97e..ef153b942 100644 --- a/cypress/integration/intro.spec.ts +++ b/cypress/integration/intro.spec.ts @@ -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('/') }) diff --git a/cypress/integration/language.spec.ts b/cypress/integration/language.spec.ts index 50c398ce8..1aa0b8964 100644 --- a/cypress/integration/language.spec.ts +++ b/cypress/integration/language.spec.ts @@ -8,6 +8,7 @@ import { languages } from '../fixtures/languages' describe('Languages', () => { beforeEach(() => { + cy.loadConfig() cy.visit('/') }) diff --git a/cypress/integration/link.spec.ts b/cypress/integration/link.spec.ts index da5c4552b..22df80efd 100644 --- a/cypress/integration/link.spec.ts +++ b/cypress/integration/link.spec.ts @@ -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() diff --git a/cypress/integration/linkEmbedder.spec.ts b/cypress/integration/linkEmbedder.spec.ts index 6b988bd2e..d3fb92e00 100644 --- a/cypress/integration/linkEmbedder.spec.ts +++ b/cypress/integration/linkEmbedder.spec.ts @@ -6,6 +6,7 @@ describe('Link gets replaced with embedding: ', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/maxLength.spec.ts b/cypress/integration/maxLength.spec.ts index 1aca4b986..8fee6cb7f 100644 --- a/cypress/integration/maxLength.spec.ts +++ b/cypress/integration/maxLength.spec.ts @@ -10,6 +10,7 @@ describe('The status bar text length info', () => { const tooMuchTestContent = `${ dangerTestContent }a` beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/profile.spec.ts b/cypress/integration/profile.spec.ts index e5183db5f..ecbc9e0cd 100644 --- a/cypress/integration/profile.spec.ts +++ b/cypress/integration/profile.spec.ts @@ -6,6 +6,7 @@ describe('profile page', () => { beforeEach(() => { + cy.loadConfig() cy.intercept({ url: '/api/v2/tokens', method: 'GET' diff --git a/cypress/integration/quote-extra.spec.ts b/cypress/integration/quote-extra.spec.ts index 29b0d25ae..a5e0aa19f 100644 --- a/cypress/integration/quote-extra.spec.ts +++ b/cypress/integration/quote-extra.spec.ts @@ -6,6 +6,7 @@ describe('Quote extra tags', function () { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/shortcodes.spec.ts b/cypress/integration/shortcodes.spec.ts index 719c3ab9b..a1bf85a6c 100644 --- a/cypress/integration/shortcodes.spec.ts +++ b/cypress/integration/shortcodes.spec.ts @@ -6,6 +6,7 @@ describe('Short code gets replaced or rendered: ', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/integration/signInButton.spec.ts b/cypress/integration/signInButton.spec.ts new file mode 100644 index 000000000..f3e7030d5 --- /dev/null +++ b/cypress/integration/signInButton.spec.ts @@ -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) => { + 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') + }) + }) +}) diff --git a/cypress/integration/toolbar.spec.ts b/cypress/integration/toolbar.spec.ts index 3053c9c0f..d2ea5417d 100644 --- a/cypress/integration/toolbar.spec.ts +++ b/cypress/integration/toolbar.spec.ts @@ -9,6 +9,7 @@ describe('Toolbar Buttons', () => { const testLink = 'http://hedgedoc.org' beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() cy.get('.CodeMirror') diff --git a/cypress/integration/yamlArrayDeprecationMessage.spec.ts b/cypress/integration/yamlArrayDeprecationMessage.spec.ts index 681a711ef..7e1a4c303 100644 --- a/cypress/integration/yamlArrayDeprecationMessage.spec.ts +++ b/cypress/integration/yamlArrayDeprecationMessage.spec.ts @@ -6,6 +6,7 @@ describe('YAML Array for deprecated syntax of document tags in frontmatter', () => { beforeEach(() => { + cy.loadConfig() cy.visitTestEditor() }) diff --git a/cypress/support/config.ts b/cypress/support/config.ts index bac8e4f95..a8fd9f391 100644 --- a/cypress/support/config.ts +++ b/cypress/support/config.ts @@ -4,6 +4,12 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +declare namespace Cypress { + interface Chainable { + loadConfig(): Chainable + } +} + 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) => { + 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 } }) }) diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts index 0b2994bbf..7228ae302 100644 --- a/src/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -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 => { const response = await fetch(getApiUrl() + '/auth/internal', { ...defaultFetchConfig, diff --git a/src/components/landing-layout/navigation/sign-in-button.tsx b/src/components/landing-layout/navigation/sign-in-button.tsx index 743ea7180..3d6909f5f 100644 --- a/src/components/landing-layout/navigation/sign-in-button.tsx +++ b/src/components/landing-layout/navigation/sign-in-button.tsx @@ -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 export const SignInButton: React.FC = ({ 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 ( - - + +