fix(site): Fix login flow (#6294)

This commit is contained in:
Bruno Quaresma
2023-02-23 15:25:12 +00:00
committed by GitHub
parent a32169ccb5
commit 8298a924f6
15 changed files with 276 additions and 389 deletions
+13 -3
View File
@@ -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> => {
@@ -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 />
@@ -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
}
@@ -22,7 +22,7 @@ const useStyles = makeStyles((theme) => ({
}))
export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
isLoading,
isSigningIn,
redirectTo,
authMethods,
}) => {
@@ -39,7 +39,7 @@ export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
>
<Button
startIcon={<GitHubIcon className={styles.buttonIcon} />}
disabled={isLoading}
disabled={isSigningIn}
fullWidth
type="submit"
variant="outlined"
@@ -68,7 +68,7 @@ export const OAuthSignInForm: FC<OAuthSignInFormProps> = ({
<KeyIcon className={styles.buttonIcon} />
)
}
disabled={isLoading}
disabled={isSigningIn}
fullWidth
type="submit"
variant="outlined"
@@ -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()
@@ -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)}
@@ -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>
+14 -22
View File
@@ -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",
@@ -49,6 +36,9 @@ const useStyles = makeStyles((theme) => ({
fontWeight: 600,
},
},
error: {
marginBottom: theme.spacing(4),
},
divider: {
paddingTop: theme.spacing(3),
paddingBottom: theme.spacing(3),
@@ -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.
@@ -87,8 +77,8 @@ export interface SignInFormProps {
export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
authMethods,
redirectTo,
isLoading,
loginErrors,
isSigningIn,
error,
onSubmit,
initialTouched,
}) => {
@@ -96,11 +86,9 @@ export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
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")
@@ -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>
<Maybe condition={passwordEnabled && showPasswordAuth}>
<PasswordSignInForm
loginErrors={loginErrors}
onSubmit={onSubmit}
initialTouched={initialTouched}
isLoading={isLoading}
isSigningIn={isSigningIn}
/>
</Maybe>
<Maybe condition={passwordEnabled && showPasswordAuth && oAuthEnabled}>
@@ -127,7 +119,7 @@ export const SignInForm: FC<React.PropsWithChildren<SignInFormProps>> = ({
</Maybe>
<Maybe condition={oAuthEnabled}>
<OAuthSignInForm
isLoading={isLoading}
isSigningIn={isSigningIn}
redirectTo={redirectTo}
authMethods={authMethods}
/>
+5 -5
View File
@@ -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")
}
+5 -5
View File
@@ -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")
}
+6 -6
View File
@@ -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.")
}
+3 -25
View File
@@ -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 }))
}),
)
@@ -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 () => {
+3 -2
View File
@@ -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 (
@@ -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 })
}}
@@ -1,5 +1,6 @@
import { action } from "@storybook/addon-actions"
import { ComponentMeta, Story } from "@storybook/react"
import { MockAuthMethods } from "testHelpers/entities"
import { LoginPageView, LoginPageViewProps } from "./LoginPageView"
export default {
@@ -15,18 +16,44 @@ export const Example = Template.bind({})
Example.args = {
isLoading: false,
onSignIn: action("onSignIn"),
context: {},
context: {
data: {
authMethods: MockAuthMethods,
hasFirstUser: false,
},
},
}
const err = new Error(
"You are signed out or your session has expired. Please sign in again to continue.",
)
const err = new Error("Username or email are wrong.")
export const AuthError = Template.bind({})
AuthError.args = {
isLoading: false,
onSignIn: action("onSignIn"),
context: {
authError: err,
error: err,
data: {
authMethods: MockAuthMethods,
hasFirstUser: false,
},
},
}
export const LoadingInitialData = Template.bind({})
LoadingInitialData.args = {
isLoading: true,
onSignIn: action("onSignIn"),
context: {},
}
export const SigningIn = Template.bind({})
SigningIn.args = {
isSigningIn: true,
onSignIn: action("onSignIn"),
context: {
data: {
authMethods: MockAuthMethods,
hasFirstUser: false,
},
},
}
+9 -20
View File
@@ -2,34 +2,28 @@ import { makeStyles } from "@material-ui/core/styles"
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
import { FC } from "react"
import { useLocation } from "react-router-dom"
import { AuthContext } from "xServices/auth/authXService"
import { LoginErrors, SignInForm } from "components/SignInForm/SignInForm"
import { AuthContext, UnauthenticatedData } from "xServices/auth/authXService"
import { SignInForm } from "components/SignInForm/SignInForm"
import { retrieveRedirect } from "util/redirect"
import { CoderIcon } from "components/Icons/CoderIcon"
interface LocationState {
isRedirect: boolean
}
export interface LoginPageViewProps {
context: AuthContext
isLoading: boolean
isSigningIn: boolean
onSignIn: (credentials: { email: string; password: string }) => void
}
export const LoginPageView: FC<LoginPageViewProps> = ({
context,
isLoading,
isSigningIn,
onSignIn,
}) => {
const location = useLocation()
const redirectTo = retrieveRedirect(location.search)
const locationState = location.state
? (location.state as LocationState)
: null
const isRedirected = locationState ? locationState.isRedirect : false
const { authError, getUserError, checkPermissionsError, getMethodsError } =
context
const { error } = context
const data = context.data as UnauthenticatedData
const styles = useStyles()
return isLoading ? (
@@ -39,15 +33,10 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
<div className={styles.container}>
<CoderIcon fill="white" opacity={1} className={styles.icon} />
<SignInForm
authMethods={context.methods}
authMethods={data.authMethods}
redirectTo={redirectTo}
isLoading={isLoading}
loginErrors={{
[LoginErrors.AUTH_ERROR]: authError,
[LoginErrors.GET_USER_ERROR]: isRedirected ? getUserError : null,
[LoginErrors.CHECK_PERMISSIONS_ERROR]: checkPermissionsError,
[LoginErrors.GET_METHODS_ERROR]: getMethodsError,
}}
isSigningIn={isSigningIn}
error={error}
onSubmit={onSignIn}
/>
<footer className={styles.footer}>
@@ -2,6 +2,8 @@ import { FC } from "react"
import { Section } from "../../../components/SettingsLayout/Section"
import { AccountForm } from "../../../components/SettingsAccountForm/SettingsAccountForm"
import { useAuth } from "components/AuthProvider/AuthProvider"
import { useMe } from "hooks/useMe"
import { usePermissions } from "hooks/usePermissions"
export const Language = {
title: "Account",
@@ -9,13 +11,11 @@ export const Language = {
export const AccountPage: FC = () => {
const [authState, authSend] = useAuth()
const { me, permissions, updateProfileError } = authState.context
const me = useMe()
const permissions = usePermissions()
const { updateProfileError } = authState.context
const canEditUsers = permissions && permissions.updateUsers
if (!me) {
throw new Error("No current user found")
}
return (
<Section title={Language.title} description="Update your account info">
<AccountForm
-18
View File
@@ -1,18 +0,0 @@
import { StateFrom } from "xstate"
import { AuthContext, authMachine } from "./authXService"
type AuthState = StateFrom<typeof authMachine>
export const selectOrgId = (state: AuthState): string | undefined => {
return state.context.me?.organization_ids[0]
}
export const selectPermissions = (
state: AuthState,
): AuthContext["permissions"] => {
return state.context.permissions
}
export const selectUser = (state: AuthState): AuthContext["me"] => {
return state.context.me
}
+166 -239
View File
@@ -78,24 +78,102 @@ export const permissionsToCheck = {
export type Permissions = Record<keyof typeof permissionsToCheck, boolean>
export type AuthenticatedData = {
user: TypesGen.User
permissions: Permissions
}
export type UnauthenticatedData = {
hasFirstUser: boolean
authMethods: TypesGen.AuthMethods
}
export type AuthData = AuthenticatedData | UnauthenticatedData
export const isAuthenticated = (data?: AuthData): data is AuthenticatedData =>
data !== undefined && "user" in data
const loadInitialAuthData = async (): Promise<AuthData> => {
const authenticatedUser = await API.getAuthenticatedUser()
if (authenticatedUser) {
const permissions = (await API.checkAuthorization({
checks: permissionsToCheck,
})) as Permissions
return {
user: authenticatedUser,
permissions,
}
}
const [hasFirstUser, authMethods] = await Promise.all([
API.hasFirstUser(),
API.getAuthMethods(),
])
return {
hasFirstUser,
authMethods,
}
}
const signIn = async (
email: string,
password: string,
): Promise<AuthenticatedData> => {
await API.login(email, password)
const [user, permissions] = await Promise.all([
API.getAuthenticatedUser(),
API.checkAuthorization({
checks: permissionsToCheck,
}),
])
return {
user: user as TypesGen.User,
permissions: permissions as Permissions,
}
}
const signOut = async () => {
// Get app hostname so we can see if we need to log out of app URLs.
// We need to load this before we log out of the API as this is an
// authenticated endpoint.
const appHost = await API.getApplicationsHost()
const [authMethods] = await Promise.all([
API.getAuthMethods(), // Antecipate and load the auth methods
API.logout(),
])
// Logout the app URLs
if (appHost.host !== "") {
const { protocol, host } = window.location
const redirect_uri = encodeURIComponent(`${protocol}//${host}/login`)
// The path doesn't matter but we use /api because the dev server
// proxies /api to the backend.
const uri = `${protocol}//${appHost.host.replace(
"*",
"coder-logout",
)}/api/logout?redirect_uri=${redirect_uri}`
return {
redirectUrl: uri,
}
}
return {
hasFirstUser: true,
authMethods,
} as UnauthenticatedData
}
export interface AuthContext {
getUserError?: Error | unknown
// The getMethods API call does not return an ApiError.
// It can only error out in a generic fashion.
getMethodsError?: Error | unknown
authError?: Error | unknown
error?: Error | unknown
updateProfileError?: Error | unknown
me?: TypesGen.User
methods?: TypesGen.AuthMethods
permissions?: Permissions
checkPermissionsError?: Error | unknown
data?: AuthData
}
export type AuthEvent =
| { type: "SIGN_OUT" }
| { type: "SIGN_IN"; email: string; password: string }
| { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }
| { type: "GET_AUTH_METHODS" }
export const authMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdLAJZQB2kA8hgMTYCSA4gHID6DLioADgPal0JPuW4gAHogBsATimEAzDIAcAFgUB2VTJ26ANCACeiAIwaADKsLKFtu-dsBfRwbRZc+IqQolyUBuS0ECJEvgBufADWXmTkAWL8gsKiSBKICubKhKpSyhoArAbGCGaqGoRSlVXVlfnOrhg4eATEsb7+gWAATl18XYQ8ADb4AGZ9ALatFPGpiSRCImKSCLLySmqa2ro6RaYFAEzWDscK9SBuTZ6EMOhCfgCqsN1BIYThUUQ3ALJgCQLzySW6Uy2VyBV2JXyUhMFRqcLqLnOjQ8LRudygj2e3V6-SGowm1zA6B+fySi1Sy32MnyhHyyksYK2ug0EJM1IURxO9jOFxRnyJ6IACt1xiRYKQRLAXpQ3uQItFCABjTBgRWRYVdUXi5LwWb-BYpUDLZRScygvKFIyIGQaQ4FHnI5r827tDVaiXkKXYvoDYboMaapUqtVusUe3W8fWAimIE1mnIW1l0rImOE1BENdxOwkuvw-LB8CBS4Iy94K75EzCFiMgOYGoEIZRUwgyVT5BQmfaW4omGxZGxcuwOrNXNHtfNVou0b24v0ByYVgtF0kA8lG2PN1vtzvd0zszmD06I3nZ7yUCABAa9EYkQahCB32j3QUAEQAggAVACibEFACUqAAMQYAAZL8V3rGMSjbGQKihLsISkOlaWHS4WjPSBLx4a9byIVAeAgfBXRwx8S1COUPkIE8rgwi9yCvPgbzvQh8MIoUSLABB3kVIiRAAbXMABdCDo3XaD8lgpCpAQq0EA0eTUL5KZzywjiWIIoi-EFDjpx6H08X9AlqPQ2JMPo7DGNw9S2OIyy7y4iieINAThL1MlDTScTJPg3dGxkfZFNPUy6OIWBMDeB8wFoJgvw-NhsGwAAJNgAGkvwATREtdPM7VQrE0XyTF7coB0PQKaOCy9xXCsc-ASxKUrAQxpXI+UiGMmIKDM0KaoFdp6sawwHIiJzkhcrKPOWIqkOscFZNTfYOXtY9HQqrqQuqnN0QGprdJxX18UDDrlO6zbaqgHahu43jyHGtzV0m0x9jyxQ5p7ExzBhUrB3Kkz1qqsLCEGPhkAgSAIsfP8vxilgvz-T8f3q1KMomht9g+8ocnMGQCtZDRZAPLkMyREc-pU+jNuB0HwcVEQb01S6-zAGBKC6TxaAAYTfFgOa-EC2ChmG4YR+KkuRzL7sgsT0bMQhzCUcxpMK20vsPBRieO2iAfCqmwYgJU6ZIBmksGpmWe6dmOaoFhgL-L4Behr9Yfh79ReStKJcjdy0Yx0Fsdx+b23MX7OvJnqgZBvXCC6ZmwFZzSLpN3ayNlNqqNWsnTsB3XwZj822e2pOrscm67q9h6GzMBQrDy7cZJ7DR1ZQlbSdDrOdcj3PY-jwuGt2mcDsMo6M7bjbs87-W87ji3e8G4a+FG-ihNRqCq5rtsO3r0wpG0ZvMzQ0eqtVVAunmQwIai5931d7Avw5+4-wYD9PdrKNsqmsoOQyBM3sQZ6pBDidDax9T7oHPqxBO2AQFnxaqnSimtKoU2gWA6ykDkHFxGqXZektRI5U-ooBkiZZIKFyHvEmB8gFH0VCfM+qDtroL2vpOcRkR6UKQdQ0B4CNL0I4Wfeei9brYPLlLPBjcCE-18m2KwGtWFa0CIwVgbAqD3A-CvaWWgYSEN-iUDIZpUxpiqBoQBZ52g0HQLAssoczFqM8k2WCW5N6+X2OYeShMTgyNbspUxdAB4GXnMpaxOD35-w0XLCRrJ0ZWH0QYqQRiW4UOVKqSI7RAJG1gOgTEXQLEUQVMdRJaoUlpIyU8Lo-CsGuWEbg5YmhCD7B3nU-YA5lA6HVhCZxYjoRshyDvJQ+NVCAIAO7IABH4QCfQPwqlSV0dJmT6DMHYJwGx1Sm7Yx0I3SwziTBOLqWaLQdIpC2HyKoLZ5gESInIIWOAYgEHrUCZU4JJRsY0iIT2akZoPEUJMX4GY9zHoIDbIcdGLzt4qFhDEpCgDzqZKWYgVQ+xWSyCyOC2okK+paRFGGHUML-mmnNNorZbIwUxI+Upc6E5qzYq2LUuQwKSitnymrI8+8lJyIYkxe8zELlfj0l0bFVcaRlCklvRspzjGILZVZEgkVCAzj5Y3AV+MfIQjyEy8hLLxUWXZRfOVRVsiKqVhCRuhxtgaBVY3A5MgxX-XMmpCB7E7K-CCX8oq+RnnaIKJa+J6rrUSrvHykwHZZq+X2bScwYbzUSTUBCr1QUfWbSlX6p1lctlusKp2Q4JLY1hzOmixOfdii-MrlIiotKPpyCtdm8e1N9YJsdYWqCmyshyH9vigoVhkWxIre3CO1aDbkHpuMRm3cZ51tft7BtqhzAZoVga+aGhexuOOJmtalaO69qnj3fqRc+UKHpIQIq87S25GXZnMea69Y7qhPuswxVCptnKNsOQ7Z2xUhPYfCmYV-WBtLVOjkJqzUkP6TGldp10EX0IFynlcroRywsI4iEu6ArAdPVQmhKDa0yqg0m1e+NNFwZ3BCNswdkPvuIGB2tcqakuPlgR4h2MWzMgAxartwDeEoLtf1dB-rXVBoQyQljqHOFfq+q2v9jHG7mqUKqm55N-UuN4-NT6DGdD5C0Gp-Ypq31eL8HcsdFcG0yA+rB5QtHijOKOYoNWgD8nJNGUU6F2GxK9LlvSTIcLGn5C2ZUNpLj5CxIUFSD6XmuyDOGeiMZXQJlgCmTMkp2LGm1OUFCHQORGkyEVqoZQbTNly2-poaERy6kh0pYl5LrZpLNIy1l2Sm5dAkPRs4wz+NlDOGcEAA */
@@ -108,46 +186,47 @@ export const authMachine =
context: {} as AuthContext,
events: {} as AuthEvent,
services: {} as {
getMe: {
data: TypesGen.User
}
getMethods: {
data: TypesGen.AuthMethods
loadInitialAuthData: {
data: Awaited<ReturnType<typeof loadInitialAuthData>>
}
signIn: {
data: TypesGen.LoginWithPasswordResponse
data: Awaited<ReturnType<typeof signIn>>
}
updateProfile: {
data: TypesGen.User
}
updateSecurity: {
data: undefined
}
checkPermissions: {
data: TypesGen.AuthorizationResponse
}
hasFirstUser: {
data: boolean
}
signOut: {
data:
| {
redirectUrl: string
}
| undefined
data: Awaited<ReturnType<typeof signOut>>
}
},
},
context: {
me: undefined,
getUserError: undefined,
authError: undefined,
updateProfileError: undefined,
methods: undefined,
getMethodsError: undefined,
},
initial: "gettingUser",
initial: "loadingInitialAuthData",
states: {
loadingInitialAuthData: {
invoke: {
src: "loadInitialAuthData",
onDone: [
{
target: "signedIn",
actions: ["assignData", "clearError"],
cond: "isAuthenticated",
},
{
target: "configuringTheFirstUser",
actions: ["assignData", "clearError"],
cond: "needSetup",
},
{
target: "signedOut",
actions: ["assignData", "clearError"],
},
],
onError: {
target: "signedOut",
actions: ["assignError"],
},
},
},
signedOut: {
on: {
SIGN_IN: {
@@ -156,85 +235,31 @@ export const authMachine =
},
},
signingIn: {
entry: "clearAuthError",
entry: "clearError",
invoke: {
src: "signIn",
id: "signIn",
onDone: [
{
target: "gettingUser",
},
],
onError: [
{
actions: "assignAuthError",
target: "signedOut",
},
],
},
tags: "loading",
},
gettingUser: {
entry: "clearGetUserError",
invoke: {
src: "getMe",
id: "getMe",
onDone: [
{
actions: "assignMe",
target: "gettingPermissions",
},
],
onError: [
{
actions: "assignGetUserError",
target: "checkingFirstUser",
},
],
},
tags: "loading",
},
gettingPermissions: {
entry: "clearGetPermissionsError",
invoke: {
src: "checkPermissions",
id: "checkPermissions",
onDone: [
{
actions: "assignPermissions",
target: "signedIn",
actions: "assignData",
},
],
onError: [
{
actions: "assignGetPermissionsError",
actions: "assignError",
target: "signedOut",
},
],
},
tags: "loading",
},
gettingMethods: {
invoke: {
src: "getMethods",
id: "getMethods",
onDone: [
{
actions: ["assignMethods", "clearGetMethodsError"],
target: "signedOut",
},
],
onError: [
{
actions: "assignGetMethodsError",
target: "signedOut",
},
],
},
tags: "loading",
},
signedIn: {
type: "parallel",
on: {
SIGN_OUT: {
target: "signingOut",
},
},
states: {
profile: {
initial: "idle",
@@ -257,7 +282,7 @@ export const authMachine =
src: "updateProfile",
onDone: [
{
actions: ["assignMe", "notifySuccessProfileUpdate"],
actions: ["updateUser", "notifySuccessProfileUpdate"],
target: "#authState.signedIn.profile.idle.noError",
},
],
@@ -271,41 +296,6 @@ export const authMachine =
},
},
},
methods: {
initial: "idle",
states: {
idle: {
on: {
GET_AUTH_METHODS: {
target: "gettingMethods",
},
},
},
gettingMethods: {
entry: "clearGetMethodsError",
invoke: {
src: "getMethods",
onDone: [
{
actions: ["assignMethods", "clearGetMethodsError"],
target: "idle",
},
],
onError: [
{
actions: "assignGetMethodsError",
target: "idle",
},
],
},
},
},
},
},
on: {
SIGN_OUT: {
target: "signingOut",
},
},
},
signingOut: {
@@ -314,40 +304,23 @@ export const authMachine =
id: "signOut",
onDone: [
{
actions: ["unassignMe", "clearAuthError", "redirect"],
actions: ["clearData", "clearError", "redirect"],
cond: "hasRedirectUrl",
},
{
actions: ["unassignMe", "clearAuthError"],
target: "gettingMethods",
actions: ["clearData", "clearError"],
target: "signedOut",
},
],
onError: [
{
actions: "assignAuthError",
actions: "assignError",
target: "signedIn",
},
],
},
tags: "loading",
},
checkingFirstUser: {
invoke: {
src: "hasFirstUser",
onDone: [
{
cond: "isTrue",
target: "gettingMethods",
},
{
target: "waitingForTheFirstUser",
},
],
onError: "signedOut",
},
tags: "loading",
},
waitingForTheFirstUser: {
configuringTheFirstUser: {
on: {
SIGN_IN: {
target: "signingIn",
@@ -358,82 +331,46 @@ export const authMachine =
},
{
services: {
signIn: async (_, event) => {
return await API.login(event.email, event.password)
},
signOut: async () => {
// Get app hostname so we can see if we need to log out of app URLs.
// We need to load this before we log out of the API as this is an
// authenticated endpoint.
const appHost = await API.getApplicationsHost()
await API.logout()
if (appHost.host !== "") {
const { protocol, host } = window.location
const redirect_uri = encodeURIComponent(
`${protocol}//${host}/login`,
)
// The path doesn't matter but we use /api because the dev server
// proxies /api to the backend.
const uri = `${protocol}//${appHost.host.replace(
"*",
"coder-logout",
)}/api/logout?redirect_uri=${redirect_uri}`
return {
redirectUrl: uri,
}
}
},
getMe: API.getUser,
getMethods: API.getAuthMethods,
updateProfile: async (context, event) => {
if (!context.me) {
throw new Error("No current user found")
loadInitialAuthData,
signIn: (_, { email, password }) => signIn(email, password),
signOut,
updateProfile: async ({ data }, event) => {
if (!data) {
throw new Error("Authenticated data is not loaded yet")
}
return API.updateProfile(context.me.id, event.data)
if (isAuthenticated(data)) {
return API.updateProfile(data.user.id, event.data)
}
throw new Error("User not authenticated")
},
checkPermissions: async () => {
return API.checkAuthorization({
checks: permissionsToCheck,
})
},
// First user
hasFirstUser: () => API.hasFirstUser(),
},
actions: {
assignMe: assign({
me: (_, event) => event.data,
assignData: assign({
data: (_, { data }) => data,
}),
unassignMe: assign((context: AuthContext) => ({
...context,
me: undefined,
})),
assignMethods: assign({
methods: (_, event) => event.data,
clearData: assign({
data: (_) => undefined,
}),
assignGetMethodsError: assign({
getMethodsError: (_, event) => event.data,
assignError: assign({
error: (_, event) => event.data,
}),
clearGetMethodsError: assign((context: AuthContext) => ({
...context,
getMethodsError: undefined,
})),
assignGetUserError: assign({
getUserError: (_, event) => event.data,
clearError: assign({
error: (_) => undefined,
}),
clearGetUserError: assign((context: AuthContext) => ({
...context,
getUserError: undefined,
})),
assignAuthError: assign({
authError: (_, event) => event.data,
updateUser: assign({
data: (context, event) => {
if (!context.data) {
throw new Error("No authentication data loaded")
}
return {
...context.data,
user: event.data,
}
},
}),
clearAuthError: assign((context: AuthContext) => ({
...context,
authError: undefined,
})),
assignUpdateProfileError: assign({
updateProfileError: (_, event) => event.data,
}),
@@ -443,20 +380,8 @@ export const authMachine =
clearUpdateProfileError: assign({
updateProfileError: (_) => undefined,
}),
assignPermissions: assign({
// Setting event.data as Permissions to be more stricted. So we know
// what permissions we asked for.
permissions: (_, event) => event.data as Permissions,
}),
assignGetPermissionsError: assign({
checkPermissionsError: (_, event) => event.data,
}),
clearGetPermissionsError: assign({
checkPermissionsError: (_) => undefined,
}),
redirect: (_, { data }) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data can be undefined
if (!data) {
if (!("redirectUrl" in data)) {
throw new Error(
"Redirect only should be called with data.redirectUrl",
)
@@ -466,7 +391,9 @@ export const authMachine =
},
},
guards: {
isTrue: (_, event) => event.data,
isAuthenticated: (_, { data }) => isAuthenticated(data),
needSetup: (_, { data }) =>
!isAuthenticated(data) && !data.hasFirstUser,
hasRedirectUrl: (_, { data }) => Boolean(data),
},
},