feat(auth): refactor auth, add oidc
Some checks are pending
Docker / build-and-push (frontend) (push) Waiting to run
Docker / build-and-push (backend) (push) Waiting to run
Deploy HD2 docs to Netlify / Deploys to netlify (push) Waiting to run
E2E Tests / backend-sqlite (push) Waiting to run
E2E Tests / backend-mariadb (push) Waiting to run
E2E Tests / backend-postgres (push) Waiting to run
E2E Tests / Build test build of frontend (push) Waiting to run
E2E Tests / frontend-cypress (1) (push) Blocked by required conditions
E2E Tests / frontend-cypress (2) (push) Blocked by required conditions
E2E Tests / frontend-cypress (3) (push) Blocked by required conditions
Lint and check format / Lint files and check formatting (push) Waiting to run
REUSE Compliance Check / reuse (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Static Analysis / Njsscan code scanning (push) Waiting to run
Static Analysis / CodeQL analysis (push) Waiting to run
Run tests & build / Test and build with NodeJS 20 (push) Waiting to run

Thanks to all HedgeDoc team members for the time discussing,
helping with weird Nest issues, providing feedback
and suggestions!

Co-authored-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-03-23 02:10:25 +01:00
parent 1609f3e01f
commit 7f665fae4b
109 changed files with 2927 additions and 1700 deletions

View file

@ -1,16 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useTranslatedText } from '../../../hooks/common/use-translated-text'
import type { CommonFieldProps } from './fields'
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { Form } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
interface DisplayNameFieldProps extends CommonFieldProps {
initialValue?: string
onValidityChange?: (valid: boolean) => void
}
/**
@ -19,10 +21,21 @@ interface DisplayNameFieldProps extends CommonFieldProps {
* @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.
* @param onValidityChange Callback that is called when the validity of the field changes.
*/
export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, value, initialValue }) => {
export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({
onChange,
value,
initialValue,
onValidityChange
}) => {
const isValid = useMemo(() => value.trim() !== '' && value !== initialValue, [value, initialValue])
const placeholderText = useTranslatedText('profile.displayName')
const profileEditsAllowed = useFrontendConfig().allowProfileEdits
useEffect(() => {
onValidityChange?.(isValid)
}, [isValid, onValidityChange])
return (
<Form.Group>
@ -37,6 +50,7 @@ export const DisplayNameField: React.FC<DisplayNameFieldProps> = ({ onChange, va
onChange={onChange}
placeholder={placeholderText}
autoComplete='name'
disabled={!profileEditsAllowed}
required
/>
<Form.Text>

View file

@ -1,12 +1,13 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { ChangeEvent } from 'react'
import type { ChangeEventHandler } from 'react'
export interface CommonFieldProps {
onChange: (event: ChangeEvent<HTMLInputElement>) => void
value: string
export interface CommonFieldProps<ValueType = undefined> {
onChange: ValueType extends undefined ? ChangeEventHandler : (set: ValueType) => void
value: ValueType extends undefined ? string : ValueType
hasError?: boolean
disabled?: boolean
}

View file

@ -16,7 +16,7 @@ import { Trans } from 'react-i18next'
* @param value The currently entered password.
*/
export const NewPasswordField: React.FC<CommonFieldProps> = ({ onChange, value, hasError = false }) => {
const isValid = useMemo(() => value.trim() !== '', [value])
const isValid = useMemo(() => value.length >= 6, [value])
const placeholderText = useTranslatedText('login.auth.password')

View file

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import type { CommonFieldProps } from './fields'
import { Form } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { useAvatarUrl } from '../user-avatar/hooks/use-avatar-url'
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
export enum ProfilePictureChoice {
PROVIDER,
FALLBACK
}
export interface ProfilePictureSelectFieldProps extends CommonFieldProps<ProfilePictureChoice> {
onChange: (choice: ProfilePictureChoice) => void
value: ProfilePictureChoice
pictureUrl?: string
username: string
}
/**
* A field to select the profile picture.
* @param onChange The callback to call when the value changes.
* @param pictureUrl The URL of the picture provided by the identity provider.
* @param username The username of the user.
* @param value The current value of the field.
*/
export const ProfilePictureSelectField: React.FC<ProfilePictureSelectFieldProps> = ({
onChange,
pictureUrl,
username,
value
}) => {
const fallbackUrl = useAvatarUrl(undefined, username)
const profileEditsAllowed = useFrontendConfig().allowProfileEdits
const onSetProviderPicture = useCallback(() => {
if (value !== ProfilePictureChoice.PROVIDER) {
onChange(ProfilePictureChoice.PROVIDER)
}
}, [onChange, value])
const onSetFallbackPicture = useCallback(() => {
if (value !== ProfilePictureChoice.FALLBACK) {
onChange(ProfilePictureChoice.FALLBACK)
}
}, [onChange, value])
if (!profileEditsAllowed) {
return null
}
return (
<Form.Group>
<Form.Label>
<Trans i18nKey='profile.selectProfilePicture.title' />
</Form.Label>
{pictureUrl && (
<Form.Check className={'d-flex gap-2 align-items-center mb-3'} type='radio'>
<Form.Check.Input
type={'radio'}
checked={value === ProfilePictureChoice.PROVIDER}
onChange={onSetProviderPicture}
/>
<Form.Check.Label>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={pictureUrl} alt={'Profile picture provided by the identity provider'} height={48} width={48} />
</Form.Check.Label>
</Form.Check>
)}
<Form.Check className={'d-flex gap-2 align-items-center'} type='radio'>
<Form.Check.Input
type='radio'
checked={value === ProfilePictureChoice.FALLBACK}
onChange={onSetFallbackPicture}
/>
<Form.Check.Label>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img alt={'Fallback profile picture'} src={fallbackUrl} height={48} width={48} />
</Form.Check.Label>
</Form.Check>
<Form.Text>
<Trans i18nKey='profile.selectProfilePicture.info' />
</Form.Text>
</Form.Group>
)
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -11,6 +11,7 @@ import { Form } from 'react-bootstrap'
export interface UsernameFieldProps extends CommonFieldProps {
isInvalid?: boolean
isValid?: boolean
onValidityChange?: (valid: boolean) => void
}
/**
@ -21,7 +22,7 @@ export interface UsernameFieldProps extends CommonFieldProps {
* @param isValid Is a valid field or not
* @param isInvalid Adds error style to label
*/
export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, isValid, isInvalid }) => {
export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, isValid, isInvalid, disabled }) => {
const placeholderText = useTranslatedText('login.auth.username')
return (
@ -29,10 +30,12 @@ export const UsernameField: React.FC<UsernameFieldProps> = ({ onChange, value, i
type='text'
size='sm'
value={value}
maxLength={64}
isValid={isValid}
isInvalid={isInvalid}
onChange={onChange}
placeholder={placeholderText}
disabled={disabled}
autoComplete='username'
autoFocus={true}
required

View file

@ -1,29 +1,77 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { UsernameFieldProps } from './username-field'
import { UsernameField } from './username-field'
import React from 'react'
import React, { useEffect, useState } from 'react'
import { Form } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { Trans, useTranslation } from 'react-i18next'
import { useDebounce } from 'react-use'
import { checkUsernameAvailability } from '../../../api/auth'
import { Logger } from '../../../utils/logger'
import { useFrontendConfig } from '../frontend-config-context/use-frontend-config'
import { REGEX_USERNAME } from '@hedgedoc/commons'
const logger = new Logger('UsernameLabelField')
/**
* Wraps and contains label and info for UsernameField
*
* @param onChange Callback that is called when the entered username changes.
* @param value The currently entered username.
* @param isValid Is a valid field or not
* @param isInvalid Adds error style to label
* @param onValidityChange Callback that is called when the validity of the field changes.
* @param props Additional props for the UsernameField.
*/
export const UsernameLabelField: React.FC<UsernameFieldProps> = (props) => {
export const UsernameLabelField: React.FC<UsernameFieldProps> = ({ value, onValidityChange, ...props }) => {
useTranslation()
const [usernameValid, setUsernameValid] = useState(false)
const [usernameInvalid, setUsernameInvalid] = useState(false)
const usernameChoosingAllowed = useFrontendConfig().allowChooseUsername
useDebounce(
() => {
if (value === '') {
setUsernameValid(false)
setUsernameInvalid(false)
return
}
if (!REGEX_USERNAME.test(value)) {
setUsernameValid(false)
setUsernameInvalid(true)
return
}
checkUsernameAvailability(value)
.then((available) => {
setUsernameValid(available)
setUsernameInvalid(!available)
})
.catch((error) => {
logger.error('Failed to check username availability', error)
setUsernameValid(false)
setUsernameInvalid(false)
})
},
500,
[value]
)
useEffect(() => {
onValidityChange?.(usernameValid && !usernameInvalid)
}, [usernameValid, usernameInvalid, onValidityChange])
return (
<Form.Group>
<Form.Label>
<Trans i18nKey='login.auth.username' />
</Form.Label>
<UsernameField {...props} />
<UsernameField
value={value}
{...props}
disabled={!usernameChoosingAllowed}
isInvalid={usernameInvalid}
isValid={usernameValid}
/>
<Form.Text>
<Trans i18nKey='login.register.usernameInfo' />
</Form.Text>

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -12,17 +12,17 @@ import * as identicon from '@dicebear/identicon'
* When an empty or no photoUrl is given, a random avatar is generated from the displayName.
*
* @param photoUrl The photo url of the user to use. Maybe empty or not set.
* @param displayName The display name of the user to use as input to the random avatar.
* @param username The username of the user to use as input to the random avatar.
* @return The correct avatar url for the user.
*/
export const useAvatarUrl = (photoUrl: string | undefined, displayName: string): string => {
export const useAvatarUrl = (photoUrl: string | undefined, username: string): string => {
return useMemo(() => {
if (photoUrl && photoUrl.trim() !== '') {
return photoUrl
}
const avatar = createAvatar(identicon, {
seed: displayName
seed: username
})
return avatar.toDataUri()
}, [photoUrl, displayName])
}, [photoUrl, username])
}

View file

@ -19,5 +19,5 @@ export interface UserAvatarForUserProps extends Omit<UserAvatarProps, 'photoUrl'
* @param props remaining avatar props
*/
export const UserAvatarForUser: React.FC<UserAvatarForUserProps> = ({ user, ...props }) => {
return <UserAvatar displayName={user.displayName} photoUrl={user.photoUrl} {...props} />
return <UserAvatar displayName={user.displayName} photoUrl={user.photoUrl} username={user.username} {...props} />
}

View file

@ -1,13 +1,13 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getUser } from '../../../api/users'
import { getUserInfo } from '../../../api/users'
import { AsyncLoadingBoundary } from '../async-loading-boundary/async-loading-boundary'
import type { UserAvatarProps } from './user-avatar'
import { UserAvatar } from './user-avatar'
import React, { Fragment, useMemo } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useAsync } from 'react-use'
@ -28,15 +28,18 @@ export const UserAvatarForUsername: React.FC<UserAvatarForUsernameProps> = ({ us
const { t } = useTranslation()
const { error, value, loading } = useAsync(async (): Promise<{ displayName: string; photo?: string }> => {
return username
? await getUser(username)
? await getUserInfo(username)
: {
displayName: t('common.guestUser')
}
}, [username, t])
const avatar = useMemo(() => {
return !value ? <Fragment /> : <UserAvatar displayName={value.displayName} photoUrl={value.photo} {...props} />
}, [props, value])
if (!value) {
return null
}
return <UserAvatar displayName={value.displayName} photoUrl={value.photo} username={username} {...props} />
}, [props, value, username])
return (
<AsyncLoadingBoundary loading={loading || !value} error={error} componentName={'UserAvatarForUsername'}>

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -14,6 +14,7 @@ export interface UserAvatarProps {
showName?: boolean
photoUrl?: string
displayName: string
username?: string | null
}
/**
@ -29,7 +30,8 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
displayName,
size,
additionalClasses = '',
showName = true
showName = true,
username
}) => {
const imageSize = useMemo(() => {
switch (size) {
@ -42,7 +44,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
}
}, [size])
const avatarUrl = useAvatarUrl(photoUrl, displayName)
const avatarUrl = useAvatarUrl(photoUrl, username ?? displayName)
const imageTranslateOptions = useMemo(
() => ({

View file

@ -1,10 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { removeUserPermission, setUserPermission } from '../../../../../../api/permissions'
import { getUser } from '../../../../../../api/users'
import { getUserInfo } from '../../../../../../api/users'
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../../../redux/note-details/methods'
import { UserAvatarForUser } from '../../../../../common/user-avatar/user-avatar-for-user'
@ -79,7 +79,7 @@ export const PermissionEntryUser: React.FC<PermissionEntryUserProps & Permission
}, [noteId, entry.username, showErrorNotification])
const { value, loading, error } = useAsync(async () => {
return await getUser(entry.username)
return await getUserInfo(entry.username)
}, [entry.username])
if (!value) {

View file

@ -1,10 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RevisionDetails } from '../../../../../../api/revisions/types'
import { getUser } from '../../../../../../api/users'
import { getUserInfo } from '../../../../../../api/users'
import type { UserInfo } from '../../../../../../api/users/types'
import { download } from '../../../../../common/download/download'
@ -34,7 +34,7 @@ export const getUserDataForRevision = async (usernames: string[]): Promise<UserI
const users: UserInfo[] = []
const usersToFetch = Math.min(usernames.length, DISPLAY_MAX_USERS_PER_REVISION) - 1
for (let i = 0; i <= usersToFetch; i++) {
const user = await getUser(usernames[i])
const user = await getUserInfo(usernames[i])
users.push(user)
}
return users

View file

@ -25,7 +25,7 @@ export const SignOutDropdownButton: React.FC = () => {
const onSignOut = useCallback(() => {
clearUser()
doLogout()
.then(() => router.push('/login'))
.then((logoutResponse) => router.push(logoutResponse.redirect))
.catch(showErrorNotification('login.logoutFailed'))
}, [showErrorNotification, router])

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React from 'react'
import { Col, Container, Row } from 'react-bootstrap'
import { EditorToRendererCommunicatorContextProvider } from '../editor-page/render-context/editor-to-renderer-communicator-context-provider'
import { HedgeDocLogoVertical } from '../common/hedge-doc-logo/hedge-doc-logo-vertical'
import { LogoSize } from '../common/hedge-doc-logo/logo-size'
import { Trans } from 'react-i18next'
import { CustomBranding } from '../common/custom-branding/custom-branding'
import { IntroCustomContent } from '../intro-page/intro-custom-content'
/**
* Layout for the login page with the intro content on the left and children on the right.
* @param children The content to show on the right
*/
export const LoginLayout: React.FC<PropsWithChildren> = ({ children }) => {
return (
<Container>
<Row>
<Col xs={8}>
<EditorToRendererCommunicatorContextProvider>
<div className={'d-flex flex-column align-items-center mt-3'}>
<HedgeDocLogoVertical size={LogoSize.BIG} autoTextColor={true} />
<h5>
<Trans i18nKey='app.slogan' />
</h5>
<div className={'mb-5'}>
<CustomBranding />
</div>
<IntroCustomContent />
</div>
</EditorToRendererCommunicatorContextProvider>
</Col>
<Col xs={4} className={'pt-3 d-flex gap-3 flex-column'}>
{children}
</Col>
</Row>
</Container>
)
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -13,6 +13,7 @@ import { Alert, Button, Card, Form } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { fetchAndSetUser } from '../utils/fetch-and-set-user'
import { PasswordField } from '../password-field'
import { useRouter } from 'next/navigation'
export interface ViaLdapProps {
providerName: string
@ -25,18 +26,32 @@ export interface ViaLdapProps {
export const LdapLoginCard: React.FC<ViaLdapProps> = ({ providerName, identifier }) => {
useTranslation()
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState<string>()
const onLoginSubmit = useCallback(
(event: FormEvent) => {
let redirect = false
doLdapLogin(identifier, username, password)
.then(() => fetchAndSetUser())
.catch((error: Error) => setError(error.message))
.then((response) => {
if (response.newUser) {
router.push('/new-user')
} else {
redirect = true
return fetchAndSetUser()
}
})
.then(() => {
if (redirect) {
router.push('/')
}
})
.catch((error: Error) => setError(String(error)))
event.preventDefault()
},
[username, password, identifier]
[username, password, identifier, router]
)
const onUsernameChange = useLowercaseOnInputChange(setUsername)
@ -49,10 +64,10 @@ export const LdapLoginCard: React.FC<ViaLdapProps> = ({ providerName, identifier
<Trans i18nKey='login.signInVia' values={{ service: providerName }} />
</Card.Title>
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
<UsernameField onChange={onUsernameChange} isValid={!!error} value={username} />
<PasswordField onChange={onPasswordChange} invalid={!!error} />
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />
<PasswordField onChange={onPasswordChange} isInvalid={!!error} />
<Alert className='small' show={!!error} variant='danger'>
<Trans i18nKey={error} />
{error}
</Alert>
<Button type='submit' variant='primary'>

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -53,7 +53,7 @@ export const LocalLoginCardBody: React.FC = () => {
</Card.Title>
<Form onSubmit={onLoginSubmit} className={'d-flex gap-3 flex-column'}>
<UsernameField onChange={onUsernameChange} isInvalid={!!error} value={username} />
<PasswordField onChange={onPasswordChange} invalid={!!error} />
<PasswordField onChange={onPasswordChange} isInvalid={!!error} />
<Alert className='small' show={!!error} variant='danger'>
<Trans i18nKey={error} />
</Alert>

View file

@ -1,9 +1,3 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
@ -25,9 +19,10 @@ import { UsernameLabelField } from '../../../common/fields/username-label-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 { RegisterInfos } from '../../../register-page/register-infos'
import { RegisterError } from '../../../register-page/register-error'
import { RegisterInfos } from './register-infos'
import { RegisterError } from './register-error'
import { fetchAndSetUser } from '../../utils/fetch-and-set-user'
import { useGetPostLoginRedirectUrl } from '../../utils/use-get-post-login-redirect-url'
/**
* Renders the registration process with fields for username, display name, password, password retype and information about terms and conditions.
@ -43,29 +38,34 @@ export const LocalRegisterForm: NextPage = () => {
const [error, setError] = useState<ApiError>()
const { dispatchUiNotification } = useUiNotifications()
const postLoginRedirectUrl = useGetPostLoginRedirectUrl()
const doRegisterSubmit = useCallback(
(event: FormEvent) => {
doLocalRegister(username, displayName, password)
.then(() => fetchAndSetUser())
.then(() => dispatchUiNotification('login.register.success.title', 'login.register.success.message', {}))
.then(() => router.push('/history'))
.then(() => router.push(postLoginRedirectUrl))
.catch((error: ApiError) => setError(error))
event.preventDefault()
},
[username, displayName, password, dispatchUiNotification, router]
[username, displayName, password, dispatchUiNotification, router, postLoginRedirectUrl]
)
const ready = useMemo(() => {
return username.trim() !== '' && displayName.trim() !== '' && password.trim() !== '' && password === passwordAgain
return (
username.length >= 3 &&
username.length <= 64 &&
displayName.trim() !== '' &&
password.length >= 6 &&
password === passwordAgain
)
}, [username, password, displayName, passwordAgain])
const isWeakPassword = useMemo(() => {
return error?.backendErrorName === 'PasswordTooWeakError'
}, [error])
const isValidUsername = useMemo(() => Boolean(username.trim()), [username])
const onUsernameChange = useLowercaseOnInputChange(setUsername)
const onDisplayNameChange = useOnInputChange(setDisplayName)
const onPasswordChange = useOnInputChange(setPassword)
@ -73,7 +73,7 @@ export const LocalRegisterForm: NextPage = () => {
return (
<Form onSubmit={doRegisterSubmit} className={'d-flex flex-column gap-3'}>
<UsernameLabelField onChange={onUsernameChange} value={username} isValid={isValidUsername} />
<UsernameLabelField onChange={onUsernameChange} value={username} />
<DisplayNameField onChange={onDisplayNameChange} value={displayName} />
<NewPasswordField onChange={onPasswordChange} value={password} hasError={isWeakPassword} />
<PasswordAgainField

View file

@ -1,9 +1,9 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ErrorToI18nKeyMapper } from '../../api/common/error-to-i18n-key-mapper'
import { ErrorToI18nKeyMapper } from '../../../../api/common/error-to-i18n-key-mapper'
import React, { useMemo } from 'react'
import { Alert } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'

View file

@ -1,10 +1,10 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useFrontendConfig } from '../common/frontend-config-context/use-frontend-config'
import { TranslatedExternalLink } from '../common/links/translated-external-link'
import { useFrontendConfig } from '../../../common/frontend-config-context/use-frontend-config'
import { TranslatedExternalLink } from '../../../common/links/translated-external-link'
import React from 'react'
import { Trans, useTranslation } from 'react-i18next'

View file

@ -0,0 +1,124 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Button, Card, Form } from 'react-bootstrap'
import { Trans } from 'react-i18next'
import { useAsync } from 'react-use'
import { cancelPendingUser, confirmPendingUser, getPendingUserInfo } from '../../../api/auth/pending-user'
import { useRouter } from 'next/navigation'
import { useUiNotifications } from '../../notifications/ui-notification-boundary'
import { UsernameLabelField } from '../../common/fields/username-label-field'
import { DisplayNameField } from '../../common/fields/display-name-field'
import { ProfilePictureChoice, ProfilePictureSelectField } from '../../common/fields/profile-picture-select-field'
import { useOnInputChange } from '../../../hooks/common/use-on-input-change'
import { fetchAndSetUser } from '../utils/fetch-and-set-user'
/**
* The card where a new user can enter their user information.
*/
export const NewUserCard: React.FC = () => {
const router = useRouter()
const { showErrorNotification } = useUiNotifications()
const { value, error, loading } = useAsync(getPendingUserInfo, [])
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [pictureChoice, setPictureChoice] = useState(ProfilePictureChoice.FALLBACK)
const [isUsernameSubmittable, setIsUsernameSubmittable] = useState(false)
const [isDisplayNameSubmittable, setIsDisplayNameSubmittable] = useState(false)
const isSubmittable = useMemo(() => {
return isUsernameSubmittable && isDisplayNameSubmittable
}, [isUsernameSubmittable, isDisplayNameSubmittable])
const onChangeUsername = useOnInputChange(setUsername)
const onChangeDisplayName = useOnInputChange(setDisplayName)
const submitUserdata = useCallback(() => {
confirmPendingUser({
username,
displayName,
profilePicture: pictureChoice === ProfilePictureChoice.PROVIDER ? value?.photoUrl : undefined
})
.then(() => fetchAndSetUser())
.then(() => {
router.push('/')
})
.catch(showErrorNotification('login.welcome.error'))
}, [username, displayName, pictureChoice, router, showErrorNotification, value?.photoUrl])
const cancelUserCreation = useCallback(() => {
cancelPendingUser()
.catch(showErrorNotification('login.welcome.cancelError'))
.finally(() => {
router.push('/login')
})
}, [router, showErrorNotification])
useEffect(() => {
if (error) {
showErrorNotification('login.welcome.error')(error)
router.push('/login')
}
}, [error, router, showErrorNotification])
useEffect(() => {
if (!value) {
return
}
setUsername(value.username ?? '')
setDisplayName(value.displayName ?? '')
if (value.photoUrl) {
setPictureChoice(ProfilePictureChoice.PROVIDER)
}
}, [value])
if (!value && !loading) {
return null
}
return (
<Card>
<Card.Body>
{loading && <p>Loading...</p>}
<Card.Title>
{displayName !== '' ? (
<Trans i18nKey={'login.welcome.title'} values={{ name: displayName }} />
) : (
<Trans i18nKey={'login.welcome.titleFallback'} />
)}
</Card.Title>
<Trans i18nKey={'login.welcome.description'} />
<hr />
<Form onSubmit={submitUserdata} className={'d-flex flex-column gap-3'}>
<DisplayNameField
onChange={onChangeDisplayName}
value={displayName}
onValidityChange={setIsDisplayNameSubmittable}
/>
<UsernameLabelField
onChange={onChangeUsername}
value={username}
onValidityChange={setIsUsernameSubmittable}
/>
<ProfilePictureSelectField
onChange={setPictureChoice}
value={pictureChoice}
pictureUrl={value?.photoUrl}
username={username}
/>
<div className={'d-flex gap-3'}>
<Button variant={'secondary'} type={'button'} className={'w-50'} onClick={cancelUserCreation}>
<Trans i18nKey={'common.cancel'} />
</Button>
<Button variant={'success'} type={'submit'} className={'w-50'} disabled={!isSubmittable}>
<Trans i18nKey={'common.continue'} />
</Button>
</div>
</Form>
</Card.Body>
</Card>
)
}

View file

@ -1,21 +1,25 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import styles from './via-one-click.module.scss'
import type { Icon } from 'react-bootstrap-icons'
import {
Exclamation as IconExclamation,
Github as IconGithub,
Google as IconGoogle,
People as IconPeople,
PersonRolodex as IconPersonRolodex
PersonRolodex as IconPersonRolodex,
Microsoft as IconMicrosoft,
Paypal as IconPaypal,
Discord as IconDiscord,
Facebook as IconFacebook,
Mastodon as IconMastodon
} from 'react-bootstrap-icons'
import { Logger } from '../../../utils/logger'
import type { AuthProvider } from '../../../api/config/types'
import { AuthProviderType } from '../../../api/config/types'
import { IconGitlab } from '../../common/icons/additional/icon-gitlab'
import styles from './one-click-login-button.module.scss'
export interface OneClickMetadata {
name: string
@ -24,10 +28,6 @@ export interface OneClickMetadata {
url: string
}
const getBackendAuthUrl = (providerIdentifer: string): string => {
return `/auth/${providerIdentifer}`
}
const logger = new Logger('GetOneClickProviderMetadata')
/**
@ -37,49 +37,57 @@ const logger = new Logger('GetOneClickProviderMetadata')
* @return Name, icon, URL and CSS class of the given provider for rendering a login button.
*/
export const getOneClickProviderMetadata = (provider: AuthProvider): OneClickMetadata => {
switch (provider.type) {
case AuthProviderType.GITHUB:
return {
name: 'GitHub',
icon: IconGithub,
className: styles['btn-social-github'],
url: getBackendAuthUrl('github')
}
case AuthProviderType.GITLAB:
return {
name: provider.providerName,
icon: IconGitlab,
className: styles['btn-social-gitlab'],
url: getBackendAuthUrl(provider.identifier)
}
case AuthProviderType.GOOGLE:
return {
name: 'Google',
icon: IconGoogle,
className: styles['btn-social-google'],
url: getBackendAuthUrl('google')
}
case AuthProviderType.OAUTH2:
return {
name: provider.providerName,
icon: IconPersonRolodex,
className: 'btn-primary',
url: getBackendAuthUrl(provider.identifier)
}
case AuthProviderType.SAML:
return {
name: provider.providerName,
icon: IconPeople,
className: 'btn-success',
url: getBackendAuthUrl(provider.identifier)
}
default:
logger.warn('Metadata for one-click-provider does not exist', provider)
return {
name: '',
icon: IconExclamation,
className: '',
url: '#'
}
if (provider.type !== AuthProviderType.OIDC) {
logger.warn('Metadata for one-click-provider does not exist', provider)
return {
name: '',
icon: IconExclamation,
className: '',
url: '#'
}
}
let icon: Icon = IconPersonRolodex
let className: string = 'btn-primary'
switch (provider.theme) {
case 'github':
className = styles.github
icon = IconGithub
break
case 'google':
className = styles.google
icon = IconGoogle
break
case 'gitlab':
className = styles.gitlab
icon = IconGitlab
break
case 'facebook':
className = styles.facebook
icon = IconFacebook
break
case 'mastodon':
className = styles.mastodon
icon = IconMastodon
break
case 'discord':
className = styles.discord
icon = IconDiscord
break
case 'paypal':
className = styles.paypal
icon = IconPaypal
break
case 'azure':
case 'microsoft':
case 'outlook':
className = styles.azure
icon = IconMicrosoft
break
}
return {
name: provider.providerName,
icon,
className,
url: `/api/private/auth/oidc/${provider.identifier}`
}
}

View file

@ -0,0 +1,50 @@
/*!
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@function brightness($color) {
@return ((red($color) * 299) + (green($color) * 587) + (blue($color) * 114)) / 1000;
}
@mixin button($color) {
$font-color: if(brightness($color) > 128, #000000, #ffffff);
color: $font-color;
background-color: $color;
&:hover {
background-color: darken($color, 15%);
}
}
.github {
@include button(#444444);
}
.gitlab {
@include button(#FA7035);
}
.google {
@include button(#DD4B39);
}
.azure {
@include button(#008AD7);
}
.facebook {
@include button(#0165E1);
}
.mastodon {
@include button(#563ACC);
}
.discord {
@include button(#5865F2);
}
.paypal {
@include button(#00457C);
}

View file

@ -1,25 +0,0 @@
/*!
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@mixin button($color) {
color: #ffffff;
background-color: $color;
&:hover {
background-color: darken($color, 15%);
}
}
.btn-social-github {
@include button(#444444);
}
.btn-social-gitlab {
@include button(#FA7035);
}
.btn-social-google {
@include button(#DD4B39);
}

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -10,7 +10,7 @@ import { useTranslatedText } from '../../hooks/common/use-translated-text'
export interface AuthFieldProps {
onChange: (event: ChangeEvent<HTMLInputElement>) => void
invalid: boolean
isInvalid: boolean
}
/**
@ -19,13 +19,13 @@ export interface AuthFieldProps {
* @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 }) => {
export const PasswordField: React.FC<AuthFieldProps> = ({ onChange, isInvalid }) => {
const placeholderText = useTranslatedText('login.auth.password')
return (
<Form.Group>
<Form.Control
isInvalid={invalid}
isInvalid={isInvalid}
type='password'
size='sm'
placeholder={placeholderText}

View file

@ -6,19 +6,13 @@
import React from 'react'
import { Redirect } from '../common/redirect'
import { useSingleStringUrlParameter } from '../../hooks/common/use-single-string-url-parameter'
const defaultFallback = '/history'
import { useGetPostLoginRedirectUrl } from './utils/use-get-post-login-redirect-url'
/**
* Redirects the browser to the relative URL that is provided via "redirectBackTo" URL parameter.
* If no parameter has been provided or if the URL is not relative then "/history" will be used.
* If no parameter has been provided or if the URL is not relative, then "/history" will be used.
*/
export const RedirectToParamOrHistory: React.FC = () => {
const redirectBackUrl = useSingleStringUrlParameter('redirectBackTo', defaultFallback)
const cleanedUrl =
redirectBackUrl.startsWith('/') && !redirectBackUrl.startsWith('//') ? redirectBackUrl : defaultFallback
return <Redirect to={cleanedUrl} replace={true} />
const redirectUrl = useGetPostLoginRedirectUrl()
return <Redirect to={redirectUrl} replace={true} />
}

View file

@ -0,0 +1,17 @@
/*
* SPDX-FileCopyrightText: 2024 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
const defaultFallback = '/history'
/**
* Returns the URL that the user should be redirected to after logging in.
* If no parameter has been provided or if the URL is not relative, then "/history" will be used.
*/
export const useGetPostLoginRedirectUrl = (): string => {
const redirectBackUrl = useSingleStringUrlParameter('redirectBackTo', defaultFallback)
return redirectBackUrl.startsWith('/') && !redirectBackUrl.startsWith('//') ? redirectBackUrl : defaultFallback
}