mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add a theme picker (#11140)
This commit is contained in:
+14
-33
@@ -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
@@ -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 />} />
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Generated
+6
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+33
-26
@@ -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,
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
+9
-9
@@ -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;
|
||||
+5
-5
@@ -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 = {};
|
||||
|
||||
+11
-11
@@ -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
-2
@@ -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
-1
@@ -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 =
|
||||
| {
|
||||
|
||||
+10
-1
@@ -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.";
|
||||
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user