mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): Fix login flow (#6294)
This commit is contained in:
+13
-3
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user