mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+3
-13
@@ -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> => {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user