From 10f3c100c77147648fe124b734a796f65d6bca6c Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 12:47:17 +0000 Subject: [PATCH 01/20] Move xstate transitions to provider --- .../components/AuthProvider/AuthProvider.tsx | 51 ++++++++++++++----- .../components/Dashboard/Navbar/Navbar.tsx | 5 +- .../components/RequireAuth/RequireAuth.tsx | 7 +-- site/src/hooks/useMe.ts | 3 +- site/src/hooks/useOrganizationId.ts | 3 +- site/src/hooks/usePermissions.ts | 3 +- site/src/pages/LoginPage/LoginPage.tsx | 5 +- site/src/pages/SetupPage/SetupPage.tsx | 9 ++-- .../AccountPage/AccountForm.tsx | 33 +++++------- .../AccountPage/AccountPage.tsx | 10 ++-- 10 files changed, 73 insertions(+), 56 deletions(-) diff --git a/site/src/components/AuthProvider/AuthProvider.tsx b/site/src/components/AuthProvider/AuthProvider.tsx index d35c117b415fb..e925689847b8b 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -1,36 +1,63 @@ import { useActor, useInterpret } from "@xstate/react"; -import { createContext, FC, PropsWithChildren, useContext } from "react"; +import { UpdateUserProfileRequest } from "api/typesGenerated"; +import { + createContext, + FC, + PropsWithChildren, + useCallback, + useContext, +} from "react"; import { authMachine } from "xServices/auth/authXService"; import { ActorRefFrom } from "xstate"; -interface AuthContextValue { +type AuthContextValue = { + signOut: () => void; + signIn: (email: string, password: string) => void; + updateProfile: (data: UpdateUserProfileRequest) => void; authService: ActorRefFrom; -} +}; const AuthContext = createContext(undefined); export const AuthProvider: FC = ({ children }) => { const authService = useInterpret(authMachine); + const signOut = useCallback(() => { + authService.send("SIGN_OUT"); + }, [authService]); + + const signIn = useCallback( + (email: string, password: string) => { + authService.send({ type: "SIGN_IN", email, password }); + }, + [authService], + ); + + const updateProfile = useCallback( + (data: UpdateUserProfileRequest) => { + authService.send({ type: "UPDATE_PROFILE", data }); + }, + [authService], + ); + return ( - + {children} ); }; -type UseAuthReturnType = ReturnType< - typeof useActor ->; - -export const useAuth = (): UseAuthReturnType => { +export const useAuth = () => { const context = useContext(AuthContext); if (!context) { throw new Error("useAuth should be used inside of "); } - const auth = useActor(context.authService); - - return auth; + return { + ...context, + actor: useActor(context.authService), + }; }; diff --git a/site/src/components/Dashboard/Navbar/Navbar.tsx b/site/src/components/Dashboard/Navbar/Navbar.tsx index de430e3fdf476..c84dc1f12b26e 100644 --- a/site/src/components/Dashboard/Navbar/Navbar.tsx +++ b/site/src/components/Dashboard/Navbar/Navbar.tsx @@ -9,7 +9,7 @@ import { useProxy } from "contexts/ProxyContext"; export const Navbar: FC = () => { const { appearance, buildInfo } = useDashboard(); - const [_, authSend] = useAuth(); + const { signOut } = useAuth(); const me = useMe(); const permissions = usePermissions(); const featureVisibility = useFeatureVisibility(); @@ -17,7 +17,6 @@ export const Navbar: FC = () => { featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog); const canViewDeployment = Boolean(permissions.viewDeploymentValues); const canViewAllUsers = Boolean(permissions.readAllUsers); - const onSignOut = () => authSend("SIGN_OUT"); const proxyContextValue = useProxy(); const dashboard = useDashboard(); @@ -27,7 +26,7 @@ export const Navbar: FC = () => { logo_url={appearance.config.logo_url} buildInfo={buildInfo} supportLinks={appearance.config.support_links} - onSignOut={onSignOut} + onSignOut={signOut} canViewAuditLog={canViewAuditLog} canViewDeployment={canViewDeployment} canViewAllUsers={canViewAllUsers} diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index 5703844848b37..939df8c54b983 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -9,7 +9,8 @@ import { ProxyProvider } from "contexts/ProxyContext"; import { isApiError } from "api/errors"; export const RequireAuth: FC = () => { - const [authState, authSend] = useAuth(); + const { signOut, actor } = useAuth(); + const [authState] = actor; const location = useLocation(); const isHomePage = location.pathname === "/"; const navigateTo = isHomePage @@ -24,7 +25,7 @@ export const RequireAuth: FC = () => { // If we encountered an authentication error, then our token is probably // invalid and we should update the auth state to reflect that. if (isApiError(error) && error.response.status === 401) { - authSend("SIGN_OUT"); + signOut(); } // Otherwise, pass the response through so that it can be displayed in the UI @@ -35,7 +36,7 @@ export const RequireAuth: FC = () => { return () => { axios.interceptors.response.eject(interceptorHandle); }; - }, [authSend]); + }, [signOut]); if (authState.matches("signedOut")) { return ; diff --git a/site/src/hooks/useMe.ts b/site/src/hooks/useMe.ts index 2d15cad0f1614..3ab606687a45c 100644 --- a/site/src/hooks/useMe.ts +++ b/site/src/hooks/useMe.ts @@ -3,7 +3,8 @@ import { useAuth } from "components/AuthProvider/AuthProvider"; import { isAuthenticated } from "xServices/auth/authXService"; export const useMe = (): User => { - const [authState] = useAuth(); + const { actor } = useAuth(); + const [authState] = actor; const { data } = authState.context; if (isAuthenticated(data)) { diff --git a/site/src/hooks/useOrganizationId.ts b/site/src/hooks/useOrganizationId.ts index c090d988e267d..228563c1ca1a5 100644 --- a/site/src/hooks/useOrganizationId.ts +++ b/site/src/hooks/useOrganizationId.ts @@ -2,7 +2,8 @@ import { useAuth } from "components/AuthProvider/AuthProvider"; import { isAuthenticated } from "xServices/auth/authXService"; export const useOrganizationId = (): string => { - const [authState] = useAuth(); + const { actor } = useAuth(); + const [authState] = actor; const { data } = authState.context; if (isAuthenticated(data)) { diff --git a/site/src/hooks/usePermissions.ts b/site/src/hooks/usePermissions.ts index b04bc0d189155..ab5f334cd6bd9 100644 --- a/site/src/hooks/usePermissions.ts +++ b/site/src/hooks/usePermissions.ts @@ -2,7 +2,8 @@ import { useAuth } from "components/AuthProvider/AuthProvider"; import { isAuthenticated, Permissions } from "xServices/auth/authXService"; export const usePermissions = (): Permissions => { - const [authState] = useAuth(); + const { actor } = useAuth(); + const [authState] = actor; const { data } = authState.context; if (isAuthenticated(data)) { diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 698be4bbaeabc..9cc58b7a6510e 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -8,7 +8,8 @@ import { getApplicationName } from "utils/appearance"; export const LoginPage: FC = () => { const location = useLocation(); - const [authState, authSend] = useAuth(); + const { actor, signIn } = useAuth(); + const [authState] = actor; const redirectTo = retrieveRedirect(location.search); const applicationName = getApplicationName(); @@ -27,7 +28,7 @@ export const LoginPage: FC = () => { isLoading={authState.matches("loadingInitialAuthData")} isSigningIn={authState.matches("signingIn")} onSignIn={({ email, password }) => { - authSend({ type: "SIGN_IN", email, password }); + signIn(email, password); }} /> diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index f83b8bbfda265..6618f16c8b7f6 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -8,7 +8,8 @@ import { useMutation } from "react-query"; import { createFirstUser } from "api/queries/users"; export const SetupPage: FC = () => { - const [authState, authSend] = useAuth(); + const { signIn, actor } = useAuth(); + const [authState] = actor; const createFirstUserMutation = useMutation(createFirstUser()); const userIsSignedIn = authState.matches("signedIn"); const setupIsComplete = @@ -35,11 +36,7 @@ export const SetupPage: FC = () => { error={createFirstUserMutation.error} onSubmit={async (firstUser) => { await createFirstUserMutation.mutateAsync(firstUser); - authSend({ - type: "SIGN_IN", - email: firstUser.email, - password: firstUser.password, - }); + signIn(firstUser.email, firstUser.password); }} /> diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx index 9c6648200fcd2..84dbb612b79e1 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx @@ -1,5 +1,5 @@ import TextField from "@mui/material/TextField"; -import { FormikContextType, FormikTouched, useFormik } from "formik"; +import { FormikTouched, useFormik } from "formik"; import { FC } from "react"; import * as Yup from "yup"; import { @@ -10,10 +10,7 @@ import { import { LoadingButton } from "components/LoadingButton/LoadingButton"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Form, FormFields } from "components/Form/Form"; - -export interface AccountFormValues { - username: string; -} +import { UpdateUserProfileRequest } from "api/typesGenerated"; export const Language = { usernameLabel: "Username", @@ -29,14 +26,14 @@ export interface AccountFormProps { editable: boolean; email: string; isLoading: boolean; - initialValues: AccountFormValues; - onSubmit: (values: AccountFormValues) => void; + initialValues: UpdateUserProfileRequest; + onSubmit: (values: UpdateUserProfileRequest) => void; updateProfileError?: unknown; // initialTouched is only used for testing the error state of the form. - initialTouched?: FormikTouched; + initialTouched?: FormikTouched; } -export const AccountForm: FC> = ({ +export const AccountForm: FC = ({ editable, email, isLoading, @@ -45,17 +42,13 @@ export const AccountForm: FC> = ({ updateProfileError, initialTouched, }) => { - const form: FormikContextType = - useFormik({ - initialValues, - validationSchema, - onSubmit, - initialTouched, - }); - const getFieldHelpers = getFormHelpers( - form, - updateProfileError, - ); + const form = useFormik({ + initialValues, + validationSchema, + onSubmit, + initialTouched, + }); + const getFieldHelpers = getFormHelpers(form, updateProfileError); return ( <> diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index 4a60e6dacf0a1..5d3c37799cced 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -6,7 +6,8 @@ import { useMe } from "hooks/useMe"; import { usePermissions } from "hooks/usePermissions"; export const AccountPage: FC = () => { - const [authState, authSend] = useAuth(); + const { updateProfile, actor } = useAuth(); + const [authState] = actor; const me = useMe(); const permissions = usePermissions(); const { updateProfileError } = authState.context; @@ -22,12 +23,7 @@ export const AccountPage: FC = () => { initialValues={{ username: me.username, }} - onSubmit={(data) => { - authSend({ - type: "UPDATE_PROFILE", - data, - }); - }} + onSubmit={updateProfile} /> ); From f577c4de087d61504a585b5816ab1bc658ce4bc8 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 13:17:11 +0000 Subject: [PATCH 02/20] Centrlize auth logic in the provider --- .../components/AuthProvider/AuthProvider.tsx | 74 ++++++++++++++++--- .../components/RequireAuth/RequireAuth.tsx | 18 +++-- site/src/hooks/useMe.ts | 11 +-- site/src/hooks/useOrganizationId.ts | 14 +--- site/src/hooks/usePermissions.ts | 12 ++- site/src/pages/LoginPage/LoginPage.tsx | 22 ++++-- site/src/pages/LoginPage/LoginPageView.tsx | 12 +-- site/src/pages/SetupPage/SetupPage.tsx | 11 +-- .../AccountPage/AccountPage.tsx | 6 +- 9 files changed, 114 insertions(+), 66 deletions(-) diff --git a/site/src/components/AuthProvider/AuthProvider.tsx b/site/src/components/AuthProvider/AuthProvider.tsx index e925689847b8b..850f6c5531ed4 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -1,5 +1,9 @@ import { useActor, useInterpret } from "@xstate/react"; -import { UpdateUserProfileRequest } from "api/typesGenerated"; +import { + AuthMethods, + UpdateUserProfileRequest, + User, +} from "api/typesGenerated"; import { createContext, FC, @@ -7,10 +11,26 @@ import { useCallback, useContext, } from "react"; -import { authMachine } from "xServices/auth/authXService"; +import { + Permissions, + authMachine, + isAuthenticated, +} from "xServices/auth/authXService"; import { ActorRefFrom } from "xstate"; type AuthContextValue = { + isSignedOut: boolean; + isLoading: boolean; + isSigningOut: boolean; + isConfiguringTheFirstUser: boolean; + isSignedIn: boolean; + isSigningIn: boolean; + isUpdatingProfile: boolean; + user: User | undefined; + permissions: Permissions | undefined; + authMethods: AuthMethods | undefined; + signInError: unknown; + updateProfileError: unknown; signOut: () => void; signIn: (email: string, password: string) => void; updateProfile: (data: UpdateUserProfileRequest) => void; @@ -21,28 +41,64 @@ const AuthContext = createContext(undefined); export const AuthProvider: FC = ({ children }) => { const authService = useInterpret(authMachine); + const [authState, authSend] = useActor(authService); + + const isSignedOut = authState.matches("signedOut"); + const isSigningOut = authState.matches("signingOut"); + const isLoading = authState.matches("loadingInitialAuthData"); + const isConfiguringTheFirstUser = authState.matches( + "configuringTheFirstUser", + ); + const isSignedIn = authState.matches("signedIn"); + const isSigningIn = authState.matches("signingIn"); + const isUpdatingProfile = authState.matches( + "signedIn.profile.updatingProfile", + ); const signOut = useCallback(() => { - authService.send("SIGN_OUT"); - }, [authService]); + authSend("SIGN_OUT"); + }, [authSend]); const signIn = useCallback( (email: string, password: string) => { - authService.send({ type: "SIGN_IN", email, password }); + authSend({ type: "SIGN_IN", email, password }); }, - [authService], + [authSend], ); const updateProfile = useCallback( (data: UpdateUserProfileRequest) => { - authService.send({ type: "UPDATE_PROFILE", data }); + authSend({ type: "UPDATE_PROFILE", data }); }, - [authService], + [authSend], ); return ( {children} diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index 939df8c54b983..2473f800de048 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -9,8 +9,13 @@ import { ProxyProvider } from "contexts/ProxyContext"; import { isApiError } from "api/errors"; export const RequireAuth: FC = () => { - const { signOut, actor } = useAuth(); - const [authState] = actor; + const { + signOut, + isSigningOut, + isLoading, + isSignedOut, + isConfiguringTheFirstUser, + } = useAuth(); const location = useLocation(); const isHomePage = location.pathname === "/"; const navigateTo = isHomePage @@ -38,14 +43,11 @@ export const RequireAuth: FC = () => { }; }, [signOut]); - if (authState.matches("signedOut")) { + if (isSignedOut) { return ; - } else if (authState.matches("configuringTheFirstUser")) { + } else if (isConfiguringTheFirstUser) { return ; - } else if ( - authState.matches("loadingInitialAuthData") || - authState.matches("signingOut") - ) { + } else if (isLoading || isSigningOut) { return ; } else { // Authenticated pages have access to some contexts for knowing enabled experiments diff --git a/site/src/hooks/useMe.ts b/site/src/hooks/useMe.ts index 3ab606687a45c..57d6335e16aad 100644 --- a/site/src/hooks/useMe.ts +++ b/site/src/hooks/useMe.ts @@ -1,15 +1,12 @@ import { User } from "api/typesGenerated"; import { useAuth } from "components/AuthProvider/AuthProvider"; -import { isAuthenticated } from "xServices/auth/authXService"; export const useMe = (): User => { - const { actor } = useAuth(); - const [authState] = actor; - const { data } = authState.context; + const { user } = useAuth(); - if (isAuthenticated(data)) { - return data.user; + if (!user) { + throw new Error("User is not authenticated"); } - throw new Error("User is not authenticated"); + return user; }; diff --git a/site/src/hooks/useOrganizationId.ts b/site/src/hooks/useOrganizationId.ts index 228563c1ca1a5..52f0c034e5410 100644 --- a/site/src/hooks/useOrganizationId.ts +++ b/site/src/hooks/useOrganizationId.ts @@ -1,14 +1,6 @@ -import { useAuth } from "components/AuthProvider/AuthProvider"; -import { isAuthenticated } from "xServices/auth/authXService"; +import { useMe } from "./useMe"; export const useOrganizationId = (): string => { - const { actor } = useAuth(); - const [authState] = actor; - const { data } = authState.context; - - if (isAuthenticated(data)) { - return data.user.organization_ids[0]; - } - - throw new Error("User is not authenticated"); + const user = useMe(); + return user.organization_ids[0]; }; diff --git a/site/src/hooks/usePermissions.ts b/site/src/hooks/usePermissions.ts index ab5f334cd6bd9..765985a2de367 100644 --- a/site/src/hooks/usePermissions.ts +++ b/site/src/hooks/usePermissions.ts @@ -1,14 +1,12 @@ import { useAuth } from "components/AuthProvider/AuthProvider"; -import { isAuthenticated, Permissions } from "xServices/auth/authXService"; +import { Permissions } from "xServices/auth/authXService"; export const usePermissions = (): Permissions => { - const { actor } = useAuth(); - const [authState] = actor; - const { data } = authState.context; + const { permissions } = useAuth(); - if (isAuthenticated(data)) { - return data.permissions; + if (!permissions) { + throw new Error("User is not authenticated."); } - throw new Error("User is not authenticated."); + return permissions; }; diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 9cc58b7a6510e..daf5431e753c8 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -8,14 +8,21 @@ import { getApplicationName } from "utils/appearance"; export const LoginPage: FC = () => { const location = useLocation(); - const { actor, signIn } = useAuth(); - const [authState] = actor; + const { + isSignedIn, + isLoading, + isConfiguringTheFirstUser, + signIn, + isSigningIn, + authMethods, + signInError, + } = useAuth(); const redirectTo = retrieveRedirect(location.search); const applicationName = getApplicationName(); - if (authState.matches("signedIn")) { + if (isSignedIn) { return ; - } else if (authState.matches("configuringTheFirstUser")) { + } else if (isConfiguringTheFirstUser) { return ; } else { return ( @@ -24,9 +31,10 @@ export const LoginPage: FC = () => { Codestin Search App { signIn(email, password); }} diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx index 65faa524cdbe8..c53becbc726c7 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -2,29 +2,29 @@ import { makeStyles } from "@mui/styles"; import { FullScreenLoader } from "components/Loader/FullScreenLoader"; import { FC } from "react"; import { useLocation } from "react-router-dom"; -import { AuthContext, UnauthenticatedData } from "xServices/auth/authXService"; import { SignInForm } from "./SignInForm"; import { retrieveRedirect } from "utils/redirect"; import { CoderIcon } from "components/Icons/CoderIcon"; import { getApplicationName, getLogoURL } from "utils/appearance"; +import { AuthMethods } from "api/typesGenerated"; export interface LoginPageViewProps { - context: AuthContext; + authMethods: AuthMethods | undefined; + error: unknown; isLoading: boolean; isSigningIn: boolean; onSignIn: (credentials: { email: string; password: string }) => void; } export const LoginPageView: FC = ({ - context, + authMethods, + error, isLoading, isSigningIn, onSignIn, }) => { const location = useLocation(); const redirectTo = retrieveRedirect(location.search); - const { error } = context; - const data = context.data as UnauthenticatedData; const styles = useStyles(); // This allows messages to be displayed at the top of the sign in form. // Helpful for any redirects that want to inform the user of something. @@ -54,7 +54,7 @@ export const LoginPageView: FC = ({
{applicationLogo} { - const { signIn, actor } = useAuth(); - const [authState] = actor; + const { signIn, isLoading, isConfiguringTheFirstUser, isSignedIn } = + useAuth(); const createFirstUserMutation = useMutation(createFirstUser()); - const userIsSignedIn = authState.matches("signedIn"); - const setupIsComplete = - !authState.matches("loadingInitialAuthData") && - !authState.matches("configuringTheFirstUser"); + const setupIsComplete = !isLoading && !isConfiguringTheFirstUser; // If the user is logged in, navigate to the app - if (userIsSignedIn) { + if (isSignedIn) { return ; } diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index 5d3c37799cced..f266383aa2ca4 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -6,11 +6,9 @@ import { useMe } from "hooks/useMe"; import { usePermissions } from "hooks/usePermissions"; export const AccountPage: FC = () => { - const { updateProfile, actor } = useAuth(); - const [authState] = actor; + const { updateProfile, updateProfileError, isUpdatingProfile } = useAuth(); const me = useMe(); const permissions = usePermissions(); - const { updateProfileError } = authState.context; const canEditUsers = permissions && permissions.updateUsers; return ( @@ -19,7 +17,7 @@ export const AccountPage: FC = () => { editable={Boolean(canEditUsers)} email={me.email} updateProfileError={updateProfileError} - isLoading={authState.matches("signedIn.profile.updatingProfile")} + isLoading={isUpdatingProfile} initialValues={{ username: me.username, }} From fd84833db8a655f143570230215d2a3c4e4190ba Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 13:21:12 +0000 Subject: [PATCH 03/20] Remove actor --- site/src/components/AuthProvider/AuthProvider.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/site/src/components/AuthProvider/AuthProvider.tsx b/site/src/components/AuthProvider/AuthProvider.tsx index 850f6c5531ed4..d6b7a0e0c3f22 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -1,4 +1,4 @@ -import { useActor, useInterpret } from "@xstate/react"; +import { useMachine } from "@xstate/react"; import { AuthMethods, UpdateUserProfileRequest, @@ -16,7 +16,6 @@ import { authMachine, isAuthenticated, } from "xServices/auth/authXService"; -import { ActorRefFrom } from "xstate"; type AuthContextValue = { isSignedOut: boolean; @@ -34,14 +33,12 @@ type AuthContextValue = { signOut: () => void; signIn: (email: string, password: string) => void; updateProfile: (data: UpdateUserProfileRequest) => void; - authService: ActorRefFrom; }; const AuthContext = createContext(undefined); export const AuthProvider: FC = ({ children }) => { - const authService = useInterpret(authMachine); - const [authState, authSend] = useActor(authService); + const [authState, authSend] = useMachine(authMachine); const isSignedOut = authState.matches("signedOut"); const isSigningOut = authState.matches("signingOut"); @@ -83,7 +80,6 @@ export const AuthProvider: FC = ({ children }) => { isSignedIn, isSigningIn, isUpdatingProfile, - authService, signOut, signIn, updateProfile, @@ -112,8 +108,5 @@ export const useAuth = () => { throw new Error("useAuth should be used inside of "); } - return { - ...context, - actor: useActor(context.authService), - }; + return context; }; From 4749547c8a9a08ce3f058e0b0c17e524e5506674 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 16:13:08 +0000 Subject: [PATCH 04/20] Remove auth xservice --- site/src/api/queries/authCheck.ts | 14 + site/src/api/queries/users.ts | 79 ++++ .../components/AuthProvider/AuthProvider.tsx | 106 ++--- .../components/AuthProvider/permissions.tsx | 98 ++++ site/src/hooks/usePermissions.ts | 2 +- .../pages/LoginPage/LoginPageView.stories.tsx | 35 +- .../AccountPage/AccountForm.test.tsx | 7 +- .../AccountPage/AccountPage.test.tsx | 5 +- site/src/testHelpers/entities.ts | 2 +- site/src/testHelpers/handlers.ts | 2 +- site/src/xServices/auth/authXService.ts | 424 ------------------ .../updateCheck/updateCheckXService.ts | 2 +- 12 files changed, 263 insertions(+), 513 deletions(-) create mode 100644 site/src/api/queries/authCheck.ts create mode 100644 site/src/components/AuthProvider/permissions.tsx delete mode 100644 site/src/xServices/auth/authXService.ts diff --git a/site/src/api/queries/authCheck.ts b/site/src/api/queries/authCheck.ts new file mode 100644 index 0000000000000..415c7676ea128 --- /dev/null +++ b/site/src/api/queries/authCheck.ts @@ -0,0 +1,14 @@ +import { AuthorizationRequest } from "api/typesGenerated"; +import * as API from "api/api"; + +export const AUTHORIZATION_KEY = "authorization"; + +export const getAuthorizationKey = (req: AuthorizationRequest) => + [AUTHORIZATION_KEY, req] as const; + +export const checkAuthorization = (req: AuthorizationRequest) => { + return { + queryKey: getAuthorizationKey(req), + queryFn: () => API.checkAuthorization(req), + }; +}; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 3c0bd47a8ead4..58490051a75ad 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -1,10 +1,15 @@ import { QueryClient, QueryOptions } from "react-query"; import * as API from "api/api"; import { + AuthorizationRequest, GetUsersResponse, UpdateUserPasswordRequest, + UpdateUserProfileRequest, + User, UsersRequest, } from "api/typesGenerated"; +import { getMetadataAsJSON } from "utils/metadata"; +import { AUTHORIZATION_KEY, getAuthorizationKey } from "./authCheck"; export const users = (req: UsersRequest): QueryOptions => { return { @@ -83,3 +88,77 @@ export const authMethods = () => { queryFn: API.getAuthMethods, }; }; + +export const me = () => { + return { + queryKey: ["me"], + queryFn: async () => + getMetadataAsJSON("user") ?? API.getAuthenticatedUser(), + }; +}; + +export const hasFirstUser = () => { + return { + queryKey: ["hasFirstUser"], + queryFn: API.hasFirstUser, + }; +}; + +export const login = ( + authorization: AuthorizationRequest, + queryClient: QueryClient, +) => { + return { + mutationFn: async (credentials: { email: string; password: string }) => + loginFn({ ...credentials, authorization }), + onSuccess: async (data: Awaited>) => { + queryClient.setQueryData(["me"], data.user); + queryClient.setQueryData( + getAuthorizationKey(authorization), + data.permissions, + ); + }, + }; +}; + +const loginFn = async ({ + email, + password, + authorization, +}: { + email: string; + password: string; + authorization: AuthorizationRequest; +}) => { + await API.login(email, password); + const [user, permissions] = await Promise.all([ + API.getAuthenticatedUser(), + API.checkAuthorization(authorization), + ]); + return { + user, + permissions, + }; +}; + +export const logout = (queryClient: QueryClient) => { + return { + mutationFn: API.logout, + onSuccess: () => { + queryClient.setQueryData(["me"], undefined); + queryClient.removeQueries([AUTHORIZATION_KEY]); + }, + }; +}; + +export const updateProfile = () => { + return { + mutationFn: ({ + userId, + req, + }: { + userId: string; + req: UpdateUserProfileRequest; + }) => API.updateProfile(userId, req), + }; +}; diff --git a/site/src/components/AuthProvider/AuthProvider.tsx b/site/src/components/AuthProvider/AuthProvider.tsx index d6b7a0e0c3f22..a41a73c0c6bc4 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -1,21 +1,21 @@ -import { useMachine } from "@xstate/react"; +import { checkAuthorization } from "api/queries/authCheck"; +import { + authMethods, + hasFirstUser, + login, + logout, + me, + updateProfile as updateProfileOptions, +} from "api/queries/users"; import { AuthMethods, UpdateUserProfileRequest, User, } from "api/typesGenerated"; -import { - createContext, - FC, - PropsWithChildren, - useCallback, - useContext, -} from "react"; -import { - Permissions, - authMachine, - isAuthenticated, -} from "xServices/auth/authXService"; +import { createContext, FC, PropsWithChildren, useContext } from "react"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { permissionsToCheck, Permissions } from "./permissions"; +import { displaySuccess } from "components/GlobalSnackbar/utils"; type AuthContextValue = { isSignedOut: boolean; @@ -38,37 +38,49 @@ type AuthContextValue = { const AuthContext = createContext(undefined); export const AuthProvider: FC = ({ children }) => { - const [authState, authSend] = useMachine(authMachine); + const meOptions = me(); + const userQuery = useQuery(meOptions); + const authMethodsQuery = useQuery(authMethods()); + const hasFirstUserQuery = useQuery(hasFirstUser()); + const permissionsQuery = useQuery({ + ...checkAuthorization({ checks: permissionsToCheck }), + enabled: userQuery.data !== undefined, + }); - const isSignedOut = authState.matches("signedOut"); - const isSigningOut = authState.matches("signingOut"); - const isLoading = authState.matches("loadingInitialAuthData"); - const isConfiguringTheFirstUser = authState.matches( - "configuringTheFirstUser", - ); - const isSignedIn = authState.matches("signedIn"); - const isSigningIn = authState.matches("signingIn"); - const isUpdatingProfile = authState.matches( - "signedIn.profile.updatingProfile", + const queryClient = useQueryClient(); + const loginMutation = useMutation( + login({ checks: permissionsToCheck }, queryClient), ); + const logoutMutation = useMutation(logout(queryClient)); + const updateProfileMutation = useMutation({ + ...updateProfileOptions(), + onSuccess: (user) => { + queryClient.setQueryData(meOptions.queryKey, user); + displaySuccess("Updated settings."); + }, + }); - const signOut = useCallback(() => { - authSend("SIGN_OUT"); - }, [authSend]); + const isSignedOut = userQuery.isSuccess && !userQuery.data; + const isSigningOut = logoutMutation.isLoading; + const isLoading = + authMethodsQuery.isLoading || + userQuery.isLoading || + permissionsQuery.isLoading || + hasFirstUserQuery.isLoading; + const isConfiguringTheFirstUser = !hasFirstUserQuery.data; + const isSignedIn = userQuery.isSuccess && userQuery.data !== undefined; + const isSigningIn = loginMutation.isLoading; + const isUpdatingProfile = updateProfileMutation.isLoading; - const signIn = useCallback( - (email: string, password: string) => { - authSend({ type: "SIGN_IN", email, password }); - }, - [authSend], - ); + const signOut = logoutMutation.mutate; - const updateProfile = useCallback( - (data: UpdateUserProfileRequest) => { - authSend({ type: "UPDATE_PROFILE", data }); - }, - [authSend], - ); + const signIn = (email: string, password: string) => { + loginMutation.mutate({ email, password }); + }; + + const updateProfile = (req: UpdateUserProfileRequest) => { + updateProfileMutation.mutate({ userId: userQuery.data!.id, req }); + }; return ( = ({ children }) => { signOut, signIn, updateProfile, - user: isAuthenticated(authState.context.data) - ? authState.context.data.user - : undefined, - permissions: isAuthenticated(authState.context.data) - ? authState.context.data.permissions - : undefined, - authMethods: !isAuthenticated(authState.context.data) - ? authState.context.data?.authMethods - : undefined, - signInError: authState.context.error, - updateProfileError: authState.context.updateProfileError, + user: userQuery.data, + permissions: permissionsQuery.data as Permissions | undefined, + authMethods: authMethodsQuery.data, + signInError: loginMutation.error, + updateProfileError: updateProfileMutation.error, }} > {children} diff --git a/site/src/components/AuthProvider/permissions.tsx b/site/src/components/AuthProvider/permissions.tsx new file mode 100644 index 0000000000000..6e39286edbbaa --- /dev/null +++ b/site/src/components/AuthProvider/permissions.tsx @@ -0,0 +1,98 @@ +export const checks = { + readAllUsers: "readAllUsers", + updateUsers: "updateUsers", + createUser: "createUser", + createTemplates: "createTemplates", + updateTemplates: "updateTemplates", + deleteTemplates: "deleteTemplates", + viewAuditLog: "viewAuditLog", + viewDeploymentValues: "viewDeploymentValues", + createGroup: "createGroup", + viewUpdateCheck: "viewUpdateCheck", + viewExternalAuthConfig: "viewExternalAuthConfig", + viewDeploymentStats: "viewDeploymentStats", + editWorkspaceProxies: "editWorkspaceProxies", +} as const; + +export const permissionsToCheck = { + [checks.readAllUsers]: { + object: { + resource_type: "user", + }, + action: "read", + }, + [checks.updateUsers]: { + object: { + resource_type: "user", + }, + action: "update", + }, + [checks.createUser]: { + object: { + resource_type: "user", + }, + action: "create", + }, + [checks.createTemplates]: { + object: { + resource_type: "template", + }, + action: "update", + }, + [checks.updateTemplates]: { + object: { + resource_type: "template", + }, + action: "update", + }, + [checks.deleteTemplates]: { + object: { + resource_type: "template", + }, + action: "delete", + }, + [checks.viewAuditLog]: { + object: { + resource_type: "audit_log", + }, + action: "read", + }, + [checks.viewDeploymentValues]: { + object: { + resource_type: "deployment_config", + }, + action: "read", + }, + [checks.createGroup]: { + object: { + resource_type: "group", + }, + action: "create", + }, + [checks.viewUpdateCheck]: { + object: { + resource_type: "deployment_config", + }, + action: "read", + }, + [checks.viewExternalAuthConfig]: { + object: { + resource_type: "deployment_config", + }, + action: "read", + }, + [checks.viewDeploymentStats]: { + object: { + resource_type: "deployment_stats", + }, + action: "read", + }, + [checks.editWorkspaceProxies]: { + object: { + resource_type: "workspace_proxy", + }, + action: "create", + }, +} as const; + +export type Permissions = Record; diff --git a/site/src/hooks/usePermissions.ts b/site/src/hooks/usePermissions.ts index 765985a2de367..0837ffb64e1d5 100644 --- a/site/src/hooks/usePermissions.ts +++ b/site/src/hooks/usePermissions.ts @@ -1,5 +1,5 @@ import { useAuth } from "components/AuthProvider/AuthProvider"; -import { Permissions } from "xServices/auth/authXService"; +import { Permissions } from "components/AuthProvider/permissions"; export const usePermissions = (): Permissions => { const { permissions } = useAuth(); diff --git a/site/src/pages/LoginPage/LoginPageView.stories.tsx b/site/src/pages/LoginPage/LoginPageView.stories.tsx index a4420489c585f..b4a6c6ad11979 100644 --- a/site/src/pages/LoginPage/LoginPageView.stories.tsx +++ b/site/src/pages/LoginPage/LoginPageView.stories.tsx @@ -1,4 +1,3 @@ -import { action } from "@storybook/addon-actions"; import { MockAuthMethods, mockApiError } from "testHelpers/entities"; import { LoginPageView } from "./LoginPageView"; import type { Meta, StoryObj } from "@storybook/react"; @@ -14,50 +13,30 @@ type Story = StoryObj; export const Example: Story = { args: { isLoading: false, - onSignIn: action("onSignIn"), - context: { - data: { - authMethods: MockAuthMethods, - hasFirstUser: false, - }, - }, + authMethods: MockAuthMethods, }, }; export const AuthError: Story = { args: { isLoading: false, - onSignIn: action("onSignIn"), - context: { - error: mockApiError({ - message: "User or password is incorrect", - detail: "Please, try again", - }), - data: { - authMethods: MockAuthMethods, - hasFirstUser: false, - }, - }, + error: mockApiError({ + message: "User or password is incorrect", + detail: "Please, try again", + }), + authMethods: MockAuthMethods, }, }; export const LoadingInitialData: Story = { args: { isLoading: true, - onSignIn: action("onSignIn"), - context: {}, }, }; export const SigningIn: Story = { args: { isSigningIn: true, - onSignIn: action("onSignIn"), - context: { - data: { - authMethods: MockAuthMethods, - hasFirstUser: false, - }, - }, + authMethods: MockAuthMethods, }, }; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx index dadb8f8881824..3b6c9951b3a52 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx @@ -1,7 +1,8 @@ import { screen } from "@testing-library/react"; import { MockUser2 } from "testHelpers/entities"; import { render } from "testHelpers/renderHelpers"; -import { AccountForm, AccountFormValues } from "./AccountForm"; +import { AccountForm } from "./AccountForm"; +import { UpdateUserProfileRequest } from "api/typesGenerated"; // NOTE: it does not matter what the role props of MockUser are set to, // only that editable is set to true or false. This is passed from @@ -10,7 +11,7 @@ describe("AccountForm", () => { describe("when editable is set to true", () => { it("allows updating username", async () => { // Given - const mockInitialValues: AccountFormValues = { + const mockInitialValues: UpdateUserProfileRequest = { username: MockUser2.username, }; @@ -40,7 +41,7 @@ describe("AccountForm", () => { describe("when editable is set to false", () => { it("does not allow updating username", async () => { // Given - const mockInitialValues: AccountFormValues = { + const mockInitialValues: UpdateUserProfileRequest = { username: MockUser2.username, }; diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index 61d868b02bf9c..8e776f0c674d6 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx @@ -2,7 +2,6 @@ import { fireEvent, screen, waitFor } from "@testing-library/react"; import * as API from "api/api"; import * as AccountForm from "./AccountForm"; import { renderWithAuth } from "testHelpers/renderHelpers"; -import * as AuthXService from "xServices/auth/authXService"; import { AccountPage } from "./AccountPage"; import { mockApiError } from "testHelpers/entities"; @@ -42,9 +41,7 @@ describe("AccountPage", () => { const { user } = renderPage(); await fillAndSubmitForm(); - const successMessage = await screen.findByText( - AuthXService.Language.successProfileUpdate, - ); + const successMessage = await screen.findByText("Updated settings."); expect(successMessage).toBeDefined(); expect(API.updateProfile).toBeCalledTimes(1); expect(API.updateProfile).toBeCalledWith(user.id, newData); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 4d5b28a869747..a2213ecaa5d3b 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -7,7 +7,7 @@ import { FieldError } from "api/errors"; import { everyOneGroup } from "utils/groups"; import * as TypesGen from "api/typesGenerated"; import range from "lodash/range"; -import { Permissions } from "xServices/auth/authXService"; +import { Permissions } from "components/AuthProvider/permissions"; import { TemplateVersionFiles } from "utils/templateVersion"; import { FileTree } from "utils/filetree"; import { ProxyLatencyReport } from "contexts/useProxyLatency"; diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index f40ad06cc1df1..8bc5dabba16c4 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -1,6 +1,6 @@ import { rest } from "msw"; import { CreateWorkspaceBuildRequest } from "api/typesGenerated"; -import { permissionsToCheck } from "xServices/auth/authXService"; +import { permissionsToCheck } from "components/AuthProvider/permissions"; import * as M from "./entities"; import { MockGroup, MockWorkspaceQuota } from "./entities"; import fs from "fs"; diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts deleted file mode 100644 index ee4bda58de3e1..0000000000000 --- a/site/src/xServices/auth/authXService.ts +++ /dev/null @@ -1,424 +0,0 @@ -import { assign, createMachine } from "xstate"; -import * as API from "api/api"; -import * as TypesGen from "api/typesGenerated"; -import { displaySuccess } from "components/GlobalSnackbar/utils"; - -export const Language = { - successProfileUpdate: "Updated settings.", -}; - -export const checks = { - readAllUsers: "readAllUsers", - updateUsers: "updateUsers", - createUser: "createUser", - createTemplates: "createTemplates", - updateTemplates: "updateTemplates", - deleteTemplates: "deleteTemplates", - viewAuditLog: "viewAuditLog", - viewDeploymentValues: "viewDeploymentValues", - createGroup: "createGroup", - viewUpdateCheck: "viewUpdateCheck", - viewExternalAuthConfig: "viewExternalAuthConfig", - viewDeploymentStats: "viewDeploymentStats", - editWorkspaceProxies: "editWorkspaceProxies", -} as const; - -export const permissionsToCheck = { - [checks.readAllUsers]: { - object: { - resource_type: "user", - }, - action: "read", - }, - [checks.updateUsers]: { - object: { - resource_type: "user", - }, - action: "update", - }, - [checks.createUser]: { - object: { - resource_type: "user", - }, - action: "create", - }, - [checks.createTemplates]: { - object: { - resource_type: "template", - }, - action: "update", - }, - [checks.updateTemplates]: { - object: { - resource_type: "template", - }, - action: "update", - }, - [checks.deleteTemplates]: { - object: { - resource_type: "template", - }, - action: "delete", - }, - [checks.viewAuditLog]: { - object: { - resource_type: "audit_log", - }, - action: "read", - }, - [checks.viewDeploymentValues]: { - object: { - resource_type: "deployment_config", - }, - action: "read", - }, - [checks.createGroup]: { - object: { - resource_type: "group", - }, - action: "create", - }, - [checks.viewUpdateCheck]: { - object: { - resource_type: "deployment_config", - }, - action: "read", - }, - [checks.viewExternalAuthConfig]: { - object: { - resource_type: "deployment_config", - }, - action: "read", - }, - [checks.viewDeploymentStats]: { - object: { - resource_type: "deployment_stats", - }, - action: "read", - }, - [checks.editWorkspaceProxies]: { - object: { - resource_type: "workspace_proxy", - }, - action: "create", - }, -} as const; - -export type Permissions = Record; - -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 => { - let authenticatedUser: TypesGen.User | undefined; - // User is injected by the Coder server into the HTML document. - const userMeta = document.querySelector("meta[property=user]"); - if (userMeta) { - const rawContent = userMeta.getAttribute("content"); - try { - authenticatedUser = JSON.parse(rawContent as string) as TypesGen.User; - } catch (ex) { - // Ignore this and fetch as normal! - } - } - - // If we have the user from the meta tag, we can skip this! - if (!authenticatedUser) { - 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 => { - 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 () => { - const [authMethods] = await Promise.all([ - API.getAuthMethods(), // Anticipate and load the auth methods - API.logout(), - ]); - - return { - hasFirstUser: true, - authMethods, - } as UnauthenticatedData; -}; -export interface AuthContext { - error?: unknown; - updateProfileError?: unknown; - data?: AuthData; -} - -export type AuthEvent = - | { type: "SIGN_OUT" } - | { type: "SIGN_IN"; email: string; password: string } - | { type: "UPDATE_PROFILE"; data: TypesGen.UpdateUserProfileRequest }; - -export const authMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QEMCuAXAFgZXc9YAdADYD2yEAlgHZQCS1l6lyxAghpgCL7IDEEUtSI0AbqQDWRNFlz4iZCjXqNmrDlh54EY0gGN8lIQG0ADAF0z5xKAAOpWEyPUbIAB6IAjAFZThACwATAAcAGwAzOGewZ7hoYH+ADQgAJ6Igaam3oSeGf7BcbnBAJyBAL5lyTI4eAQk5FS0DE7qnFr8gsKEulKE1XJ1io0qLextvDrU4gbMJhbGntZIIPaOsy7LHgg+fkFhkdGx8Ump6bEA7ITn56HnkaFFpRVVnAMKDcrNamOavAJCIimkmkr1q7yUTVULB+3AmuhmzisxkCSzsDicQlcWx2ARCESiMTiCWSaQQgUC5124UCxQKDxCT0qIH6YPqEJG3w0sLwfDAACc+aQ+YRbMR8AAzIUAWz6oPkbOGX2hXPak2mhjmlgsrlWGI2oGxvlx+wJR2JpzJ-lM4UIphKgXuj3KTJZ8scUGEEAA8hg+Ng6ABxAByAH06EGrDr0essV5TOdslloqZPKZ-Od-NESV4Hjb-OESqFvAyEudgs9mXK6u7GJD-l0ekQawxI8tdTHNl5ybtPKnPKFin3ivns2TU3mi1FrmFSuEK67q5QPZ9qLyBUKRWL0JK+TLm9RW2i1s5Y9tu4Q8ZksiFgmnR93PLbad5h6FMgm5y6q02l56GH7A1DL0AFUABVDxWaMT07BAogHQhgmCfwBxvbwiwKe87WyYIM1ibxPHzG5PxeWRWRrSAGBFQVxUoYgRAgOi+GAgAFLg2FAgBRENmIAJS9AAxOgABkOIg9toINdIi0fcJzn7dMshfXtR2ibx-EIUJkOKbwiVw4p52-QhyIgSjbGo2iiFQWwIEMWhmPMxjOkBcRegXH8PQo6gqNIGi6MIKybOYOyHLANV9A1A95m1NsoMxGDPGfHIiPCfxvDSwcCJUwsci0nT4j0gzSLdX9PO83zLOs2yoHsnyLLXQVhVFCVpVlIrFw8kyvLM2q-ICqqavKsKEU1MTYv1dwvESzxktS9LexOUlonyHKBzyilM30r82vc2soB9dB62c4EjN-fbRuPOLJNghK-HJckX2KFLimHFTSkCQhZIKUwByQotnRImpiuXWh9sO7ogV6GszsWKMLvGrY4n8dSbn8bTfFyXx01e8JsLU580unG5CsB9rdtB-kGs3ZrdxOj0zuio89VPWTTHenHTEegpwm+nDwkwzSPuKIjEPOckkOJt5CD0IQaKgVA+WUUDMDAfjKD5WB0GA2B+QA4MwwjBnILh09b1CHIOdTN8Uoe+9pve0WhZKHHNJRiomWoUgIDgVw3NhpmYIAWlCFS3w04cCwyDmKWpYjK22hUV1GFVeD9jsroD1MAhxule1zfNimDi1DnU0IYgyUoblTBIJbIkrvQwVOJIm2CE2CK5olKCIskQrLvovfCkOua14kQmugd2hhG8u5uC78FKHRCO4cPzQvSUCI4LxpUpEMiNNQjH0nPKn+GvDiM3KW52dcO+vmLQSNuEvzRC0uQtDZIPnbSu68rj9PAiCKuNaKOslMw31HJEbIA5874SvOEbSH9aZ-i6iFboDEwC-3igXM28RfB2kCH9I4o4gg2kflaeSalyShH3ltEmn9OplQsqgvyHsOLrj5Bgq669tIfTki7RSGVXpBBWtpXSmYcIIOMqZFBlA0GEApkKDhzcuHZFkvJSkc1PCYUzgRVaojojnAkXXKRPUKqBWUANCyijsQPGyEPO0s0lKZSLtlHRIj8obUMcDPaDcYrGxgvPG0dwaTHAIimfI-M2Z6WmlA8RNDJbS2oLLeWitlaq3VprbW7DfH+yumlPwj83zBBvKXYI3hbaiyuDSMsj00Lpk0m7MoQA */ - createMachine( - { - id: "authState", - predictableActionArguments: true, - tsTypes: {} as import("./authXService.typegen").Typegen0, - schema: { - context: {} as AuthContext, - events: {} as AuthEvent, - services: {} as { - loadInitialAuthData: { - data: Awaited>; - }; - signIn: { - data: Awaited>; - }; - updateProfile: { - data: TypesGen.User; - }; - signOut: { - data: Awaited>; - }; - }, - }, - 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: { - target: "signingIn", - }, - }, - }, - - signingIn: { - entry: "clearError", - invoke: { - src: "signIn", - id: "signIn", - onDone: [ - { - target: "signedIn", - actions: "assignData", - }, - ], - onError: [ - { - actions: "assignError", - target: "signedOut", - }, - ], - }, - }, - - signedIn: { - type: "parallel", - on: { - SIGN_OUT: { - target: "signingOut", - }, - }, - states: { - profile: { - initial: "idle", - states: { - idle: { - initial: "noError", - states: { - noError: {}, - error: {}, - }, - on: { - UPDATE_PROFILE: { - target: "updatingProfile", - }, - }, - }, - updatingProfile: { - entry: "clearUpdateProfileError", - invoke: { - src: "updateProfile", - onDone: [ - { - actions: ["updateUser", "notifySuccessProfileUpdate"], - target: "#authState.signedIn.profile.idle.noError", - }, - ], - onError: [ - { - actions: "assignUpdateProfileError", - target: "#authState.signedIn.profile.idle.error", - }, - ], - }, - }, - }, - }, - }, - }, - - signingOut: { - invoke: { - src: "signOut", - id: "signOut", - onDone: [ - { - actions: ["clearData", "clearError", "redirect"], - cond: "hasRedirectUrl", - }, - { - actions: ["clearData", "clearError"], - target: "signedOut", - }, - ], - onError: [ - { - // The main way this is likely to fail is from the backend refusing - // to talk to you because your token is already invalid - actions: "assignError", - target: "signedOut", - }, - ], - }, - }, - - configuringTheFirstUser: { - on: { - SIGN_IN: { - target: "signingIn", - }, - }, - }, - }, - }, - { - services: { - loadInitialAuthData, - signIn: (_, { email, password }) => signIn(email, password), - signOut, - updateProfile: async ({ data }, event) => { - if (!data) { - throw new Error("Authenticated data is not loaded yet"); - } - - if (isAuthenticated(data)) { - return API.updateProfile(data.user.id, event.data); - } - - throw new Error("User not authenticated"); - }, - }, - actions: { - assignData: assign({ - data: (_, { data }) => data, - }), - clearData: assign({ - data: (_) => undefined, - }), - assignError: assign({ - error: (_, event) => event.data, - }), - clearError: assign({ - error: (_) => undefined, - }), - updateUser: assign({ - data: (context, event) => { - if (!context.data) { - throw new Error("No authentication data loaded"); - } - - return { - ...context.data, - user: event.data, - }; - }, - }), - assignUpdateProfileError: assign({ - updateProfileError: (_, event) => event.data, - }), - notifySuccessProfileUpdate: () => { - displaySuccess(Language.successProfileUpdate); - }, - clearUpdateProfileError: assign({ - updateProfileError: (_) => undefined, - }), - redirect: (_, _data) => { - window.location.href = location.origin; - }, - }, - guards: { - isAuthenticated: (_, { data }) => isAuthenticated(data), - needSetup: (_, { data }) => - !isAuthenticated(data) && !data.hasFirstUser, - hasRedirectUrl: (_, { data }) => Boolean(data), - }, - }, - ); diff --git a/site/src/xServices/updateCheck/updateCheckXService.ts b/site/src/xServices/updateCheck/updateCheckXService.ts index 7005d2f042341..099c8501b320f 100644 --- a/site/src/xServices/updateCheck/updateCheckXService.ts +++ b/site/src/xServices/updateCheck/updateCheckXService.ts @@ -1,7 +1,7 @@ import { assign, createMachine } from "xstate"; import { getUpdateCheck } from "api/api"; import { AuthorizationResponse, UpdateCheckResponse } from "api/typesGenerated"; -import { checks, Permissions } from "xServices/auth/authXService"; +import { checks, Permissions } from "components/AuthProvider/permissions"; export interface UpdateCheckContext { permissions: Permissions; From 6d0e3abcdef3f9694e0a9aa545eda04fec7afc1e Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 16:16:37 +0000 Subject: [PATCH 05/20] Add loader while AuthProvider is loading --- site/src/components/AuthProvider/AuthProvider.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/site/src/components/AuthProvider/AuthProvider.tsx b/site/src/components/AuthProvider/AuthProvider.tsx index a41a73c0c6bc4..2bf2824e0e499 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -16,6 +16,7 @@ import { createContext, FC, PropsWithChildren, useContext } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { permissionsToCheck, Permissions } from "./permissions"; import { displaySuccess } from "components/GlobalSnackbar/utils"; +import { FullScreenLoader } from "components/Loader/FullScreenLoader"; type AuthContextValue = { isSignedOut: boolean; @@ -82,6 +83,10 @@ export const AuthProvider: FC = ({ children }) => { updateProfileMutation.mutate({ userId: userQuery.data!.id, req }); }; + if (isLoading) { + return ; + } + return ( Date: Tue, 10 Oct 2023 16:41:55 +0000 Subject: [PATCH 06/20] Simplify and fix a few computed states --- site/src/api/api.ts | 16 +++----------- .../components/AuthProvider/AuthProvider.tsx | 22 +++++++++++-------- .../components/RequireAuth/RequireAuth.tsx | 11 +++------- site/src/pages/LoginPage/LoginPage.tsx | 10 ++++----- .../pages/LoginPage/LoginPageView.stories.tsx | 6 ++--- site/src/pages/LoginPage/LoginPageView.tsx | 7 +----- site/src/pages/SetupPage/SetupPage.tsx | 12 +++++----- 7 files changed, 34 insertions(+), 50 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 8ff9169c162b0..fe110dce6cdca 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -120,19 +120,9 @@ export const logout = async (): Promise => { await axios.post("/api/v2/users/logout"); }; -export const getAuthenticatedUser = async (): Promise< - TypesGen.User | undefined -> => { - try { - const response = await axios.get("/api/v2/users/me"); - return response.data; - } catch (error) { - if (axios.isAxiosError(error) && error.response?.status === 401) { - return undefined; - } - - throw error; - } +export const getAuthenticatedUser = async () => { + const response = await axios.get("/api/v2/users/me"); + return response.data; }; export const getAuthMethods = async (): Promise => { diff --git a/site/src/components/AuthProvider/AuthProvider.tsx b/site/src/components/AuthProvider/AuthProvider.tsx index 2bf2824e0e499..94048c3af0b56 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -17,10 +17,10 @@ import { useMutation, useQuery, useQueryClient } from "react-query"; import { permissionsToCheck, Permissions } from "./permissions"; import { displaySuccess } from "components/GlobalSnackbar/utils"; import { FullScreenLoader } from "components/Loader/FullScreenLoader"; +import { isApiError } from "api/errors"; type AuthContextValue = { isSignedOut: boolean; - isLoading: boolean; isSigningOut: boolean; isConfiguringTheFirstUser: boolean; isSignedIn: boolean; @@ -32,7 +32,7 @@ type AuthContextValue = { signInError: unknown; updateProfileError: unknown; signOut: () => void; - signIn: (email: string, password: string) => void; + signIn: (email: string, password: string) => Promise; updateProfile: (data: UpdateUserProfileRequest) => void; }; @@ -61,22 +61,27 @@ export const AuthProvider: FC = ({ children }) => { }, }); - const isSignedOut = userQuery.isSuccess && !userQuery.data; + const isSignedOut = + userQuery.isError && + isApiError(userQuery.error) && + userQuery.error.response.status === 401; const isSigningOut = logoutMutation.isLoading; const isLoading = authMethodsQuery.isLoading || userQuery.isLoading || - permissionsQuery.isLoading || - hasFirstUserQuery.isLoading; + hasFirstUserQuery.isLoading || + (userQuery.isSuccess && permissionsQuery.isLoading); const isConfiguringTheFirstUser = !hasFirstUserQuery.data; const isSignedIn = userQuery.isSuccess && userQuery.data !== undefined; const isSigningIn = loginMutation.isLoading; const isUpdatingProfile = updateProfileMutation.isLoading; - const signOut = logoutMutation.mutate; + const signOut = () => { + logoutMutation.mutate(); + }; - const signIn = (email: string, password: string) => { - loginMutation.mutate({ email, password }); + const signIn = async (email: string, password: string) => { + await loginMutation.mutateAsync({ email, password }); }; const updateProfile = (req: UpdateUserProfileRequest) => { @@ -92,7 +97,6 @@ export const AuthProvider: FC = ({ children }) => { value={{ isSignedOut, isSigningOut, - isLoading, isConfiguringTheFirstUser, isSignedIn, isSigningIn, diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index 2473f800de048..865e9b5865e28 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -9,13 +9,8 @@ import { ProxyProvider } from "contexts/ProxyContext"; import { isApiError } from "api/errors"; export const RequireAuth: FC = () => { - const { - signOut, - isSigningOut, - isLoading, - isSignedOut, - isConfiguringTheFirstUser, - } = useAuth(); + const { signOut, isSigningOut, isSignedOut, isConfiguringTheFirstUser } = + useAuth(); const location = useLocation(); const isHomePage = location.pathname === "/"; const navigateTo = isHomePage @@ -47,7 +42,7 @@ export const RequireAuth: FC = () => { return ; } else if (isConfiguringTheFirstUser) { return ; - } else if (isLoading || isSigningOut) { + } else if (isSigningOut) { return ; } else { // Authenticated pages have access to some contexts for knowing enabled experiments diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index daf5431e753c8..9dc8f3f4eea4a 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -1,7 +1,7 @@ import { useAuth } from "components/AuthProvider/AuthProvider"; import { FC } from "react"; import { Helmet } from "react-helmet-async"; -import { Navigate, useLocation } from "react-router-dom"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { retrieveRedirect } from "utils/redirect"; import { LoginPageView } from "./LoginPageView"; import { getApplicationName } from "utils/appearance"; @@ -10,7 +10,6 @@ export const LoginPage: FC = () => { const location = useLocation(); const { isSignedIn, - isLoading, isConfiguringTheFirstUser, signIn, isSigningIn, @@ -19,6 +18,7 @@ export const LoginPage: FC = () => { } = useAuth(); const redirectTo = retrieveRedirect(location.search); const applicationName = getApplicationName(); + const navigate = useNavigate(); if (isSignedIn) { return ; @@ -33,10 +33,10 @@ export const LoginPage: FC = () => { { - signIn(email, password); + onSignIn={async ({ email, password }) => { + await signIn(email, password); + navigate("/"); }} /> diff --git a/site/src/pages/LoginPage/LoginPageView.stories.tsx b/site/src/pages/LoginPage/LoginPageView.stories.tsx index b4a6c6ad11979..f017030488fc3 100644 --- a/site/src/pages/LoginPage/LoginPageView.stories.tsx +++ b/site/src/pages/LoginPage/LoginPageView.stories.tsx @@ -12,14 +12,12 @@ type Story = StoryObj; export const Example: Story = { args: { - isLoading: false, authMethods: MockAuthMethods, }, }; export const AuthError: Story = { args: { - isLoading: false, error: mockApiError({ message: "User or password is incorrect", detail: "Please, try again", @@ -28,9 +26,9 @@ export const AuthError: Story = { }, }; -export const LoadingInitialData: Story = { +export const LoadingAuthMethods: Story = { args: { - isLoading: true, + authMethods: undefined, }, }; diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx index c53becbc726c7..520de317c25f9 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -1,5 +1,4 @@ import { makeStyles } from "@mui/styles"; -import { FullScreenLoader } from "components/Loader/FullScreenLoader"; import { FC } from "react"; import { useLocation } from "react-router-dom"; import { SignInForm } from "./SignInForm"; @@ -11,7 +10,6 @@ import { AuthMethods } from "api/typesGenerated"; export interface LoginPageViewProps { authMethods: AuthMethods | undefined; error: unknown; - isLoading: boolean; isSigningIn: boolean; onSignIn: (credentials: { email: string; password: string }) => void; } @@ -19,7 +17,6 @@ export interface LoginPageViewProps { export const LoginPageView: FC = ({ authMethods, error, - isLoading, isSigningIn, onSignIn, }) => { @@ -47,9 +44,7 @@ export const LoginPageView: FC = ({ ); - return isLoading ? ( - - ) : ( + return (
{applicationLogo} diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index 05a351e01605b..bb7f96f08b397 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -3,15 +3,16 @@ import { FC } from "react"; import { Helmet } from "react-helmet-async"; import { pageTitle } from "utils/page"; import { SetupPageView } from "./SetupPageView"; -import { Navigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { useMutation } from "react-query"; import { createFirstUser } from "api/queries/users"; export const SetupPage: FC = () => { - const { signIn, isLoading, isConfiguringTheFirstUser, isSignedIn } = + const { signIn, isConfiguringTheFirstUser, isSignedIn, isSigningIn } = useAuth(); const createFirstUserMutation = useMutation(createFirstUser()); - const setupIsComplete = !isLoading && !isConfiguringTheFirstUser; + const setupIsComplete = !isConfiguringTheFirstUser; + const navigate = useNavigate(); // If the user is logged in, navigate to the app if (isSignedIn) { @@ -29,11 +30,12 @@ export const SetupPage: FC = () => { Codestin Search App { await createFirstUserMutation.mutateAsync(firstUser); - signIn(firstUser.email, firstUser.password); + await signIn(firstUser.email, firstUser.password); + navigate("/"); }} /> From de37d44c1162a2794a25cb0f4870ca45814991c0 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 16:47:16 +0000 Subject: [PATCH 07/20] Add a few replaces --- site/src/components/RequireAuth/RequireAuth.tsx | 9 ++++----- site/src/pages/LoginPage/LoginPage.tsx | 2 +- site/src/pages/SetupPage/SetupPage.tsx | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index 865e9b5865e28..f6054a629fb91 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -9,8 +9,7 @@ import { ProxyProvider } from "contexts/ProxyContext"; import { isApiError } from "api/errors"; export const RequireAuth: FC = () => { - const { signOut, isSigningOut, isSignedOut, isConfiguringTheFirstUser } = - useAuth(); + const { signOut, isSigningOut, isSignedOut } = useAuth(); const location = useLocation(); const isHomePage = location.pathname === "/"; const navigateTo = isHomePage @@ -39,9 +38,9 @@ export const RequireAuth: FC = () => { }, [signOut]); if (isSignedOut) { - return ; - } else if (isConfiguringTheFirstUser) { - return ; + return ( + + ); } else if (isSigningOut) { return ; } else { diff --git a/site/src/pages/LoginPage/LoginPage.tsx b/site/src/pages/LoginPage/LoginPage.tsx index 9dc8f3f4eea4a..8eb2b114e30cb 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -23,7 +23,7 @@ export const LoginPage: FC = () => { if (isSignedIn) { return ; } else if (isConfiguringTheFirstUser) { - return ; + return ; } else { return ( <> diff --git a/site/src/pages/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index bb7f96f08b397..7dae420224546 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -16,12 +16,12 @@ export const SetupPage: FC = () => { // If the user is logged in, navigate to the app if (isSignedIn) { - return ; + return ; } // If we've already completed setup, navigate to the login page if (setupIsComplete) { - return ; + return ; } return ( From cc18ecd17a4aef92bae087c23b7c0c6576e1daff Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 16:50:14 +0000 Subject: [PATCH 08/20] Fix logout --- site/src/api/queries/users.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 58490051a75ad..72937804ddf1c 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -145,8 +145,7 @@ export const logout = (queryClient: QueryClient) => { return { mutationFn: API.logout, onSuccess: () => { - queryClient.setQueryData(["me"], undefined); - queryClient.removeQueries([AUTHORIZATION_KEY]); + queryClient.removeQueries(); }, }; }; From c6bee93e8581f46012caf50881f473f0ad9d64ed Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 16:55:04 +0000 Subject: [PATCH 09/20] Remove unused import --- site/src/api/queries/users.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 72937804ddf1c..f39b3629757cd 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -9,7 +9,7 @@ import { UsersRequest, } from "api/typesGenerated"; import { getMetadataAsJSON } from "utils/metadata"; -import { AUTHORIZATION_KEY, getAuthorizationKey } from "./authCheck"; +import { getAuthorizationKey } from "./authCheck"; export const users = (req: UsersRequest): QueryOptions => { return { From 4aec6a5efeb87b4df5a32297cd68cf3537091d96 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 17:05:50 +0000 Subject: [PATCH 10/20] Fix RequireAuth test --- .../components/RequireAuth/RequireAuth.test.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/site/src/components/RequireAuth/RequireAuth.test.tsx b/site/src/components/RequireAuth/RequireAuth.test.tsx index 29e18a6ace6db..e7604fcaba062 100644 --- a/site/src/components/RequireAuth/RequireAuth.test.tsx +++ b/site/src/components/RequireAuth/RequireAuth.test.tsx @@ -4,29 +4,23 @@ import { renderWithAuth } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; describe("RequireAuth", () => { - it("redirects to /setup if there is no first user", async () => { + it("redirects to /login if user is not authenticated", async () => { // appear logged out server.use( rest.get("/api/v2/users/me", (req, res, ctx) => { return res(ctx.status(401), ctx.json({ message: "no user here" })); }), ); - // No first user - server.use( - rest.get("/api/v2/users/first", async (req, res, ctx) => { - return res(ctx.status(404)); - }), - ); renderWithAuth(

Test

, { nonAuthenticatedRoutes: [ { - path: "setup", - element:

Setup

, + path: "login", + element:

Login

, }, ], }); - await screen.findByText("Setup"); + await screen.findByText("Login"); }); }); From b588271169dc96a098708ae998bd1ef0fd5cce4f Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 17:20:24 +0000 Subject: [PATCH 11/20] Fix wait loader --- site/src/testHelpers/renderHelpers.tsx | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 3e87234d739f5..9eeb2f2171c3d 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -1,8 +1,4 @@ -import { - render as tlRender, - screen, - waitForElementToBeRemoved, -} from "@testing-library/react"; +import { render as tlRender, screen, waitFor } from "@testing-library/react"; import { AppProviders } from "App"; import { DashboardLayout } from "components/Dashboard/DashboardLayout"; import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"; @@ -22,7 +18,7 @@ export const renderWithRouter = ( return { ...tlRender( - () + , ), router, @@ -159,10 +155,13 @@ export function renderWithWorkspaceSettingsLayout( }; } -export const waitForLoaderToBeRemoved = (): Promise => - // Sometimes, we have pages that are doing a lot of requests to get done, so the - // default timeout of 1_000 is not enough. We should revisit this when we unify - // some of the endpoints - waitForElementToBeRemoved(() => screen.queryByTestId("loader"), { - timeout: 5_000, - }); +export const waitForLoaderToBeRemoved = async (): Promise => { + return waitFor( + () => { + expect(screen.queryByTestId("loader")).not.toBeInTheDocument(); + }, + { + timeout: 5_000, + }, + ); +}; From ed42cbb6051f1f0a662553d17d1431cb9d06c2af Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Tue, 10 Oct 2023 18:34:36 +0000 Subject: [PATCH 12/20] Fix tests --- site/src/App.tsx | 38 +++++--- .../UserDropdown/UserDropdownContent.test.tsx | 8 +- .../ConfirmDialog/ConfirmDialog.test.tsx | 6 +- .../DeleteDialog/DeleteDialog.test.tsx | 8 +- site/src/pages/SetupPage/SetupPage.test.tsx | 91 ++----------------- .../WorkspacePage/WorkspacePage.test.tsx | 75 +-------------- site/src/testHelpers/renderHelpers.tsx | 6 +- 7 files changed, 49 insertions(+), 183 deletions(-) diff --git a/site/src/App.tsx b/site/src/App.tsx index 5ba7f68c605c5..8aecfba6d48fc 100644 --- a/site/src/App.tsx +++ b/site/src/App.tsx @@ -25,24 +25,32 @@ const queryClient = new QueryClient({ }, }); +export const ThemeProviders: FC = ({ children }) => { + return ( + + + + + {children} + + + + ); +}; + export const AppProviders: FC = ({ children }) => { return ( - - - - - - - - {children} - - - - - - - + + + + + {children} + + + + + ); }; diff --git a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx index ebb75d82e893f..4e488f04d0b3d 100644 --- a/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx +++ b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx @@ -1,10 +1,10 @@ import { screen } from "@testing-library/react"; import { MockUser } from "testHelpers/entities"; -import { render } from "testHelpers/renderHelpers"; +import { render, waitForLoaderToBeRemoved } from "testHelpers/renderHelpers"; import { Language, UserDropdownContent } from "./UserDropdownContent"; describe("UserDropdownContent", () => { - it("has the correct link for the account item", () => { + it("has the correct link for the account item", async () => { render( { onPopoverClose={jest.fn()} />, ); + await waitForLoaderToBeRemoved(); const link = screen.getByText(Language.accountLabel).closest("a"); if (!link) { @@ -21,7 +22,7 @@ describe("UserDropdownContent", () => { expect(link.getAttribute("href")).toBe("/settings/account"); }); - it("calls the onSignOut function", () => { + it("calls the onSignOut function", async () => { const onSignOut = jest.fn(); render( { onPopoverClose={jest.fn()} />, ); + await waitForLoaderToBeRemoved(); screen.getByText(Language.signOutLabel).click(); expect(onSignOut).toBeCalledTimes(1); }); diff --git a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.test.tsx b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.test.tsx index de4498bbebf48..2390ad3aee93d 100644 --- a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.test.tsx +++ b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, screen } from "@testing-library/react"; import { ConfirmDialog } from "./ConfirmDialog"; -import { render } from "testHelpers/renderHelpers"; +import { renderComponent } from "testHelpers/renderHelpers"; describe("ConfirmDialog", () => { it("onClose is called when cancelled", () => { @@ -15,7 +15,7 @@ describe("ConfirmDialog", () => { }; // When - render(); + renderComponent(); fireEvent.click(screen.getByText("CANCEL")); // Then @@ -37,7 +37,7 @@ describe("ConfirmDialog", () => { }; // When - render(); + renderComponent(); fireEvent.click(screen.getByText("CONFIRM")); // Then diff --git a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.test.tsx b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.test.tsx index de01e14cc4419..10f8f9d89e805 100644 --- a/site/src/components/Dialogs/DeleteDialog/DeleteDialog.test.tsx +++ b/site/src/components/Dialogs/DeleteDialog/DeleteDialog.test.tsx @@ -1,6 +1,6 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { render } from "testHelpers/renderHelpers"; +import { renderComponent } from "testHelpers/renderHelpers"; import { DeleteDialog } from "./DeleteDialog"; import { act } from "react-dom/test-utils"; @@ -22,7 +22,7 @@ async function fillInputField(inputElement: HTMLElement, text: string) { describe("DeleteDialog", () => { it("disables confirm button when the text field is empty", () => { - render( + renderComponent( { }); it("disables confirm button when the text field is filled incorrectly", async () => { - render( + renderComponent( { }); it("enables confirm button when the text field is filled correctly", async () => { - render( + renderComponent( { ); }); - it("shows validation error message", async () => { - render(); - await fillForm({ email: "test" }); - const errorMessage = await screen.findByText(PageViewLanguage.emailInvalid); - expect(errorMessage).toBeDefined(); - }); - - it("shows API error message", async () => { - const fieldErrorMessage = "invalid username"; - server.use( - rest.post("/api/v2/users/first", async (req, res, ctx) => { - return res( - ctx.status(400), - ctx.json({ - message: "invalid field", - validations: [ - { - detail: fieldErrorMessage, - field: "username", - }, - ], - }), - ); - }), - ); - - render(); - await fillForm(); - const errorMessage = await screen.findByText(fieldErrorMessage); - expect(errorMessage).toBeDefined(); - }); - it("redirects to the app when setup is successful", async () => { let userHasBeenCreated = false; @@ -108,55 +79,6 @@ describe("Setup Page", () => { }), ); - render(); - await fillForm(); - await waitFor(() => expect(window.location).toBeAt("/")); - }); - - it("redirects to login if setup has already completed", async () => { - // simulates setup having already been completed - server.use( - rest.get("/api/v2/users/first", (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ message: "hooray, someone exists!" }), - ); - }), - ); - - renderWithRouter( - createMemoryRouter( - [ - { - path: "/setup", - element: , - }, - { - path: "/login", - element:

Login

, - }, - ], - { initialEntries: ["/setup"] }, - ), - ); - - await screen.findByText("Login"); - }); - - it("redirects to the app when already logged in", async () => { - // simulates the user will be authenticated - server.use( - rest.get("/api/v2/users/me", (req, res, ctx) => { - return res(ctx.status(200), ctx.json(MockUser)); - }), - rest.get("/api/v2/users/first", (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ message: "hooray, someone exists!" }), - ); - }), - ); - renderWithRouter( createMemoryRouter( [ @@ -172,7 +94,8 @@ describe("Setup Page", () => { { initialEntries: ["/setup"] }, ), ); - - await screen.findByText("Workspaces"); + await waitForLoaderToBeRemoved(); + await fillForm(); + await waitFor(() => screen.findByText("Workspaces")); }); }); diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 01da4ef08dc81..8a79bccea4513 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -11,25 +11,13 @@ import { MockOutdatedWorkspace, MockTemplateVersionParameter1, MockTemplateVersionParameter2, - MockStoppingWorkspace, - MockFailedWorkspace, - MockCancelingWorkspace, - MockCanceledWorkspace, - MockDeletingWorkspace, - MockDeletedWorkspace, - MockWorkspaceWithDeletion, MockBuilds, MockTemplateVersion3, MockUser, - MockEntitlementsWithScheduling, MockDeploymentConfig, } from "testHelpers/entities"; import * as api from "api/api"; -import { Workspace } from "api/typesGenerated"; -import { - renderWithAuth, - waitForLoaderToBeRemoved, -} from "testHelpers/renderHelpers"; +import { renderWithAuth } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; import { WorkspacePage } from "./WorkspacePage"; @@ -50,8 +38,7 @@ const renderWorkspacePage = async () => { route: `/@${MockWorkspace.owner_name}/${MockWorkspace.name}`, path: "/:username/:workspace", }); - - await waitForLoaderToBeRemoved(); + await screen.findByText(MockWorkspace.name); }; /** @@ -69,21 +56,6 @@ const testButton = async (label: string, actionMock: jest.SpyInstance) => { expect(actionMock).toBeCalled(); }; -const testStatus = async (ws: Workspace, label: string) => { - server.use( - rest.get( - `/api/v2/users/:username/workspace/:workspaceName`, - (req, res, ctx) => { - return res(ctx.status(200), ctx.json(ws)); - }, - ), - ); - await renderWorkspacePage(); - const header = screen.getByTestId("header"); - const status = within(header).getByRole("status"); - expect(status).toHaveTextContent(label); -}; - let originalEventSource: typeof window.EventSource; beforeAll(() => { @@ -283,49 +255,6 @@ describe("WorkspacePage", () => { }); }); - it("shows the Stopping status when the workspace is stopping", async () => { - await testStatus(MockStoppingWorkspace, "Stopping"); - }); - - it("shows the Stopped status when the workspace is stopped", async () => { - await testStatus(MockStoppedWorkspace, "Stopped"); - }); - - it("shows the Building status when the workspace is starting", async () => { - await testStatus(MockStartingWorkspace, "Starting"); - }); - - it("shows the Running status when the workspace is running", async () => { - await testStatus(MockWorkspace, "Running"); - }); - - it("shows the Failed status when the workspace is failed or canceled", async () => { - await testStatus(MockFailedWorkspace, "Failed"); - }); - - it("shows the Canceling status when the workspace is canceling", async () => { - await testStatus(MockCancelingWorkspace, "Canceling"); - }); - - it("shows the Canceled status when the workspace is canceling", async () => { - await testStatus(MockCanceledWorkspace, "Canceled"); - }); - - it("shows the Deleting status when the workspace is deleting", async () => { - await testStatus(MockDeletingWorkspace, "Deleting"); - }); - - it("shows the Deleted status when the workspace is deleted", async () => { - await testStatus(MockDeletedWorkspace, "Deleted"); - }); - - it("shows the Impending deletion status when the workspace is impending deletion", async () => { - jest - .spyOn(api, "getEntitlements") - .mockResolvedValue(MockEntitlementsWithScheduling); - await testStatus(MockWorkspaceWithDeletion, "Impending deletion"); - }); - it("shows the timeline build", async () => { await renderWorkspacePage(); const table = await screen.findByTestId("builds-table"); diff --git a/site/src/testHelpers/renderHelpers.tsx b/site/src/testHelpers/renderHelpers.tsx index 9eeb2f2171c3d..a4dc89be94cca 100644 --- a/site/src/testHelpers/renderHelpers.tsx +++ b/site/src/testHelpers/renderHelpers.tsx @@ -1,5 +1,5 @@ import { render as tlRender, screen, waitFor } from "@testing-library/react"; -import { AppProviders } from "App"; +import { AppProviders, ThemeProviders } from "App"; import { DashboardLayout } from "components/Dashboard/DashboardLayout"; import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout"; import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"; @@ -165,3 +165,7 @@ export const waitForLoaderToBeRemoved = async (): Promise => { }, ); }; + +export const renderComponent = (component: React.ReactNode) => { + return tlRender({component}); +}; From 0f7a3005e8b248078125cfd8744a6051212f742e Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 11 Oct 2023 12:14:56 +0000 Subject: [PATCH 13/20] Remove unecessary type --- .../xServices/createWorkspace/createWorkspaceXService.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 79dc44701430f..29ed960dd3518 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -40,10 +40,6 @@ type CreateWorkspaceEvent = { owner: User; }; -type RefreshGitAuthEvent = { - type: "REFRESH_GITAUTH"; -}; - export const createWorkspaceMachine = /** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMB1A9qgNawAOGyYAyltmAHTIAWYyRAlgHZQCy+EYAMQBtAAwBdRKFL5Y7LO3ycpIAB6IATABYAnPR2iDWgOwBmAGynRo4wFZbAGhABPRDoAc9d+8OnTARlF-Uy0NUQ0AXwinNEwcAmIyCmpaHEYWNi5efiFhf0kkEBk5BSUVdQQNU1svEPcq9wsDU3ctJ1dKv3pRd3NzHVNjf1sjHX8omPQ6BJJySho6egwAVyx8AGEphW5BCCUGLgA3fCIGWOnCWeSFtJW1zbishCP8ZGxFTjFxL5Vi+Q-yoh7MZ9MZjLoQqItN5jDp2ogALT+PSWWwaDR9dzGDTeXTuCYgc7xS5JeapBh3DZbLKCMCoVCEeikAA22AAZoQALaMLZ4ElzFKLSkPd7cZ6cY5vUqfCQ-Qp-aWAhAtPQtWxDaE+OxQ4zwhD+dw1US2cw4-zmLQ9WyicwEol8xICm4MZn4DAQLIAMS5ABFsBhdvt6C9Tjy4g6rmTFq73V7ff7xZL3kovnLpLJ-mVChVzNb6P5tDoMei7P5-G0XIgQqZ6BjbFrWuZGrC7byZqTBWkYx7uN7UJy-bRafTGSz2VywxdHddyfRu3H+4OMInXsmZd8JL8M4rs4hejWCzYLOYDSfjOY9SENPpgmYy+bgjodLbooS2-yZ4t2BBmUJ1gAlABRABBAAVQCAH1cAAeX-ABpKgAAVgPWQC0yKbcAV3BBLHMWs61EQZjQ0ZF3D1ewa36S0QlhasQlbcN2ydWciSyJjkkDTgDglE4znfacozSVjuHYygVylD5U03eVMKzUAc1MPRGh0cEtBMJ9Wj1MxPFsKwmg0DwwmGBip0jTs+MeESP0oYcGVQJlWSwDl+0nYkBPM1y2OssBxLXKSCnTEosPkxBQn8eh7FaewBjRU1THIwIb2CLQ+nrXN1SiV9OByeBCntUTzK3IK5LUKstFrWE4uNGxwQxPUkRsfNqnRfxzybE1+hMtyzOddJWA4bg+AEIrM2UbD6gq58qmqsFQgvSsEGfehLEMExWp0LRSK6iMO164VqW4EadxC5Ui2W0wNDBDVekfLTrx8cIAnBKxgVsbaCt6+de3jWgjuC0qcIsZbfBtPE-F1BaGn0AzIWxGK-HxV98u83rv1-P6SpzSx6EUtT+kIsYT38PUtFscKDTUw1zGxMmy3elGWIOqACoxsaTtNPCLs2-oGlNVq9SJ2tQcCS0eZS+n3N6+0IFZpV3A2-MtBadx1uRcIyIWuxPDsforEM6x7AlnrZ27QCR1QWXxthHHLrBJ8nxtJsSd0ehsQsJWNHrYYi0yiIgA */ createMachine( @@ -53,7 +49,7 @@ export const createWorkspaceMachine = tsTypes: {} as import("./createWorkspaceXService.typegen").Typegen0, schema: { context: {} as CreateWorkspaceContext, - events: {} as CreateWorkspaceEvent | RefreshGitAuthEvent, + events: {} as CreateWorkspaceEvent, services: {} as { loadFormData: { data: { From 7f2c6056258d5be9f45919269d7fb1c855c6530b Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 11 Oct 2023 12:19:50 +0000 Subject: [PATCH 14/20] Rename workspace queries module --- site/src/api/queries/{workspace.ts => workspaces.ts} | 0 .../WorkspaceSchedulePage/WorkspaceSchedulePage.tsx | 2 +- .../src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename site/src/api/queries/{workspace.ts => workspaces.ts} (100%) diff --git a/site/src/api/queries/workspace.ts b/site/src/api/queries/workspaces.ts similarity index 100% rename from site/src/api/queries/workspace.ts rename to site/src/api/queries/workspaces.ts diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index 4c2acb973e178..08d116744e4a6 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -15,7 +15,7 @@ import { Helmet } from "react-helmet-async"; import { Navigate, useNavigate, useParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import * as TypesGen from "api/typesGenerated"; -import { workspaceByOwnerAndNameKey } from "api/queries/workspace"; +import { workspaceByOwnerAndNameKey } from "api/queries/workspaces"; import { WorkspaceScheduleForm } from "./WorkspaceScheduleForm"; import { workspaceSchedule } from "xServices/workspaceSchedule/workspaceScheduleXService"; import { diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx index 1a391e6ec44b0..4c9d5aea616dc 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsLayout.tsx @@ -7,7 +7,7 @@ import { pageTitle } from "utils/page"; import { Loader } from "components/Loader/Loader"; import { Outlet, useParams } from "react-router-dom"; import { Margins } from "components/Margins/Margins"; -import { workspaceByOwnerAndName } from "api/queries/workspace"; +import { workspaceByOwnerAndName } from "api/queries/workspaces"; import { useQuery } from "react-query"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { type Workspace } from "api/typesGenerated"; From 15930686b6f79b3ea993d4f4e913356a4417bc8a Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 11 Oct 2023 12:37:32 +0000 Subject: [PATCH 15/20] Remove auto create from workspace xservice --- site/src/api/queries/workspaces.ts | 45 +++++++++++++++- .../CreateWorkspacePage.tsx | 54 ++++++++++++++++--- .../createWorkspaceXService.ts | 52 +----------------- 3 files changed, 90 insertions(+), 61 deletions(-) diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 793c482adb920..ce65c9ef2b973 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -1,6 +1,6 @@ import * as API from "api/api"; -import { type Workspace } from "api/typesGenerated"; -import { type QueryOptions } from "react-query"; +import { WorkspaceBuildParameter, type Workspace } from "api/typesGenerated"; +import { QueryClient, type QueryOptions } from "react-query"; export const workspaceByOwnerAndNameKey = (owner: string, name: string) => [ "workspace", @@ -18,3 +18,44 @@ export const workspaceByOwnerAndName = ( queryFn: () => API.getWorkspaceByOwnerAndName(owner, name), }; }; + +type AutoCreateWorkspaceOptions = { + templateName: string; + versionId?: string; + organizationId: string; + defaultBuildParameters?: WorkspaceBuildParameter[]; + defaultName: string; +}; + +export const autoCreateWorkspace = (queryClient: QueryClient) => { + return { + mutationFn: async ({ + templateName, + versionId, + organizationId, + defaultBuildParameters, + defaultName, + }: AutoCreateWorkspaceOptions) => { + let templateVersionParameters; + + if (versionId) { + templateVersionParameters = { template_version_id: versionId }; + } else { + const template = await API.getTemplateByName( + organizationId, + templateName, + ); + templateVersionParameters = { template_id: template.id }; + } + + return API.createWorkspace(organizationId, "me", { + ...templateVersionParameters, + name: defaultName, + rich_parameter_values: defaultBuildParameters, + }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries(["workspaces"]); + }, + }; +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index a0ad1e3502deb..0258c248cc529 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -11,7 +11,6 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom"; import { pageTitle } from "utils/page"; import { CreateWSPermissions, - CreateWorkspaceMode, createWorkspaceMachine, } from "xServices/createWorkspace/createWorkspaceXService"; import { CreateWorkspacePageView } from "./CreateWorkspacePageView"; @@ -23,8 +22,11 @@ import { colors, NumberDictionary, } from "unique-names-generator"; -import { useQuery } from "react-query"; +import { useMutation, useQuery, useQueryClient } from "react-query"; import { templateVersionExternalAuth } from "api/queries/templates"; +import { autoCreateWorkspace } from "api/queries/workspaces"; + +type CreateWorkspaceMode = "form" | "auto"; export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; @@ -33,17 +35,15 @@ const CreateWorkspacePage: FC = () => { const { template: templateName } = useParams() as { template: string }; const me = useMe(); const navigate = useNavigate(); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const defaultBuildParameters = getDefaultBuildParameters(searchParams); const mode = (searchParams.get("mode") ?? "form") as CreateWorkspaceMode; const [createWorkspaceState, send] = useMachine(createWorkspaceMachine, { context: { organizationId, templateName, - mode, defaultBuildParameters, - defaultName: - mode === "auto" ? generateUniqueName() : searchParams.get("name") ?? "", + defaultName: searchParams.get("name") ?? "", versionId: searchParams.get("version") ?? undefined, }, actions: { @@ -54,7 +54,13 @@ const CreateWorkspacePage: FC = () => { }); const { template, parameters, permissions, defaultName, versionId } = createWorkspaceState.context; - const title = createWorkspaceState.matches("autoCreating") + + const queryClient = useQueryClient(); + const autoCreateWorkspaceMutation = useMutation( + autoCreateWorkspace(queryClient), + ); + + const title = autoCreateWorkspaceMutation.isLoading ? "Creating workspace..." : "Create workspace"; @@ -97,6 +103,38 @@ const CreateWorkspacePage: FC = () => { }; }, [externalAuthPollingState, allSignedIn]); + useEffect(() => { + if (mode === "auto") { + autoCreateWorkspaceMutation + .mutateAsync({ + templateName, + organizationId, + defaultBuildParameters, + defaultName: + mode === "auto" + ? generateUniqueName() + : searchParams.get("name") ?? "", + versionId: searchParams.get("version") ?? undefined, + }) + .then((workspace) => { + navigate(`/@${workspace.owner_name}/${workspace.name}`); + }) + .catch(() => { + searchParams.delete("mode"); + setSearchParams(searchParams); + }); + } + }, [ + autoCreateWorkspaceMutation, + defaultBuildParameters, + mode, + navigate, + organizationId, + searchParams, + setSearchParams, + templateName, + ]); + return ( <> @@ -104,7 +142,7 @@ const CreateWorkspacePage: FC = () => { {Boolean( createWorkspaceState.matches("loadingFormData") || - createWorkspaceState.matches("autoCreating"), + autoCreateWorkspaceMutation.isLoading, ) && } {createWorkspaceState.matches("loadError") && ( diff --git a/site/src/xServices/createWorkspace/createWorkspaceXService.ts b/site/src/xServices/createWorkspace/createWorkspaceXService.ts index 29ed960dd3518..cf9ced880b669 100644 --- a/site/src/xServices/createWorkspace/createWorkspaceXService.ts +++ b/site/src/xServices/createWorkspace/createWorkspaceXService.ts @@ -15,12 +15,9 @@ import { import { assign, createMachine } from "xstate"; import { paramsUsedToCreateWorkspace } from "utils/workspace"; -export type CreateWorkspaceMode = "form" | "auto"; - type CreateWorkspaceContext = { organizationId: string; templateName: string; - mode: CreateWorkspaceMode; defaultName: string; // Not exposed in the form yet, but can be set as a search param to // create a workspace with a specific version of a template @@ -61,34 +58,10 @@ export const createWorkspaceMachine = createWorkspace: { data: Workspace; }; - autoCreateWorkspace: { - data: Workspace; - }; }, }, - initial: "checkingMode", + initial: "loadingFormData", states: { - checkingMode: { - always: [ - { - target: "autoCreating", - cond: ({ mode }) => mode === "auto", - }, - { target: "loadingFormData" }, - ], - }, - autoCreating: { - invoke: { - src: "autoCreateWorkspace", - onDone: { - actions: ["onCreateWorkspace"], - }, - onError: { - actions: ["assignError"], - target: "loadingFormData", - }, - }, - }, loadingFormData: { invoke: { src: "loadFormData", @@ -147,29 +120,6 @@ export const createWorkspaceMachine = return createWorkspace(organizationId, owner.id, request); }, - autoCreateWorkspace: async ({ - templateName, - versionId, - organizationId, - defaultBuildParameters, - defaultName, - }) => { - let templateVersionParameters; - if (versionId) { - templateVersionParameters = { template_version_id: versionId }; - } else { - const template = await getTemplateByName( - organizationId, - templateName, - ); - templateVersionParameters = { template_id: template.id }; - } - return createWorkspace(organizationId, "me", { - ...templateVersionParameters, - name: defaultName, - rich_parameter_values: defaultBuildParameters, - }); - }, loadFormData: async ({ templateName, organizationId, versionId }) => { const [template, permissions] = await Promise.all([ getTemplateByName(organizationId, templateName), From 902b4b13e1b64f2d86334bab42212b604c079c19 Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 11 Oct 2023 12:46:54 +0000 Subject: [PATCH 16/20] Move external auth into its own hook --- .../CreateWorkspacePage.tsx | 105 ++++++++++-------- 1 file changed, 61 insertions(+), 44 deletions(-) diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index 0258c248cc529..c6cd06cb4ec80 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -52,8 +52,17 @@ const CreateWorkspacePage: FC = () => { }, }, }); - const { template, parameters, permissions, defaultName, versionId } = - createWorkspaceState.context; + const { + template, + parameters, + permissions, + defaultName, + versionId, + error: createWorkspaceError, + } = createWorkspaceState.context; + + const { externalAuth, externalAuthPollingState, startPollingExternalAuth } = + useExternalAuth(versionId); const queryClient = useQueryClient(); const autoCreateWorkspaceMutation = useMutation( @@ -64,45 +73,6 @@ const CreateWorkspacePage: FC = () => { ? "Creating workspace..." : "Create workspace"; - const [externalAuthPollingState, setExternalAuthPollingState] = - useState("idle"); - - const startPollingExternalAuth = useCallback(() => { - setExternalAuthPollingState("polling"); - }, []); - - const { data: externalAuth, error } = useQuery( - versionId - ? { - ...templateVersionExternalAuth(versionId), - refetchInterval: - externalAuthPollingState === "polling" ? 1000 : false, - } - : { enabled: false }, - ); - - const allSignedIn = externalAuth?.every((it) => it.authenticated); - - useEffect(() => { - if (allSignedIn) { - setExternalAuthPollingState("idle"); - return; - } - - if (externalAuthPollingState !== "polling") { - return; - } - - // Poll for a maximum of one minute - const quitPolling = setTimeout( - () => setExternalAuthPollingState("abandoned"), - 60_000, - ); - return () => { - clearTimeout(quitPolling); - }; - }, [externalAuthPollingState, allSignedIn]); - useEffect(() => { if (mode === "auto") { autoCreateWorkspaceMutation @@ -145,14 +115,14 @@ const CreateWorkspacePage: FC = () => { autoCreateWorkspaceMutation.isLoading, ) && } {createWorkspaceState.matches("loadError") && ( - + )} {createWorkspaceState.matches("idle") && ( { ); }; -export default CreateWorkspacePage; +const useExternalAuth = (versionId: string | undefined) => { + const [externalAuthPollingState, setExternalAuthPollingState] = + useState("idle"); + + const startPollingExternalAuth = useCallback(() => { + setExternalAuthPollingState("polling"); + }, []); + + const { data: externalAuth } = useQuery( + versionId + ? { + ...templateVersionExternalAuth(versionId), + refetchInterval: + externalAuthPollingState === "polling" ? 1000 : false, + } + : { enabled: false }, + ); + + const allSignedIn = externalAuth?.every((it) => it.authenticated); + + useEffect(() => { + if (allSignedIn) { + setExternalAuthPollingState("idle"); + return; + } + + if (externalAuthPollingState !== "polling") { + return; + } + + // Poll for a maximum of one minute + const quitPolling = setTimeout( + () => setExternalAuthPollingState("abandoned"), + 60_000, + ); + return () => { + clearTimeout(quitPolling); + }; + }, [externalAuthPollingState, allSignedIn]); + + return { + startPollingExternalAuth, + externalAuth, + externalAuthPollingState, + }; +}; const getDefaultBuildParameters = ( urlSearchParams: URLSearchParams, @@ -216,3 +231,5 @@ const generateUniqueName = () => { style: "lowerCase", }); }; + +export default CreateWorkspacePage; From 1bcc73e202810556eec0273c9a256386517098db Mon Sep 17 00:00:00 2001 From: BrunoQuaresma Date: Wed, 11 Oct 2023 13:02:23 +0000 Subject: [PATCH 17/20] Remove permissions fetching from templateByName query --- site/src/api/queries/templates.ts | 20 ++--------- .../DuplicateTemplateView.tsx | 5 ++- .../TemplateSettingsLayout.tsx | 34 ++++++++++++++----- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index a25eea3753bbc..f4082e362fd29 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -1,7 +1,6 @@ import * as API from "api/api"; import { type Template, - type AuthorizationResponse, type CreateTemplateVersionRequest, type ProvisionerJobStatus, type TemplateVersion, @@ -21,25 +20,10 @@ export const templateByNameKey = (orgId: string, name: string) => [ export const templateByName = ( orgId: string, name: string, -): QueryOptions<{ template: Template; permissions: AuthorizationResponse }> => { +): QueryOptions