Skip to content

Commit

Permalink
feat: Implement refresh tokens in UI (#772)
Browse files Browse the repository at this point in the history
  • Loading branch information
dogmar authored Mar 12, 2024
1 parent 590c646 commit 521753e
Show file tree
Hide file tree
Showing 14 changed files with 409 additions and 57 deletions.
1 change: 1 addition & 0 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"eslint-plugin-react-hooks": "4.6.0",
"husky": "8.0.3",
"jsdom": "22.1.0",
"jwt-decode": "4.0.0",
"lint-staged": "15.0.2",
"moment-timezone": "0.5.43",
"npm-run-all": "4.1.5",
Expand Down
70 changes: 67 additions & 3 deletions assets/src/components/contexts.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { ComponentProps, createContext, useContext, useMemo } from 'react'
import {
ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react'
import { jwtDecode } from 'jwt-decode'
import {
fetchRefreshToken,
fetchToken,
setToken,
wipeRefreshToken,
wipeToken,
} from 'helpers/auth'

import { MeQuery, PersonaConfigurationFragment } from '../generated/graphql'
import {
MeQuery,
PersonaConfigurationFragment,
useLogoutMutation,
useRefreshLazyQuery,
} from '../generated/graphql'

import { reducePersonaConfigs } from './login/reducePersonaConfigs'

Expand All @@ -9,18 +29,28 @@ export type Login = {
configuration: MeQuery['configuration']
personaConfiguration: Omit<PersonaConfigurationFragment, '__typeName'>
token: MeQuery['externalToken']
logout: () => void
}

const JWT_REFRESH_THRESHOLD = 900_000 as const // 15 minutes

const DEFAULT_LOGIN = {
me: undefined,
configuration: undefined,
personaConfiguration: undefined,
token: undefined,
logout: completeLogout,
} as const satisfies Partial<Login>
const LoginContext = createContext<Partial<Login>>(DEFAULT_LOGIN)

export const useLogin = () => useContext(LoginContext)

function completeLogout() {
wipeToken()
wipeRefreshToken()
window.location = '/login' as any as Location
}

export function LoginContextProvider({
value: valueProp,
...props
Expand All @@ -31,6 +61,39 @@ export function LoginContextProvider({
() => reducePersonaConfigs(valueProp?.me?.personas),
[valueProp?.me?.personas]
)

const [logout] = useLogoutMutation({
onCompleted: completeLogout,
onError: completeLogout,
})
const [refreshQuery, { loading: refreshLoading }] = useRefreshLazyQuery({
onCompleted: (res) => {
setToken(res.refresh?.jwt)
if (!res.refresh?.jwt) {
logout()
}
},
onError: () => {
logout()
},
fetchPolicy: 'network-only',
})
const jwt = fetchToken()

const refresh = useCallback(() => {
refreshQuery({ variables: { token: fetchRefreshToken() || '' } })
}, [refreshQuery])

useEffect(() => {
if (
!refreshLoading &&
(!jwt ||
(jwtDecode(jwt)?.exp ?? 0) * 1000 < Date.now() + JWT_REFRESH_THRESHOLD)
) {
refresh()
}
}, [jwt, refresh, refreshLoading, refreshQuery])

const value = useMemo(
() =>
!valueProp
Expand All @@ -40,8 +103,9 @@ export function LoginContextProvider({
configuration: valueProp.configuration,
token: valueProp.externalToken,
personaConfiguration: personaConfig,
logout,
},
[personaConfig, valueProp]
[logout, personaConfig, valueProp]
)

return (
Expand Down
22 changes: 22 additions & 0 deletions assets/src/components/graphql/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export const UserFragment = gql`
}
}
`

export const RefreshTokenFragment = gql`
fragment RefreshTokenFragment on RefreshToken {
id
token
insertedAt
updatedAt
}
`

export const InviteFragment = gql`
fragment InviteFragment on Invite {
secureId
Expand Down Expand Up @@ -135,9 +145,13 @@ export const SIGNIN = gql`
signIn(email: $email, password: $password) {
...UserFragment
jwt
refreshToken {
...RefreshTokenFragment
}
}
}
${UserFragment}
${RefreshTokenFragment}
`

export const UPDATE_USER = gql`
Expand Down Expand Up @@ -187,19 +201,27 @@ export const SIGNUP = gql`
signup(inviteId: $inviteId, attributes: $attributes) {
...UserFragment
jwt
refreshToken {
...RefreshTokenFragment
}
}
}
${UserFragment}
${RefreshTokenFragment}
`

export const LOGIN_LINK = gql`
mutation Link($key: String!) {
loginLink(key: $key) {
...UserFragment
jwt
refreshToken {
...RefreshTokenFragment
}
}
}
${UserFragment}
${RefreshTokenFragment}
`

export const NOTIFICATIONS_Q = gql`
Expand Down
9 changes: 3 additions & 6 deletions assets/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
import { Link, useLocation } from 'react-router-dom'
import { ReactElement, useCallback, useMemo, useRef, useState } from 'react'
import { Avatar, Flex, Menu, MenuItem, useOutsideClick } from 'honorable'
import { wipeToken } from 'helpers/auth'
import { ME_Q } from 'components/graphql/users'
import { useMutation } from '@apollo/client'
import { updateCache } from 'utils/graphql'
Expand Down Expand Up @@ -258,13 +257,11 @@ export default function Sidebar() {
[mutation, setIsNotificationsPanelOpen]
)

const { logout } = useLogin()
const handleLogout = useCallback(() => {
setIsMenuOpen(false)
wipeToken()
const w: Window = window

w.location = '/login'
}, [])
logout?.()
}, [logout])

useOutsideClick(menuRef, (event) => {
if (!menuItemRef.current?.contains(event.target as any)) {
Expand Down
14 changes: 7 additions & 7 deletions assets/src/components/login/Invite.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { ComponentProps, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { useMutation, useQuery } from '@apollo/client'
import { Div, Flex, Form, P } from 'honorable'
import { Button } from '@pluralsh/design-system'

import { GqlError } from 'components/utils/Alert'

import { WelcomeHeader } from 'components/utils/WelcomeHeader'

import { setToken } from '../../helpers/auth'
import { setRefreshToken, setToken } from '../../helpers/auth'

import { LabelledInput } from '../utils/LabelledInput'
import { INVITE_Q, SIGNUP } from '../graphql/users'
Expand Down Expand Up @@ -93,16 +91,18 @@ export function ConfirmPasswordField({
}

export default function Invite() {
const navigate = useNavigate()
const { inviteId } = useParams()
const [attributes, setAttributes] = useState({ name: '', password: '' })
const [confirm, setConfirm] = useState('')
const [mutation, { loading, error: signupError }] = useMutation(SIGNUP, {
variables: { inviteId, attributes },
onCompleted: ({ signup: { jwt } }) => {
onCompleted: ({ signup: { jwt, refreshToken } }) => {
setToken(jwt)
window.location = '/' as any as Location
setRefreshToken(refreshToken?.token)
navigate('/')
},
onError: console.log,
onError: console.error,
})
const { data, error } = useQuery(INVITE_Q, { variables: { id: inviteId } })

Expand Down
12 changes: 7 additions & 5 deletions assets/src/components/login/LinkLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,24 @@ import { useEffect } from 'react'
import { useMutation } from '@apollo/client'
import { useParams } from 'react-router'
import { LoopingLogo } from '@pluralsh/design-system'
import { useNavigate } from 'react-router-dom'

import { setToken } from '../../helpers/auth'

import { setRefreshToken, setToken } from '../../helpers/auth'
import { LOGIN_LINK } from '../graphql/users'

import { LoginPortal } from './LoginPortal'

export function LinkLogin() {
const navigate = useNavigate()
const { key } = useParams()
const [mutation, { error }] = useMutation(LOGIN_LINK, {
variables: { key },
onCompleted: ({ loginLink: { jwt } }) => {
onCompleted: ({ loginLink: { jwt, refreshToken } }) => {
setToken(jwt)
window.location = '/' as any as Location
setRefreshToken(refreshToken)
navigate('/')
},
onError: console.log,
onError: console.error,
})

useEffect(() => {
Expand Down
33 changes: 22 additions & 11 deletions assets/src/components/login/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { RefObject, useEffect, useRef, useState } from 'react'
import { RefObject, useEffect, useMemo, useRef, useState } from 'react'
import { Button, LoopingLogo } from '@pluralsh/design-system'
import { Div, Flex, Form, P } from 'honorable'
import { useMutation, useQuery } from '@apollo/client'
import { Box } from 'grommet'
import { v4 as uuidv4 } from 'uuid'
import gql from 'graphql-tag'
import { IntercomProps, useIntercom } from 'react-use-intercom'
import { useLocation } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { WelcomeHeader } from 'components/utils/WelcomeHeader'
import { isValidEmail } from 'utils/email'
import { User, useMeQuery } from 'generated/graphql'
import { useHelpSpacing } from 'components/help/HelpLauncher'

import { GqlError } from '../utils/Alert'
import { setToken, wipeToken } from '../../helpers/auth'
import {
setRefreshToken,
setToken,
wipeRefreshToken,
wipeToken,
} from '../../helpers/auth'
import { localized } from '../../helpers/hostname'
import { ME_Q, SIGNIN } from '../graphql/users'
import { IncidentContext } from '../incidents/context'
Expand Down Expand Up @@ -42,13 +47,14 @@ function LoginError({ error }) {
useEffect(() => {
const to = setTimeout(() => {
wipeToken()
wipeRefreshToken()
window.location = '/login' as any as Location
}, 2000)

return () => clearTimeout(to)
}, [])

console.error(error)
console.error('Login error:', error)

return (
<LoginPortal>
Expand Down Expand Up @@ -191,18 +197,20 @@ export function EnsureLogin({ children }) {

const loginContextValue = data

if (error || (!loading && !data?.clusterInfo)) {
console.log(error)
const incidentContextValue = useMemo(() => {
const { __typename: _, ...clusterInformation } = data?.clusterInfo || {}

return { clusterInformation }
}, [data?.clusterInfo])

if (error || (!loading && !data?.clusterInfo)) {
return <LoginError error={error} />
}

if (!data?.clusterInfo) return null
const { __typename, ...clusterInformation } = data.clusterInfo

return (
// eslint-disable-next-line react/jsx-no-constructed-context-values
<IncidentContext.Provider value={{ clusterInformation }}>
<IncidentContext.Provider value={incidentContextValue}>
<LoginContextProvider value={loginContextValue}>
{children}
</LoginContextProvider>
Expand Down Expand Up @@ -246,6 +254,7 @@ function OIDCLogin({ oidcUri, external }) {
}

export default function Login() {
const navigate = useNavigate()
const [form, setForm] = useState({ email: '', password: '' })
const emailRef = useRef<any>()

Expand All @@ -257,12 +266,14 @@ export default function Login() {
const { data: loginData } = useQuery(LOGIN_INFO, {
variables: { redirect: localized('/oauth/callback') },
})

const [loginMutation, { loading: loginMLoading, error: loginMError }] =
useMutation(SIGNIN, {
variables: form,
onCompleted: ({ signIn: { jwt } }) => {
onCompleted: ({ signIn: { jwt, refreshToken } }) => {
setToken(jwt)
window.location = '/' as any as Location
setRefreshToken(refreshToken?.token)
navigate('/')
},
onError: console.error,
})
Expand Down
Loading

0 comments on commit 521753e

Please sign in to comment.