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

@ -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 (
<Fragment>
<DocumentInfobar
changedAuthor={noteDetails.lastChange.userName ?? ''}
changedAuthor={noteDetails.lastChange.username ?? ''}
changedTime={noteDetails.lastChange.timestamp}
createdAuthor={'Test'}
createdTime={noteDetails.createTime}

View file

@ -28,7 +28,7 @@ const allSupportedLinks = [
const getUserName = (): string => {
const user = store.getState().user
return user ? user.name : 'Anonymous'
return user ? user.displayName : 'Anonymous'
}
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 { 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<ButtonProps, 'href'>
export const SignInButton: React.FC<SignInButtonProps> = ({ 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<SignInButtonProps> = ({ 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 (
<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 { 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 (
<Dropdown alignRight>
<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.Menu className='text-start'>
@ -41,15 +41,7 @@ export const UserDropdown: React.FC = () => {
<Trans i18nKey='profile.userProfile' />
</Dropdown.Item>
</LinkContainer>
<Dropdown.Item
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>
<SignOutDropdownButton />
</Dropdown.Menu>
</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 { 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 () => {
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)
}
}

View file

@ -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<AuthErrorType>()
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 (
<Card className='bg-dark mb-4'>
<Card.Body>
@ -40,33 +59,9 @@ export const ViaLdap: React.FC = () => {
<Trans i18nKey='login.signInVia' values={{ service: name }} />
</Card.Title>
<Form onSubmit={onLoginSubmit}>
<Form.Group controlId='ldap-username'>
<Form.Control
isInvalid={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>
<UsernameField onChange={onUsernameChange} invalid={!!error} />
<PasswordField onChange={onPasswordChange} invalid={!!error} />
<AuthError error={error} />
<Button type='submit' variant='primary'>
<Trans i18nKey='login.signIn' />

View file

@ -4,33 +4,54 @@
* 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 { Alert, Button, Card, Form } from 'react-bootstrap'
import { Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
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 { 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'
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 [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(false)
const [error, setError] = useState<AuthErrorType>()
const allowRegister = useApplicationState((state) => state.config.allowRegister)
const onLoginSubmit = useCallback(
(event: FormEvent) => {
doInternalLogin(username, password)
doLocalLogin(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 = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value)
}, [])
const onPasswordChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value)
}, [])
return (
<Card className='bg-dark mb-4'>
<Card.Body>
@ -38,33 +59,9 @@ export const ViaInternal: React.FC = () => {
<Trans i18nKey='login.signInVia' values={{ service: t('login.auth.username') }} />
</Card.Title>
<Form onSubmit={onLoginSubmit}>
<Form.Group controlId='internal-username'>
<Form.Control
isInvalid={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>
<UsernameField onChange={onUsernameChange} invalid={!!error} />
<PasswordField onChange={onPasswordChange} invalid={!!error} />
<AuthError error={error} />
<div className='flex flex-row' dir='auto'>
<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
*/
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) => (
<div className='p-2 d-flex flex-column social-button-container' key={value}>
<ViaOneClick oneClickType={value} optionalName={oneClickCustomName(value)} />
</div>
))
}, [authProviders, oneClickCustomName])
if (userLoggedIn) {
// TODO Redirect to previous page?
@ -53,17 +69,14 @@ export const LoginPage: React.FC = () => {
<Fragment>
<div className='my-3'>
<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}>
<ShowIf condition={authProviders.internal}>
<ViaInternal />
<ShowIf condition={authProviders.local}>
<ViaLocal />
</ShowIf>
<ShowIf condition={authProviders.ldap}>
<ViaLdap />
</ShowIf>
<ShowIf condition={authProviders.openid}>
<ViaOpenId />
</ShowIf>
</Col>
</ShowIf>
<ShowIf condition={oneClickProviders.includes(true)}>
@ -73,13 +86,7 @@ export const LoginPage: React.FC = () => {
<Card.Title>
<Trans i18nKey='login.signInVia' values={{ service: '' }} />
</Card.Title>
{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>
))}
{oneClickButtonsDom}
</Card.Body>
</Card>
</Col>

View file

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

View file

@ -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<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 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 (
<Card className='bg-dark mb-4'>
<Card.Body>
@ -57,53 +52,11 @@ export const ProfileChangePassword: React.FC = () => {
<Trans i18nKey='profile.changePassword.title' />
</Card.Title>
<Form onSubmit={onSubmitPasswordChange} className='text-left'>
<Form.Group controlId='oldPassword'>
<Form.Label>
<Trans i18nKey='profile.changePassword.old' />
</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>
<CurrentPasswordField onChange={onChangeOldPassword} value={oldPassword} />
<NewPasswordField onChange={onChangeNewPassword} value={newPassword} />
<PasswordAgainField password={newPassword} onChange={onChangeNewPasswordAgain} value={newPasswordAgain} />
<Button type='submit' variant='primary' disabled={!newPasswordValid || !newPasswordAgainValid}>
<Button type='submit' variant='primary' disabled={!ready}>
<Trans i18nKey='common.save' />
</Button>
</Form>

View file

@ -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<HTMLInputElement>) => {
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 (
<Card className='bg-dark mb-4'>
@ -52,24 +45,7 @@ export const ProfileDisplayName: React.FC = () => {
<Trans i18nKey='profile.userProfile' />
</Card.Title>
<Form onSubmit={onSubmitNameChange} className='text-left'>
<Form.Group controlId='displayName'>
<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>
<DisplayNameField onChange={onChangeDisplayName} value={displayName} initialValue={userName} />
<Button type='submit' variant='primary' disabled={!formSubmittable}>
<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 React, { Fragment, useCallback, useEffect, useState } from 'react'
import { Alert, Button, Card, Col, Form, Row } from 'react-bootstrap'
import React, { useCallback, useMemo, useState } from 'react'
import { Button, Card, Col, Form, Row } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
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 { TranslatedExternalLink } from '../common/links/translated-external-link'
import { ShowIf } from '../common/show-if/show-if'
import { fetchAndSetUser } from '../login-page/auth/utils'
import { Logger } from '../../utils/logger'
const log = new Logger('RegisterPage')
export enum RegisterError {
NONE = 'none',
USERNAME_EXISTING = 'usernameExisting',
OTHER = 'other'
}
import { RegisterError as RegisterErrorType } from '../../api/auth'
import { RegisterInfos } from './register-infos/register-infos'
import { UsernameField } from '../common/fields/username-field'
import { DisplayNameField } from '../common/fields/display-name-field'
import { NewPasswordField } from '../common/fields/new-password-field'
import { PasswordAgainField } from '../common/fields/password-again-field'
import { useOnInputChange } from '../../hooks/common/use-on-input-change'
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 = () => {
const { t } = useTranslation()
useTranslation()
const allowRegister = useApplicationState((state) => state.config.allowRegister)
const specialUrls = useApplicationState((state) => state.config.specialUrls)
const userExists = useApplicationState((state) => !!state.user)
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [password, setPassword] = useState('')
const [passwordAgain, setPasswordAgain] = useState('')
const [error, setError] = useState(RegisterError.NONE)
const [ready, setReady] = useState(false)
const [error, setError] = useState<RegisterErrorType>()
const doRegisterSubmit = useCallback(
(event: FormEvent) => {
doInternalRegister(username, password)
doLocalRegister(username, displayName, password)
.then(() => fetchAndSetUser())
.catch((err: Error) => {
log.error(err)
setError(err.message === RegisterError.USERNAME_EXISTING ? err.message : RegisterError.OTHER)
.catch((error: Error) => {
setError(
Object.values(RegisterErrorType).includes(error.message as RegisterErrorType)
? (error.message as RegisterErrorType)
: RegisterErrorType.OTHER
)
})
event.preventDefault()
},
[username, password]
[username, displayName, password]
)
useEffect(() => {
setReady(username !== '' && password !== '' && password.length >= 8 && password === passwordAgain)
}, [username, password, passwordAgain])
const ready = useMemo(() => {
return (
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) {
return <Redirect to={'/login'} />
@ -62,102 +75,32 @@ export const RegisterPage: React.FC = () => {
}
return (
<Fragment>
<div className='my-3'>
<h1 className='mb-4'>
<Trans i18nKey='login.register.title' />
</h1>
<Row className='h-100 d-flex justify-content-center'>
<Col lg={6}>
<Card className='bg-dark mb-4 text-start'>
<Card.Body>
<Form onSubmit={doRegisterSubmit}>
<Form.Group controlId='username'>
<Form.Label>
<Trans i18nKey='login.auth.username' />
</Form.Label>
<Form.Control
type='text'
size='sm'
value={username}
isValid={username !== ''}
onChange={(event) => setUsername(event.target.value)}
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>
<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>
<div className='my-3'>
<h1 className='mb-4'>
<Trans i18nKey='login.register.title' />
</h1>
<Row className='h-100 d-flex justify-content-center'>
<Col lg={6}>
<Card className='bg-dark mb-4 text-start'>
<Card.Body>
<Form onSubmit={doRegisterSubmit}>
<UsernameField onChange={onUsernameChange} value={username} />
<DisplayNameField onChange={onDisplayNameChange} value={displayName} />
<NewPasswordField onChange={onPasswordChange} value={password} />
<PasswordAgainField password={password} onChange={onPasswordAgainChange} value={passwordAgain} />
<RegisterInfos />
<Button variant='primary' type='submit' block={true} disabled={!ready}>
<Trans i18nKey='login.register.title' />
</Button>
</Form>
<RegisterError error={error} />
</Card.Body>
</Card>
</Col>
</Row>
</div>
)
}