feat: add a theme picker (#11140)

This commit is contained in:
Kayla Washburn
2023-12-14 10:38:44 -07:00
committed by GitHub
parent 0cd4842d18
commit 133dc66143
63 changed files with 1283 additions and 317 deletions
+14 -33
View File
@@ -1,18 +1,12 @@
import CssBaseline from "@mui/material/CssBaseline";
import { QueryClient, QueryClientProvider } from "react-query";
import { AuthProvider } from "components/AuthProvider/AuthProvider";
import type { FC, PropsWithChildren, ReactNode } from "react";
import type { FC, ReactNode } from "react";
import { HelmetProvider } from "react-helmet-async";
import { AppRouter } from "./AppRouter";
import { ThemeProviders } from "./contexts/ThemeProviders";
import { AuthProvider } from "./contexts/AuthProvider/AuthProvider";
import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary";
import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar";
import theme from "./theme";
import "./theme/globalFonts";
import {
StyledEngineProvider,
ThemeProvider as MuiThemeProvider,
} from "@mui/material/styles";
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
const defaultQueryClient = new QueryClient({
defaultOptions: {
@@ -23,19 +17,6 @@ const defaultQueryClient = new QueryClient({
},
});
export const ThemeProviders: FC<PropsWithChildren> = ({ children }) => {
return (
<StyledEngineProvider injectFirst>
<MuiThemeProvider theme={theme.dark}>
<EmotionThemeProvider theme={theme.dark}>
<CssBaseline enableColorScheme />
{children}
</EmotionThemeProvider>
</MuiThemeProvider>
</StyledEngineProvider>
);
};
interface AppProvidersProps {
children: ReactNode;
queryClient?: QueryClient;
@@ -47,16 +28,14 @@ export const AppProviders: FC<AppProvidersProps> = ({
}) => {
return (
<HelmetProvider>
<ThemeProviders>
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<AuthProvider>
{children}
<GlobalSnackbar />
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
</ThemeProviders>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProviders>
{children}
<GlobalSnackbar />
</ThemeProviders>
</AuthProvider>
</QueryClientProvider>
</HelmetProvider>
);
};
@@ -64,7 +43,9 @@ export const AppProviders: FC<AppProvidersProps> = ({
export const App: FC = () => {
return (
<AppProviders>
<AppRouter />
<ErrorBoundary>
<AppRouter />
</ErrorBoundary>
</AppProviders>
);
};
+20 -17
View File
@@ -1,13 +1,3 @@
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
import { UsersLayout } from "components/UsersLayout/UsersLayout";
import AuditPage from "pages/AuditPage/AuditPage";
import LoginPage from "pages/LoginPage/LoginPage";
import { SetupPage } from "pages/SetupPage/SetupPage";
import { TemplateLayout } from "pages/TemplatePage/TemplateLayout";
import { HealthLayout } from "pages/HealthPage/HealthLayout";
import TemplatesPage from "pages/TemplatesPage/TemplatesPage";
import UsersPage from "pages/UsersPage/UsersPage";
import WorkspacesPage from "pages/WorkspacesPage/WorkspacesPage";
import { FC, lazy, Suspense } from "react";
import {
Route,
@@ -16,11 +6,21 @@ import {
Navigate,
} from "react-router-dom";
import { DashboardLayout } from "./components/Dashboard/DashboardLayout";
import { DeploySettingsLayout } from "./components/DeploySettingsLayout/DeploySettingsLayout";
import { FullScreenLoader } from "./components/Loader/FullScreenLoader";
import { RequireAuth } from "./components/RequireAuth/RequireAuth";
import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout";
import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout";
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
import { UsersLayout } from "./components/UsersLayout/UsersLayout";
import AuditPage from "./pages/AuditPage/AuditPage";
import LoginPage from "./pages/LoginPage/LoginPage";
import { SetupPage } from "./pages/SetupPage/SetupPage";
import { TemplateLayout } from "./pages/TemplatePage/TemplateLayout";
import { HealthLayout } from "./pages/HealthPage/HealthLayout";
import TemplatesPage from "./pages/TemplatesPage/TemplatesPage";
import UsersPage from "./pages/UsersPage/UsersPage";
import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage";
import UserSettingsLayout from "./pages/UserSettingsPage/Layout";
import { TemplateSettingsLayout } from "./pages/TemplateSettingsPage/TemplateSettingsLayout";
import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
// Lazy load pages
// - Pages that are secondary, not in the main navigation or not usually accessed
@@ -32,6 +32,9 @@ const CliAuthenticationPage = lazy(
const AccountPage = lazy(
() => import("./pages/UserSettingsPage/AccountPage/AccountPage"),
);
const AppearancePage = lazy(
() => import("./pages/UserSettingsPage/AppearancePage/AppearancePage"),
);
const SchedulePage = lazy(
() => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"),
);
@@ -133,8 +136,7 @@ const ExternalAuthPage = lazy(
() => import("./pages/ExternalAuthPage/ExternalAuthPage"),
);
const UserExternalAuthSettingsPage = lazy(
() =>
import("./pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPage"),
() => import("./pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPage"),
);
const TemplateVersionPage = lazy(
() => import("./pages/TemplateVersionPage/TemplateVersionPage"),
@@ -319,8 +321,9 @@ export const AppRouter: FC = () => {
/>
</Route>
<Route path="/settings" element={<SettingsLayout />}>
<Route path="/settings" element={<UserSettingsLayout />}>
<Route path="account" element={<AccountPage />} />
<Route path="appearance" element={<AppearancePage />} />
<Route path="schedule" element={<SchedulePage />} />
<Route path="security" element={<SecurityPage />} />
<Route path="ssh-keys" element={<SSHKeysPage />} />
+8
View File
@@ -712,6 +712,14 @@ export const updateProfile = async (
return response.data;
};
export const updateAppearanceSettings = async (
userId: string,
data: TypesGen.UpdateUserAppearanceSettingsRequest,
): Promise<TypesGen.User> => {
const response = await axios.put(`/api/v2/users/${userId}/appearance`, data);
return response.data;
};
export const getUserQuietHoursSchedule = async (
userId: TypesGen.User["id"],
): Promise<TypesGen.UserQuietHoursScheduleResponse> => {
+46 -11
View File
@@ -1,10 +1,16 @@
import { QueryClient, type UseQueryOptions } from "react-query";
import {
type UseMutationOptions,
type QueryClient,
type QueryKey,
type UseQueryOptions,
} from "react-query";
import * as API from "api/api";
import type {
AuthorizationRequest,
GetUsersResponse,
UpdateUserPasswordRequest,
UpdateUserProfileRequest,
UpdateUserAppearanceSettingsRequest,
UsersRequest,
User,
} from "api/typesGenerated";
@@ -116,11 +122,13 @@ export const authMethods = () => {
const initialUserData = getMetadataAsJSON<User>("user");
const meKey = ["me"];
export const me = (): UseQueryOptions<User> & {
queryKey: NonNullable<UseQueryOptions<User>["queryKey"]>;
queryKey: QueryKey;
} => {
return {
queryKey: ["me"],
queryKey: meKey,
initialData: initialUserData,
queryFn: API.getAuthenticatedUser,
};
@@ -179,14 +187,41 @@ export const logout = (queryClient: QueryClient) => {
};
};
export const updateProfile = () => {
export const updateProfile = (userId: string) => {
return {
mutationFn: ({
userId,
req,
}: {
userId: string;
req: UpdateUserProfileRequest;
}) => API.updateProfile(userId, req),
mutationFn: (req: UpdateUserProfileRequest) =>
API.updateProfile(userId, req),
};
};
export const updateAppearanceSettings = (
userId: string,
queryClient: QueryClient,
): UseMutationOptions<
User,
unknown,
UpdateUserAppearanceSettingsRequest,
unknown
> => {
return {
mutationFn: (req) => API.updateAppearanceSettings(userId, req),
onMutate: async (patch) => {
// Mutate the `queryClient` optimistically to make the theme switcher
// more responsive.
const me: User | undefined = queryClient.getQueryData(meKey);
if (userId === "me" && me) {
queryClient.setQueryData(meKey, {
...me,
theme_preference: patch.theme_preference,
});
}
},
onSuccess: async () => {
// Could technically invalidate more, but we only ever care about the
// `theme_preference` for the `me` query.
if (userId === "me") {
await queryClient.invalidateQueries(meKey);
}
},
};
};
+6
View File
@@ -1229,6 +1229,11 @@ export interface UpdateTemplateMeta {
readonly deprecation_message?: string;
}
// From codersdk/users.go
export interface UpdateUserAppearanceSettingsRequest {
readonly theme_preference: string;
}
// From codersdk/users.go
export interface UpdateUserPasswordRequest {
readonly old_password: string;
@@ -1293,6 +1298,7 @@ export interface User {
readonly roles: Role[];
readonly avatar_url: string;
readonly login_type: LoginType;
readonly theme_preference: string;
}
// From codersdk/insights.go
@@ -1,11 +1,11 @@
import { useAuth } from "components/AuthProvider/AuthProvider";
import { type FC } from "react";
import { useAuth } from "contexts/AuthProvider/AuthProvider";
import { useProxy } from "contexts/ProxyContext";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { useFeatureVisibility } from "hooks/useFeatureVisibility";
import { useMe } from "hooks/useMe";
import { usePermissions } from "hooks/usePermissions";
import { FC } from "react";
import { NavbarView } from "./NavbarView";
import { useProxy } from "contexts/ProxyContext";
export const Navbar: FC = () => {
const { appearance, buildInfo } = useDashboard();
+26 -19
View File
@@ -1,15 +1,16 @@
import axios from "axios";
import { useAuth } from "components/AuthProvider/AuthProvider";
import { FC, useEffect } from "react";
import { type FC, useEffect } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { embedRedirect } from "utils/redirect";
import { FullScreenLoader } from "../Loader/FullScreenLoader";
import { DashboardProvider } from "components/Dashboard/DashboardProvider";
import { ProxyProvider } from "contexts/ProxyContext";
import { isApiError } from "api/errors";
import { useAuth } from "contexts/AuthProvider/AuthProvider";
import { ProxyProvider } from "contexts/ProxyContext";
import { DashboardProvider } from "../Dashboard/DashboardProvider";
import { FullScreenLoader } from "../Loader/FullScreenLoader";
export const RequireAuth: FC = () => {
const { signOut, isSigningOut, isSignedOut } = useAuth();
const { signOut, isSigningOut, isSignedOut, isSignedIn, isLoading } =
useAuth();
const location = useLocation();
const isHomePage = location.pathname === "/";
const navigateTo = isHomePage
@@ -17,6 +18,10 @@ export const RequireAuth: FC = () => {
: embedRedirect(`${location.pathname}${location.search}`);
useEffect(() => {
if (isLoading || isSigningOut || !isSignedIn) {
return;
}
const interceptorHandle = axios.interceptors.response.use(
(okResponse) => okResponse,
(error: unknown) => {
@@ -35,23 +40,25 @@ export const RequireAuth: FC = () => {
return () => {
axios.interceptors.response.eject(interceptorHandle);
};
}, [signOut]);
}, [isLoading, isSigningOut, isSignedIn, signOut]);
if (isLoading || isSigningOut) {
return <FullScreenLoader />;
}
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
// and where to route workspace connections.
return (
<DashboardProvider>
<ProxyProvider>
<Outlet />
</ProxyProvider>
</DashboardProvider>
);
}
// Authenticated pages have access to some contexts for knowing enabled experiments
// and where to route workspace connections.
return (
<DashboardProvider>
<ProxyProvider>
<Outlet />
</ProxyProvider>
</DashboardProvider>
);
};
@@ -1,3 +1,11 @@
import {
createContext,
type FC,
type PropsWithChildren,
useCallback,
useContext,
} from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { checkAuthorization } from "api/queries/authCheck";
import {
authMethods,
@@ -7,25 +15,17 @@ import {
me,
updateProfile as updateProfileOptions,
} from "api/queries/users";
import {
import { isApiError } from "api/errors";
import type {
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";
import { permissionsToCheck, type Permissions } from "./permissions";
type AuthContextValue = {
export type AuthContextValue = {
isLoading: boolean;
isSignedOut: boolean;
isSigningOut: boolean;
isConfiguringTheFirstUser: boolean;
@@ -42,7 +42,9 @@ type AuthContextValue = {
updateProfile: (data: UpdateUserProfileRequest) => void;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthContext = createContext<AuthContextValue | undefined>(
undefined,
);
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
const queryClient = useQueryClient();
@@ -61,7 +63,8 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
);
const logoutMutation = useMutation(logout(queryClient));
const updateProfileMutation = useMutation({
...updateProfileOptions(),
...updateProfileOptions("me"),
onSuccess: (user) => {
queryClient.setQueryData(meOptions.queryKey, user);
displaySuccess("Updated settings.");
@@ -78,7 +81,8 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
userQuery.isLoading ||
hasFirstUserQuery.isLoading ||
(userQuery.isSuccess && permissionsQuery.isLoading);
const isConfiguringTheFirstUser = !hasFirstUserQuery.data;
const isConfiguringTheFirstUser =
!hasFirstUserQuery.isLoading && !hasFirstUserQuery.data;
const isSignedIn = userQuery.isSuccess && userQuery.data !== undefined;
const isSigningIn = loginMutation.isLoading;
const isUpdatingProfile = updateProfileMutation.isLoading;
@@ -87,21 +91,24 @@ export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
logoutMutation.mutate();
}, [logoutMutation]);
const signIn = async (email: string, password: string) => {
await loginMutation.mutateAsync({ email, password });
};
const signIn = useCallback(
async (email: string, password: string) => {
await loginMutation.mutateAsync({ email, password });
},
[loginMutation],
);
const updateProfile = (req: UpdateUserProfileRequest) => {
updateProfileMutation.mutate({ userId: userQuery.data!.id, req });
};
if (isLoading) {
return <FullScreenLoader />;
}
const updateProfile = useCallback(
(req: UpdateUserProfileRequest) => {
updateProfileMutation.mutate(req);
},
[updateProfileMutation],
);
return (
<AuthContext.Provider
value={{
isLoading,
isSignedOut,
isSigningOut,
isConfiguringTheFirstUser,
+67
View File
@@ -0,0 +1,67 @@
import { ThemeProvider as EmotionThemeProvider } from "@emotion/react";
import CssBaseline from "@mui/material/CssBaseline";
import {
StyledEngineProvider,
ThemeProvider as MuiThemeProvider,
} from "@mui/material/styles";
import {
type FC,
type PropsWithChildren,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import themes, { DEFAULT_THEME } from "theme";
import { AuthContext } from "./AuthProvider/AuthProvider";
export const ThemeProviders: FC<PropsWithChildren> = ({ children }) => {
// We need to use the `AuthContext` directly, rather than the `useAuth` hook,
// because Storybook and many tests depend on this component, but do not provide
// an `AuthProvider`, and `useAuth` will throw in that case.
const user = useContext(AuthContext)?.user;
const themeQuery = useMemo(
() => window.matchMedia?.("(prefers-color-scheme: light)"),
[],
);
const [preferredColorScheme, setPreferredColorScheme] = useState<
"dark" | "light"
>(themeQuery?.matches ? "light" : "dark");
useEffect(() => {
if (!themeQuery) {
return;
}
const listener = (event: MediaQueryListEvent) => {
setPreferredColorScheme(event.matches ? "light" : "dark");
};
// `addEventListener` here is a recent API that only _very_ up-to-date
// browsers support, and that isn't mocked in Jest.
themeQuery.addEventListener?.("change", listener);
return () => {
themeQuery.removeEventListener?.("change", listener);
};
}, [themeQuery]);
// We might not be logged in yet, or the `theme_preference` could be an empty string.
const themePreference = user?.theme_preference || DEFAULT_THEME;
// The janky casting here is find because of the much more type safe fallback
// We need to support `themePreference` being wrong anyway because the database
// value could be anything, like an empty string.
const theme =
themes[themePreference as keyof typeof themes] ??
themes[preferredColorScheme];
return (
<StyledEngineProvider injectFirst>
<MuiThemeProvider theme={theme}>
<EmotionThemeProvider theme={theme}>
<CssBaseline enableColorScheme />
{children}
</EmotionThemeProvider>
</MuiThemeProvider>
</StyledEngineProvider>
);
};
+2 -2
View File
@@ -1,5 +1,5 @@
import { User } from "api/typesGenerated";
import { useAuth } from "components/AuthProvider/AuthProvider";
import type { User } from "api/typesGenerated";
import { useAuth } from "contexts/AuthProvider/AuthProvider";
export const useMe = (): User => {
const { user } = useAuth();
+2 -2
View File
@@ -1,5 +1,5 @@
import { useAuth } from "components/AuthProvider/AuthProvider";
import { Permissions } from "components/AuthProvider/permissions";
import { useAuth } from "contexts/AuthProvider/AuthProvider";
import type { Permissions } from "contexts/AuthProvider/permissions";
export const usePermissions = (): Permissions => {
const { permissions } = useAuth();
+3 -2
View File
@@ -13,8 +13,8 @@ import { LoginPage } from "./LoginPage";
describe("LoginPage", () => {
beforeEach(() => {
// appear logged out
server.use(
// Appear logged out
rest.get("/api/v2/users/me", (req, res, ctx) => {
return res(ctx.status(401), ctx.json({ message: "no user here" }));
}),
@@ -50,7 +50,8 @@ describe("LoginPage", () => {
it("redirects to the setup page if there is no first user", async () => {
// Given
server.use(
rest.get("/api/v2/users/first", async (req, res, ctx) => {
// No first user
rest.get("/api/v2/users/first", (req, res, ctx) => {
return res(ctx.status(404));
}),
);
+29 -24
View File
@@ -1,14 +1,15 @@
import { useAuth } from "components/AuthProvider/AuthProvider";
import { FC } from "react";
import { type FC } from "react";
import { Helmet } from "react-helmet-async";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "contexts/AuthProvider/AuthProvider";
import { getApplicationName } from "utils/appearance";
import { retrieveRedirect } from "utils/redirect";
import { LoginPageView } from "./LoginPageView";
import { getApplicationName } from "utils/appearance";
export const LoginPage: FC = () => {
const location = useLocation();
const {
isLoading,
isSignedIn,
isConfiguringTheFirstUser,
signIn,
@@ -33,7 +34,7 @@ export const LoginPage: FC = () => {
const redirectURL = new URL(redirectTo);
if (redirectURL.host !== window.location.host) {
window.location.href = redirectTo;
return <></>;
return null;
}
} catch {
// Do nothing
@@ -41,30 +42,34 @@ export const LoginPage: FC = () => {
// Path based apps.
if (redirectTo.includes("/apps/")) {
window.location.href = redirectTo;
return <></>;
return null;
}
}
return <Navigate to={redirectTo} replace />;
} else if (isConfiguringTheFirstUser) {
return <Navigate to="/setup" replace />;
} else {
return (
<>
<Helmet>
<title>Sign in to {applicationName}</title>
</Helmet>
<LoginPageView
authMethods={authMethods}
error={signInError}
isSigningIn={isSigningIn}
onSignIn={async ({ email, password }) => {
await signIn(email, password);
navigate("/");
}}
/>
</>
);
}
if (isConfiguringTheFirstUser) {
return <Navigate to="/setup" replace />;
}
return (
<>
<Helmet>
<title>Sign in to {applicationName}</title>
</Helmet>
<LoginPageView
authMethods={authMethods}
error={signInError}
isLoading={isLoading}
isSigningIn={isSigningIn}
onSignIn={async ({ email, password }) => {
await signIn(email, password);
navigate("/");
}}
/>
</>
);
};
export default LoginPage;
+19 -12
View File
@@ -1,15 +1,17 @@
import { type Interpolation, type Theme } from "@emotion/react";
import { type FC } from "react";
import { useLocation } from "react-router-dom";
import { SignInForm } from "./SignInForm";
import { retrieveRedirect } from "utils/redirect";
import { CoderIcon } from "components/Icons/CoderIcon";
import { getApplicationName, getLogoURL } from "utils/appearance";
import type { AuthMethods } from "api/typesGenerated";
import { getApplicationName, getLogoURL } from "utils/appearance";
import { retrieveRedirect } from "utils/redirect";
import { Loader } from "components/Loader/Loader";
import { CoderIcon } from "components/Icons/CoderIcon";
import { SignInForm } from "./SignInForm";
export interface LoginPageViewProps {
authMethods: AuthMethods | undefined;
error: unknown;
isLoading: boolean;
isSigningIn: boolean;
onSignIn: (credentials: { email: string; password: string }) => void;
}
@@ -17,6 +19,7 @@ export interface LoginPageViewProps {
export const LoginPageView: FC<LoginPageViewProps> = ({
authMethods,
error,
isLoading,
isSigningIn,
onSignIn,
}) => {
@@ -47,14 +50,18 @@ export const LoginPageView: FC<LoginPageViewProps> = ({
<div css={styles.root}>
<div css={styles.container}>
{applicationLogo}
<SignInForm
authMethods={authMethods}
redirectTo={redirectTo}
isSigningIn={isSigningIn}
error={error}
message={message}
onSubmit={onSignIn}
/>
{isLoading ? (
<Loader />
) : (
<SignInForm
authMethods={authMethods}
redirectTo={redirectTo}
isSigningIn={isSigningIn}
error={error}
message={message}
onSubmit={onSignIn}
/>
)}
<footer css={styles.footer}>
Copyright © {new Date().getFullYear()} Coder Technologies, Inc.
</footer>
+2 -2
View File
@@ -1,4 +1,4 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { rest } from "msw";
import { createMemoryRouter } from "react-router-dom";
@@ -29,7 +29,7 @@ const fillForm = async ({
const submitButton = screen.getByRole("button", {
name: PageViewLanguage.create,
});
fireEvent.click(submitButton);
await userEvent.click(submitButton);
};
describe("Setup Page", () => {
+18 -8
View File
@@ -1,19 +1,29 @@
import { useAuth } from "components/AuthProvider/AuthProvider";
import { FC } from "react";
import { type FC } from "react";
import { Helmet } from "react-helmet-async";
import { pageTitle } from "utils/page";
import { SetupPageView } from "./SetupPageView";
import { Navigate, useNavigate } from "react-router-dom";
import { useMutation } from "react-query";
import { Navigate, useNavigate } from "react-router-dom";
import { pageTitle } from "utils/page";
import { createFirstUser } from "api/queries/users";
import { useAuth } from "contexts/AuthProvider/AuthProvider";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
import { SetupPageView } from "./SetupPageView";
export const SetupPage: FC = () => {
const { signIn, isConfiguringTheFirstUser, isSignedIn, isSigningIn } =
useAuth();
const {
isLoading,
signIn,
isConfiguringTheFirstUser,
isSignedIn,
isSigningIn,
} = useAuth();
const createFirstUserMutation = useMutation(createFirstUser());
const setupIsComplete = !isConfiguringTheFirstUser;
const navigate = useNavigate();
if (isLoading) {
return <FullScreenLoader />;
}
// If the user is logged in, navigate to the app
if (isSignedIn) {
return <Navigate to="/" state={{ isRedirect: true }} replace />;
@@ -30,7 +40,7 @@ export const SetupPage: FC = () => {
<title>{pageTitle("Set up your account")}</title>
</Helmet>
<SetupPageView
isLoading={createFirstUserMutation.isLoading || isSigningIn}
isLoading={isSigningIn || createFirstUserMutation.isLoading}
error={createFirstUserMutation.error}
onSubmit={async (firstUser) => {
await createFirstUserMutation.mutateAsync(firstUser);
@@ -51,40 +51,39 @@ export const AccountForm: FC<AccountFormProps> = ({
const getFieldHelpers = getFormHelpers(form, updateProfileError);
return (
<>
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(updateProfileError) && (
<ErrorAlert error={updateProfileError} />
)}
<TextField
disabled
fullWidth
label={Language.emailLabel}
value={email}
/>
<TextField
{...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)}
aria-disabled={!editable}
autoComplete="username"
disabled={!editable}
fullWidth
label={Language.usernameLabel}
/>
<Form onSubmit={form.handleSubmit}>
<FormFields>
{Boolean(updateProfileError) && (
<ErrorAlert error={updateProfileError} />
)}
<div>
<LoadingButton
loading={isLoading}
disabled={!editable}
type="submit"
variant="contained"
>
{Language.updateSettings}
</LoadingButton>
</div>
</FormFields>
</Form>
</>
<TextField
disabled
fullWidth
label={Language.emailLabel}
value={email}
/>
<TextField
{...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)}
aria-disabled={!editable}
autoComplete="username"
disabled={!editable}
fullWidth
label={Language.usernameLabel}
/>
<div>
<LoadingButton
loading={isLoading}
disabled={!editable}
type="submit"
variant="contained"
>
{Language.updateSettings}
</LoadingButton>
</div>
</FormFields>
</Form>
);
};
@@ -5,10 +5,6 @@ import { renderWithAuth } from "testHelpers/renderHelpers";
import { AccountPage } from "./AccountPage";
import { mockApiError } from "testHelpers/entities";
const renderPage = () => {
return renderWithAuth(<AccountPage />);
};
const newData = {
username: "user",
};
@@ -35,16 +31,17 @@ describe("AccountPage", () => {
avatar_url: "",
last_seen_at: new Date().toString(),
login_type: "password",
theme_preference: "",
...data,
}),
);
const { user } = renderPage();
renderWithAuth(<AccountPage />);
await fillAndSubmitForm();
const successMessage = await screen.findByText("Updated settings.");
expect(successMessage).toBeDefined();
expect(API.updateProfile).toBeCalledTimes(1);
expect(API.updateProfile).toBeCalledWith(user.id, newData);
expect(API.updateProfile).toBeCalledWith("me", newData);
});
});
@@ -59,7 +56,7 @@ describe("AccountPage", () => {
}),
);
const { user } = renderPage();
renderWithAuth(<AccountPage />);
await fillAndSubmitForm();
const errorMessage = await screen.findByText(
@@ -67,7 +64,7 @@ describe("AccountPage", () => {
);
expect(errorMessage).toBeDefined();
expect(API.updateProfile).toBeCalledTimes(1);
expect(API.updateProfile).toBeCalledWith(user.id, newData);
expect(API.updateProfile).toBeCalledWith("me", newData);
});
});
@@ -77,13 +74,13 @@ describe("AccountPage", () => {
data: "unknown error",
});
const { user } = renderPage();
renderWithAuth(<AccountPage />);
await fillAndSubmitForm();
const errorMessage = await screen.findByText("Something went wrong.");
expect(errorMessage).toBeDefined();
expect(API.updateProfile).toBeCalledTimes(1);
expect(API.updateProfile).toBeCalledWith(user.id, newData);
expect(API.updateProfile).toBeCalledWith("me", newData);
});
});
});
@@ -5,9 +5,9 @@ import { useOrganizationId } from "hooks";
import { useMe } from "hooks/useMe";
import { usePermissions } from "hooks/usePermissions";
import { groupsForUser } from "api/queries/groups";
import { useAuth } from "components/AuthProvider/AuthProvider";
import { Section } from "components/SettingsLayout/Section";
import { useAuth } from "contexts/AuthProvider/AuthProvider";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { Section } from "../Section";
import { AccountUserGroups } from "./AccountUserGroups";
import { AccountForm } from "./AccountForm";
@@ -6,7 +6,7 @@ import type { Group } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { AvatarCard } from "components/AvatarCard/AvatarCard";
import { Loader } from "components/Loader/Loader";
import { Section } from "components/SettingsLayout/Section";
import { Section } from "../Section";
type AccountGroupsProps = {
groups: readonly Group[] | undefined;
@@ -0,0 +1,24 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { AppearanceForm } from "./AppearanceForm";
const onUpdateTheme = action("update");
const meta: Meta<typeof AppearanceForm> = {
title: "pages/UserSettingsPage/AppearanceForm",
component: AppearanceForm,
args: {
onSubmit: (update) =>
Promise.resolve(onUpdateTheme(update.theme_preference)),
},
};
export default meta;
type Story = StoryObj<typeof AppearanceForm>;
export const Example: Story = {
args: {
enableAuto: true,
initialValues: { theme_preference: "" },
},
};
@@ -0,0 +1,296 @@
import { visuallyHidden } from "@mui/utils";
import { type Interpolation } from "@emotion/react";
import { type FC, useMemo } from "react";
import type { UpdateUserAppearanceSettingsRequest } from "api/typesGenerated";
import themes, { DEFAULT_THEME, type Theme } from "theme";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Stack } from "components/Stack/Stack";
export interface AppearanceFormProps {
isUpdating?: boolean;
error?: unknown;
initialValues: UpdateUserAppearanceSettingsRequest;
onSubmit: (values: UpdateUserAppearanceSettingsRequest) => Promise<unknown>;
// temporary, so that storybook can test the right thing without showing
// a semi-broken auto theme to users. will be removed when light mode is done.
enableAuto?: boolean;
}
export const AppearanceForm: FC<AppearanceFormProps> = ({
isUpdating,
error,
onSubmit,
initialValues,
enableAuto,
}) => {
const currentTheme = initialValues.theme_preference || DEFAULT_THEME;
const onChangeTheme = async (theme: string) => {
if (isUpdating) {
return;
}
await onSubmit({ theme_preference: theme });
};
return (
<form>
{Boolean(error) && <ErrorAlert error={error} />}
<Stack direction="row" wrap="wrap">
{enableAuto && (
<AutoThemePreviewButton
displayName="Auto"
active={currentTheme === "auto"}
themes={[themes.dark, themes.light]}
onSelect={() => onChangeTheme("auto")}
/>
)}
<ThemePreviewButton
displayName="Dark"
active={currentTheme === "dark"}
theme={themes.dark}
onSelect={() => onChangeTheme("dark")}
/>
<ThemePreviewButton
displayName="Dark blue"
active={currentTheme === "darkBlue"}
theme={themes.darkBlue}
onSelect={() => onChangeTheme("darkBlue")}
/>
</Stack>
</form>
);
};
interface AutoThemePreviewButtonProps {
active?: boolean;
className?: string;
displayName: string;
themes: [Theme, Theme];
onSelect?: () => void;
}
const AutoThemePreviewButton: FC<AutoThemePreviewButtonProps> = ({
active,
className,
displayName,
themes,
onSelect,
}) => {
const [leftTheme, rightTheme] = themes;
return (
<>
<input
type="radio"
name="theme"
id={displayName}
value={displayName}
checked={active}
onChange={onSelect}
css={{ ...visuallyHidden }}
/>
<label htmlFor={displayName} className={className}>
<ThemePreview
css={{
// This half is absolute to not advance the layout (which would offset the second half)
position: "absolute",
// Slightly past the bounding box to avoid cutting off the outline
clipPath: "polygon(-5% -5%, 50% -5%, 50% 105%, -5% 105%)",
}}
active={active}
displayName={displayName}
theme={leftTheme}
/>
<ThemePreview
active={active}
displayName={displayName}
theme={rightTheme}
/>
</label>
</>
);
};
interface ThemePreviewButtonProps {
active?: boolean;
className?: string;
displayName: string;
theme: Theme;
onSelect?: () => void;
}
const ThemePreviewButton: FC<ThemePreviewButtonProps> = ({
active,
className,
displayName,
theme,
onSelect,
}) => {
return (
<>
<input
type="radio"
name="theme"
id={displayName}
value={displayName}
checked={active}
onChange={onSelect}
css={{ ...visuallyHidden }}
/>
<label htmlFor={displayName} className={className}>
<ThemePreview active={active} displayName={displayName} theme={theme} />
</label>
</>
);
};
interface ThemePreviewProps {
active?: boolean;
className?: string;
displayName: string;
theme: Theme;
}
const ThemePreview: FC<ThemePreviewProps> = ({
active,
className,
displayName,
theme,
}) => {
const styles = useMemo(
() =>
({
container: {
backgroundColor: theme.palette.background.default,
border: `1px solid ${theme.palette.divider}`,
width: 220,
color: theme.palette.text.primary,
borderRadius: 6,
overflow: "clip",
userSelect: "none",
},
containerActive: {
outline: `2px solid ${theme.experimental.roles.active.outline}`,
},
page: {
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
},
header: {
backgroundColor: theme.palette.background.paper,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "6px 10px",
marginBottom: 8,
borderBottom: `1px solid ${theme.palette.divider}`,
},
headerLinks: {
display: "flex",
alignItems: "center",
gap: 6,
},
headerLink: {
backgroundColor: theme.palette.text.secondary,
height: 6,
width: 20,
borderRadius: 3,
},
activeHeaderLink: {
backgroundColor: theme.palette.text.primary,
},
proxy: {
backgroundColor: theme.palette.success.light,
height: 6,
width: 12,
borderRadius: 3,
},
user: {
backgroundColor: theme.palette.text.primary,
height: 8,
width: 8,
borderRadius: 4,
float: "right",
},
body: {
width: 120,
margin: "auto",
},
title: {
backgroundColor: theme.palette.text.primary,
height: 8,
width: 45,
borderRadius: 4,
marginBottom: 6,
},
table: {
border: `1px solid ${theme.palette.divider}`,
borderBottom: "none",
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
overflow: "clip",
},
tableHeader: {
backgroundColor: theme.palette.background.paper,
height: 10,
margin: -1,
},
label: {
borderTop: `1px solid ${theme.palette.divider}`,
padding: "4px 12px",
fontSize: 14,
},
workspace: {
borderTop: `1px solid ${theme.palette.divider}`,
height: 15,
"&::after": {
content: '""',
display: "block",
marginTop: 4,
marginLeft: 4,
backgroundColor: theme.palette.text.disabled,
height: 6,
width: 30,
borderRadius: 3,
},
},
}) satisfies Record<string, Interpolation<never>>,
[theme],
);
return (
<div
css={[styles.container, active && styles.containerActive]}
className={className}
>
<div css={styles.page}>
<div css={styles.header}>
<div css={styles.headerLinks}>
<div css={[styles.headerLink, styles.activeHeaderLink]}></div>
<div css={styles.headerLink}></div>
<div css={styles.headerLink}></div>
</div>
<div css={styles.headerLinks}>
<div css={styles.proxy}></div>
<div css={styles.user}></div>
</div>
</div>
<div css={styles.body}>
<div css={styles.title}></div>
<div css={styles.table}>
<div css={styles.tableHeader}></div>
<div css={styles.workspace}></div>
<div css={styles.workspace}></div>
<div css={styles.workspace}></div>
<div css={styles.workspace}></div>
</div>
</div>
</div>
<div css={styles.label}>{displayName}</div>
</div>
);
};
@@ -0,0 +1,44 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as API from "api/api";
import { renderWithAuth } from "testHelpers/renderHelpers";
import { AppearancePage } from "./AppearancePage";
import { MockUser } from "testHelpers/entities";
describe("appearance page", () => {
it("changes theme to dark", async () => {
renderWithAuth(<AppearancePage />);
jest.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUser,
theme_preference: "dark",
});
const dark = await screen.findByText("Dark");
await userEvent.click(dark);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toBeCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith("me", {
theme_preference: "dark",
});
});
it("changes theme to dark blue", async () => {
renderWithAuth(<AppearancePage />);
jest.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUser,
theme_preference: "darkBlue",
});
const darkBlue = await screen.findByText("Dark blue");
await userEvent.click(darkBlue);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toBeCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith("me", {
theme_preference: "darkBlue",
});
});
});
@@ -0,0 +1,41 @@
import CircularProgress from "@mui/material/CircularProgress";
import { type FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { updateAppearanceSettings } from "api/queries/users";
import { Stack } from "components/Stack/Stack";
import { useMe } from "hooks";
import { Section } from "../Section";
import { AppearanceForm } from "./AppearanceForm";
export const AppearancePage: FC = () => {
const me = useMe();
const queryClient = useQueryClient();
const updateAppearanceSettingsMutation = useMutation(
updateAppearanceSettings("me", queryClient),
);
return (
<>
<Section
title={
<Stack direction="row" alignItems="center">
<span>Theme</span>
{updateAppearanceSettingsMutation.isLoading && (
<CircularProgress size={16} />
)}
</Stack>
}
layout="fluid"
>
<AppearanceForm
isUpdating={updateAppearanceSettingsMutation.isLoading}
error={updateAppearanceSettingsMutation.error}
initialValues={{ theme_preference: me.theme_preference }}
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
/>
</Section>
</>
);
};
export default AppearancePage;
@@ -1,17 +1,17 @@
import { FC, useState } from "react";
import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
import { type FC, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
externalAuths,
unlinkExternalAuths,
validateExternalAuth,
} from "api/queries/externalAuth";
import { Section } from "components/SettingsLayout/Section";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { getErrorMessage } from "api/errors";
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { Section } from "../Section";
import { ExternalAuthPageView } from "./ExternalAuthPageView";
const UserExternalAuthSettingsPage: FC = () => {
const ExternalAuthPage: FC = () => {
const queryClient = useQueryClient();
// This is used to tell the child components something was unlinked and things
// need to be refetched
@@ -24,7 +24,7 @@ const UserExternalAuthSettingsPage: FC = () => {
return (
<Section title="External Authentication" layout="fluid">
<UserExternalAuthSettingsPageView
<ExternalAuthPageView
isLoading={externalAuthsQuery.isLoading}
getAuthsError={externalAuthsQuery.error}
auths={externalAuthsQuery.data}
@@ -83,4 +83,4 @@ const UserExternalAuthSettingsPage: FC = () => {
);
};
export default UserExternalAuthSettingsPage;
export default ExternalAuthPage;
@@ -2,12 +2,12 @@ import {
MockGithubAuthLink,
MockGithubExternalProvider,
} from "testHelpers/entities";
import { UserExternalAuthSettingsPageView } from "./UserExternalAuthSettingsPageView";
import { ExternalAuthPageView } from "./ExternalAuthPageView";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof UserExternalAuthSettingsPageView> = {
title: "pages/UserExternalAuthSettingsPage/UserExternalAuthSettingsPageView",
component: UserExternalAuthSettingsPageView,
const meta: Meta<typeof ExternalAuthPageView> = {
title: "pages/UserSettingsPage/ExternalAuthPageView",
component: ExternalAuthPageView,
args: {
isLoading: false,
getAuthsError: undefined,
@@ -22,7 +22,7 @@ const meta: Meta<typeof UserExternalAuthSettingsPageView> = {
};
export default meta;
type Story = StoryObj<typeof UserExternalAuthSettingsPageView>;
type Story = StoryObj<typeof ExternalAuthPageView>;
export const NoProviders: Story = {};
@@ -1,9 +1,13 @@
import Divider from "@mui/material/Divider";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import { type FC, useState, useCallback, useEffect } from "react";
import { useQuery } from "react-query";
import { externalAuthProvider } from "api/queries/externalAuth";
import type {
ListUserExternalAuthResponse,
ExternalAuthLinkProvider,
@@ -12,8 +16,7 @@ import type {
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/AvatarData/AvatarData";
import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth";
import Divider from "@mui/material/Divider";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
import {
MoreMenu,
MoreMenuContent,
@@ -21,13 +24,10 @@ import {
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import { ExternalAuth } from "pages/CreateWorkspacePage/ExternalAuth";
import { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage";
import { useState, useCallback, useEffect } from "react";
import { useQuery } from "react-query";
import { externalAuthProvider } from "api/queries/externalAuth";
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
export type UserExternalAuthSettingsPageViewProps = {
export type ExternalAuthPageViewProps = {
isLoading: boolean;
getAuthsError?: unknown;
unlinked: number;
@@ -36,14 +36,14 @@ export type UserExternalAuthSettingsPageViewProps = {
onValidateExternalAuth: (provider: string) => void;
};
export const UserExternalAuthSettingsPageView = ({
export const ExternalAuthPageView: FC<ExternalAuthPageViewProps> = ({
isLoading,
getAuthsError,
auths,
unlinked,
onUnlinkExternalAuth,
onValidateExternalAuth,
}: UserExternalAuthSettingsPageViewProps): JSX.Element => {
}) => {
if (getAuthsError) {
// Nothing to show if there is an error
return <ErrorAlert error={getAuthsError} />;
@@ -105,13 +105,13 @@ interface ExternalAuthRowProps {
onValidateExternalAuth: () => void;
}
const ExternalAuthRow = ({
const ExternalAuthRow: FC<ExternalAuthRowProps> = ({
app,
unlinked,
link,
onUnlinkExternalAuth,
onValidateExternalAuth,
}: ExternalAuthRowProps): JSX.Element => {
}) => {
const name = app.id || app.type;
const authURL = "/external-auth/" + app.id;
@@ -4,11 +4,11 @@ import { Helmet } from "react-helmet-async";
import { pageTitle } from "utils/page";
import { useMe } from "hooks/useMe";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { Stack } from "components/Stack/Stack";
import { Margins } from "../Margins/Margins";
import { Sidebar } from "./Sidebar";
export const SettingsLayout: FC = () => {
const Layout: FC = () => {
const me = useMe();
return (
@@ -30,3 +30,5 @@ export const SettingsLayout: FC = () => {
</>
);
};
export default Layout;
@@ -1,7 +1,7 @@
import { PropsWithChildren, FC, useState } from "react";
import { type FC, useState } from "react";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { Section } from "components/SettingsLayout/Section";
import { Section } from "../Section";
import { SSHKeysPageView } from "./SSHKeysPageView";
import { regenerateUserSSHKey, userSSHKey } from "api/queries/sshKeys";
import { useMutation, useQuery, useQueryClient } from "react-query";
@@ -18,7 +18,7 @@ export const Language = {
cancelLabel: "Cancel",
};
export const SSHKeysPage: FC<PropsWithChildren<unknown>> = () => {
export const SSHKeysPage: FC = () => {
const [isConfirmingRegeneration, setIsConfirmingRegeneration] =
useState(false);
@@ -1,15 +1,15 @@
import { FC } from "react";
import { type FC } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Section } from "components/SettingsLayout/Section";
import { ScheduleForm } from "./ScheduleForm";
import { useMe } from "hooks/useMe";
import { Loader } from "components/Loader/Loader";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
updateUserQuietHoursSchedule,
userQuietHoursSchedule,
} from "api/queries/settings";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Section } from "../Section";
import { ScheduleForm } from "./ScheduleForm";
export const SchedulePage: FC = () => {
const me = useMe();
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SecurityForm } from "./SettingsSecurityForm";
import { SecurityForm } from "./SecurityForm";
import { mockApiError } from "testHelpers/entities";
const meta: Meta<typeof SecurityForm> = {
@@ -1,6 +1,6 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import * as API from "api/api";
import * as SecurityForm from "./SettingsSecurityForm";
import { Language } from "./SecurityForm";
import {
renderWithAuth,
waitForLoaderToBeRemoved,
@@ -33,7 +33,7 @@ const fillAndSubmitSecurityForm = () => {
fireEvent.change(screen.getByLabelText("Confirm Password"), {
target: { value: newSecurityFormValues.confirm_password },
});
fireEvent.click(screen.getByText(SecurityForm.Language.updatePassword));
fireEvent.click(screen.getByText(Language.updatePassword));
};
beforeEach(() => {
@@ -1,17 +1,17 @@
import { useMe } from "hooks/useMe";
import { ComponentProps, FC } from "react";
import { Section } from "components/SettingsLayout/Section";
import { SecurityForm } from "./SettingsSecurityForm";
import { type ComponentProps, type FC } from "react";
import { useMutation, useQuery } from "react-query";
import { getUserLoginType } from "api/api";
import { authMethods, updatePassword } from "api/queries/users";
import { useMe } from "hooks/useMe";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Section } from "../Section";
import { SecurityForm } from "./SecurityForm";
import {
SingleSignOnSection,
useSingleSignOnSection,
} from "./SingleSignOnSection";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { authMethods, updatePassword } from "api/queries/users";
import { displaySuccess } from "components/GlobalSnackbar/utils";
export const SecurityPage: FC = () => {
const me = useMe();
@@ -1,25 +1,25 @@
import { useTheme } from "@emotion/react";
import { type FC, useState } from "react";
import { Section } from "components/SettingsLayout/Section";
import { useMutation } from "react-query";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import TextField from "@mui/material/TextField";
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
import GitHubIcon from "@mui/icons-material/GitHub";
import KeyIcon from "@mui/icons-material/VpnKey";
import Button from "@mui/material/Button";
import { convertToOAUTH } from "api/api";
import { getErrorMessage } from "api/errors";
import type {
AuthMethods,
LoginType,
OIDCAuthMethod,
UserLoginType,
} from "api/typesGenerated";
import { Stack } from "components/Stack/Stack";
import { useMutation } from "react-query";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { getErrorMessage } from "api/errors";
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
import { EmptyState } from "components/EmptyState/EmptyState";
import Link from "@mui/material/Link";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Stack } from "components/Stack/Stack";
import { docs } from "utils/docs";
import { Section } from "../Section";
type LoginTypeConfirmation =
| {
@@ -1,6 +1,8 @@
import { type FC } from "react";
import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined";
import FingerprintOutlinedIcon from "@mui/icons-material/FingerprintOutlined";
import AccountIcon from "@mui/icons-material/Person";
import AppearanceIcon from "@mui/icons-material/Brush";
import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined";
import SecurityIcon from "@mui/icons-material/LockOutlined";
import type { User } from "api/typesGenerated";
@@ -13,7 +15,11 @@ import {
} from "components/Sidebar/Sidebar";
import { GitIcon } from "components/Icons/GitIcon";
export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
interface SidebarProps {
user: User;
}
export const Sidebar: FC<SidebarProps> = ({ user }) => {
const { entitlements } = useDashboard();
const allowAutostopRequirement =
entitlements.features.template_autostop_requirement.enabled;
@@ -30,6 +36,9 @@ export const Sidebar: React.FC<{ user: User }> = ({ user }) => {
<SidebarNavItem href="account" icon={AccountIcon}>
Account
</SidebarNavItem>
<SidebarNavItem href="appearance" icon={AppearanceIcon}>
Appearance
</SidebarNavItem>
{allowAutostopRequirement && (
<SidebarNavItem href="schedule" icon={ScheduleIcon}>
Schedule
@@ -1,26 +1,18 @@
import { FC, PropsWithChildren, useState } from "react";
import { Section } from "components/SettingsLayout/Section";
import { TokensPageView } from "./TokensPageView";
import { useTokensData } from "./hooks";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { Stack } from "components/Stack/Stack";
import Button from "@mui/material/Button";
import { css, type Interpolation, type Theme } from "@emotion/react";
import { type FC, useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import Button from "@mui/material/Button";
import AddIcon from "@mui/icons-material/AddOutlined";
import { APIKeyWithOwner } from "api/typesGenerated";
import { css } from "@emotion/react";
import type { APIKeyWithOwner } from "api/typesGenerated";
import { Stack } from "components/Stack/Stack";
import { Section } from "../Section";
import { useTokensData } from "./hooks";
import { TokensPageView } from "./TokensPageView";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
const cliCreateCommand = "coder tokens create";
const TokenActions = () => (
<Stack direction="row" justifyContent="end" css={{ marginBottom: 8 }}>
<Button startIcon={<AddIcon />} component={RouterLink} to="new">
Add token
</Button>
</Stack>
);
const cliCreateCommand = "coder tokens create";
export const TokensPage: FC = () => {
const [tokenToDelete, setTokenToDelete] = useState<
APIKeyWithOwner | undefined
>(undefined);
@@ -41,15 +33,7 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
<>
<Section
title="Tokens"
css={(theme) => css`
& code {
background: ${theme.palette.divider};
font-size: 12px;
padding: 2px 4px;
color: ${theme.palette.text.primary};
border-radius: 2px;
}
`}
css={styles.section}
description={
<>
Tokens are used to authenticate with the Coder API. You can create a
@@ -79,4 +63,24 @@ export const TokensPage: FC<PropsWithChildren<unknown>> = () => {
);
};
const TokenActions: FC = () => (
<Stack direction="row" justifyContent="end" css={{ marginBottom: 8 }}>
<Button startIcon={<AddIcon />} component={RouterLink} to="new">
Add token
</Button>
</Stack>
);
const styles = {
section: (theme) => css`
& code {
background: ${theme.palette.divider};
font-size: 12px;
padding: 2px 4px;
color: ${theme.palette.text.primary};
border-radius: 2px;
}
`,
} satisfies Record<string, Interpolation<Theme>>;
export default TokensPage;
@@ -1,9 +1,9 @@
import { type FC, type PropsWithChildren } from "react";
import { Section } from "components/SettingsLayout/Section";
import { WorkspaceProxyView } from "./WorkspaceProxyView";
import { type FC } from "react";
import { useProxy } from "contexts/ProxyContext";
import { Section } from "../Section";
import { WorkspaceProxyView } from "./WorkspaceProxyView";
export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
export const WorkspaceProxyPage: FC = () => {
const description =
"Workspace proxies improve terminal and web app connections to workspaces.";
+6 -2
View File
@@ -4,9 +4,9 @@ import {
type DeploymentConfig,
} from "api/api";
import { FieldError } from "api/errors";
import * as TypesGen from "api/typesGenerated";
import type * as TypesGen from "api/typesGenerated";
import range from "lodash/range";
import { Permissions } from "components/AuthProvider/permissions";
import { Permissions } from "contexts/AuthProvider/permissions";
import { TemplateVersionFiles } from "utils/templateVersion";
import { FileTree } from "utils/filetree";
import { ProxyLatencyReport } from "contexts/useProxyLatency";
@@ -284,6 +284,7 @@ export const MockUser: TypesGen.User = {
avatar_url: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
last_seen_at: "",
login_type: "password",
theme_preference: "",
};
export const MockUserAdmin: TypesGen.User = {
@@ -297,6 +298,7 @@ export const MockUserAdmin: TypesGen.User = {
avatar_url: "",
last_seen_at: "",
login_type: "password",
theme_preference: "",
};
export const MockUser2: TypesGen.User = {
@@ -310,6 +312,7 @@ export const MockUser2: TypesGen.User = {
avatar_url: "",
last_seen_at: "2022-09-14T19:12:21Z",
login_type: "oidc",
theme_preference: "",
};
export const SuspendedMockUser: TypesGen.User = {
@@ -323,6 +326,7 @@ export const SuspendedMockUser: TypesGen.User = {
avatar_url: "",
last_seen_at: "",
login_type: "password",
theme_preference: "",
};
export const MockProvisioner: TypesGen.ProvisionerDaemon = {
+1 -1
View File
@@ -1,6 +1,6 @@
import { rest } from "msw";
import { CreateWorkspaceBuildRequest } from "api/typesGenerated";
import { permissionsToCheck } from "components/AuthProvider/permissions";
import { permissionsToCheck } from "contexts/AuthProvider/permissions";
import * as M from "./entities";
import { MockGroup, MockWorkspaceQuota } from "./entities";
import fs from "fs";
+7 -6
View File
@@ -4,19 +4,20 @@ import {
waitFor,
renderHook,
} from "@testing-library/react";
import { AppProviders, ThemeProviders } from "App";
import { type ReactNode, useState } from "react";
import { QueryClient } from "react-query";
import { AppProviders } from "App";
import { ThemeProviders } from "contexts/ThemeProviders";
import { DashboardLayout } from "components/Dashboard/DashboardLayout";
import { RequireAuth } from "components/RequireAuth/RequireAuth";
import { TemplateSettingsLayout } from "pages/TemplateSettingsPage/TemplateSettingsLayout";
import { WorkspaceSettingsLayout } from "pages/WorkspaceSettingsPage/WorkspaceSettingsLayout";
import {
RouterProvider,
createMemoryRouter,
RouteObject,
RouterProvider,
type RouteObject,
} from "react-router-dom";
import { RequireAuth } from "../components/RequireAuth/RequireAuth";
import { MockUser } from "./entities";
import { ReactNode, useState } from "react";
import { QueryClient } from "react-query";
function createTestQueryClient() {
// Helps create one query client for each test case, to make sure that tests
+3
View File
@@ -9,9 +9,12 @@ export interface Theme extends MuiTheme {
experimental: NewTheme;
}
export const DEFAULT_THEME = "auto";
const theme = {
dark,
darkBlue,
light: darkBlue,
} satisfies Record<string, Theme>;
export default theme;