aws-amplify / amplify-ui

Amplify UI is a collection of accessible, themeable, performant React (and more!) components that can connect directly to the cloud.
https://ui.docs.amplify.aws
Apache License 2.0
909 stars 289 forks source link

useAuthenticator does not read Authenticator.Provider context #3258

Closed jbowen28 closed 1 year ago

jbowen28 commented 1 year ago

Before creating a new issue, please confirm:

On which framework/platform are you having an issue?

React

Which UI component?

Authenticator

How is your app built?

CRA

What browsers are you seeing the problem on?

Chrome

Please describe your bug.

I try to get the auth context from Authenticator.Provider using useAuthenticator but user, error, and isPending are always undefined. Only the route context is undefined but does not appear to show all auth states. Also, the component which uses useAuthenticator does not rerender when auth context changes.

What's the expected behaviour?

The auth context from Authenticator.Provider using useAuthenticator is not undefined and updated when the auth context changes. The component which uses useAuthenticator rerenders when auth context changes.

Help us reproduce the bug!

// File: tsconfig.json
{
    "compilerOptions": {
        /* Basic Options */
        "target": "es6",
        "module": "esnext",
        "lib": ["dom", "dom.iterable", "esnext"],
        "jsx": "react",
        "resolveJsonModule": true,
        "forceConsistentCasingInFileNames": true,
        "declaration": true /* Instructs the compiler to generate declaration (.d.ts) files */,
        "declarationMap": true,
        "allowJs": false /* Allow javascript files to be compiled. Incompatible with "declaration": true */,

        /* Strict Type-Checking Options */
        "strict": true /* Enable all strict type checking options (noImplicitAny, noImplicitThis, etc.) */,
        "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */,

        /* Module Resolution Options */
        "moduleResolution": "node",
        "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
        "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports */
    }
}

// File: package.json snippet
    "dependencies": {
        "@date-io/date-fns": "1.x",
        "@redux-devtools/extension": "^3.2.2",
        "axios": "^0.21.2",
        "classnames": "^2.2.6",
        "connected-react-router": "^6.5.2",
        "date-fns": "^2.12.0",
        "date-fns-tz": "^1.0.10",
        "jest": "^29.0.0",
        "js-base64": "^2.5.2",
        "jsoneditor": "^9.7.4",
        "jsoneditor-react": "^3.1.2",
        "query-string": "^6.8.3",
        "react-redux": "^7.1.1",
        "recharts": "^2.2.0",
        "redux": "^4.0.4",
        "redux-logger": "^3.0.6",
        "redux-saga": "^1.1.2",
        "timers-browserify": "^2.0.12",
        "timezone-support": "^3.1.0",
        "webpack": "^5.0.0",
        "yup": "^0.28.4"
    },
    "peerDependencies": {
        "@aws-amplify/ui-react": "^4.3.0",
        "@azure/msal-browser": "^2.22.0",
        "@azure/msal-react": "^1.3.0",
        "@material-ui/core": "^4.9.7",
        "@material-ui/icons": "^4.9.1",
        "@material-ui/pickers": "^3.2.10",
        "@material-ui/styles": "^4.9.6",
        "aws-amplify": "^5.0.7",
        "formik": "^2.2.9",
        "prop-types": "^15.7.2",
        "react": "^16.13.1",
        "react-dom": "^16.13.1",
        "react-native": "^0.71.0-rc.5",
        "react-router-dom": "^5.1.2"
    },
    "devDependencies": {
        "@aws-amplify/ui-react": "^4.3.0",
        "@azure/msal-browser": "^2.22.0",
        "@azure/msal-react": "^1.3.0",
        "@babel/core": "^7.6.0",
        "@material-ui/core": "^4.9.7",
        "@material-ui/icons": "^4.9.1",
        "@material-ui/lab": "^4.0.0-alpha.28",
        "@material-ui/pickers": "^3.2.10",
        "@storybook/addon-actions": "^5.1.11",
        "@storybook/addon-links": "^5.1.11",
        "@storybook/react": "^6.0.0",
        "@types/axios": "^0.14.0",
        "@types/classnames": "^2.2.9",
        "@types/enzyme": "^3.10.3",
        "@types/enzyme-adapter-react-16": "^1.0.5",
        "@types/jest": "^29.0.0",
        "@types/material-ui": "^0.21.7",
        "@types/node": "13.11.1",
        "@types/prop-types": "^15.7.3",
        "@types/react": "^17.0.14",
        "@types/react-dom": "17.0.14",
        "@types/react-redux": "^7.1.5",
        "@types/react-router-dom": "^5.1.0",
        "@types/redux": "^3.6.0",
        "@types/redux-saga": "^0.10.5",
        "@types/yup": "^0.28.0",
        "aws-amplify": "^5.0.7",
        "enzyme": "^3.10.0",
        "enzyme-adapter-react-16": "^1.14.0",
        "formik": "2.2.9",
        "husky": "^8.0.2",
        "react-router-dom": "^5.1.2",
        "react-test-renderer": "^16.10.2",
        "rimraf": "^3.0.0",
        "ts-jest": "^29.0.0",
        "typescript": "4.4.4"
    }

Code Snippet

// File: AWSAuthMiddleware.tsx
import React, { useState, useEffect } from 'react'
import { Amplify} from 'aws-amplify'
import { Authenticator, useAuthenticator  } from '@aws-amplify/ui-react'
import { Typography } from '@material-ui/core'
import { Middleware, defaultAtlasTheme } from '../..'
import { AWSLoginPage, configureAWSAuthProvider } from '.'
import '@aws-amplify/ui-react/styles.css'

Amplify.configure({
    Auth: {
        region: window.env.REACT_APP_AWS_REGION,
        userPoolId: window.env.REACT_APP_USER_POOL_ID,
        userPoolWebClientId: window.env.REACT_APP_USER_POOL_WEB_CLIENT_ID,
    },
})

export const authProvider = configureAWSAuthProvider()

const awsTheme = {
    button: { backgroundColor: '#0095C8' },
    a: { color: '#0095C8' },
}

const atlasTheme = defaultAtlasTheme()

interface CheckAuthenticatedProps {
  children: any
  loginPage?: any
}
const CheckAuthenticated = (props: CheckAuthenticatedProps) => {
    const [localState, setLocalState] = useState<string>('')
    const [authData, setAuthData] = useState<any>(null)
  const { children, loginPage } = props
  //const { route } = useAuthenticator(context => [context.route])
  //const { user } = useAuthenticator((context) => [context.user])
  const { user, error, route, isPending } = useAuthenticator()

  console.log('AWS route = ', route)
  console.log('AWS user = ', user)
  console.log('AWS error = ', error)
  console.log('AWS isPending = ', isPending)
  console.log('localState = ', localState)

    useEffect(() => {
    console.log('AWS route2 = ', route)
    console.log('AWS user2 = ', user)
    console.log('AWS error2 = ', error)
    console.log('AWS isPending2 = ', isPending)
    console.log('localState2 = ', localState)
        getAuthData()
    }, [localState])

    const getAuthData = async () => {
        try {
            const authDataLocal = await authProvider.checkAuth()
            setAuthData(authDataLocal)
        } catch (e) {
            console.log('CheckAuthenticated exception: ', e)
        }
    }

    const onStateChange = (state: string, params: {}) => {
        setLocalState(state)
    }

    if (authData?.loggedInUser) {
        return <>{children}</>
    } else {
        return (
      /* <>{loginPage}</> */

            <AWSLoginPage
                atlasTheme={atlasTheme}
                copyright="Sky Republic"
                title={
                    <Typography variant="h6" gutterBottom align="center">
                        Atlas Admin Console
                    </Typography>
                }
                onStateChange={onStateChange}
            />

        )
    }
}

export const AWSAuthMiddleware: Middleware = ({ children, loginPage }) => (
    <Authenticator.Provider>
        <CheckAuthenticated loginPage={loginPage}>{children}</CheckAuthenticated>
    </Authenticator.Provider>
)

// File: AWSLoginPage.tsx
import React, { useState, ReactNode } from 'react'
import { Auth } from 'aws-amplify'
import { isEmpty } from '@aws-amplify/core'
import Card from '@material-ui/core/Card'
import Avatar from '@material-ui/core/Avatar'
import {
    MuiThemeProvider,
    createStyles,
    makeStyles,
    Theme,
} from '@material-ui/core/styles'
import LockIcon from '@material-ui/icons/Lock'
import { defaultAtlasTheme } from '../../themes'
import LoginForm from '../LoginForm'
import { Credentials } from '../authProvider'
import { Notification } from '../../layout'
import { Typography } from '@material-ui/core'
import { Copyright } from '../../detail'

const styles = (theme: Theme) =>
    createStyles({
        main: {
            display: 'flex',
            flexDirection: 'column',
            minHeight: '100vh',
            height: '1px',
            alignItems: 'center',
            justifyContent: 'flex-start',
            backgroundRepeat: 'no-repeat',
            backgroundSize: 'cover',
            backgroundColor: theme.palette.background.default,
        },
        card: {
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'flex-start',
            minWidth: 375,
            marginTop: '6em',
        },
        title: {
            marginTop: '2em',
            marginBottom: theme.spacing(1),
        },
        avatar: {
            margin: '0 1em .5em 1em',
            display: 'flex',
            justifyContent: 'center',
            backgroundColor: theme.palette.secondary[500],
        },
        form: {
            width: '100%',
        },
    })
const useStyles = makeStyles(styles)
const defaultTheme = defaultAtlasTheme()

interface AWSLoginProps {
    title?: ReactNode
    bgImageURL?: string
    copyright?: string
    authState?: string
    authData?: any
    onStateChange?: (state: string, params: {}) => void
}

const AWSLogin = (props: AWSLoginProps) => {
    const classes = useStyles()
    const [loading, setLoading] = useState(false)
    const [error, setError] = useState<Error | undefined>(undefined)

    const onStateChange = props.onStateChange || (() => {})

    const signIn = async ({ username, password }: Credentials) => {
        if (!Auth || typeof Auth.signIn !== 'function') {
            throw new Error(
                'No Auth module found, please ensure @aws-amplify/auth is imported'
            )
        }
        setLoading(true)
        try {
            const user = await Auth.signIn(username, password)
            if (
                user.challengeName === 'SMS_MFA' ||
                user.challengeName === 'SOFTWARE_TOKEN_MFA'
            ) {
                console.warn('confirm user with ' + user.challengeName)
                onStateChange('confirmSignIn', user)
            } else if (user.challengeName === 'NEW_PASSWORD_REQUIRED') {
                console.warn('require new password', user.challengeParam)
                onStateChange('requireNewPassword', user)
            } else if (user.challengeName === 'MFA_SETUP') {
                console.warn('TOTP setup', user.challengeParam)
                onStateChange('TOTPSetup', user)
            } else if (
                user.challengeName === 'CUSTOM_CHALLENGE' &&
                user.challengeParam &&
                user.challengeParam.trigger === 'true'
            ) {
                console.warn('custom challenge', user.challengeParam)
                onStateChange('customConfirmSignIn', user)
            } else {
                Auth.verifiedContact(user).then((data) => {
                    if (!isEmpty(data.verified)) {
                        onStateChange('signedIn', user)
                    } else {
                        const user2 = Object.assign(user, data)
                        onStateChange('verifyContact', user2)
                    }
                })
            }
        } catch (err: any) {
            if (err.code === 'UserNotConfirmedException') {
                console.warn('user is not confirmed')
                onStateChange('confirmSignUp', { username })
            } else if (err.code === 'PasswordResetRequiredException') {
                console.warn('user requires a new password')
                onStateChange('forgotPassword', { username })
            } else {
                setError(err)
            }
        } finally {
            setLoading(false)
        }
    }

    const bgImageURL = props.bgImageURL

    return (
        <div
            className={classes.main}
            style={bgImageURL ? { backgroundImage: `url(${bgImageURL})` } : {}}
        >
            <Card className={classes.card}>
                <div className={classes.title}>{props.title}</div>
                <Avatar className={classes.avatar}>
                    <LockIcon />
                </Avatar>
                <Typography component="h1" variant="h5">
                    Sign in
                </Typography>
                <form className={classes.form}>
                    <LoginForm
                        onSubmit={signIn}
                        isLoading={loading}
                        error={error}
                        showResetPassword
                        onPasswordReset={() =>
                            props.onStateChange && props.onStateChange('forgotPassword', {})
                        }
                    />
                </form>
                <Copyright text={props.copyright} />
            </Card>
            <Notification />
        </div>
    )
}

interface Props extends AWSLoginProps {
    atlasTheme?: Theme
}

const AWSLoginPage = ({ atlasTheme = defaultTheme, ...other }: Props) => (
    <MuiThemeProvider theme={atlasTheme}>
        <AWSLogin {...other} />
    </MuiThemeProvider>
)

export default AWSLoginPage

Additional information and screenshots

On app initial load, the browser console showed the following:

AWS route =  idle
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  
AWSAuthMiddleware.js:43 AWS route2 =  idle
AWSAuthMiddleware.js:44 AWS user2 =  undefined
AWSAuthMiddleware.js:45 AWS error2 =  undefined
AWSAuthMiddleware.js:46 AWS isPending2 =  undefined
AWSAuthMiddleware.js:47 localState2 =  
AWSAuthMiddleware.js:37 AWS route =  idle
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  
AWSAuthMiddleware.js:56 CheckAuthenticated exception:  No current user
AWSAuthMiddleware.js:37 AWS route =  setup
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  

After logging-in the browser console log showed the following:

AWS route =  setup
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  signedIn
AWSAuthMiddleware.js:43 AWS route2 =  setup
AWSAuthMiddleware.js:44 AWS user2 =  undefined
AWSAuthMiddleware.js:45 AWS error2 =  undefined
AWSAuthMiddleware.js:46 AWS isPending2 =  undefined
AWSAuthMiddleware.js:47 localState2 =  signedIn
AWSAuthMiddleware.js:37 AWS route =  setup
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  signedIn

After logging-out the browser console log showed the following:
AWSAuthMiddleware.js:37 AWS route =  idle
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  
AWSAuthMiddleware.js:43 AWS route2 =  idle
AWSAuthMiddleware.js:44 AWS user2 =  undefined
AWSAuthMiddleware.js:45 AWS error2 =  undefined
AWSAuthMiddleware.js:46 AWS isPending2 =  undefined
AWSAuthMiddleware.js:47 localState2 =  
AWSAuthMiddleware.js:37 AWS route =  idle
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  
AWSAuthMiddleware.js:56 CheckAuthenticated exception:  No current user
AWSAuthMiddleware.js:37 AWS route =  setup
AWSAuthMiddleware.js:38 AWS user =  undefined
AWSAuthMiddleware.js:39 AWS error =  undefined
AWSAuthMiddleware.js:40 AWS isPending =  undefined
AWSAuthMiddleware.js:41 localState =  

* edited by wlee221: code formatting

reesscot commented 1 year ago

@jbowen28 From your code above, it looks like you are not using the Authenticator UI component in your code. The Authenticator.Provider will not work correctly without Authenticator component being rendered in the DOM. We are working on providing React hooks which will allow you to keep track of auth state without the Authenticator UI being rendered, but that's not currently possible.

If you don't want to use the Authenticator UI, you probably don't need the Authenticator.Provider hook, and should use the Amplify Auth JS API's directly. Please lLet me know if I'm understanding your use case correctly!

jbowen28 commented 1 year ago

I was following the instructions at https://ui.docs.amplify.aws/react/connected-components/authenticator/headless. No where in those instructions does it indicate that the Authenticator UI component ( I am not exactly sure which component that is) needs to be used. From the title of that web page "Headless - For advanced use cases you can run the Authenticator in a headless mode", I assumed that I did not have to use any AWS Authenticator UI (display) components. I do not want to use the AWS Authentication UI components because they use a different UI component framework than the rest of my app, which uses MUI. Is there a way to use Authenticator.Provider context and useAuthenticator hook as I hoping to (and as indicated by the AWS instructions) by instantiating a Authenticator UI component without rendering it?

reesscot commented 1 year ago

@jbowen28, I agree, I think this is a miss in the documentation. I'll get that updated.

Yes, if you still want to use the Authenticator.Provider, you can do so by rendering a visually hidden Authenticator like:

CSS:

.hide-authenticator {
  display: none;
}

JSX:

<Authenticator className="hide-authenticator">

Just note that the Authenticator.Provider keeps track primarily of the state of the Authenticator UI, so route refers to the screen being displayed in the UI, and user may not be updated if you make changes such as updating the user attributes (in that case using Auth.currentAuthenticatedUser may work better).

EDIT: Updated to show how to pass a classname in since I realized that Authenticator doesn't take the style prop.

ioanabrooks commented 1 year ago

useAuthenticator hook documentation updated in latest docs.

Regarding using the hook without the Authenticator component, that is being tracked separately https://github.com/aws-amplify/amplify-ui/issues/2634