diff --git a/site/src/api/api.ts b/site/src/api/api.ts index e688a9c78d..32f8889542 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/api/queries/authCheck.ts b/site/src/api/queries/authCheck.ts new file mode 100644 index 0000000000..415c7676ea --- /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 3c0bd47a8e..f39b362975 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 { getAuthorizationKey } from "./authCheck"; export const users = (req: UsersRequest): QueryOptions => { return { @@ -83,3 +88,76 @@ 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.removeQueries(); + }, + }; +}; + +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 d35c117b41..cdcba35660 100644 --- a/site/src/components/AuthProvider/AuthProvider.tsx +++ b/site/src/components/AuthProvider/AuthProvider.tsx @@ -1,36 +1,133 @@ -import { useActor, useInterpret } from "@xstate/react"; -import { createContext, FC, PropsWithChildren, useContext } from "react"; -import { authMachine } from "xServices/auth/authXService"; -import { ActorRefFrom } from "xstate"; +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 { 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"; -interface AuthContextValue { - authService: ActorRefFrom; -} +type AuthContextValue = { + isSignedOut: 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) => Promise; + updateProfile: (data: UpdateUserProfileRequest) => void; +}; const AuthContext = createContext(undefined); export const AuthProvider: FC = ({ children }) => { - const authService = useInterpret(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 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 isSignedOut = + userQuery.isError && + isApiError(userQuery.error) && + userQuery.error.response.status === 401; + const isSigningOut = logoutMutation.isLoading; + const isLoading = + authMethodsQuery.isLoading || + userQuery.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 = useCallback(() => { + logoutMutation.mutate(); + }, [logoutMutation]); + + const signIn = async (email: string, password: string) => { + await loginMutation.mutateAsync({ email, password }); + }; + + const updateProfile = (req: UpdateUserProfileRequest) => { + updateProfileMutation.mutate({ userId: userQuery.data!.id, req }); + }; + + if (isLoading) { + return ; + } 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; }; diff --git a/site/src/components/AuthProvider/permissions.tsx b/site/src/components/AuthProvider/permissions.tsx new file mode 100644 index 0000000000..6e39286edb --- /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/components/Dashboard/Navbar/Navbar.tsx b/site/src/components/Dashboard/Navbar/Navbar.tsx index de430e3fdf..c84dc1f12b 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/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx b/site/src/components/Dashboard/Navbar/UserDropdown/UserDropdownContent.test.tsx index ebb75d82e8..4e488f04d0 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 de4498bbeb..2390ad3aee 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 de01e14cc4..10f8f9d89e 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("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"); }); }); diff --git a/site/src/components/RequireAuth/RequireAuth.tsx b/site/src/components/RequireAuth/RequireAuth.tsx index 5703844848..f6054a629f 100644 --- a/site/src/components/RequireAuth/RequireAuth.tsx +++ b/site/src/components/RequireAuth/RequireAuth.tsx @@ -9,7 +9,7 @@ import { ProxyProvider } from "contexts/ProxyContext"; import { isApiError } from "api/errors"; export const RequireAuth: FC = () => { - const [authState, authSend] = useAuth(); + const { signOut, isSigningOut, isSignedOut } = useAuth(); const location = useLocation(); const isHomePage = location.pathname === "/"; const navigateTo = isHomePage @@ -24,7 +24,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,16 +35,13 @@ export const RequireAuth: FC = () => { return () => { axios.interceptors.response.eject(interceptorHandle); }; - }, [authSend]); + }, [signOut]); - if (authState.matches("signedOut")) { - return ; - } else if (authState.matches("configuringTheFirstUser")) { - return ; - } else if ( - authState.matches("loadingInitialAuthData") || - authState.matches("signingOut") - ) { + if (isSignedOut) { + return ( + + ); + } else if (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 2d15cad0f1..57d6335e16 100644 --- a/site/src/hooks/useMe.ts +++ b/site/src/hooks/useMe.ts @@ -1,14 +1,12 @@ import { User } from "api/typesGenerated"; import { useAuth } from "components/AuthProvider/AuthProvider"; -import { isAuthenticated } from "xServices/auth/authXService"; export const useMe = (): User => { - const [authState] = useAuth(); - 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 c090d988e2..52f0c034e5 100644 --- a/site/src/hooks/useOrganizationId.ts +++ b/site/src/hooks/useOrganizationId.ts @@ -1,13 +1,6 @@ -import { useAuth } from "components/AuthProvider/AuthProvider"; -import { isAuthenticated } from "xServices/auth/authXService"; +import { useMe } from "./useMe"; export const useOrganizationId = (): string => { - const [authState] = useAuth(); - 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 b04bc0d189..0837ffb64e 100644 --- a/site/src/hooks/usePermissions.ts +++ b/site/src/hooks/usePermissions.ts @@ -1,13 +1,12 @@ import { useAuth } from "components/AuthProvider/AuthProvider"; -import { isAuthenticated, Permissions } from "xServices/auth/authXService"; +import { Permissions } from "components/AuthProvider/permissions"; export const usePermissions = (): Permissions => { - const [authState] = useAuth(); - 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 698be4bbae..8eb2b114e3 100644 --- a/site/src/pages/LoginPage/LoginPage.tsx +++ b/site/src/pages/LoginPage/LoginPage.tsx @@ -1,21 +1,29 @@ 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"; export const LoginPage: FC = () => { const location = useLocation(); - const [authState, authSend] = useAuth(); + const { + isSignedIn, + isConfiguringTheFirstUser, + signIn, + isSigningIn, + authMethods, + signInError, + } = useAuth(); const redirectTo = retrieveRedirect(location.search); const applicationName = getApplicationName(); + const navigate = useNavigate(); - if (authState.matches("signedIn")) { + if (isSignedIn) { return ; - } else if (authState.matches("configuringTheFirstUser")) { - return ; + } else if (isConfiguringTheFirstUser) { + return ; } else { return ( <> @@ -23,11 +31,12 @@ export const LoginPage: FC = () => { Sign in to {applicationName} { - authSend({ type: "SIGN_IN", email, password }); + authMethods={authMethods} + error={signInError} + isSigningIn={isSigningIn} + 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 a55cef6a40..13d1734507 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"; @@ -13,51 +12,29 @@ 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 = { +export const LoadingAuthMethods: Story = { args: { - isLoading: true, - onSignIn: action("onSignIn"), - context: {}, + authMethods: undefined, }, }; export const SigningIn: Story = { args: { isSigningIn: true, - onSignIn: action("onSignIn"), - context: { - data: { - authMethods: MockAuthMethods, - hasFirstUser: false, - }, - }, + authMethods: MockAuthMethods, }, }; diff --git a/site/src/pages/LoginPage/LoginPageView.tsx b/site/src/pages/LoginPage/LoginPageView.tsx index 65faa524cd..520de317c2 100644 --- a/site/src/pages/LoginPage/LoginPageView.tsx +++ b/site/src/pages/LoginPage/LoginPageView.tsx @@ -1,30 +1,27 @@ 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; - isLoading: boolean; + authMethods: AuthMethods | undefined; + error: unknown; isSigningIn: boolean; onSignIn: (credentials: { email: string; password: string }) => void; } export const LoginPageView: FC = ({ - context, - isLoading, + authMethods, + error, 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. @@ -47,14 +44,12 @@ export const LoginPageView: FC = ({ ); - return isLoading ? ( - - ) : ( + return (
{applicationLogo} { ); }); - 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/SetupPage/SetupPage.tsx b/site/src/pages/SetupPage/SetupPage.tsx index f83b8bbfda..7dae420224 100644 --- a/site/src/pages/SetupPage/SetupPage.tsx +++ b/site/src/pages/SetupPage/SetupPage.tsx @@ -3,26 +3,25 @@ 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 [authState, authSend] = useAuth(); + const { signIn, isConfiguringTheFirstUser, isSignedIn, isSigningIn } = + useAuth(); const createFirstUserMutation = useMutation(createFirstUser()); - const userIsSignedIn = authState.matches("signedIn"); - const setupIsComplete = - !authState.matches("loadingInitialAuthData") && - !authState.matches("configuringTheFirstUser"); + const setupIsComplete = !isConfiguringTheFirstUser; + const navigate = useNavigate(); // If the user is logged in, navigate to the app - if (userIsSignedIn) { - return ; + if (isSignedIn) { + return ; } // If we've already completed setup, navigate to the login page if (setupIsComplete) { - return ; + return ; } return ( @@ -31,15 +30,12 @@ export const SetupPage: FC = () => { {pageTitle("Set up your account")} { await createFirstUserMutation.mutateAsync(firstUser); - authSend({ - type: "SIGN_IN", - email: firstUser.email, - password: firstUser.password, - }); + await signIn(firstUser.email, firstUser.password); + navigate("/"); }} /> diff --git a/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.test.tsx index dadb8f8881..3b6c9951b3 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/AccountForm.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountForm.tsx index 9c6648200f..84dbb612b7 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.test.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.test.tsx index 61d868b02b..8e776f0c67 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/pages/UserSettingsPage/AccountPage/AccountPage.tsx b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx index 4a60e6dacf..f266383aa2 100644 --- a/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx +++ b/site/src/pages/UserSettingsPage/AccountPage/AccountPage.tsx @@ -6,10 +6,9 @@ import { useMe } from "hooks/useMe"; import { usePermissions } from "hooks/usePermissions"; export const AccountPage: FC = () => { - const [authState, authSend] = useAuth(); + const { updateProfile, updateProfileError, isUpdatingProfile } = useAuth(); const me = useMe(); const permissions = usePermissions(); - const { updateProfileError } = authState.context; const canEditUsers = permissions && permissions.updateUsers; return ( @@ -18,16 +17,11 @@ export const AccountPage: FC = () => { editable={Boolean(canEditUsers)} email={me.email} updateProfileError={updateProfileError} - isLoading={authState.matches("signedIn.profile.updatingProfile")} + isLoading={isUpdatingProfile} initialValues={{ username: me.username, }} - onSubmit={(data) => { - authSend({ - type: "UPDATE_PROFILE", - data, - }); - }} + onSubmit={updateProfile} /> ); diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 273e578761..8a79bccea4 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -11,21 +11,12 @@ 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 } from "testHelpers/renderHelpers"; import { server } from "testHelpers/server"; import { WorkspacePage } from "./WorkspacePage"; @@ -65,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(() => { @@ -279,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/entities.ts b/site/src/testHelpers/entities.ts index 606332a6e4..dd91f44f24 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 f40ad06cc1..8bc5dabba1 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 ee4bda58de..0000000000 --- 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 7005d2f042..099c8501b3 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;