SignInScreen.tsx 8.56 KB
Newer Older
Stefan Probst's avatar
Stefan Probst committed
1
2
3
import Image from 'next/image'
import { useRouter } from 'next/router'
import { Fragment, useEffect, useMemo } from 'react'
Stefan Probst's avatar
Stefan Probst committed
4

Stefan Probst's avatar
Stefan Probst committed
5
6
7
8
import {
  useSignInUser,
  useValidateImplicitGrantTokenWithoutRegistration,
} from '@/api/sshoc/client'
9
import { Button } from '@/elements/Button/Button'
10
import { toast } from '@/elements/Toast/useToast'
Stefan Probst's avatar
Stefan Probst committed
11
import { useAuth } from '@/modules/auth/AuthContext'
12
13
import { FormTextField } from '@/modules/form/components/FormTextField/FormTextField'
import { Form } from '@/modules/form/Form'
Stefan Probst's avatar
Stefan Probst committed
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import ContentColumn from '@/modules/layout/ContentColumn'
import GridLayout from '@/modules/layout/GridLayout'
import VStack from '@/modules/layout/VStack'
import Metadata from '@/modules/metadata/Metadata'
import { Title } from '@/modules/ui/typography/Title'
import { createUrlFromPath } from '@/utils/createUrlFromPath'
import { getRedirectPath } from '@/utils/getRedirectPath'
import { getScalarQueryParameter } from '@/utils/getScalarQueryParameter'
import { Svg as EoscLogo } from '@@/assets/icons/eosc.svg'

/**
 * Sign in screen.
 */
export default function SignInScreen(): JSX.Element {
  return (
    <Fragment>
      <Metadata noindex nofollow title="Sign in" />
      <GridLayout style={{ gridTemplateRows: '1fr' }}>
        <ContentColumn style={{ gridColumn: '4 / -2' }}>
          <Image
            src={'/assets/images/auth/signin/people@2x.png'}
            alt=""
            loading="lazy"
            layout="fill"
            quality={100}
Stefan Probst's avatar
Stefan Probst committed
39
            className="object-contain object-right-bottom -z-10"
Stefan Probst's avatar
Stefan Probst committed
40
          />
Stefan Probst's avatar
Stefan Probst committed
41
42
          <div className="relative max-w-1.5xl px-16 py-16 my-12 space-y-8 bg-white rounded-md shadow-md">
            <Title className="font-bold">Sign in</Title>
Stefan Probst's avatar
Stefan Probst committed
43
            <hr className="border-gray-200" />
Stefan Probst's avatar
Stefan Probst committed
44
45
46
47
48
49
50
            <p>
              Sign in with EOSC using existing accounts such as{' '}
              <span className="font-bold">Google</span>,{' '}
              <span className="font-bold">Dariah</span>,{' '}
              <span className="font-bold">eduTEAMS</span> and multiple academic
              accounts.
            </p>
Stefan Probst's avatar
Stefan Probst committed
51
            <EoscLoginLink />
Stefan Probst's avatar
Stefan Probst committed
52
53
54
55
56
57
            <div className="flex items-baseline space-x-4">
              <span className="text-xl font-bold">or</span>
              <span className="flex-1 border-b border-gray-200" />
            </div>
            <p>Sign in with a local account.</p>
            <SignInForm />
Stefan Probst's avatar
Stefan Probst committed
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
          </div>
        </ContentColumn>
      </GridLayout>
      <style jsx global>{`
        body {
          background: linear-gradient(41deg, #e7f5ff, transparent);
        }
      `}</style>
    </Fragment>
  )
}

type SignInFormData = {
  username: string
  password: string
}

/**
 * Login form for username/password authentication, and link
 * to kick off OpenID Connect Implicit Grant Flow to autenticate
 * via EGI.
 *
 * This also doubles as the redirect url for OAuth2 in case of failure,
 * and in case of successful auth for users already registered with
 * the marketplace. First-time users authenticating via OAuth2 will be
 * redirected to `/auth/register`.
 *
 * Note that the authentication code from EGI is handled client-side.
 * We cannot do this in `getServerSideProps` since we receive the auth
 * code as a URL fragment.
 */
function SignInForm() {
  const router = useRouter()
  const auth = useAuth()
  useValidateToken()
93
  const signInUser = useSignInUser()
Stefan Probst's avatar
Stefan Probst committed
94
95

  function onSubmit(formData: SignInFormData) {
96
    return signInUser.mutateAsync([formData], {
Stefan Probst's avatar
Stefan Probst committed
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
      onSuccess({ token }) {
        if (token !== null) {
          auth.signIn(token)
          router.replace(
            getRedirectPath(getScalarQueryParameter(router.query.from)) ?? '/',
          )
        }
      },
      onError(error) {
        const message =
          (error instanceof Error && error.message) ||
          'An unexpected error has occurred.'
        toast.error(message)
      },
    })
  }

114
115
116
117
118
119
120
121
122
123
124
125
126
  function onValidate(values: Partial<SignInFormData>) {
    const errors: Partial<Record<keyof typeof values, string>> = {}

    if (values.username === undefined) {
      errors.username = 'Username is required.'
    }

    if (values.password === undefined) {
      errors.password = 'Password is required.'
    }

    return errors
  }
Stefan Probst's avatar
Stefan Probst committed
127
128

  return (
129
130
131
132
    <Form onSubmit={onSubmit} validate={onValidate}>
      {({ handleSubmit, pristine, submitting, invalid }) => {
        return (
          <VStack as="form" onSubmit={handleSubmit} className="space-y-5">
Stefan Probst's avatar
Stefan Probst committed
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
            <FormTextField
              name="username"
              label="Username"
              isRequired
              variant="form"
              size="lg"
            />
            <FormTextField
              name="password"
              label="Password"
              type="password"
              isRequired
              variant="form"
              size="lg"
            />
            <div className="self-end py-3">
149
150
151
              <Button
                type="submit"
                isDisabled={pristine || invalid || submitting}
Stefan Probst's avatar
Stefan Probst committed
152
                variant="gradient"
153
154
155
156
157
158
159
160
              >
                Sign in
              </Button>
            </div>
          </VStack>
        )
      }}
    </Form>
Stefan Probst's avatar
Stefan Probst committed
161
162
163
164
165
166
  )
}

function useValidateToken() {
  const router = useRouter()
  const auth = useAuth()
Stefan Probst's avatar
Stefan Probst committed
167
168
169
170
171
  const {
    status,
    error,
    mutate: validateToken,
  } = useValidateImplicitGrantTokenWithoutRegistration()
Stefan Probst's avatar
Stefan Probst committed
172
173
174
175
176
177

  useEffect(() => {
    if (status !== 'idle') return

    const url = createUrlFromPath(router.asPath)
    const { hash, searchParams } = url
178
179
180
181
182
    /**
     * The develop egi instance uses 68+1 chars, the demo instance (which is used on staging)
     * uses 64+1 chars.
     */
    if (hash && (hash.length === 69 || hash.length === 65)) {
Stefan Probst's avatar
Stefan Probst committed
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
      /** remove leading "#" character */
      const authCode = hash.slice(1)
      validateToken([{ token: authCode, registration: false }], {
        onSuccess({ token }) {
          if (token !== null) {
            auth.signIn(token)
            router.replace(
              getRedirectPath(
                /**
                 * we use `url.searchParams` instead of `router.query.from`,
                 * which is only populated *after* hydration, i.e. in the
                 * second effect run.
                 */
                getScalarQueryParameter(searchParams.get('from') ?? undefined),
              ) ?? '/',
            )
          }
        },
        onError(error) {
          const message =
            (error instanceof Error && error.message) ||
            'An unexpected error has occurred.'
          toast.error(message)
        },
      })
      /** remove token fragment from url */
      router.replace(
        { pathname: url.pathname, query: url.search.slice(1) },
        undefined,
        {
          shallow: true,
        },
      )
216
217
218
    } else {
      toast.error('Received invalid token.')
      router.replace({ pathname: '/auth/sign-in' })
Stefan Probst's avatar
Stefan Probst committed
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
    }
  }, [router, auth, validateToken, status])

  return { status, error }
}

/**
 * Sign in with OpenID Connect (OAuth2) Implicit Grant Flow via EOSC.
 */
function EoscLoginLink() {
  const router = useRouter()
  const url = useMemo(() => {
    const backendBaseUrl =
      process.env.NEXT_PUBLIC_SSHOC_API_BASE_URL ?? 'http://localhost:8080'
    const frontEndBaseUrl =
      process.env.NEXT_PUBLIC_SSHOC_BASE_URL ?? 'http://localhost:3000'

    const eoscAuthLink = new URL('/oauth2/authorize/eosc', backendBaseUrl)

    const successRedirectUrl = new URL('/auth/sign-in', frontEndBaseUrl)
    const failureRedirectUrl = new URL('/auth/sign-in', frontEndBaseUrl)
    const registrationRedirectUrl = new URL('/auth/sign-up', frontEndBaseUrl)

    const from = getRedirectPath(getScalarQueryParameter(router.query.from))
    if (from !== undefined) {
      successRedirectUrl.searchParams.set('from', from)
      failureRedirectUrl.searchParams.set('from', from)
      registrationRedirectUrl.searchParams.set('from', from)
    }

    eoscAuthLink.searchParams.set(
      'success-redirect-url',
      String(successRedirectUrl),
    )
    eoscAuthLink.searchParams.set(
      'failure-redirect-url',
      String(failureRedirectUrl),
    )
    eoscAuthLink.searchParams.set(
      'registration-redirect-url',
      String(registrationRedirectUrl),
    )

    return String(eoscAuthLink)
  }, [router])

  return (
    <div>
      <a
Stefan Probst's avatar
Stefan Probst committed
268
        className="flex items-center justify-between px-4 text-sm font-medium bg-gray-100 border border-gray-200 rounded shadow text-primary-800 hover:bg-gray-200"
Stefan Probst's avatar
Stefan Probst committed
269
270
271
272
273
274
275
276
277
278
279
        href={url}
      >
        <span className="w-10">
          <EoscLogo />
        </span>
        <span className="inline-block py-4">Sign in with EOSC</span>
        <span className="w-10"></span>
      </a>
    </div>
  )
}