Refactor login components and adjust login API routes (#1678)

* Refactor login components and adjust API routes

Signed-off-by: Erik Michelson <github@erik.michelson.eu>

* Adjust API /me response and redux state

Signed-off-by: Erik Michelson <github@erik.michelson.eu>

* Fix moved function

Signed-off-by: Erik Michelson <github@erik.michelson.eu>

* Update cypress tests

Signed-off-by: Erik Michelson <github@erik.michelson.eu>

* Adjust mock response

Signed-off-by: Erik Michelson <github@erik.michelson.eu>

* Integrate new common fields and hook into profile page

Signed-off-by: Erik Michelson <github@erik.michelson.eu>

* Remove openid

Signed-off-by: Erik Michelson <github@erik.michelson.eu>

* Fix config mock

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>

Co-authored-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Erik Michelson 2021-12-11 01:32:38 +01:00 committed by GitHub
parent fe640268c5
commit eab189c3c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 911 additions and 507 deletions

View file

@ -14,8 +14,7 @@ const authProvidersDisabled = {
google: false, google: false,
saml: false, saml: false,
oauth2: false, oauth2: false,
internal: false, local: false
openid: false
} }
const initLoggedOutTestWithCustomAuthProviders = ( const initLoggedOutTestWithCustomAuthProviders = (
@ -50,7 +49,7 @@ describe('When logged-out ', () => {
describe('and an interactive auth-provider is enabled, ', () => { describe('and an interactive auth-provider is enabled, ', () => {
it('sign-in button points to login route: internal', () => { it('sign-in button points to login route: internal', () => {
initLoggedOutTestWithCustomAuthProviders(cy, { initLoggedOutTestWithCustomAuthProviders(cy, {
internal: true local: true
}) })
cy.getById('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') 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') 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, ', () => { describe('and only one one-click auth-provider is enabled, ', () => {
@ -78,7 +70,7 @@ describe('When logged-out ', () => {
cy.getById('sign-in-button') cy.getById('sign-in-button')
.should('be.visible') .should('be.visible')
// The absolute URL is used because it is defined as API base URL absolute. // 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', () => { it('sign-in button points to login route', () => {
initLoggedOutTestWithCustomAuthProviders(cy, { initLoggedOutTestWithCustomAuthProviders(cy, {
saml: true, saml: true,
internal: true local: true
}) })
cy.getById('sign-in-button').should('be.visible').should('have.attr', 'href', '/login') cy.getById('sign-in-button').should('be.visible').should('have.attr', 'href', '/login')
}) })

View file

@ -25,8 +25,7 @@ export const authProviders = {
google: true, google: true,
saml: true, saml: true,
oauth2: true, oauth2: true,
internal: true, local: true
openid: true
} }
export const config = { export const config = {

View file

@ -494,13 +494,16 @@
"signInVia": "Sign in via {{service}}", "signInVia": "Sign in via {{service}}",
"signIn": "Sign In", "signIn": "Sign In",
"signOut": "Sign Out", "signOut": "Sign Out",
"logoutFailed": "There was an error logging you out.\nClose your browser window to ensure session data is removed.",
"auth": { "auth": {
"email": "Email", "email": "Email",
"password": "Password", "password": "Password",
"username": "Username", "username": "Username",
"error": { "error": {
"openIdLogin": "Invalid OpenID provided", "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": { "register": {
@ -510,6 +513,7 @@
"passwordInfo": "Choose a unique and secure password. It must contain at least 8 characters.", "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:", "infoTermsPrivacy": "With the registration of my user account I agree to the following terms:",
"error": { "error": {
"registrationDisabled": "The registration is disabled",
"usernameExisting": "There is already an account with this username.", "usernameExisting": "There is already an account with this username.",
"other": "There was an error while registering your account. Just try it again." "other": "There was an error while registering your account. Just try it again."
} }

View file

@ -10,8 +10,7 @@
"google": true, "google": true,
"saml": true, "saml": true,
"oauth2": true, "oauth2": true,
"internal": true, "local": true
"openid": true
}, },
"allowRegister": true, "allowRegister": true,
"branding": { "branding": {

View file

@ -1,7 +1,6 @@
{ {
"id": "mockUser", "username": "mockUser",
"photo": "/mock-backend/public/img/avatar.png", "photo": "/mock-backend/public/img/avatar.png",
"name": "Test", "displayName": "Test",
"status": "ok", "email": "mock@hedgedoc.dev"
"provider": "internal"
} }

View file

@ -3,62 +3,31 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { RegisterError } from '../../components/register-page/register-page'
import { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils' 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<void> => { export enum AuthError {
const response = await fetch(getApiUrl() + 'auth/internal', { 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<void> => {
const response = await fetch(getApiUrl() + 'auth/logout', {
...defaultFetchConfig, ...defaultFetchConfig,
method: 'POST', method: 'DELETE'
body: JSON.stringify({
username: username,
password: password
})
})
expectResponseCode(response)
}
export const doInternalRegister = async (username: string, password: string): Promise<void> => {
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<void> => {
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<void> => {
const response = await fetch(getApiUrl() + 'auth/openid', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
openId: openId
})
}) })
expectResponseCode(response) expectResponseCode(response)

25
src/api/auth/ldap.ts Normal file
View file

@ -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<void> => {
const response = await fetch(getApiUrl() + 'auth/ldap', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
username: username,
password: password
})
})
expectResponseCode(response)
}

94
src/api/auth/local.ts Normal file
View file

@ -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<void> => {
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<void> => {
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<void> => {
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)
}

View file

@ -46,8 +46,7 @@ export interface AuthProvidersState {
google: boolean google: boolean
saml: boolean saml: boolean
oauth2: boolean oauth2: boolean
internal: boolean local: boolean
openid: boolean
} }
export interface CustomAuthNames { export interface CustomAuthNames {

View file

@ -4,16 +4,21 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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 { defaultFetchConfig, expectResponseCode, getApiUrl } from '../utils'
import { isMockMode } from '../../utils/test-modes' import { isMockMode } from '../../utils/test-modes'
export const getMe = async (): Promise<UserResponse> => { /**
* 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<UserInfoDto> => {
const response = await fetch(getApiUrl() + `me${isMockMode() ? '-get' : ''}`, { const response = await fetch(getApiUrl() + `me${isMockMode() ? '-get' : ''}`, {
...defaultFetchConfig ...defaultFetchConfig
}) })
expectResponseCode(response) expectResponseCode(response)
return (await response.json()) as UserResponse return (await response.json()) as UserInfoDto
} }
export const updateDisplayName = async (displayName: string): Promise<void> => { export const updateDisplayName = async (displayName: string): Promise<void> => {
@ -28,19 +33,6 @@ export const updateDisplayName = async (displayName: string): Promise<void> => {
expectResponseCode(response) expectResponseCode(response)
} }
export const changePassword = async (oldPassword: string, newPassword: string): Promise<void> => {
const response = await fetch(getApiUrl() + 'me/password', {
...defaultFetchConfig,
method: 'POST',
body: JSON.stringify({
oldPassword,
newPassword
})
})
expectResponseCode(response)
}
export const deleteUser = async (): Promise<void> => { export const deleteUser = async (): Promise<void> => {
const response = await fetch(getApiUrl() + 'me', { const response = await fetch(getApiUrl() + 'me', {
...defaultFetchConfig, ...defaultFetchConfig,

View file

@ -14,7 +14,7 @@ export interface UserResponse {
} }
export interface UserInfoDto { export interface UserInfoDto {
userName: string username: string
displayName: string displayName: string
photo: string photo: string
email: string email: string

View file

@ -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<CommonFieldProps> = ({ onChange }) => {
const { t } = useTranslation()
return (
<Form.Group>
<Form.Label>
<Trans i18nKey='profile.changePassword.old' />
</Form.Label>
<Form.Control
type='password'
size='sm'
onChange={onChange}
placeholder={t('login.auth.password')}
className='bg-dark text-light'
autoComplete='current-password'
required
/>
</Form.Group>
)
}

View file

@ -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<DisplayNameFieldProps> = ({ onChange, value, initialValue }) => {
const { t } = useTranslation()
const isValid = useMemo(() => {
return value.trim() !== '' && value !== initialValue
}, [value, initialValue])
return (
<Form.Group>
<Form.Label>
<Trans i18nKey='profile.displayName' />
</Form.Label>
<Form.Control
type='text'
size='sm'
value={value}
isValid={isValid}
onChange={onChange}
placeholder={t('profile.displayName')}
className='bg-dark text-light'
autoComplete='name'
required
/>
<Form.Text>
<Trans i18nKey='profile.displayNameInfo' />
</Form.Text>
</Form.Group>
)
}

View file

@ -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<HTMLInputElement>) => void
value: string
}

View file

@ -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<CommonFieldProps> = ({ onChange, value }) => {
const { t } = useTranslation()
const isValid = useMemo(() => {
return value.trim() !== '' && value.length >= 8
}, [value])
return (
<Form.Group>
<Form.Label>
<Trans i18nKey='profile.changePassword.new' />
</Form.Label>
<Form.Control
type='password'
size='sm'
isValid={isValid}
onChange={onChange}
placeholder={t('login.auth.password')}
className='bg-dark text-light'
minLength={8}
autoComplete='new-password'
required
/>
<Form.Text>
<Trans i18nKey='login.register.passwordInfo' />
</Form.Text>
</Form.Group>
)
}

View file

@ -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<PasswordAgainFieldProps> = ({ 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 (
<Form.Group>
<Form.Label>
<Trans i18nKey='login.register.passwordAgain' />
</Form.Label>
<Form.Control
type='password'
size='sm'
isInvalid={isInvalid}
isValid={isValid}
onChange={onChange}
placeholder={t('login.register.passwordAgain')}
className='bg-dark text-light'
autoComplete='new-password'
required
/>
</Form.Group>
)
}

View file

@ -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<CommonFieldProps> = ({ onChange, value }) => {
const { t } = useTranslation()
const isValid = useMemo(() => {
return value?.trim() !== ''
}, [value])
return (
<Form.Group>
<Form.Label>
<Trans i18nKey='login.auth.username' />
</Form.Label>
<Form.Control
type='text'
size='sm'
value={value}
isValid={isValid}
onChange={onChange}
placeholder={t('login.auth.username')}
className='bg-dark text-light'
autoComplete='username'
autoFocus={true}
required
/>
<Form.Text>
<Trans i18nKey='login.register.usernameInfo' />
</Form.Text>
</Form.Group>
)
}

View file

@ -24,7 +24,7 @@ export const DocumentReadOnlyPageContent: React.FC = () => {
return ( return (
<Fragment> <Fragment>
<DocumentInfobar <DocumentInfobar
changedAuthor={noteDetails.lastChange.userName ?? ''} changedAuthor={noteDetails.lastChange.username ?? ''}
changedTime={noteDetails.lastChange.timestamp} changedTime={noteDetails.lastChange.timestamp}
createdAuthor={'Test'} createdAuthor={'Test'}
createdTime={noteDetails.createTime} createdTime={noteDetails.createTime}

View file

@ -28,7 +28,7 @@ const allSupportedLinks = [
const getUserName = (): string => { const getUserName = (): string => {
const user = store.getState().user const user = store.getState().user
return user ? user.name : 'Anonymous' return user ? user.displayName : 'Anonymous'
} }
const linkAndExtraTagHint = (editor: Editor): Promise<Hints | null> => { const linkAndExtraTagHint = (editor: Editor): Promise<Hints | null> => {

View file

@ -10,15 +10,16 @@ import type { ButtonProps } from 'react-bootstrap/Button'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { LinkContainer } from 'react-router-bootstrap' import { LinkContainer } from 'react-router-bootstrap'
import { ShowIf } from '../../common/show-if/show-if' import { ShowIf } from '../../common/show-if/show-if'
import { getApiUrl } from '../../../api/utils'
import { INTERACTIVE_LOGIN_METHODS } from '../../../api/auth' import { INTERACTIVE_LOGIN_METHODS } from '../../../api/auth'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { useBackendBaseUrl } from '../../../hooks/common/use-backend-base-url'
export type SignInButtonProps = Omit<ButtonProps, 'href'> export type SignInButtonProps = Omit<ButtonProps, 'href'>
export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => { export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props }) => {
const { t } = useTranslation() const { t } = useTranslation()
const backendBaseUrl = useBackendBaseUrl()
const authProviders = useApplicationState((state) => state.config.authProviders) const authProviders = useApplicationState((state) => state.config.authProviders)
const authEnabled = useMemo(() => Object.values(authProviders).includes(true), [authProviders]) const authEnabled = useMemo(() => Object.values(authProviders).includes(true), [authProviders])
@ -29,10 +30,10 @@ export const SignInButton: React.FC<SignInButtonProps> = ({ variant, ...props })
const activeOneClickProviders = activeProviders.filter((entry) => !INTERACTIVE_LOGIN_METHODS.includes(entry)) const activeOneClickProviders = activeProviders.filter((entry) => !INTERACTIVE_LOGIN_METHODS.includes(entry))
if (activeProviders.length === 1 && activeOneClickProviders.length === 1) { if (activeProviders.length === 1 && activeOneClickProviders.length === 1) {
return `${getApiUrl()}auth/${activeOneClickProviders[0]}` return `${backendBaseUrl}auth/${activeOneClickProviders[0]}`
} }
return '/login' return '/login'
}, [authProviders]) }, [authProviders, backendBaseUrl])
return ( return (
<ShowIf condition={authEnabled}> <ShowIf condition={authEnabled}>

View file

@ -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 (
<Dropdown.Item dir='auto' onClick={onSignOut} {...cypressId('user-dropdown-sign-out-button')}>
<ForkAwesomeIcon icon='sign-out' fixedWidth={true} className='mx-2' />
<Trans i18nKey='login.signOut' />
</Dropdown.Item>
)
}

View file

@ -8,11 +8,11 @@ import React from 'react'
import { Dropdown } from 'react-bootstrap' import { Dropdown } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { LinkContainer } from 'react-router-bootstrap' import { LinkContainer } from 'react-router-bootstrap'
import { clearUser } from '../../../redux/user/methods'
import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon' import { ForkAwesomeIcon } from '../../common/fork-awesome/fork-awesome-icon'
import { UserAvatar } from '../../common/user-avatar/user-avatar' import { UserAvatar } from '../../common/user-avatar/user-avatar'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { cypressId } from '../../../utils/cypress-attribute' import { cypressId } from '../../../utils/cypress-attribute'
import { SignOutDropdownButton } from './sign-out-dropdown-button'
export const UserDropdown: React.FC = () => { export const UserDropdown: React.FC = () => {
useTranslation() useTranslation()
@ -25,7 +25,7 @@ export const UserDropdown: React.FC = () => {
return ( return (
<Dropdown alignRight> <Dropdown alignRight>
<Dropdown.Toggle size='sm' variant='dark' {...cypressId('user-dropdown')} className={'d-flex align-items-center'}> <Dropdown.Toggle size='sm' variant='dark' {...cypressId('user-dropdown')} className={'d-flex align-items-center'}>
<UserAvatar name={user.name} photo={user.photo} /> <UserAvatar name={user.displayName} photo={user.photo} />
</Dropdown.Toggle> </Dropdown.Toggle>
<Dropdown.Menu className='text-start'> <Dropdown.Menu className='text-start'>
@ -41,15 +41,7 @@ export const UserDropdown: React.FC = () => {
<Trans i18nKey='profile.userProfile' /> <Trans i18nKey='profile.userProfile' />
</Dropdown.Item> </Dropdown.Item>
</LinkContainer> </LinkContainer>
<Dropdown.Item <SignOutDropdownButton />
dir='auto'
onClick={() => {
clearUser()
}}
{...cypressId('user-dropdown-sign-out-button')}>
<ForkAwesomeIcon icon='sign-out' fixedWidth={true} className='mx-2' />
<Trans i18nKey='login.signOut' />
</Dropdown.Item>
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>
) )

View file

@ -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<AuthErrorProps> = ({ 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 (
<Alert className='small' show={!!error} variant='danger'>
<Trans i18nKey={errorMessageI18nKey} />
</Alert>
)
}

View file

@ -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<HTMLInputElement>) => void
invalid: boolean
}

View file

@ -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<AuthFieldProps> = ({ onChange, invalid }) => {
return (
<Form.Group>
<Form.Control
isInvalid={invalid}
type='text'
size='sm'
placeholder={'OpenID'}
onChange={onChange}
className='bg-dark text-light'
/>
</Form.Group>
)
}

View file

@ -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<AuthFieldProps> = ({ onChange, invalid }) => {
const { t } = useTranslation()
return (
<Form.Group>
<Form.Control
isInvalid={invalid}
type='password'
size='sm'
placeholder={t('login.auth.password')}
onChange={onChange}
className='bg-dark text-light'
autoComplete='current-password'
/>
</Form.Group>
)
}

View file

@ -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<AuthFieldProps> = ({ onChange, invalid }) => {
const { t } = useTranslation()
return (
<Form.Group>
<Form.Control
isInvalid={invalid}
type='text'
size='sm'
placeholder={t('login.auth.username')}
onChange={onChange}
className='bg-dark text-light'
autoComplete='username'
/>
</Form.Group>
)
}

View file

@ -6,13 +6,22 @@
import { getMe } from '../../../api/me' import { getMe } from '../../../api/me'
import { setUser } from '../../../redux/user/methods' 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<void> = async () => { export const fetchAndSetUser: () => Promise<void> = async () => {
const me = await getMe() try {
setUser({ const me = await getMe()
id: me.id, setUser({
name: me.name, username: me.username,
photo: me.photo, displayName: me.displayName,
provider: me.provider photo: me.photo,
}) provider: LoginProvider.LOCAL, // TODO Use real provider instead
email: me.email
})
} catch (error) {
console.error(error)
}
} }

View file

@ -5,34 +5,53 @@
*/ */
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import React, { useCallback, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Alert, Button, Card, Form } from 'react-bootstrap' import { Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { doLdapLogin } from '../../../api/auth' import { doLdapLogin } from '../../../api/auth/ldap'
import { fetchAndSetUser } from './utils' import { fetchAndSetUser } from './utils'
import { useApplicationState } from '../../../hooks/common/use-application-state' 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 = () => { export const ViaLdap: React.FC = () => {
const { t } = useTranslation() useTranslation()
const ldapCustomName = useApplicationState((state) => state.config.customAuthNames.ldap) const ldapCustomName = useApplicationState((state) => state.config.customAuthNames.ldap)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState(false) const [error, setError] = useState<AuthErrorType>()
const name = ldapCustomName ? `${ldapCustomName} (LDAP)` : 'LDAP' const name = useMemo(() => {
return ldapCustomName ? `${ldapCustomName} (LDAP)` : 'LDAP'
}, [ldapCustomName])
const onLoginSubmit = useCallback( const onLoginSubmit = useCallback(
(event: FormEvent) => { (event: FormEvent) => {
doLdapLogin(username, password) doLdapLogin(username, password)
.then(() => fetchAndSetUser()) .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() event.preventDefault()
}, },
[username, password] [username, password]
) )
const onUsernameChange = useOnInputChange(setUsername)
const onPasswordChange = useOnInputChange(setPassword)
return ( return (
<Card className='bg-dark mb-4'> <Card className='bg-dark mb-4'>
<Card.Body> <Card.Body>
@ -40,33 +59,9 @@ export const ViaLdap: React.FC = () => {
<Trans i18nKey='login.signInVia' values={{ service: name }} /> <Trans i18nKey='login.signInVia' values={{ service: name }} />
</Card.Title> </Card.Title>
<Form onSubmit={onLoginSubmit}> <Form onSubmit={onLoginSubmit}>
<Form.Group controlId='ldap-username'> <UsernameField onChange={onUsernameChange} invalid={!!error} />
<Form.Control <PasswordField onChange={onPasswordChange} invalid={!!error} />
isInvalid={error} <AuthError error={error} />
type='text'
size='sm'
placeholder={t('login.auth.username')}
onChange={(event) => setUsername(event.currentTarget.value)}
className='bg-dark text-light'
autoComplete='username'
/>
</Form.Group>
<Form.Group controlId='ldap-password'>
<Form.Control
isInvalid={error}
type='password'
size='sm'
placeholder={t('login.auth.password')}
onChange={(event) => setPassword(event.currentTarget.value)}
className='bg-dark text-light'
autoComplete='current-password'
/>
</Form.Group>
<Alert className='small' show={error} variant='danger'>
<Trans i18nKey='login.auth.error.usernamePassword' />
</Alert>
<Button type='submit' variant='primary'> <Button type='submit' variant='primary'>
<Trans i18nKey='login.signIn' /> <Trans i18nKey='login.signIn' />

View file

@ -4,33 +4,54 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { FormEvent } from 'react' import type { ChangeEvent, FormEvent } from 'react'
import React, { useCallback, useState } from 'react' import React, { useCallback, useState } from 'react'
import { Alert, Button, Card, Form } from 'react-bootstrap' import { Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { doInternalLogin } from '../../../api/auth' import { doLocalLogin } from '../../../api/auth/local'
import { ShowIf } from '../../common/show-if/show-if' import { ShowIf } from '../../common/show-if/show-if'
import { fetchAndSetUser } from './utils' import { fetchAndSetUser } from './utils'
import { useApplicationState } from '../../../hooks/common/use-application-state' 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'
export const ViaInternal: React.FC = () => { /**
* Renders the local login box with username and password field and the optional button for registering a new user.
*/
export const ViaLocal: React.FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState(false) const [error, setError] = useState<AuthErrorType>()
const allowRegister = useApplicationState((state) => state.config.allowRegister) const allowRegister = useApplicationState((state) => state.config.allowRegister)
const onLoginSubmit = useCallback( const onLoginSubmit = useCallback(
(event: FormEvent) => { (event: FormEvent) => {
doInternalLogin(username, password) doLocalLogin(username, password)
.then(() => fetchAndSetUser()) .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() event.preventDefault()
}, },
[username, password] [username, password]
) )
const onUsernameChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value)
}, [])
const onPasswordChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value)
}, [])
return ( return (
<Card className='bg-dark mb-4'> <Card className='bg-dark mb-4'>
<Card.Body> <Card.Body>
@ -38,33 +59,9 @@ export const ViaInternal: React.FC = () => {
<Trans i18nKey='login.signInVia' values={{ service: t('login.auth.username') }} /> <Trans i18nKey='login.signInVia' values={{ service: t('login.auth.username') }} />
</Card.Title> </Card.Title>
<Form onSubmit={onLoginSubmit}> <Form onSubmit={onLoginSubmit}>
<Form.Group controlId='internal-username'> <UsernameField onChange={onUsernameChange} invalid={!!error} />
<Form.Control <PasswordField onChange={onPasswordChange} invalid={!!error} />
isInvalid={error} <AuthError error={error} />
type='text'
size='sm'
placeholder={t('login.auth.username')}
onChange={(event) => setUsername(event.currentTarget.value)}
className='bg-dark text-light'
autoComplete='username'
/>
</Form.Group>
<Form.Group controlId='internal-password'>
<Form.Control
isInvalid={error}
type='password'
size='sm'
placeholder={t('login.auth.password')}
onChange={(event) => setPassword(event.currentTarget.value)}
className='bg-dark text-light'
autoComplete='current-password'
/>
</Form.Group>
<Alert className='small' show={error} variant='danger'>
<Trans i18nKey='login.auth.error.usernamePassword' />
</Alert>
<div className='flex flex-row' dir='auto'> <div className='flex flex-row' dir='auto'>
<Button type='submit' variant='primary' className='mx-2'> <Button type='submit' variant='primary' className='mx-2'>

View file

@ -1,58 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { FormEvent } from 'react'
import React, { useState } from 'react'
import { Alert, Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { doOpenIdLogin } from '../../../api/auth'
import { fetchAndSetUser } from './utils'
export const ViaOpenId: React.FC = () => {
useTranslation()
const [openId, setOpenId] = useState('')
const [error, setError] = useState(false)
const doAsyncLogin: () => Promise<void> = async () => {
await doOpenIdLogin(openId)
await fetchAndSetUser()
}
const onFormSubmit = (event: FormEvent) => {
doAsyncLogin().catch(() => setError(true))
event.preventDefault()
}
return (
<Card className='bg-dark mb-4'>
<Card.Body>
<Card.Title>
<Trans i18nKey='login.signInVia' values={{ service: 'OpenID' }} />
</Card.Title>
<Form onSubmit={onFormSubmit}>
<Form.Group controlId='openid'>
<Form.Control
isInvalid={error}
type='text'
size='sm'
placeholder={'OpenID'}
onChange={(event) => setOpenId(event.currentTarget.value)}
className='bg-dark text-light'
/>
</Form.Group>
<Alert className='small' show={error} variant='danger'>
<Trans i18nKey='login.auth.error.openIdLogin' />
</Alert>
<Button type='submit' variant='primary'>
<Trans i18nKey='login.signIn' />
</Button>
</Form>
</Card.Body>
</Card>
)
}

View file

@ -4,17 +4,20 @@
SPDX-License-Identifier: AGPL-3.0-only 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 { Card, Col, Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { Redirect } from 'react-router' import { Redirect } from 'react-router'
import { ShowIf } from '../common/show-if/show-if' 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 { ViaLdap } from './auth/via-ldap'
import { OneClickType, ViaOneClick } from './auth/via-one-click' import { OneClickType, ViaOneClick } from './auth/via-one-click'
import { ViaOpenId } from './auth/via-openid'
import { useApplicationState } from '../../hooks/common/use-application-state' 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 = () => { export const LoginPage: React.FC = () => {
useTranslation() useTranslation()
const authProviders = useApplicationState((state) => state.config.authProviders) const authProviders = useApplicationState((state) => state.config.authProviders)
@ -33,16 +36,29 @@ export const LoginPage: React.FC = () => {
authProviders.twitter authProviders.twitter
] ]
const oneClickCustomName: (type: OneClickType) => string | undefined = (type) => { const oneClickCustomName = useCallback(
switch (type) { (type: OneClickType): string | undefined => {
case OneClickType.SAML: switch (type) {
return customSamlAuthName case OneClickType.SAML:
case OneClickType.OAUTH2: return customSamlAuthName
return customOauthAuthName case OneClickType.OAUTH2:
default: return customOauthAuthName
return undefined default:
} return undefined
} }
},
[customOauthAuthName, customSamlAuthName]
)
const oneClickButtonsDom = useMemo(() => {
return Object.values(OneClickType)
.filter((value) => authProviders[value])
.map((value) => (
<div className='p-2 d-flex flex-column social-button-container' key={value}>
<ViaOneClick oneClickType={value} optionalName={oneClickCustomName(value)} />
</div>
))
}, [authProviders, oneClickCustomName])
if (userLoggedIn) { if (userLoggedIn) {
// TODO Redirect to previous page? // TODO Redirect to previous page?
@ -53,17 +69,14 @@ export const LoginPage: React.FC = () => {
<Fragment> <Fragment>
<div className='my-3'> <div className='my-3'>
<Row className='h-100 flex justify-content-center'> <Row className='h-100 flex justify-content-center'>
<ShowIf condition={authProviders.internal || authProviders.ldap || authProviders.openid}> <ShowIf condition={authProviders.local || authProviders.ldap}>
<Col xs={12} sm={10} lg={4}> <Col xs={12} sm={10} lg={4}>
<ShowIf condition={authProviders.internal}> <ShowIf condition={authProviders.local}>
<ViaInternal /> <ViaLocal />
</ShowIf> </ShowIf>
<ShowIf condition={authProviders.ldap}> <ShowIf condition={authProviders.ldap}>
<ViaLdap /> <ViaLdap />
</ShowIf> </ShowIf>
<ShowIf condition={authProviders.openid}>
<ViaOpenId />
</ShowIf>
</Col> </Col>
</ShowIf> </ShowIf>
<ShowIf condition={oneClickProviders.includes(true)}> <ShowIf condition={oneClickProviders.includes(true)}>
@ -73,13 +86,7 @@ export const LoginPage: React.FC = () => {
<Card.Title> <Card.Title>
<Trans i18nKey='login.signInVia' values={{ service: '' }} /> <Trans i18nKey='login.signInVia' values={{ service: '' }} />
</Card.Title> </Card.Title>
{Object.values(OneClickType) {oneClickButtonsDom}
.filter((value) => authProviders[value])
.map((value) => (
<div className='p-2 d-flex flex-column social-button-container' key={value}>
<ViaOneClick oneClickType={value} optionalName={oneClickCustomName(value)} />
</div>
))}
</Card.Body> </Card.Body>
</Card> </Card>
</Col> </Col>

View file

@ -32,7 +32,7 @@ export const ProfilePage: React.FC = () => {
<Row className='h-100 flex justify-content-center'> <Row className='h-100 flex justify-content-center'>
<Col lg={6}> <Col lg={6}>
<ProfileDisplayName /> <ProfileDisplayName />
<ShowIf condition={userProvider === LoginProvider.INTERNAL}> <ShowIf condition={userProvider === LoginProvider.LOCAL}>
<ProfileChangePassword /> <ProfileChangePassword />
</ShowIf> </ShowIf>
<ProfileAccessTokens /> <ProfileAccessTokens />

View file

@ -4,14 +4,16 @@
SPDX-License-Identifier: AGPL-3.0-only 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 React, { useCallback, useMemo, useState } from 'react'
import { Button, Card, Form } from 'react-bootstrap' import { Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { changePassword } from '../../../api/me' import { doLocalPasswordChange } from '../../../api/auth/local'
import { showErrorNotification } from '../../../redux/ui-notifications/methods' import { showErrorNotification } from '../../../redux/ui-notifications/methods'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
const REGEX_VALID_PASSWORD = /^[^\s].{5,}$/ 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. * 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 [newPassword, setNewPassword] = useState('')
const [newPasswordAgain, setNewPasswordAgain] = useState('') const [newPasswordAgain, setNewPasswordAgain] = useState('')
const newPasswordValid = useMemo(() => { const onChangeOldPassword = useOnInputChange(setOldPassword)
return REGEX_VALID_PASSWORD.test(newPassword) const onChangeNewPassword = useOnInputChange(setNewPassword)
}, [newPassword]) const onChangeNewPasswordAgain = useOnInputChange(setNewPasswordAgain)
const newPasswordAgainValid = useMemo(() => {
return newPassword === newPasswordAgain
}, [newPassword, newPasswordAgain])
const onChangeOldPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setOldPassword(event.target.value)
}, [])
const onChangeNewPassword = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setNewPassword(event.target.value)
}, [])
const onChangeNewPasswordAgain = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setNewPasswordAgain(event.target.value)
}, [])
const onSubmitPasswordChange = useCallback( const onSubmitPasswordChange = useCallback(
(event: FormEvent) => { (event: FormEvent) => {
event.preventDefault() event.preventDefault()
changePassword(oldPassword, newPassword).catch(showErrorNotification('profile.changePassword.failed')) doLocalPasswordChange(oldPassword, newPassword).catch(showErrorNotification('profile.changePassword.failed'))
}, },
[oldPassword, newPassword] [oldPassword, newPassword]
) )
const ready = useMemo(() => {
return (
oldPassword.trim() !== '' &&
newPassword.trim() !== '' &&
newPasswordAgain.trim() !== '' &&
newPassword === newPasswordAgain
)
}, [oldPassword, newPassword, newPasswordAgain])
return ( return (
<Card className='bg-dark mb-4'> <Card className='bg-dark mb-4'>
<Card.Body> <Card.Body>
@ -57,53 +52,11 @@ export const ProfileChangePassword: React.FC = () => {
<Trans i18nKey='profile.changePassword.title' /> <Trans i18nKey='profile.changePassword.title' />
</Card.Title> </Card.Title>
<Form onSubmit={onSubmitPasswordChange} className='text-left'> <Form onSubmit={onSubmitPasswordChange} className='text-left'>
<Form.Group controlId='oldPassword'> <CurrentPasswordField onChange={onChangeOldPassword} value={oldPassword} />
<Form.Label> <NewPasswordField onChange={onChangeNewPassword} value={newPassword} />
<Trans i18nKey='profile.changePassword.old' /> <PasswordAgainField password={newPassword} onChange={onChangeNewPasswordAgain} value={newPasswordAgain} />
</Form.Label>
<Form.Control
type='password'
size='sm'
className='bg-dark text-light'
autoComplete='current-password'
required
onChange={onChangeOldPassword}
/>
</Form.Group>
<Form.Group controlId='newPassword'>
<Form.Label>
<Trans i18nKey='profile.changePassword.new' />
</Form.Label>
<Form.Control
type='password'
size='sm'
className='bg-dark text-light'
autoComplete='new-password'
required
onChange={onChangeNewPassword}
isValid={newPasswordValid}
/>
<Form.Text>
<Trans i18nKey='profile.changePassword.info' />
</Form.Text>
</Form.Group>
<Form.Group controlId='newPasswordAgain'>
<Form.Label>
<Trans i18nKey='profile.changePassword.newAgain' />
</Form.Label>
<Form.Control
type='password'
size='sm'
className='bg-dark text-light'
required
autoComplete='new-password'
onChange={onChangeNewPasswordAgain}
isValid={newPasswordAgainValid}
isInvalid={newPasswordAgain !== '' && !newPasswordAgainValid}
/>
</Form.Group>
<Button type='submit' variant='primary' disabled={!newPasswordValid || !newPasswordAgainValid}> <Button type='submit' variant='primary' disabled={!ready}>
<Trans i18nKey='common.save' /> <Trans i18nKey='common.save' />
</Button> </Button>
</Form> </Form>

View file

@ -4,33 +4,26 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import type { ChangeEvent, FormEvent } from 'react' import type { FormEvent } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Button, Card, Form } from 'react-bootstrap' import { Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { updateDisplayName } from '../../../api/me' import { updateDisplayName } from '../../../api/me'
import { fetchAndSetUser } from '../../login-page/auth/utils' import { fetchAndSetUser } from '../../login-page/auth/utils'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { showErrorNotification } from '../../../redux/ui-notifications/methods' 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. * Profile page section for changing the current display name.
*/ */
export const ProfileDisplayName: React.FC = () => { export const ProfileDisplayName: React.FC = () => {
const { t } = useTranslation() useTranslation()
const userName = useApplicationState((state) => state.user?.name) const userName = useApplicationState((state) => state.user?.displayName)
const [displayName, setDisplayName] = useState('') const [displayName, setDisplayName] = useState(userName ?? '')
useEffect(() => {
if (userName !== undefined) {
setDisplayName(userName)
}
}, [userName])
const onChangeDisplayName = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setDisplayName(event.target.value)
}, [])
const onChangeDisplayName = useOnInputChange(setDisplayName)
const onSubmitNameChange = useCallback( const onSubmitNameChange = useCallback(
(event: FormEvent) => { (event: FormEvent) => {
event.preventDefault() event.preventDefault()
@ -42,8 +35,8 @@ export const ProfileDisplayName: React.FC = () => {
) )
const formSubmittable = useMemo(() => { const formSubmittable = useMemo(() => {
return displayName.trim() !== '' return displayName.trim() !== '' && displayName !== userName
}, [displayName]) }, [displayName, userName])
return ( return (
<Card className='bg-dark mb-4'> <Card className='bg-dark mb-4'>
@ -52,24 +45,7 @@ export const ProfileDisplayName: React.FC = () => {
<Trans i18nKey='profile.userProfile' /> <Trans i18nKey='profile.userProfile' />
</Card.Title> </Card.Title>
<Form onSubmit={onSubmitNameChange} className='text-left'> <Form onSubmit={onSubmitNameChange} className='text-left'>
<Form.Group controlId='displayName'> <DisplayNameField onChange={onChangeDisplayName} value={displayName} initialValue={userName} />
<Form.Label>
<Trans i18nKey='profile.displayName' />
</Form.Label>
<Form.Control
type='text'
size='sm'
placeholder={t('profile.displayName')}
value={displayName}
className='bg-dark text-light'
onChange={onChangeDisplayName}
isValid={formSubmittable}
required
/>
<Form.Text>
<Trans i18nKey='profile.displayNameInfo' />
</Form.Text>
</Form.Group>
<Button type='submit' variant='primary' disabled={!formSubmittable}> <Button type='submit' variant='primary' disabled={!formSubmittable}>
<Trans i18nKey='common.save' /> <Trans i18nKey='common.save' />

View file

@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { RegisterError as RegisterErrorType } from '../../../api/auth'
import { Trans, useTranslation } from 'react-i18next'
import { Alert } from 'react-bootstrap'
export interface RegisterErrorProps {
error?: RegisterErrorType
}
/**
* Renders an error message for registration fields when an error is present.
* @param error The error to render. Can be {@code undefined} when no error should be rendered.
*/
export const RegisterError: React.FC<RegisterErrorProps> = ({ error }) => {
useTranslation()
const errorMessageI18nKey = useMemo(() => {
switch (error) {
case RegisterErrorType.REGISTRATION_DISABLED:
return 'login.register.error.registrationDisabled'
case RegisterErrorType.USERNAME_EXISTING:
return 'login.register.error.usernameExisting'
default:
return 'login.register.error.other'
}
}, [error])
return (
<Alert className='small' show={!!error} variant='danger'>
<Trans i18nKey={errorMessageI18nKey} />
</Alert>
)
}

View file

@ -0,0 +1,37 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { ShowIf } from '../../common/show-if/show-if'
import { TranslatedExternalLink } from '../../common/links/translated-external-link'
import { useApplicationState } from '../../../hooks/common/use-application-state'
/**
* Renders the links to information and conditions on registering an account.
*/
export const RegisterInfos: React.FC = () => {
useTranslation()
const specialUrls = useApplicationState((state) => state.config.specialUrls)
return (
<ShowIf condition={!!specialUrls.termsOfUse || !!specialUrls.privacy}>
<Trans i18nKey='login.register.infoTermsPrivacy' />
<ul>
<ShowIf condition={!!specialUrls.termsOfUse}>
<li>
<TranslatedExternalLink i18nKey='landing.footer.termsOfUse' href={specialUrls.termsOfUse ?? ''} />
</li>
</ShowIf>
<ShowIf condition={!!specialUrls.privacy}>
<li>
<TranslatedExternalLink i18nKey='landing.footer.privacy' href={specialUrls.privacy ?? ''} />
</li>
</ShowIf>
</ul>
</ShowIf>
)
}

View file

@ -5,53 +5,66 @@
*/ */
import type { FormEvent } from 'react' import type { FormEvent } from 'react'
import React, { Fragment, useCallback, useEffect, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Alert, Button, Card, Col, Form, Row } from 'react-bootstrap' import { Button, Card, Col, Form, Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { Redirect } from 'react-router' import { Redirect } from 'react-router'
import { doInternalRegister } from '../../api/auth' import { doLocalRegister } from '../../api/auth/local'
import { useApplicationState } from '../../hooks/common/use-application-state' import { useApplicationState } from '../../hooks/common/use-application-state'
import { TranslatedExternalLink } from '../common/links/translated-external-link'
import { ShowIf } from '../common/show-if/show-if'
import { fetchAndSetUser } from '../login-page/auth/utils' import { fetchAndSetUser } from '../login-page/auth/utils'
import { Logger } from '../../utils/logger' import { RegisterError as RegisterErrorType } from '../../api/auth'
import { RegisterInfos } from './register-infos/register-infos'
const log = new Logger('RegisterPage') import { UsernameField } from '../common/fields/username-field'
import { DisplayNameField } from '../common/fields/display-name-field'
export enum RegisterError { import { NewPasswordField } from '../common/fields/new-password-field'
NONE = 'none', import { PasswordAgainField } from '../common/fields/password-again-field'
USERNAME_EXISTING = 'usernameExisting', import { useOnInputChange } from '../../hooks/common/use-on-input-change'
OTHER = 'other' import { RegisterError } from './register-error/register-error'
}
/**
* Renders the registration page with fields for username, display name, password, password retype and information about terms and conditions.
*/
export const RegisterPage: React.FC = () => { export const RegisterPage: React.FC = () => {
const { t } = useTranslation() useTranslation()
const allowRegister = useApplicationState((state) => state.config.allowRegister) const allowRegister = useApplicationState((state) => state.config.allowRegister)
const specialUrls = useApplicationState((state) => state.config.specialUrls)
const userExists = useApplicationState((state) => !!state.user) const userExists = useApplicationState((state) => !!state.user)
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [passwordAgain, setPasswordAgain] = useState('') const [passwordAgain, setPasswordAgain] = useState('')
const [error, setError] = useState(RegisterError.NONE) const [error, setError] = useState<RegisterErrorType>()
const [ready, setReady] = useState(false)
const doRegisterSubmit = useCallback( const doRegisterSubmit = useCallback(
(event: FormEvent) => { (event: FormEvent) => {
doInternalRegister(username, password) doLocalRegister(username, displayName, password)
.then(() => fetchAndSetUser()) .then(() => fetchAndSetUser())
.catch((err: Error) => { .catch((error: Error) => {
log.error(err) setError(
setError(err.message === RegisterError.USERNAME_EXISTING ? err.message : RegisterError.OTHER) Object.values(RegisterErrorType).includes(error.message as RegisterErrorType)
? (error.message as RegisterErrorType)
: RegisterErrorType.OTHER
)
}) })
event.preventDefault() event.preventDefault()
}, },
[username, password] [username, displayName, password]
) )
useEffect(() => { const ready = useMemo(() => {
setReady(username !== '' && password !== '' && password.length >= 8 && password === passwordAgain) return (
}, [username, password, passwordAgain]) username.trim() !== '' &&
displayName.trim() !== '' &&
password.trim() !== '' &&
password.length >= 8 &&
password === passwordAgain
)
}, [username, password, displayName, passwordAgain])
const onUsernameChange = useOnInputChange(setUsername)
const onDisplayNameChange = useOnInputChange(setDisplayName)
const onPasswordChange = useOnInputChange(setPassword)
const onPasswordAgainChange = useOnInputChange(setPasswordAgain)
if (!allowRegister) { if (!allowRegister) {
return <Redirect to={'/login'} /> return <Redirect to={'/login'} />
@ -62,102 +75,32 @@ export const RegisterPage: React.FC = () => {
} }
return ( return (
<Fragment> <div className='my-3'>
<div className='my-3'> <h1 className='mb-4'>
<h1 className='mb-4'> <Trans i18nKey='login.register.title' />
<Trans i18nKey='login.register.title' /> </h1>
</h1> <Row className='h-100 d-flex justify-content-center'>
<Row className='h-100 d-flex justify-content-center'> <Col lg={6}>
<Col lg={6}> <Card className='bg-dark mb-4 text-start'>
<Card className='bg-dark mb-4 text-start'> <Card.Body>
<Card.Body> <Form onSubmit={doRegisterSubmit}>
<Form onSubmit={doRegisterSubmit}> <UsernameField onChange={onUsernameChange} value={username} />
<Form.Group controlId='username'> <DisplayNameField onChange={onDisplayNameChange} value={displayName} />
<Form.Label> <NewPasswordField onChange={onPasswordChange} value={password} />
<Trans i18nKey='login.auth.username' /> <PasswordAgainField password={password} onChange={onPasswordAgainChange} value={passwordAgain} />
</Form.Label>
<Form.Control <RegisterInfos />
type='text'
size='sm' <Button variant='primary' type='submit' block={true} disabled={!ready}>
value={username} <Trans i18nKey='login.register.title' />
isValid={username !== ''} </Button>
onChange={(event) => setUsername(event.target.value)} </Form>
placeholder={t('login.auth.username')}
className='bg-dark text-light' <RegisterError error={error} />
autoComplete='username' </Card.Body>
autoFocus={true} </Card>
required </Col>
/> </Row>
<Form.Text> </div>
<Trans i18nKey='login.register.usernameInfo' />
</Form.Text>
</Form.Group>
<Form.Group controlId='password'>
<Form.Label>
<Trans i18nKey='login.auth.password' />
</Form.Label>
<Form.Control
type='password'
size='sm'
isValid={password !== '' && password.length >= 8}
onChange={(event) => setPassword(event.target.value)}
placeholder={t('login.auth.password')}
className='bg-dark text-light'
minLength={8}
autoComplete='new-password'
required
/>
<Form.Text>
<Trans i18nKey='login.register.passwordInfo' />
</Form.Text>
</Form.Group>
<Form.Group controlId='re-password'>
<Form.Label>
<Trans i18nKey='login.register.passwordAgain' />
</Form.Label>
<Form.Control
type='password'
size='sm'
isInvalid={passwordAgain !== '' && password !== passwordAgain}
isValid={passwordAgain !== '' && password === passwordAgain}
onChange={(event) => setPasswordAgain(event.target.value)}
placeholder={t('login.register.passwordAgain')}
className='bg-dark text-light'
autoComplete='new-password'
required
/>
</Form.Group>
<ShowIf condition={!!specialUrls.termsOfUse || !!specialUrls.privacy}>
<Trans i18nKey='login.register.infoTermsPrivacy' />
<ul>
<ShowIf condition={!!specialUrls.termsOfUse}>
<li>
<TranslatedExternalLink
i18nKey='landing.footer.termsOfUse'
href={specialUrls.termsOfUse ?? ''}
/>
</li>
</ShowIf>
<ShowIf condition={!!specialUrls.privacy}>
<li>
<TranslatedExternalLink i18nKey='landing.footer.privacy' href={specialUrls.privacy ?? ''} />
</li>
</ShowIf>
</ul>
</ShowIf>
<Button variant='primary' type='submit' block={true} disabled={!ready}>
<Trans i18nKey='login.register.title' />
</Button>
</Form>
<br />
<Alert show={error !== RegisterError.NONE} variant='danger'>
<Trans i18nKey={`login.register.error.${error}`} />
</Alert>
</Card.Body>
</Card>
</Col>
</Row>
</div>
</Fragment>
) )
} }

View file

@ -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<HTMLInputElement>) => void) => {
return useCallback(
(event) => {
setter(event.target.value)
},
[setter]
)
}

View file

@ -22,8 +22,7 @@ export const initialState: Config = {
google: false, google: false,
saml: false, saml: false,
oauth2: false, oauth2: false,
internal: false, local: false
openid: false
}, },
branding: { branding: {
name: '', name: '',

View file

@ -6,8 +6,8 @@
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import type { NoteDetails } from './types/note-details' import type { NoteDetails } from './types/note-details'
import type { SlideOptions } from './types/slide-show-options'
import { NoteTextDirection, NoteType } from './types/note-details' import { NoteTextDirection, NoteType } from './types/note-details'
import type { SlideOptions } from './types/slide-show-options'
export const initialSlideOptions: SlideOptions = { export const initialSlideOptions: SlideOptions = {
transition: 'zoom', transition: 'zoom',
@ -30,7 +30,7 @@ export const initialState: NoteDetails = {
createTime: DateTime.fromSeconds(0), createTime: DateTime.fromSeconds(0),
lastChange: { lastChange: {
timestamp: DateTime.fromSeconds(0), timestamp: DateTime.fromSeconds(0),
userName: '' username: ''
}, },
alias: '', alias: '',
viewCount: 0, viewCount: 0,

View file

@ -182,7 +182,7 @@ const convertNoteDtoToNoteDetails = (note: NoteDto): NoteDetails => {
noteTitle: initialState.noteTitle, noteTitle: initialState.noteTitle,
createTime: DateTime.fromISO(note.metadata.createTime), createTime: DateTime.fromISO(note.metadata.createTime),
lastChange: { lastChange: {
userName: note.metadata.updateUser.userName, username: note.metadata.updateUser.username,
timestamp: DateTime.fromISO(note.metadata.updateTime) timestamp: DateTime.fromISO(note.metadata.updateTime)
}, },
firstHeading: initialState.firstHeading, firstHeading: initialState.firstHeading,

View file

@ -19,7 +19,7 @@ export interface NoteDetails {
id: string id: string
createTime: DateTime createTime: DateTime
lastChange: { lastChange: {
userName: string username: string
timestamp: DateTime timestamp: DateTime
} }
viewCount: number viewCount: number

View file

@ -23,8 +23,9 @@ export interface ClearUserAction extends Action<UserActionType> {
} }
export interface UserState { export interface UserState {
id: string username: string
name: string displayName: string
email: string
photo: string photo: string
provider: LoginProvider provider: LoginProvider
} }
@ -38,9 +39,8 @@ export enum LoginProvider {
GOOGLE = 'google', GOOGLE = 'google',
SAML = 'saml', SAML = 'saml',
OAUTH2 = 'oauth2', OAUTH2 = 'oauth2',
INTERNAL = 'internal', LOCAL = 'local',
LDAP = 'ldap', LDAP = 'ldap'
OPENID = 'openid'
} }
export type OptionalUserState = UserState | null export type OptionalUserState = UserState | null