Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

877: Link checker #878

Merged
merged 16 commits into from
Mar 28, 2023
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion administration/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const Router = () => {
const isLoggedIn = authData !== null && authData.expiry > new Date()
const routes: (RouteObject | null)[] = [
{ path: '/forgot-password', element: <ForgotPasswordController /> },
{ path: '/reset-password/:passwordResetKey', element: <ResetPasswordController /> },
{ path: '/reset-password/', element: <ResetPasswordController /> },
{ path: '/data-privacy-policy', element: <DataPrivacyPolicy /> },
...(projectConfig.applicationFeatureEnabled
? [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { ProjectConfigContext } from '../project-configs/ProjectConfigContext'
import { useAppToaster } from '../components/AppToaster'
import { CARD_PADDING } from '../components/applications/ApplicationsOverview'
import { format } from 'date-fns'
import InvalidLink from '../components/InvalidLink'
import getMessageFromApolloError from '../components/getMessageFromApolloError'

const Container = styled.div`
display: flex;
Expand Down Expand Up @@ -63,7 +65,7 @@ const ApplicationVerification = ({ applicationVerificationAccessKey }: Applicati
console.error(error)
showErrorToaster()
},
onCompleted: ({ result }, clientOptions) => {
onCompleted: ({ result }) => {
if (!result) {
console.error('Verify operation returned false.')
showErrorToaster()
Expand All @@ -86,8 +88,17 @@ const ApplicationVerification = ({ applicationVerificationAccessKey }: Applicati
variables: { applicationVerificationAccessKey },
})

// TODO handle two error messages of graphQL queries
michael-markl marked this conversation as resolved.
Show resolved Hide resolved

if (loading) return <Spinner />
else if (error || !data) return <ErrorHandler refetch={refetch} />
else if (error)
return (
<InvalidLink
title={getMessageFromApolloError(error).title}
description={getMessageFromApolloError(error).description}
/>
)
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
else if (!data) return <ErrorHandler refetch={refetch} />
if (data.verification.rejectedDate || data.verification.verifiedDate)
return <CenteredMessage title='Sie haben diesen Antrag bereits bearbeitet.' />
if (verificationFinised)
Expand Down
19 changes: 19 additions & 0 deletions administration/src/components/InvalidLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Alert, AlertTitle, styled } from '@mui/material'
import React, { ReactElement, ReactNode } from 'react'

const CenteredAlert = styled(Alert)`
margin: auto;
`
type InvalidLinkType = {
title: string
description?: string | ReactNode
}

const InvalidLink = ({ title, description }: InvalidLinkType): ReactElement => (
<CenteredAlert severity='info'>
<AlertTitle>{title}</AlertTitle>
{description}
</CenteredAlert>
)

export default InvalidLink
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { useGetApplicationByApplicantQuery } from '../../generated/graphql'
import ApplicationApplicantView from './ApplicationApplicantView'
import { CircularProgress } from '@mui/material'
import { SnackbarProvider, useSnackbar } from 'notistack'
import InvalidLink from '../InvalidLink'
import getMessageFromApolloError from '../getMessageFromApolloError'

const CenteredMessage = styled(NonIdealState)`
margin: auto;
Expand All @@ -25,7 +27,14 @@ const ApplicationApplicantController = (props: { providedKey: string }) => {
})

if (loading) return <CircularProgress style={{ margin: 'auto' }} />
else if (error || !data) return <ErrorHandler refetch={refetch} />
else if (error) {
return (
<InvalidLink
title={getMessageFromApolloError(error).title}
description={getMessageFromApolloError(error).description}
/>
)
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
} else if (!data) return <ErrorHandler refetch={refetch} />
if (data.application.withdrawalDate) return <CenteredMessage title='Ihr Antrag wurde bereits zurückgezogen' />
if (withdrawed) return <CenteredMessage title='Ihr Antrag wurde zurückgezogen' />
else {
Expand Down
40 changes: 31 additions & 9 deletions administration/src/components/auth/ResetPasswordController.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { Button, Callout, Card, Classes, FormGroup, H2, H3, H4, InputGroup } from '@blueprintjs/core'
import { Button, Callout, Card, Classes, FormGroup, H2, H3, H4, InputGroup, NonIdealState } from '@blueprintjs/core'
import { useContext, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { ProjectConfigContext } from '../../project-configs/ProjectConfigContext'
import StandaloneCenter from '../StandaloneCenter'
import { useAppToaster } from '../AppToaster'
import { useResetPasswordMutation } from '../../generated/graphql'
import { useCheckPasswordResetLinkQuery, useResetPasswordMutation } from '../../generated/graphql'
import PasswordInput from '../PasswordInput'
import validateNewPasswordInput from './validateNewPasswordInput'
import styled from 'styled-components'
import getMessageFromApolloError from '../getMessageFromApolloError'

const CenteredMessage = styled(NonIdealState)`
margin: auto;
`

const ResetPasswordController = () => {
const config = useContext(ProjectConfigContext)
const appToaster = useAppToaster()
const [email, setEmail] = useState('')
const [queryParams] = useSearchParams()
const [adminEmail, setAdminEmail] = useState(queryParams.get('email') ?? '')
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
const [newPassword, setNewPassword] = useState('')
const [repeatNewPassword, setRepeatNewPassword] = useState('')
const { passwordResetKey } = useParams()
const navigate = useNavigate()

const { error } = useCheckPasswordResetLinkQuery({
variables: {
project: config.projectId,
resetKey: queryParams.get('token')!,
},
})

const [resetPassword, { loading }] = useResetPasswordMutation({
onCompleted: () => {
appToaster?.show({ intent: 'success', message: 'Ihr Passwort wurde erfolgreich zurückgesetzt.' })
Expand All @@ -33,15 +46,24 @@ const ResetPasswordController = () => {
resetPassword({
variables: {
project: config.projectId,
email,
email: adminEmail,
newPassword,
passwordResetKey: passwordResetKey!,
passwordResetKey: queryParams.get('token')!,
},
})

const warnMessage = validateNewPasswordInput(newPassword, repeatNewPassword)
const isDirty = newPassword !== '' || repeatNewPassword !== ''

if (error) {
console.log(getMessageFromApolloError(error))
return (
<CenteredMessage title={getMessageFromApolloError(error).title}>
{getMessageFromApolloError(error).description}
</CenteredMessage>
)
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
}

return (
<StandaloneCenter>
<Card style={{ width: '100%', maxWidth: '500px' }}>
Expand All @@ -59,8 +81,8 @@ const ResetPasswordController = () => {
}}>
<FormGroup label='Email-Adresse'>
<InputGroup
value={email}
onChange={e => setEmail(e.target.value)}
value={adminEmail}
onChange={e => setAdminEmail(e.target.value)}
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
type='email'
placeholder='erika.musterfrau@example.org'
/>
Expand Down
33 changes: 29 additions & 4 deletions administration/src/components/getMessageFromApolloError.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,43 @@
import { ApolloError } from '@apollo/client'
import { ReactNode } from 'react'

const getMessageFromApolloError = (error: ApolloError): string => {
type GraphQLErrorMessage = {
title: string
description?: string | ReactNode
}
const getMessageFromApolloError = (error: ApolloError): GraphQLErrorMessage => {
const defaultMessage = 'Etwas ist schief gelaufen.'
if (error.graphQLErrors.length !== 1) {
return defaultMessage
return { title: defaultMessage }
}

const graphQLError = error.graphQLErrors[0]
if ('code' in graphQLError.extensions) {
switch (graphQLError.extensions['code']) {
case 'EMAIL_ALREADY_EXISTS':
return 'Die Email-Adresse wird bereits verwendet.'
return { title: 'Die Email-Adresse wird bereits verwendet.' }
case 'PASSWORD_RESET_KEY_EXPIRED':
return {
title: 'Die Gültigkeit ihres Links ist abgelaufen',
description: (
<>
{' '}
Unter folgendem Link können Sie Ihr Passwort erneut zurücksetzen und erhalten einen neuen Link.
<a href={window.location.origin + '/forgot-password'} target='_blank' rel='noreferrer'>
{window.location.origin + '/forgot-password'}
</a>
</>
),
}
case 'INVALID_LINK':
return {
title: 'Ihr Link ist ungültig',
description:
'Ihr Link konnte nicht korrekt aufgelöst werden. Bitte kopieren Sie den Link manuell aus Ihrer E-Mail.',
michael-markl marked this conversation as resolved.
Show resolved Hide resolved
}
}
}
return defaultMessage
return { title: defaultMessage }
}

export default getMessageFromApolloError
2 changes: 1 addition & 1 deletion administration/src/components/users/CreateUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const CreateUserDialog = ({
const [createAdministrator, { loading }] = useCreateAdministratorMutation({
onError: error => {
console.error(error)
appToaster?.show({ intent: 'danger', message: 'Fehler: ' + getMessageFromApolloError(error) })
appToaster?.show({ intent: 'danger', message: 'Fehler: ' + getMessageFromApolloError(error).title })
},
onCompleted: () => {
appToaster?.show({ intent: 'success', message: 'Benutzer erfolgreich erstellt.' })
Expand Down
2 changes: 1 addition & 1 deletion administration/src/components/users/DeleteUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const DeleteUserDialog = ({
const [deleteAdministrator, { loading }] = useDeleteAdministratorMutation({
onError: error => {
console.error(error)
appToaster?.show({ intent: 'danger', message: 'Fehler: ' + getMessageFromApolloError(error) })
appToaster?.show({ intent: 'danger', message: 'Fehler: ' + getMessageFromApolloError(error).title })
},
onCompleted: () => {
appToaster?.show({ intent: 'success', message: 'Benutzer erfolgreich gelöscht.' })
Expand Down
2 changes: 1 addition & 1 deletion administration/src/components/users/EditUserDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const EditUserDialog = ({
const [editAdministrator, { loading }] = useEditAdministratorMutation({
onError: error => {
console.error(error)
appToaster?.show({ intent: 'danger', message: 'Fehler: ' + getMessageFromApolloError(error) })
appToaster?.show({ intent: 'danger', message: 'Fehler: ' + getMessageFromApolloError(error).title })
},
onCompleted: () => {
appToaster?.show({ intent: 'success', message: 'Benutzer erfolgreich bearbeitet.' })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
query checkPasswordResetLink($project: String!, $resetKey: String!) {
valid: checkPasswordResetLink(project: $project, resetKey: $resetKey)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import app.ehrenamtskarte.backend.application.webservice.schema.view.JsonField
import app.ehrenamtskarte.backend.application.webservice.utils.ExtractedApplicationVerification
import app.ehrenamtskarte.backend.common.database.sortByKeys
import app.ehrenamtskarte.backend.common.webservice.GraphQLContext
import app.ehrenamtskarte.backend.common.webservice.InvalidLinkException
import app.ehrenamtskarte.backend.projects.database.ProjectEntity
import app.ehrenamtskarte.backend.projects.database.Projects
import app.ehrenamtskarte.backend.regions.database.Regions
Expand Down Expand Up @@ -98,28 +99,28 @@ object ApplicationRepository {

fun getApplicationByApplicant(accessKey: String): ApplicationView {
return transaction {
ApplicationEntity.find { Applications.accessKey eq accessKey }
.single()
.let { ApplicationView.fromDbEntity(it) }
val application = ApplicationEntity.find { Applications.accessKey eq accessKey }
.singleOrNull()
application?.let { ApplicationView.fromDbEntity(it) } ?: throw InvalidLinkException()
}
}

fun getApplicationByApplicationVerificationAccessKey(applicationVerificationAccessKey: String): ApplicationView {
return transaction {
(Applications innerJoin ApplicationVerifications)
val application = (Applications innerJoin ApplicationVerifications)
.slice(Applications.columns)
.select { ApplicationVerifications.accessKey eq applicationVerificationAccessKey }
.single()
.let {
ApplicationView.fromDbEntity(ApplicationEntity.wrapRow(it))
}
.singleOrNull()
application?.let {
ApplicationView.fromDbEntity(ApplicationEntity.wrapRow(it))
} ?: throw InvalidLinkException()
}
}

fun getApplicationVerification(accessKey: String): ApplicationVerificationEntity {
return transaction {
ApplicationVerificationEntity.find { ApplicationVerifications.accessKey eq accessKey }
.single()
.singleOrNull() ?: throw InvalidLinkException()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import app.ehrenamtskarte.backend.auth.webservice.dataloader.administratorLoader
import app.ehrenamtskarte.backend.auth.webservice.schema.ChangePasswordMutationService
import app.ehrenamtskarte.backend.auth.webservice.schema.ManageUsersMutationService
import app.ehrenamtskarte.backend.auth.webservice.schema.ResetPasswordMutationService
import app.ehrenamtskarte.backend.auth.webservice.schema.ResetPasswordQueryService
import app.ehrenamtskarte.backend.auth.webservice.schema.SignInMutationService
import app.ehrenamtskarte.backend.auth.webservice.schema.ViewAdministratorsQueryService
import app.ehrenamtskarte.backend.common.webservice.GraphQLParams
Expand All @@ -22,5 +23,6 @@ val authGraphQlParams = GraphQLParams(
),
queries = listOf(
TopLevelObject(ViewAdministratorsQueryService()),
TopLevelObject(ResetPasswordQueryService())
),
)
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class ManageUsersMutationService {
key,
projectConfig.administrationName,
projectConfig.administrationBaseUrl,
email
),
)
}
Expand Down Expand Up @@ -142,13 +143,14 @@ class ManageUsersMutationService {
key: String,
administrationName: String,
administrationBaseUrl: String,
email: String
): String {
return """
Guten Tag,

für Sie wurde ein Account für $administrationName erstellt.
Sie können Ihr Passwort unter dem folgenden Link setzen:
$administrationBaseUrl/reset-password/${URLEncoder.encode(key, StandardCharsets.UTF_8)}
$administrationBaseUrl/reset-password?email=${URLEncoder.encode(email, StandardCharsets.UTF_8)}&token=${URLEncoder.encode(key, StandardCharsets.UTF_8)}

Dieser Link ist 24 Stunden gültig.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class ResetPasswordMutationService {
projectConfig.administrationName,
email,
"Passwort Zurücksetzen",
generateResetMailMessage(key, projectConfig.administrationName, projectConfig.administrationBaseUrl),
generateResetMailMessage(key, projectConfig.administrationName, projectConfig.administrationBaseUrl, email),
)
}
return true
Expand All @@ -45,13 +45,14 @@ class ResetPasswordMutationService {
key: String,
administrationName: String,
administrationBaseUrl: String,
email: String
): String {
return """
Guten Tag,

Sie haben angefragt, Ihr Passwort für $administrationName zurückzusetzen.
Sie können Ihr Passwort unter dem folgenden Link zurücksetzen:
$administrationBaseUrl/reset-password/${URLEncoder.encode(key, StandardCharsets.UTF_8)}
$administrationBaseUrl/reset-password?email=${URLEncoder.encode(email, StandardCharsets.UTF_8)}&token=${URLEncoder.encode(key, StandardCharsets.UTF_8)}

Dieser Link ist 24 Stunden gültig.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package app.ehrenamtskarte.backend.auth.webservice.schema

import app.ehrenamtskarte.backend.auth.database.AdministratorEntity
import app.ehrenamtskarte.backend.auth.database.Administrators
import app.ehrenamtskarte.backend.common.webservice.InvalidLinkException
import app.ehrenamtskarte.backend.projects.database.ProjectEntity
import app.ehrenamtskarte.backend.projects.database.Projects
import com.expediagroup.graphql.generator.annotations.GraphQLDescription
import graphql.GraphqlErrorException
import org.jetbrains.exposed.sql.and
import org.jetbrains.exposed.sql.transactions.transaction
import java.time.LocalDateTime

class PasswordResetKeyExpiredException() : GraphqlErrorException(
newErrorException().extensions(
mapOf(
Pair("code", "PASSWORD_RESET_KEY_EXPIRED"),
),
),
)

@Suppress("unused")
class ResetPasswordQueryService {
@GraphQLDescription("Verify password reset link")
fun checkPasswordResetLink(project: String, resetKey: String): Boolean {
return transaction {
val projectId = ProjectEntity.find { Projects.project eq project }.single().id.value
val admin = AdministratorEntity
.find { Administrators.passwordResetKey eq resetKey and (Administrators.projectId eq projectId) }.singleOrNull()
f1sh1918 marked this conversation as resolved.
Show resolved Hide resolved
if (admin == null) {
throw InvalidLinkException()
} else if (admin.passwordResetKeyExpiry!!.isBefore(LocalDateTime.now())) {
throw PasswordResetKeyExpiredException()
}
true
}
}
}
Loading