diff --git a/cypress/integration/signInButton.spec.ts b/cypress/integration/signInButton.spec.ts index 062da9c82..b7823409c 100644 --- a/cypress/integration/signInButton.spec.ts +++ b/cypress/integration/signInButton.spec.ts @@ -14,8 +14,7 @@ const authProvidersDisabled = { google: false, saml: false, oauth2: false, - internal: false, - openid: false + local: false } const initLoggedOutTestWithCustomAuthProviders = ( @@ -50,7 +49,7 @@ describe('When logged-out ', () => { describe('and an interactive auth-provider is enabled, ', () => { it('sign-in button points to login route: internal', () => { initLoggedOutTestWithCustomAuthProviders(cy, { - internal: true + local: true }) cy.getById('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') }) @@ -61,13 +60,6 @@ describe('When logged-out ', () => { }) cy.getById('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.getById('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') - }) }) describe('and only one one-click auth-provider is enabled, ', () => { @@ -78,7 +70,7 @@ describe('When logged-out ', () => { cy.getById('sign-in-button') .should('be.visible') // The absolute URL is used because it is defined as API base URL absolute. - .should('have.attr', 'href', '/mock-backend/api/private/auth/saml') + .should('have.attr', 'href', '/mock-backend/auth/saml') }) }) @@ -96,7 +88,7 @@ describe('When logged-out ', () => { it('sign-in button points to login route', () => { initLoggedOutTestWithCustomAuthProviders(cy, { saml: true, - internal: true + local: true }) cy.getById('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') }) diff --git a/cypress/support/config.ts b/cypress/support/config.ts index 56614d597..040a576ec 100644 --- a/cypress/support/config.ts +++ b/cypress/support/config.ts @@ -25,8 +25,7 @@ export const authProviders = { google: true, saml: true, oauth2: true, - internal: true, - openid: true + local: true } export const config = { diff --git a/locales/en.json b/locales/en.json index b0147ce88..7ec833274 100644 --- a/locales/en.json +++ b/locales/en.json @@ -494,13 +494,16 @@ "signInVia": "Sign in via {{service}}", "signIn": "Sign In", "signOut": "Sign Out", + "logoutFailed": "There was an error logging you out.\nClose your browser window to ensure session data is removed.", "auth": { "email": "Email", "password": "Password", "username": "Username", "error": { "openIdLogin": "Invalid OpenID provided", - "usernamePassword": "Invalid username or password" + "usernamePassword": "Invalid username or password", + "loginDisabled": "The login is disabled", + "other": "There was an error logging you in." } }, "register": { @@ -510,6 +513,7 @@ "passwordInfo": "Choose a unique and secure password. It must contain at least 8 characters.", "infoTermsPrivacy": "With the registration of my user account I agree to the following terms:", "error": { + "registrationDisabled": "The registration is disabled", "usernameExisting": "There is already an account with this username.", "other": "There was an error while registering your account. Just try it again." } diff --git a/public/mock-backend/api/private/config b/public/mock-backend/api/private/config index ebacd27cc..062652494 100644 --- a/public/mock-backend/api/private/config +++ b/public/mock-backend/api/private/config @@ -10,8 +10,7 @@ "google": true, "saml": true, "oauth2": true, - "internal": true, - "openid": true + "local": true }, "allowRegister": true, "branding": { diff --git a/public/mock-backend/api/private/me-get b/public/mock-backend/api/private/me-get index 7d2100883..c21c60a1a 100644 --- a/public/mock-backend/api/private/me-get +++ b/public/mock-backend/api/private/me-get @@ -1,7 +1,6 @@ { - "id": "mockUser", + "username": "mockUser", "photo": "/mock-backend/public/img/avatar.png", - "name": "Test", - "status": "ok", - "provider": "internal" + "displayName": "Test", + "email": "mock@hedgedoc.dev" } diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts index e02419509..e3348cbaa 100644 --- a/src/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -3,62 +3,31 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ - -import { RegisterError } from '../../components/register-page/register-page' import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' -export const INTERACTIVE_LOGIN_METHODS = ['internal', 'ldap', 'openid'] +export const INTERACTIVE_LOGIN_METHODS = ['local', 'ldap'] -export const doInternalLogin = async (username: string, password: string): Promise => { - const response = await fetch(getApiUrl() + 'auth/internal', { +export enum AuthError { + INVALID_CREDENTIALS = 'invalidCredentials', + LOGIN_DISABLED = 'loginDisabled', + OPENID_ERROR = 'openIdError', + OTHER = 'other' +} + +export enum RegisterError { + USERNAME_EXISTING = 'usernameExisting', + REGISTRATION_DISABLED = 'registrationDisabled', + OTHER = 'other' +} + +/** + * Requests to logout the current user. + * @throws Error if logout is not possible. + */ +export const doLogout = async (): Promise => { + const response = await fetch(getApiUrl() + 'auth/logout', { ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - username: username, - password: password - }) - }) - - expectResponseCode(response) -} - -export const doInternalRegister = async (username: string, password: string): Promise => { - const response = await fetch(getApiUrl() + 'auth/register', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - username: username, - password: password - }) - }) - - if (response.status === 409) { - throw new Error(RegisterError.USERNAME_EXISTING) - } - - expectResponseCode(response) -} - -export const doLdapLogin = async (username: string, password: string): Promise => { - const response = await fetch(getApiUrl() + 'auth/ldap', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - username: username, - password: password - }) - }) - - expectResponseCode(response) -} - -export const doOpenIdLogin = async (openId: string): Promise => { - const response = await fetch(getApiUrl() + 'auth/openid', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - openId: openId - }) + method: 'DELETE' }) expectResponseCode(response) diff --git a/src/api/auth/ldap.ts b/src/api/auth/ldap.ts new file mode 100644 index 000000000..41ed218b9 --- /dev/null +++ b/src/api/auth/ldap.ts @@ -0,0 +1,25 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' + +/** + * Requests to login a user via LDAP credentials. + * @param username The username with which to try the login. + * @param password The password of the user. + */ +export const doLdapLogin = async (username: string, password: string): Promise => { + const response = await fetch(getApiUrl() + 'auth/ldap', { + ...defaultFetchConfig, + method: 'POST', + body: JSON.stringify({ + username: username, + password: password + }) + }) + + expectResponseCode(response) +} diff --git a/src/api/auth/local.ts b/src/api/auth/local.ts new file mode 100644 index 000000000..578238619 --- /dev/null +++ b/src/api/auth/local.ts @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' +import { AuthError, RegisterError } from './index' + +/** + * Requests to do a local login with a provided username and password. + * @param username The username for which the login should be tried. + * @param password The password which should be used to login. + * @throws {AuthError.INVALID_CREDENTIALS} when the username or password is wrong. + * @throws {AuthError.LOGIN_DISABLED} when the local login is disabled on the backend. + */ +export const doLocalLogin = async (username: string, password: string): Promise => { + const response = await fetch(getApiUrl() + 'auth/local/login', { + ...defaultFetchConfig, + method: 'POST', + body: JSON.stringify({ + username, + password + }) + }) + + if (response.status === 400) { + throw new Error(AuthError.LOGIN_DISABLED) + } + + if (response.status === 401) { + throw new Error(AuthError.INVALID_CREDENTIALS) + } + + expectResponseCode(response, 201) +} + +/** + * Requests to register a new local user in the backend. + * @param username The username of the new user. + * @param displayName The display name of the new user. + * @param password The password of the new user. + * @throws {RegisterError.USERNAME_EXISTING} when there is already an existing user with the same user name. + * @throws {RegisterError.REGISTRATION_DISABLED} when the registration of local users has been disabled on the backend. + */ +export const doLocalRegister = async (username: string, displayName: string, password: string): Promise => { + const response = await fetch(getApiUrl() + 'auth/local', { + ...defaultFetchConfig, + method: 'POST', + body: JSON.stringify({ + username, + displayName, + password + }) + }) + + if (response.status === 409) { + throw new Error(RegisterError.USERNAME_EXISTING) + } + + if (response.status === 400) { + throw new Error(RegisterError.REGISTRATION_DISABLED) + } + + expectResponseCode(response) +} + +/** + * Requests to update the user's current password to a new one. + * @param currentPassword The current password of the user for confirmation. + * @param newPassword The new password of the user. + * @throws {AuthError.INVALID_CREDENTIALS} when the current password is wrong. + * @throws {AuthError.LOGIN_DISABLED} when local login is disabled on the backend. + */ +export const doLocalPasswordChange = async (currentPassword: string, newPassword: string): Promise => { + const response = await fetch(getApiUrl() + 'auth/local', { + ...defaultFetchConfig, + method: 'PUT', + body: JSON.stringify({ + currentPassword, + newPassword + }) + }) + + if (response.status === 401) { + throw new Error(AuthError.INVALID_CREDENTIALS) + } + + if (response.status === 400) { + throw new Error(AuthError.LOGIN_DISABLED) + } + + expectResponseCode(response) +} diff --git a/src/api/config/types.d.ts b/src/api/config/types.d.ts index 73ae42f66..d8407b6f9 100644 --- a/src/api/config/types.d.ts +++ b/src/api/config/types.d.ts @@ -46,8 +46,7 @@ export interface AuthProvidersState { google: boolean saml: boolean oauth2: boolean - internal: boolean - openid: boolean + local: boolean } export interface CustomAuthNames { diff --git a/src/api/me/index.ts b/src/api/me/index.ts index 3f29defef..2727eeebb 100644 --- a/src/api/me/index.ts +++ b/src/api/me/index.ts @@ -4,16 +4,21 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { UserResponse } from '../users/types' +import type { UserInfoDto } from '../users/types' import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' import { isMockMode } from '../../utils/test-modes' -export const getMe = async (): Promise => { +/** + * Returns metadata about the currently signed-in user from the API. + * @throws Error when the user is not signed-in. + * @return The user metadata. + */ +export const getMe = async (): Promise => { const response = await fetch(getApiUrl() + `me${isMockMode() ? '-get' : ''}`, { ...defaultFetchConfig }) expectResponseCode(response) - return (await response.json()) as UserResponse + return (await response.json()) as UserInfoDto } export const updateDisplayName = async (displayName: string): Promise => { @@ -28,19 +33,6 @@ export const updateDisplayName = async (displayName: string): Promise => { expectResponseCode(response) } -export const changePassword = async (oldPassword: string, newPassword: string): Promise => { - const response = await fetch(getApiUrl() + 'me/password', { - ...defaultFetchConfig, - method: 'POST', - body: JSON.stringify({ - oldPassword, - newPassword - }) - }) - - expectResponseCode(response) -} - export const deleteUser = async (): Promise => { const response = await fetch(getApiUrl() + 'me', { ...defaultFetchConfig, diff --git a/src/api/users/types.d.ts b/src/api/users/types.d.ts index 0d0ab8882..4fa22f6d6 100644 --- a/src/api/users/types.d.ts +++ b/src/api/users/types.d.ts @@ -14,7 +14,7 @@ export interface UserResponse { } export interface UserInfoDto { - userName: string + username: string displayName: string photo: string email: string diff --git a/src/components/common/fields/current-password-field.tsx b/src/components/common/fields/current-password-field.tsx new file mode 100644 index 000000000..0f6065f01 --- /dev/null +++ b/src/components/common/fields/current-password-field.tsx @@ -0,0 +1,35 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React from 'react' +import type { CommonFieldProps } from './fields' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +/** + * Renders an input field for the current password when changing passwords. + * @param onChange Hook that is called when the entered password changes. + */ +export const CurrentPasswordField: React.FC = ({ onChange }) => { + const { t } = useTranslation() + + return ( + + + + + + + ) +} diff --git a/src/components/common/fields/display-name-field.tsx b/src/components/common/fields/display-name-field.tsx new file mode 100644 index 000000000..ee3078b82 --- /dev/null +++ b/src/components/common/fields/display-name-field.tsx @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useMemo } from 'react' +import type { CommonFieldProps } from './fields' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +interface DisplayNameFieldProps extends CommonFieldProps { + initialValue?: string +} + +/** + * Renders an input field for the display name when registering. + * @param onChange Hook that is called when the entered display name changes. + * @param value The currently entered display name. + * @param initialValue The initial input field value. + */ +export const DisplayNameField: React.FC = ({ onChange, value, initialValue }) => { + const { t } = useTranslation() + + const isValid = useMemo(() => { + return value.trim() !== '' && value !== initialValue + }, [value, initialValue]) + + return ( + + + + + + + + + + ) +} diff --git a/src/components/common/fields/fields.ts b/src/components/common/fields/fields.ts new file mode 100644 index 000000000..67df47d90 --- /dev/null +++ b/src/components/common/fields/fields.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ChangeEvent } from 'react' + +export interface CommonFieldProps { + onChange: (event: ChangeEvent) => void + value: string +} diff --git a/src/components/common/fields/new-password-field.tsx b/src/components/common/fields/new-password-field.tsx new file mode 100644 index 000000000..6b584df92 --- /dev/null +++ b/src/components/common/fields/new-password-field.tsx @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useMemo } from 'react' +import type { CommonFieldProps } from './fields' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +/** + * Renders an input field for the new password when registering. + * @param onChange Hook that is called when the entered password changes. + * @param value The currently entered password. + */ +export const NewPasswordField: React.FC = ({ onChange, value }) => { + const { t } = useTranslation() + + const isValid = useMemo(() => { + return value.trim() !== '' && value.length >= 8 + }, [value]) + + return ( + + + + + + + + + + ) +} diff --git a/src/components/common/fields/password-again-field.tsx b/src/components/common/fields/password-again-field.tsx new file mode 100644 index 000000000..5e1d32e11 --- /dev/null +++ b/src/components/common/fields/password-again-field.tsx @@ -0,0 +1,51 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useMemo } from 'react' +import type { CommonFieldProps } from './fields' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +interface PasswordAgainFieldProps extends CommonFieldProps { + password: string +} + +/** + * Renders an input field for typing the new password again when registering. + * @param onChange Hook that is called when the entered retype of the password changes. + * @param value The currently entered retype of the password. + * @param password The password entered into the password input field. + */ +export const PasswordAgainField: React.FC = ({ onChange, value, password }) => { + const { t } = useTranslation() + + const isInvalid = useMemo(() => { + return value !== '' && password !== value + }, [password, value]) + + const isValid = useMemo(() => { + return password !== '' && password === value + }, [password, value]) + + return ( + + + + + + + ) +} diff --git a/src/components/common/fields/username-field.tsx b/src/components/common/fields/username-field.tsx new file mode 100644 index 000000000..fa37be8c7 --- /dev/null +++ b/src/components/common/fields/username-field.tsx @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useMemo } from 'react' +import type { CommonFieldProps } from './fields' +import { Form } from 'react-bootstrap' +import { Trans, useTranslation } from 'react-i18next' + +/** + * Renders an input field for the username when registering. + * @param onChange Hook that is called when the entered username changes. + * @param value The currently entered username. + */ +export const UsernameField: React.FC = ({ onChange, value }) => { + const { t } = useTranslation() + + const isValid = useMemo(() => { + return value?.trim() !== '' + }, [value]) + + return ( + + + + + + + + + + ) +} diff --git a/src/components/document-read-only-page/document-read-only-page-content.tsx b/src/components/document-read-only-page/document-read-only-page-content.tsx index c0d3c0f6b..662fd15c2 100644 --- a/src/components/document-read-only-page/document-read-only-page-content.tsx +++ b/src/components/document-read-only-page/document-read-only-page-content.tsx @@ -24,7 +24,7 @@ export const DocumentReadOnlyPageContent: React.FC = () => { return ( { const user = store.getState().user - return user ? user.name : 'Anonymous' + return user ? user.displayName : 'Anonymous' } const linkAndExtraTagHint = (editor: Editor): Promise => { diff --git a/src/components/landing-layout/navigation/sign-in-button.tsx b/src/components/landing-layout/navigation/sign-in-button.tsx index 9cd5b224b..804e6043a 100644 --- a/src/components/landing-layout/navigation/sign-in-button.tsx +++ b/src/components/landing-layout/navigation/sign-in-button.tsx @@ -10,15 +10,16 @@ import type { ButtonProps } from 'react-bootstrap/Button' import { Trans, useTranslation } from 'react-i18next' import { LinkContainer } from 'react-router-bootstrap' import { ShowIf } from '../../common/show-if/show-if' -import { getApiUrl } from '../../../api/utils' import { INTERACTIVE_LOGIN_METHODS } from '../../../api/auth' import { useApplicationState } from '../../../hooks/common/use-application-state' import { cypressId } from '../../../utils/cypress-attribute' +import { useBackendBaseUrl } from '../../../hooks/common/use-backend-base-url' export type SignInButtonProps = Omit export const SignInButton: React.FC = ({ variant, ...props }) => { const { t } = useTranslation() + const backendBaseUrl = useBackendBaseUrl() const authProviders = useApplicationState((state) => state.config.authProviders) const authEnabled = useMemo(() => Object.values(authProviders).includes(true), [authProviders]) @@ -29,10 +30,10 @@ export const SignInButton: React.FC = ({ variant, ...props }) const activeOneClickProviders = activeProviders.filter((entry) => !INTERACTIVE_LOGIN_METHODS.includes(entry)) if (activeProviders.length === 1 && activeOneClickProviders.length === 1) { - return `${getApiUrl()}auth/${activeOneClickProviders[0]}` + return `${backendBaseUrl}auth/${activeOneClickProviders[0]}` } return '/login' - }, [authProviders]) + }, [authProviders, backendBaseUrl]) return ( diff --git a/src/components/landing-layout/navigation/sign-out-dropdown-button.tsx b/src/components/landing-layout/navigation/sign-out-dropdown-button.tsx new file mode 100644 index 000000000..8499a3079 --- /dev/null +++ b/src/components/landing-layout/navigation/sign-out-dropdown-button.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useCallback } from 'react' +import { clearUser } from '../../../redux/user/methods' +import { cypressId } from '../../../utils/cypress-attribute' +import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' +import { Trans, useTranslation } from 'react-i18next' +import { Dropdown } from 'react-bootstrap' +import { doLogout } from '../../../api/auth' +import { showErrorNotification } from '../../../redux/ui-notifications/methods' + +/** + * Renders a sign-out button as a dropdown item for the user-dropdown. + */ +export const SignOutDropdownButton: React.FC = () => { + useTranslation() + + const onSignOut = useCallback(() => { + clearUser() + doLogout().catch(showErrorNotification('login.logoutFailed')) + }, []) + + return ( + + + + + ) +} diff --git a/src/components/landing-layout/navigation/user-dropdown.tsx b/src/components/landing-layout/navigation/user-dropdown.tsx index 0375e3d7e..2c3bdfa3a 100644 --- a/src/components/landing-layout/navigation/user-dropdown.tsx +++ b/src/components/landing-layout/navigation/user-dropdown.tsx @@ -8,11 +8,11 @@ import React from 'react' import { Dropdown } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { LinkContainer } from 'react-router-bootstrap' -import { clearUser } from '../../../redux/user/methods' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { UserAvatar } from '../../common/user-avatar/user-avatar' import { useApplicationState } from '../../../hooks/common/use-application-state' import { cypressId } from '../../../utils/cypress-attribute' +import { SignOutDropdownButton } from './sign-out-dropdown-button' export const UserDropdown: React.FC = () => { useTranslation() @@ -25,7 +25,7 @@ export const UserDropdown: React.FC = () => { return ( - + @@ -41,15 +41,7 @@ export const UserDropdown: React.FC = () => { - { - clearUser() - }} - {...cypressId('user-dropdown-sign-out-button')}> - - - + ) diff --git a/src/components/login-page/auth/auth-error/auth-error.tsx b/src/components/login-page/auth/auth-error/auth-error.tsx new file mode 100644 index 000000000..0ad4e2c09 --- /dev/null +++ b/src/components/login-page/auth/auth-error/auth-error.tsx @@ -0,0 +1,41 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React, { useMemo } from 'react' +import { AuthError as AuthErrorType } from '../../../../api/auth' +import { Trans, useTranslation } from 'react-i18next' +import { Alert } from 'react-bootstrap' + +export interface AuthErrorProps { + error?: AuthErrorType +} + +/** + * Renders an error message for auth fields when an error is present. + * @param error The error to render. Can be {@code undefined} when no error should be rendered. + */ +export const AuthError: React.FC = ({ error }) => { + useTranslation() + + const errorMessageI18nKey = useMemo(() => { + switch (error) { + case AuthErrorType.INVALID_CREDENTIALS: + return 'login.auth.error.usernamePassword' + case AuthErrorType.LOGIN_DISABLED: + return 'login.auth.error.loginDisabled' + case AuthErrorType.OPENID_ERROR: + return 'login.auth.error.openIdLogin' + default: + return 'login.auth.error.other' + } + }, [error]) + + return ( + + + + ) +} diff --git a/src/components/login-page/auth/fields/fields.ts b/src/components/login-page/auth/fields/fields.ts new file mode 100644 index 000000000..5bd9892e0 --- /dev/null +++ b/src/components/login-page/auth/fields/fields.ts @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ChangeEvent } from 'react' + +export interface AuthFieldProps { + onChange: (event: ChangeEvent) => void + invalid: boolean +} diff --git a/src/components/login-page/auth/fields/openid-field.tsx b/src/components/login-page/auth/fields/openid-field.tsx new file mode 100644 index 000000000..433f553ea --- /dev/null +++ b/src/components/login-page/auth/fields/openid-field.tsx @@ -0,0 +1,29 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React from 'react' +import { Form } from 'react-bootstrap' +import type { AuthFieldProps } from './fields' + +/** + * Renders an input field for the OpenID URL. + * @param onChange Hook that is called when the entered OpenID URL changes. + * @param invalid True when the entered OpenID URL is invalid, false otherwise. + */ +export const OpenidField: React.FC = ({ onChange, invalid }) => { + return ( + + + + ) +} diff --git a/src/components/login-page/auth/fields/password-field.tsx b/src/components/login-page/auth/fields/password-field.tsx new file mode 100644 index 000000000..a9b006f95 --- /dev/null +++ b/src/components/login-page/auth/fields/password-field.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React from 'react' +import type { AuthFieldProps } from './fields' +import { Form } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' + +/** + * Renders an input field for the password of a user. + * @param onChange Hook that is called when the entered password changes. + * @param invalid True when the entered password is invalid, false otherwise. + */ +export const PasswordField: React.FC = ({ onChange, invalid }) => { + const { t } = useTranslation() + + return ( + + + + ) +} diff --git a/src/components/login-page/auth/fields/username-field.tsx b/src/components/login-page/auth/fields/username-field.tsx new file mode 100644 index 000000000..567f8f7f7 --- /dev/null +++ b/src/components/login-page/auth/fields/username-field.tsx @@ -0,0 +1,33 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import React from 'react' +import type { AuthFieldProps } from './fields' +import { Form } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' + +/** + * Renders an input field for a username. + * @param onChange Hook that is called when the input is changed. + * @param invalid True indicates that the username is invalid, false otherwise. + */ +export const UsernameField: React.FC = ({ onChange, invalid }) => { + const { t } = useTranslation() + + return ( + + + + ) +} diff --git a/src/components/login-page/auth/utils.ts b/src/components/login-page/auth/utils.ts index 5bd2c91c1..63ea6c004 100644 --- a/src/components/login-page/auth/utils.ts +++ b/src/components/login-page/auth/utils.ts @@ -6,13 +6,22 @@ import { getMe } from '../../../api/me' import { setUser } from '../../../redux/user/methods' +import { LoginProvider } from '../../../redux/user/types' +/** + * Fetches metadata about the currently signed-in user from the API and stores it into the redux. + */ export const fetchAndSetUser: () => Promise = async () => { - const me = await getMe() - setUser({ - id: me.id, - name: me.name, - photo: me.photo, - provider: me.provider - }) + try { + const me = await getMe() + setUser({ + username: me.username, + displayName: me.displayName, + photo: me.photo, + provider: LoginProvider.LOCAL, // TODO Use real provider instead + email: me.email + }) + } catch (error) { + console.error(error) + } } diff --git a/src/components/login-page/auth/via-ldap.tsx b/src/components/login-page/auth/via-ldap.tsx index c9feb6108..ab2709fd2 100644 --- a/src/components/login-page/auth/via-ldap.tsx +++ b/src/components/login-page/auth/via-ldap.tsx @@ -5,34 +5,53 @@ */ import type { FormEvent } from 'react' -import React, { useCallback, useState } from 'react' -import { Alert, Button, Card, Form } from 'react-bootstrap' +import React, { useCallback, useMemo, useState } from 'react' +import { Button, Card, Form } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' -import { doLdapLogin } from '../../../api/auth' +import { doLdapLogin } from '../../../api/auth/ldap' import { fetchAndSetUser } from './utils' import { useApplicationState } from '../../../hooks/common/use-application-state' +import { AuthError as AuthErrorType } from '../../../api/auth' +import { UsernameField } from './fields/username-field' +import { PasswordField } from './fields/password-field' +import { AuthError } from './auth-error/auth-error' +import { useOnInputChange } from '../../../hooks/common/use-on-input-change' +/** + * Renders the LDAP login box with username and password field. + */ export const ViaLdap: React.FC = () => { - const { t } = useTranslation() + useTranslation() const ldapCustomName = useApplicationState((state) => state.config.customAuthNames.ldap) const [username, setUsername] = useState('') const [password, setPassword] = useState('') - const [error, setError] = useState(false) + const [error, setError] = useState() - const name = ldapCustomName ? `${ldapCustomName} (LDAP)` : 'LDAP' + const name = useMemo(() => { + return ldapCustomName ? `${ldapCustomName} (LDAP)` : 'LDAP' + }, [ldapCustomName]) const onLoginSubmit = useCallback( (event: FormEvent) => { doLdapLogin(username, password) .then(() => fetchAndSetUser()) - .catch(() => setError(true)) + .catch((error: Error) => { + setError( + Object.values(AuthErrorType).includes(error.message as AuthErrorType) + ? (error.message as AuthErrorType) + : AuthErrorType.OTHER + ) + }) event.preventDefault() }, [username, password] ) + const onUsernameChange = useOnInputChange(setUsername) + const onPasswordChange = useOnInputChange(setPassword) + return ( @@ -40,33 +59,9 @@ export const ViaLdap: React.FC = () => {
- - setUsername(event.currentTarget.value)} - className='bg-dark text-light' - autoComplete='username' - /> - - - - setPassword(event.currentTarget.value)} - className='bg-dark text-light' - autoComplete='current-password' - /> - - - - - + + + - -
-
- ) -} diff --git a/src/components/login-page/login-page.tsx b/src/components/login-page/login-page.tsx index d57b0aea6..55003b25a 100644 --- a/src/components/login-page/login-page.tsx +++ b/src/components/login-page/login-page.tsx @@ -4,17 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import React, { Fragment } from 'react' +import React, { Fragment, useCallback, useMemo } from 'react' import { Card, Col, Row } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { Redirect } from 'react-router' import { ShowIf } from '../common/show-if/show-if' -import { ViaInternal } from './auth/via-internal' +import { ViaLocal } from './auth/via-local' import { ViaLdap } from './auth/via-ldap' import { OneClickType, ViaOneClick } from './auth/via-one-click' -import { ViaOpenId } from './auth/via-openid' import { useApplicationState } from '../../hooks/common/use-application-state' +/** + * Renders the login page with buttons and fields for the enabled auth providers. + * Redirects the user to the history page if they are already logged in. + */ export const LoginPage: React.FC = () => { useTranslation() const authProviders = useApplicationState((state) => state.config.authProviders) @@ -33,16 +36,29 @@ export const LoginPage: React.FC = () => { authProviders.twitter ] - const oneClickCustomName: (type: OneClickType) => string | undefined = (type) => { - switch (type) { - case OneClickType.SAML: - return customSamlAuthName - case OneClickType.OAUTH2: - return customOauthAuthName - default: - return undefined - } - } + const oneClickCustomName = useCallback( + (type: OneClickType): string | undefined => { + switch (type) { + case OneClickType.SAML: + return customSamlAuthName + case OneClickType.OAUTH2: + return customOauthAuthName + default: + return undefined + } + }, + [customOauthAuthName, customSamlAuthName] + ) + + const oneClickButtonsDom = useMemo(() => { + return Object.values(OneClickType) + .filter((value) => authProviders[value]) + .map((value) => ( +
+ +
+ )) + }, [authProviders, oneClickCustomName]) if (userLoggedIn) { // TODO Redirect to previous page? @@ -53,17 +69,14 @@ export const LoginPage: React.FC = () => {
- + - - + + - - - @@ -73,13 +86,7 @@ export const LoginPage: React.FC = () => { - {Object.values(OneClickType) - .filter((value) => authProviders[value]) - .map((value) => ( -
- -
- ))} + {oneClickButtonsDom} diff --git a/src/components/profile-page/profile-page.tsx b/src/components/profile-page/profile-page.tsx index 2d13bfadc..af67e418d 100644 --- a/src/components/profile-page/profile-page.tsx +++ b/src/components/profile-page/profile-page.tsx @@ -32,7 +32,7 @@ export const ProfilePage: React.FC = () => { - + diff --git a/src/components/profile-page/settings/profile-change-password.tsx b/src/components/profile-page/settings/profile-change-password.tsx index 837abc94e..6080034ac 100644 --- a/src/components/profile-page/settings/profile-change-password.tsx +++ b/src/components/profile-page/settings/profile-change-password.tsx @@ -4,14 +4,16 @@ SPDX-License-Identifier: AGPL-3.0-only */ -import type { ChangeEvent, FormEvent } from 'react' +import type { FormEvent } from 'react' import React, { useCallback, useMemo, useState } from 'react' import { Button, Card, Form } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' -import { changePassword } from '../../../api/me' +import { doLocalPasswordChange } from '../../../api/auth/local' import { showErrorNotification } from '../../../redux/ui-notifications/methods' - -const REGEX_VALID_PASSWORD = /^[^\s].{5,}$/ +import { useOnInputChange } from '../../../hooks/common/use-on-input-change' +import { NewPasswordField } from '../../common/fields/new-password-field' +import { PasswordAgainField } from '../../common/fields/password-again-field' +import { CurrentPasswordField } from '../../common/fields/current-password-field' /** * Profile page section for changing the password when using internal login. @@ -22,34 +24,27 @@ export const ProfileChangePassword: React.FC = () => { const [newPassword, setNewPassword] = useState('') const [newPasswordAgain, setNewPasswordAgain] = useState('') - const newPasswordValid = useMemo(() => { - return REGEX_VALID_PASSWORD.test(newPassword) - }, [newPassword]) - - const newPasswordAgainValid = useMemo(() => { - return newPassword === newPasswordAgain - }, [newPassword, newPasswordAgain]) - - const onChangeOldPassword = useCallback((event: ChangeEvent) => { - setOldPassword(event.target.value) - }, []) - - const onChangeNewPassword = useCallback((event: ChangeEvent) => { - setNewPassword(event.target.value) - }, []) - - const onChangeNewPasswordAgain = useCallback((event: ChangeEvent) => { - setNewPasswordAgain(event.target.value) - }, []) + const onChangeOldPassword = useOnInputChange(setOldPassword) + const onChangeNewPassword = useOnInputChange(setNewPassword) + const onChangeNewPasswordAgain = useOnInputChange(setNewPasswordAgain) const onSubmitPasswordChange = useCallback( (event: FormEvent) => { event.preventDefault() - changePassword(oldPassword, newPassword).catch(showErrorNotification('profile.changePassword.failed')) + doLocalPasswordChange(oldPassword, newPassword).catch(showErrorNotification('profile.changePassword.failed')) }, [oldPassword, newPassword] ) + const ready = useMemo(() => { + return ( + oldPassword.trim() !== '' && + newPassword.trim() !== '' && + newPasswordAgain.trim() !== '' && + newPassword === newPasswordAgain + ) + }, [oldPassword, newPassword, newPasswordAgain]) + return ( @@ -57,53 +52,11 @@ export const ProfileChangePassword: React.FC = () => {
- - - - - - - - - - - - - - - - - - - - - + + + - diff --git a/src/components/profile-page/settings/profile-display-name.tsx b/src/components/profile-page/settings/profile-display-name.tsx index fffc98500..e98292efa 100644 --- a/src/components/profile-page/settings/profile-display-name.tsx +++ b/src/components/profile-page/settings/profile-display-name.tsx @@ -4,33 +4,26 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import type { ChangeEvent, FormEvent } from 'react' -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import type { FormEvent } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import { Button, Card, Form } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' import { updateDisplayName } from '../../../api/me' import { fetchAndSetUser } from '../../login-page/auth/utils' import { useApplicationState } from '../../../hooks/common/use-application-state' import { showErrorNotification } from '../../../redux/ui-notifications/methods' +import { DisplayNameField } from '../../common/fields/display-name-field' +import { useOnInputChange } from '../../../hooks/common/use-on-input-change' /** * Profile page section for changing the current display name. */ export const ProfileDisplayName: React.FC = () => { - const { t } = useTranslation() - const userName = useApplicationState((state) => state.user?.name) - const [displayName, setDisplayName] = useState('') - - useEffect(() => { - if (userName !== undefined) { - setDisplayName(userName) - } - }, [userName]) - - const onChangeDisplayName = useCallback((event: ChangeEvent) => { - setDisplayName(event.target.value) - }, []) + useTranslation() + const userName = useApplicationState((state) => state.user?.displayName) + const [displayName, setDisplayName] = useState(userName ?? '') + const onChangeDisplayName = useOnInputChange(setDisplayName) const onSubmitNameChange = useCallback( (event: FormEvent) => { event.preventDefault() @@ -42,8 +35,8 @@ export const ProfileDisplayName: React.FC = () => { ) const formSubmittable = useMemo(() => { - return displayName.trim() !== '' - }, [displayName]) + return displayName.trim() !== '' && displayName !== userName + }, [displayName, userName]) return ( @@ -52,24 +45,7 @@ export const ProfileDisplayName: React.FC = () => {
- - - - - - - - - + - -
- - - -
-
- -
-
-
+
+

+ +

+ + + + +
+ + + + + + + + + + + +
+
+ +
+
) } diff --git a/src/hooks/common/use-on-input-change.ts b/src/hooks/common/use-on-input-change.ts new file mode 100644 index 000000000..e5b2cd50b --- /dev/null +++ b/src/hooks/common/use-on-input-change.ts @@ -0,0 +1,22 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import type { ChangeEvent } from 'react' +import { useCallback } from 'react' + +/** + * Takes an input change event and sends the event value to a state setter. + * @param setter The setter method for the state. + * @return Hook that can be used as callback for onChange. + */ +export const useOnInputChange = (setter: (value: string) => void): ((event: ChangeEvent) => void) => { + return useCallback( + (event) => { + setter(event.target.value) + }, + [setter] + ) +} diff --git a/src/redux/config/reducers.ts b/src/redux/config/reducers.ts index 3bcc62ba4..9d00b10dc 100644 --- a/src/redux/config/reducers.ts +++ b/src/redux/config/reducers.ts @@ -22,8 +22,7 @@ export const initialState: Config = { google: false, saml: false, oauth2: false, - internal: false, - openid: false + local: false }, branding: { name: '', diff --git a/src/redux/note-details/initial-state.ts b/src/redux/note-details/initial-state.ts index 0f57a2e86..6d3778678 100644 --- a/src/redux/note-details/initial-state.ts +++ b/src/redux/note-details/initial-state.ts @@ -6,8 +6,8 @@ import { DateTime } from 'luxon' import type { NoteDetails } from './types/note-details' -import type { SlideOptions } from './types/slide-show-options' import { NoteTextDirection, NoteType } from './types/note-details' +import type { SlideOptions } from './types/slide-show-options' export const initialSlideOptions: SlideOptions = { transition: 'zoom', @@ -30,7 +30,7 @@ export const initialState: NoteDetails = { createTime: DateTime.fromSeconds(0), lastChange: { timestamp: DateTime.fromSeconds(0), - userName: '' + username: '' }, alias: '', viewCount: 0, diff --git a/src/redux/note-details/reducer.ts b/src/redux/note-details/reducer.ts index cfeabd0f6..327f72ea5 100644 --- a/src/redux/note-details/reducer.ts +++ b/src/redux/note-details/reducer.ts @@ -182,7 +182,7 @@ const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => { noteTitle: initialState.noteTitle, createTime: DateTime.fromISO(note.metadata.createTime), lastChange: { - userName: note.metadata.updateUser.userName, + username: note.metadata.updateUser.username, timestamp: DateTime.fromISO(note.metadata.updateTime) }, firstHeading: initialState.firstHeading, diff --git a/src/redux/note-details/types/note-details.ts b/src/redux/note-details/types/note-details.ts index 562e55123..4052d7bf2 100644 --- a/src/redux/note-details/types/note-details.ts +++ b/src/redux/note-details/types/note-details.ts @@ -19,7 +19,7 @@ export interface NoteDetails { id: string createTime: DateTime lastChange: { - userName: string + username: string timestamp: DateTime } viewCount: number diff --git a/src/redux/user/types.ts b/src/redux/user/types.ts index 06b80123f..5c81015d6 100644 --- a/src/redux/user/types.ts +++ b/src/redux/user/types.ts @@ -23,8 +23,9 @@ export interface ClearUserAction extends Action { } export interface UserState { - id: string - name: string + username: string + displayName: string + email: string photo: string provider: LoginProvider } @@ -38,9 +39,8 @@ export enum LoginProvider { GOOGLE = 'google', SAML = 'saml', OAUTH2 = 'oauth2', - INTERNAL = 'internal', - LDAP = 'ldap', - OPENID = 'openid' + LOCAL = 'local', + LDAP = 'ldap' } export type OptionalUserState = UserState | null