Skip to content

fix(site): Fix login flow #6294

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

Merged
merged 6 commits into from
Feb 23, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
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
16 changes: 13 additions & 3 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,19 @@ export const logout = async (): Promise<void> => {
await axios.post("/api/v2/users/logout")
}

export const getUser = async (): Promise<TypesGen.User> => {
const response = await axios.get<TypesGen.User>("/api/v2/users/me")
return response.data
export const getAuthenticatedUser = async (): Promise<
TypesGen.User | undefined
> => {
try {
const response = await axios.get<TypesGen.User>("/api/v2/users/me")
return response.data
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
return undefined
}

throw error
}
}

export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
Expand Down
7 changes: 5 additions & 2 deletions site/src/components/RequireAuth/RequireAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ export const RequireAuth: FC = () => {

if (authState.matches("signedOut")) {
return <Navigate to={navigateTo} state={{ isRedirect: !isHomePage }} />
} else if (authState.matches("waitingForTheFirstUser")) {
} else if (authState.matches("configuringTheFirstUser")) {
return <Navigate to="/setup" />
} else if (authState.hasTag("loading")) {
} else if (
authState.matches("loadingInitialAuthData") ||
authState.matches("signingOut")
) {
return <FullScreenLoader />
} else {
return <Outlet />
Expand Down
8 changes: 4 additions & 4 deletions site/src/components/SignInForm/OAuthSignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { FC } from "react"
import { makeStyles } from "@material-ui/core/styles"

type OAuthSignInFormProps = {
isLoading: boolean
isSigningIn: boolean
redirectTo: string
authMethods?: AuthMethods
}
Expand All @@ -22,7 +22,7 @@ const useStyles = makeStyles((theme) => ({
}))

export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
isLoading,
isSigningIn,
redirectTo,
authMethods,
}) => {
Expand All @@ -39,7 +39,7 @@ export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
>
<Button
startIcon={<GitHubIcon className={styles.buttonIcon} />}
disabled={isLoading}
disabled={isSigningIn}
fullWidth
type="submit"
variant="outlined"
Expand Down Expand Up @@ -68,7 +68,7 @@ export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
<KeyIcon className={styles.buttonIcon} />
)
}
disabled={isLoading}
disabled={isSigningIn}
fullWidth
type="submit"
variant="outlined"
Expand Down
34 changes: 6 additions & 28 deletions site/src/components/SignInForm/PasswordSignInForm.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import { Stack } from "../Stack/Stack"
import { AlertBanner } from "../AlertBanner/AlertBanner"
import TextField from "@material-ui/core/TextField"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { LoadingButton } from "../LoadingButton/LoadingButton"
import { Language, LoginErrors } from "./SignInForm"
import { Language } from "./SignInForm"
import { FormikContextType, FormikTouched, useFormik } from "formik"
import * as Yup from "yup"
import { FC } from "react"
import { BuiltInAuthFormValues } from "./SignInForm.types"

type PasswordSignInFormProps = {
loginErrors: Partial<Record<LoginErrors, Error | unknown>>
onSubmit: (credentials: { email: string; password: string }) => void
initialTouched?: FormikTouched<BuiltInAuthFormValues>
isLoading: boolean
isSigningIn: boolean
}

export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
loginErrors,
onSubmit,
initialTouched,
isLoading,
isSigningIn,
}) => {
const validationSchema = Yup.object({
email: Yup.string()
Expand All @@ -37,33 +34,14 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
password: "",
},
validationSchema,
// The email field has an autoFocus, but users may log in with a button click.
// This is set to `false` in order to keep the autoFocus, validateOnChange
// and Formik experience friendly. Validation will kick in onChange (any
// field), or after a submission attempt.
validateOnBlur: false,
onSubmit,
initialTouched,
})
const getFieldHelpers = getFormHelpers<BuiltInAuthFormValues>(
form,
loginErrors.authError,
)
const getFieldHelpers = getFormHelpers<BuiltInAuthFormValues>(form)

return (
<form onSubmit={form.handleSubmit}>
<Stack>
{Object.keys(loginErrors).map(
(errorKey: string) =>
Boolean(loginErrors[errorKey as LoginErrors]) && (
<AlertBanner
key={errorKey}
severity="error"
error={loginErrors[errorKey as LoginErrors]}
text={Language.errorMessages[errorKey as LoginErrors]}
/>
),
)}
<TextField
{...getFieldHelpers("email")}
onChange={onChangeTrimmed(form)}
Expand All @@ -85,12 +63,12 @@ export const PasswordSignInForm: FC<PasswordSignInFormProps> = ({
/>
<div>
<LoadingButton
loading={isLoading}
loading={isSigningIn}
fullWidth
type="submit"
variant="outlined"
>
{isLoading ? "" : Language.passwordSignIn}
{isSigningIn ? "" : Language.passwordSignIn}
</LoadingButton>
</div>
</Stack>
Expand Down
36 changes: 14 additions & 22 deletions site/src/components/SignInForm/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,11 @@ import Button from "@material-ui/core/Button"
import EmailIcon from "@material-ui/icons/EmailOutlined"
import { AlertBanner } from "components/AlertBanner/AlertBanner"

export enum LoginErrors {
AUTH_ERROR = "authError",
GET_USER_ERROR = "getUserError",
CHECK_PERMISSIONS_ERROR = "checkPermissionsError",
GET_METHODS_ERROR = "getMethodsError",
}

export const Language = {
emailLabel: "Email",
passwordLabel: "Password",
emailInvalid: "Please enter a valid email address.",
emailRequired: "Please enter an email address.",
errorMessages: {
[LoginErrors.AUTH_ERROR]: "Incorrect email or password.",
[LoginErrors.GET_USER_ERROR]: "Failed to fetch user details.",
[LoginErrors.CHECK_PERMISSIONS_ERROR]: "Unable to fetch user permissions.",
[LoginErrors.GET_METHODS_ERROR]: "Unable to fetch auth methods.",
},
passwordSignIn: "Sign In",
githubSignIn: "GitHub",
oidcSignIn: "OpenID Connect",
Expand All @@ -49,6 +36,9 @@ const useStyles = makeStyles((theme) => ({
fontWeight: 600,
},
},
error: {
marginBottom: theme.spacing(4),
},
divider: {
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
Expand All @@ -75,9 +65,9 @@ const useStyles = makeStyles((theme) => ({
}))

export interface SignInFormProps {
isLoading: boolean
isSigningIn: boolean
redirectTo: string
loginErrors: Partial<Record<LoginErrors, Error | unknown>>
error?: unknown
authMethods?: AuthMethods
onSubmit: (credentials: { email: string; password: string }) => void
// initialTouched is only used for testing the error state of the form.
Expand All @@ -87,20 +77,18 @@ export interface SignInFormProps {
export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
authMethods,
redirectTo,
isLoading,
loginErrors,
isSigningIn,
error,
onSubmit,
initialTouched,
}) => {
const oAuthEnabled = Boolean(
authMethods?.github.enabled || authMethods?.oidc.enabled,
)
const passwordEnabled = authMethods?.password.enabled ?? true

// Hide password auth by default if any OAuth method is enabled
const [showPasswordAuth, setShowPasswordAuth] = useState(!oAuthEnabled)
const styles = useStyles()

const commonTranslation = useTranslation("common")
const loginPageTranslation = useTranslation("loginPage")

Expand All @@ -110,12 +98,16 @@ export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
{loginPageTranslation.t("signInTo")}{" "}
<strong>{commonTranslation.t("coder")}</strong>
</h1>
<Maybe condition={error !== undefined}>
<div className={styles.error}>
<AlertBanner severity="error" error={error} />
</div>
</Maybe>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 good simplification

<Maybe condition={passwordEnabled && showPasswordAuth}>
<PasswordSignInForm
loginErrors={loginErrors}
onSubmit={onSubmit}
initialTouched={initialTouched}
isLoading={isLoading}
isSigningIn={isSigningIn}
/>
</Maybe>
<Maybe condition={passwordEnabled && showPasswordAuth && oAuthEnabled}>
Expand All @@ -127,7 +119,7 @@ export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
</Maybe>
<Maybe condition={oAuthEnabled}>
<OAuthSignInForm
isLoading={isLoading}
isSigningIn={isSigningIn}
redirectTo={redirectTo}
authMethods={authMethods}
/>
Expand Down
10 changes: 5 additions & 5 deletions site/src/hooks/useMe.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { User } from "api/typesGenerated"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { selectUser } from "xServices/auth/authSelectors"
import { isAuthenticated } from "xServices/auth/authXService"

export const useMe = (): User => {
const [authState] = useAuth()
const me = selectUser(authState)
const { data } = authState.context

if (!me) {
throw new Error("User not found.")
if (isAuthenticated(data)) {
return data.user
}

return me
throw new Error("User is not authenticated")
}
10 changes: 5 additions & 5 deletions site/src/hooks/useOrganizationId.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useAuth } from "components/AuthProvider/AuthProvider"
import { selectOrgId } from "../xServices/auth/authSelectors"
import { isAuthenticated } from "xServices/auth/authXService"

export const useOrganizationId = (): string => {
const [authState] = useAuth()
const organizationId = selectOrgId(authState)
const { data } = authState.context

if (!organizationId) {
throw new Error("No organization ID found")
if (isAuthenticated(data)) {
return data.user.organization_ids[0]
}

return organizationId
throw new Error("User is not authenticated")
}
12 changes: 6 additions & 6 deletions site/src/hooks/usePermissions.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useAuth } from "components/AuthProvider/AuthProvider"
import { AuthContext } from "xServices/auth/authXService"
import { isAuthenticated, Permissions } from "xServices/auth/authXService"

export const usePermissions = (): NonNullable<AuthContext["permissions"]> => {
export const usePermissions = (): Permissions => {
const [authState] = useAuth()
const { permissions } = authState.context
const { data } = authState.context

if (!permissions) {
throw new Error("Permissions are not loaded yet.")
if (isAuthenticated(data)) {
return data.permissions
}

return permissions
throw new Error("User is not authenticated.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A lot of these hooks look very similar. Perhaps down the road (not in this PR) we could consolidate them into 1 hook that takes an arg.

}
28 changes: 3 additions & 25 deletions site/src/pages/LoginPage/LoginPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,11 @@ describe("LoginPage", () => {

it("shows an error message if SignIn fails", async () => {
// Given
const apiErrorMessage = "Something wrong happened"
server.use(
// Make login fail
rest.post("/api/v2/users/login", async (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: Language.errorMessages.authError }),
)
return res(ctx.status(500), ctx.json({ message: apiErrorMessage }))
}),
)

Expand All @@ -57,30 +55,10 @@ describe("LoginPage", () => {
const signInButton = await screen.findByText(Language.passwordSignIn)
fireEvent.click(signInButton)

// Then
const errorMessage = await screen.findByText(
Language.errorMessages.authError,
)
expect(errorMessage).toBeDefined()
expect(history.location.pathname).toEqual("/login")
})

it("shows an error if fetching auth methods fails", async () => {
// Given
const apiErrorMessage = "Unable to fetch methods"
server.use(
// Make login fail
rest.get("/api/v2/users/authmethods", async (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: apiErrorMessage }))
}),
)

// When
render(<LoginPage />)

// Then
const errorMessage = await screen.findByText(apiErrorMessage)
expect(errorMessage).toBeDefined()
expect(history.location.pathname).toEqual("/login")
})

it("shows github authentication when enabled", async () => {
Expand Down
5 changes: 3 additions & 2 deletions site/src/pages/LoginPage/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const LoginPage: FC = () => {

if (authState.matches("signedIn")) {
return <Navigate to={redirectTo} replace />
} else if (authState.matches("waitingForTheFirstUser")) {
} else if (authState.matches("configuringTheFirstUser")) {
return <Navigate to="/setup" />
} else {
return (
Expand All @@ -27,7 +27,8 @@ export const LoginPage: FC = () => {
</Helmet>
<LoginPageView
context={authState.context}
isLoading={authState.hasTag("loading")}
isLoading={authState.matches("loadingInitialAuthData")}
isSigningIn={authState.matches("signingIn")}
onSignIn={({ email, password }) => {
authSend({ type: "SIGN_IN", email, password })
}}
Expand Down
Loading