chore(site): refactor AuthProvider to not use authXService (#10184)

* Move xstate transitions to provider

* Centrlize auth logic in the provider

* Remove actor

* Remove auth xservice

* Add loader while AuthProvider is loading

* Simplify and fix a few computed states

* Add a few replaces

* Fix logout

* Remove unused import

* Fix RequireAuth test

* Fix wait loader

* Fix tests

* Wrap signout with callback
This commit is contained in:
Bruno Quaresma
2023-10-11 16:13:32 -04:00
committed by GitHub
parent 7c6687813d
commit 5be4b12378
28 changed files with 422 additions and 769 deletions
+3 -13
View File
@@ -120,19 +120,9 @@ export const logout = async (): Promise<void> => {
await axios.post("/api/v2/users/logout");
};
export const getAuthenticatedUser = async (): Promise<
TypesGen.User | undefined
> => {
try {
const response = await axios.get<TypesGen.User>("/api/v2/users/me");
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 401) {
return undefined;
}
throw error;
}
export const getAuthenticatedUser = async () => {
const response = await axios.get<TypesGen.User>("/api/v2/users/me");
return response.data;
};
export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
+14
View File
@@ -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),
};
};
+78
View File
@@ -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<GetUsersResponse> => {
return {
@@ -83,3 +88,76 @@ export const authMethods = () => {
queryFn: API.getAuthMethods,
};
};
export const me = () => {
return {
queryKey: ["me"],
queryFn: async () =>
getMetadataAsJSON<User>("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<ReturnType<typeof loginFn>>) => {
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),
};
};
+114 -17
View File
@@ -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<typeof authMachine>;
}
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<void>;
updateProfile: (data: UpdateUserProfileRequest) => void;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: FC<PropsWithChildren> = ({ 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 <FullScreenLoader />;
}
return (
<AuthContext.Provider value={{ authService }}>
<AuthContext.Provider
value={{
isSignedOut,
isSigningOut,
isConfiguringTheFirstUser,
isSignedIn,
isSigningIn,
isUpdatingProfile,
signOut,
signIn,
updateProfile,
user: userQuery.data,
permissions: permissionsQuery.data as Permissions | undefined,
authMethods: authMethodsQuery.data,
signInError: loginMutation.error,
updateProfileError: updateProfileMutation.error,
}}
>
{children}
</AuthContext.Provider>
);
};
type UseAuthReturnType = ReturnType<
typeof useActor<AuthContextValue["authService"]>
>;
export const useAuth = (): UseAuthReturnType => {
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth should be used inside of <AuthProvider />");
}
const auth = useActor(context.authService);
return auth;
return context;
};
@@ -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<keyof typeof permissionsToCheck, boolean>;
@@ -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}
@@ -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(
<UserDropdownContent
user={MockUser}
@@ -12,6 +12,7 @@ describe("UserDropdownContent", () => {
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(
<UserDropdownContent
@@ -30,6 +31,7 @@ describe("UserDropdownContent", () => {
onPopoverClose={jest.fn()}
/>,
);
await waitForLoaderToBeRemoved();
screen.getByText(Language.signOutLabel).click();
expect(onSignOut).toBeCalledTimes(1);
});
@@ -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(<ConfirmDialog {...props} />);
renderComponent(<ConfirmDialog {...props} />);
fireEvent.click(screen.getByText("CANCEL"));
// Then
@@ -37,7 +37,7 @@ describe("ConfirmDialog", () => {
};
// When
render(<ConfirmDialog {...props} />);
renderComponent(<ConfirmDialog {...props} />);
fireEvent.click(screen.getByText("CONFIRM"));
// Then
@@ -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(
<DeleteDialog
isOpen
onConfirm={jest.fn()}
@@ -37,7 +37,7 @@ describe("DeleteDialog", () => {
});
it("disables confirm button when the text field is filled incorrectly", async () => {
render(
renderComponent(
<DeleteDialog
isOpen
onConfirm={jest.fn()}
@@ -55,7 +55,7 @@ describe("DeleteDialog", () => {
});
it("enables confirm button when the text field is filled correctly", async () => {
render(
renderComponent(
<DeleteDialog
isOpen
onConfirm={jest.fn()}
@@ -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(<h1>Test</h1>, {
nonAuthenticatedRoutes: [
{
path: "setup",
element: <h1>Setup</h1>,
path: "login",
element: <h1>Login</h1>,
},
],
});
await screen.findByText("Setup");
await screen.findByText("Login");
});
});
@@ -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 <Navigate to={navigateTo} state={{ isRedirect: !isHomePage }} />;
} else if (authState.matches("configuringTheFirstUser")) {
return <Navigate to="/setup" />;
} else if (
authState.matches("loadingInitialAuthData") ||
authState.matches("signingOut")
) {
if (isSignedOut) {
return (
<Navigate to={navigateTo} state={{ isRedirect: !isHomePage }} replace />
);
} else if (isSigningOut) {
return <FullScreenLoader />;
} else {
// Authenticated pages have access to some contexts for knowing enabled experiments
+4 -6
View File
@@ -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;
};
+3 -10
View File
@@ -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];
};
+5 -6
View File
@@ -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;
};
+19 -10
View File
@@ -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 <Navigate to={redirectTo} replace />;
} else if (authState.matches("configuringTheFirstUser")) {
return <Navigate to="/setup" />;
} else if (isConfiguringTheFirstUser) {
return <Navigate to="/setup" replace />;
} else {
return (
<>
@@ -23,11 +31,12 @@ export const LoginPage: FC = () => {
<title>Sign in to {applicationName}</title>
</Helmet>
<LoginPageView
context={authState.context}
isLoading={authState.matches("loadingInitialAuthData")}
isSigningIn={authState.matches("signingIn")}
onSignIn={({ email, password }) => {
authSend({ type: "SIGN_IN", email, password });
authMethods={authMethods}
error={signInError}
isSigningIn={isSigningIn}
onSignIn={async ({ email, password }) => {
await signIn(email, password);
navigate("/");
}}
/>
</>
@@ -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<typeof LoginPageView>;
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,
},
};
+7 -12
View File
@@ -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<LoginPageViewProps> = ({
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<LoginPageViewProps> = ({
<CoderIcon fill="white" opacity={1} className={styles.icon} />
);
return isLoading ? (
<FullScreenLoader />
) : (
return (
<div className={styles.root}>
<div className={styles.container}>
{applicationLogo}
<SignInForm
authMethods={data.authMethods}
authMethods={authMethods}
redirectTo={redirectTo}
isSigningIn={isSigningIn}
error={error}
+7 -84
View File
@@ -2,7 +2,10 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { createMemoryRouter } from "react-router-dom";
import { render, renderWithRouter } from "testHelpers/renderHelpers";
import {
renderWithRouter,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { server } from "testHelpers/server";
import { SetupPage } from "./SetupPage";
import { Language as PageViewLanguage } from "./SetupPageView";
@@ -45,38 +48,6 @@ describe("Setup Page", () => {
);
});
it("shows validation error message", async () => {
render(<SetupPage />);
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(<SetupPage />);
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(<SetupPage />);
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: <SetupPage />,
},
{
path: "/login",
element: <h1>Login</h1>,
},
],
{ 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"));
});
});
+11 -15
View File
@@ -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 <Navigate to="/" state={{ isRedirect: true }} />;
if (isSignedIn) {
return <Navigate to="/" state={{ isRedirect: true }} replace />;
}
// If we've already completed setup, navigate to the login page
if (setupIsComplete) {
return <Navigate to="/login" state={{ isRedirect: true }} />;
return <Navigate to="/login" state={{ isRedirect: true }} replace />;
}
return (
@@ -31,15 +30,12 @@ export const SetupPage: FC = () => {
<title>{pageTitle("Set up your account")}</title>
</Helmet>
<SetupPageView
isLoading={createFirstUserMutation.isLoading}
isLoading={createFirstUserMutation.isLoading || isSigningIn}
error={createFirstUserMutation.error}
onSubmit={async (firstUser) => {
await createFirstUserMutation.mutateAsync(firstUser);
authSend({
type: "SIGN_IN",
email: firstUser.email,
password: firstUser.password,
});
await signIn(firstUser.email, firstUser.password);
navigate("/");
}}
/>
</>
@@ -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,
};
@@ -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<AccountFormValues>;
initialTouched?: FormikTouched<UpdateUserProfileRequest>;
}
export const AccountForm: FC<React.PropsWithChildren<AccountFormProps>> = ({
export const AccountForm: FC<AccountFormProps> = ({
editable,
email,
isLoading,
@@ -45,17 +42,13 @@ export const AccountForm: FC<React.PropsWithChildren<AccountFormProps>> = ({
updateProfileError,
initialTouched,
}) => {
const form: FormikContextType<AccountFormValues> =
useFormik<AccountFormValues>({
initialValues,
validationSchema,
onSubmit,
initialTouched,
});
const getFieldHelpers = getFormHelpers<AccountFormValues>(
form,
updateProfileError,
);
const form = useFormik({
initialValues,
validationSchema,
onSubmit,
initialTouched,
});
const getFieldHelpers = getFormHelpers(form, updateProfileError);
return (
<>
@@ -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);
@@ -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}
/>
</Section>
);
@@ -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");
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
-424
View File
@@ -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<keyof typeof permissionsToCheck, boolean>;
export type AuthenticatedData = {
user: TypesGen.User;
permissions: Permissions;
};
export type UnauthenticatedData = {
hasFirstUser: boolean;
authMethods: TypesGen.AuthMethods;
};
export type AuthData = AuthenticatedData | UnauthenticatedData;
export const isAuthenticated = (data?: AuthData): data is AuthenticatedData =>
data !== undefined && "user" in data;
const loadInitialAuthData = async (): Promise<AuthData> => {
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<AuthenticatedData> => {
await API.login(email, password);
const [user, permissions] = await Promise.all([
API.getAuthenticatedUser(),
API.checkAuthorization({
checks: permissionsToCheck,
}),
]);
return {
user: user as TypesGen.User,
permissions: permissions as Permissions,
};
};
const signOut = async () => {
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<ReturnType<typeof loadInitialAuthData>>;
};
signIn: {
data: Awaited<ReturnType<typeof signIn>>;
};
updateProfile: {
data: TypesGen.User;
};
signOut: {
data: Awaited<ReturnType<typeof signOut>>;
};
},
},
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),
},
},
);
@@ -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;