diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx new file mode 100644 index 0000000000000..f67d887e2890e --- /dev/null +++ b/site/src/AppRouter.tsx @@ -0,0 +1,76 @@ +import React from "react" +import { Routes, Route } from "react-router-dom" +import { RequireAuth, AuthAndNav } from "./components" +import { IndexPage } from "./pages" +import { NotFoundPage } from "./pages/404" +import { CliAuthenticationPage } from "./pages/cli-auth" +import { HealthzPage } from "./pages/healthz" +import { SignInPage } from "./pages/login" +import { ProjectsPage } from "./pages/projects" +import { ProjectPage } from "./pages/projects/[organization]/[project]" +import { CreateWorkspacePage } from "./pages/projects/[organization]/[project]/create" +import { WorkspacePage } from "./pages/workspaces/[workspace]" + +export const AppRouter: React.FC = () => ( + + + + + + } + /> + + } /> + } /> + } /> + + + + + + } + /> + + + + + } + /> + + + + } + /> + + + + + + + + } + /> + + + {/* Using path="*"" means "match anything", so this route + acts like a catch-all for URLs that we don't have explicit + routes for. */} + } /> + + +) diff --git a/site/src/api/index.ts b/site/src/api/index.ts index 30b63da9cec81..0d4ff993fb0ad 100644 --- a/site/src/api/index.ts +++ b/site/src/api/index.ts @@ -17,26 +17,6 @@ export const provisioners: Types.Provisioner[] = [ }, ] -export namespace Project { - export const create = async (request: Types.CreateProjectRequest): Promise => { - const response = await fetch(`/api/v2/projects/${request.organizationId}/`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(request), - }) - - const body = await response.json() - await mutate("/api/v2/projects") - if (!response.ok) { - throw new Error(body.message) - } - - return body - } -} - export namespace Workspace { export const create = async (request: Types.CreateWorkspaceRequest): Promise => { const response = await fetch(`/api/v2/users/me/workspaces`, { diff --git a/site/src/app.tsx b/site/src/app.tsx index e9078ef4105be..fcbc455d5dcf6 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -3,19 +3,10 @@ import CssBaseline from "@material-ui/core/CssBaseline" import ThemeProvider from "@material-ui/styles/ThemeProvider" import { SWRConfig } from "swr" import { light } from "./theme" -import { BrowserRouter as Router, Route, Routes } from "react-router-dom" +import { BrowserRouter as Router } from "react-router-dom" -import { CliAuthenticationPage } from "./pages/cli-auth" -import { NotFoundPage } from "./pages/404" -import { IndexPage } from "./pages/index" -import { SignInPage } from "./pages/login" -import { ProjectsPage } from "./pages/projects" -import { ProjectPage } from "./pages/projects/[organization]/[project]" -import { CreateWorkspacePage } from "./pages/projects/[organization]/[project]/create" -import { WorkspacePage } from "./pages/workspaces/[workspace]" -import { HealthzPage } from "./pages/healthz" -import { AuthAndNav, RequireAuth } from "./components/Page" import { XServiceProvider } from "./xServices/StateContext" +import { AppRouter } from "./AppRouter" import "./theme/global-fonts" export const App: React.FC = () => { @@ -42,68 +33,7 @@ export const App: React.FC = () => { - - - - - - - } - /> - - } /> - } /> - } /> - - - - - - } - /> - - - - - } - /> - - - - } - /> - - - - - - - - } - /> - - - {/* Using path="*"" means "match anything", so this route - acts like a catch-all for URLs that we don't have explicit - routes for. */} - } /> - - + diff --git a/site/src/components/Page/RequireAuth.tsx b/site/src/components/Page/RequireAuth.tsx index 099713cc1d286..16fc5c835f4f6 100644 --- a/site/src/components/Page/RequireAuth.tsx +++ b/site/src/components/Page/RequireAuth.tsx @@ -1,6 +1,7 @@ import { useActor } from "@xstate/react" import React, { useContext } from "react" import { Navigate, useLocation } from "react-router" +import { embedRedirect } from "../../util/redirect" import { XServiceContext } from "../../xServices/StateContext" import { FullScreenLoader } from "../Loader/FullScreenLoader" @@ -12,9 +13,10 @@ export const RequireAuth: React.FC = ({ children }) => { const xServices = useContext(XServiceContext) const [userState] = useActor(xServices.userXService) const location = useLocation() + const redirectTo = embedRedirect(location.pathname) if (userState.matches("signedOut") || !userState.context.me) { - return + return } else if (userState.hasTag("loading")) { return } else { diff --git a/site/src/components/SignIn/SignInForm.tsx b/site/src/components/SignIn/SignInForm.tsx index 3c52f3d936fd8..880ddb093a202 100644 --- a/site/src/components/SignIn/SignInForm.tsx +++ b/site/src/components/SignIn/SignInForm.tsx @@ -61,6 +61,7 @@ export const SignInForm: React.FC = ({ isLoading, authErrorMess = ({ isLoading, authErrorMess inputProps={{ id: "signin-form-inpt-email", }} - placeholder="Email" variant="outlined" /> = ({ isLoading, authErrorMess id: "signin-form-inpt-password", }} isPassword - placeholder="Password" variant="outlined" /> {authErrorMessage && ( diff --git a/site/src/pages/login.tsx b/site/src/pages/login.tsx index f6f2c08f67917..f072bb51d8789 100644 --- a/site/src/pages/login.tsx +++ b/site/src/pages/login.tsx @@ -3,8 +3,8 @@ import { useActor } from "@xstate/react" import React, { useContext } from "react" import { SignInForm } from "./../components/SignIn" import { Navigate, useLocation } from "react-router-dom" -import { Location } from "history" import { XServiceContext } from "../xServices/StateContext" +import { retrieveRedirect } from "../util/redirect" export const useStyles = makeStyles((theme) => ({ root: { @@ -20,21 +20,13 @@ export const useStyles = makeStyles((theme) => ({ }, })) -const getRedirectFromLocation = (location: Location) => { - const defaultRedirect = "/" - - const searchParams = new URLSearchParams(location.search) - const redirect = searchParams.get("redirect") - return redirect ? redirect : defaultRedirect -} - export const SignInPage: React.FC = () => { const styles = useStyles() const location = useLocation() const xServices = useContext(XServiceContext) const [userState, userSend] = useActor(xServices.userXService) const isLoading = userState.hasTag("loading") - const redirectTo = getRedirectFromLocation(location) + const redirectTo = retrieveRedirect(location.search) const authErrorMessage = userState.context.authError ? (userState.context.authError as Error).message : undefined const onSubmit = async ({ email, password }: { email: string; password: string }) => { diff --git a/site/src/test_helpers/entities.ts b/site/src/test_helpers/entities.ts index 7633161b4a23a..fdd0b756dabc4 100644 --- a/site/src/test_helpers/entities.ts +++ b/site/src/test_helpers/entities.ts @@ -5,20 +5,17 @@ export const MockSessionToken = { session_token: "my-session-token" } export const MockAPIKey = { key: "my-api-key" } export const MockUser: UserResponse = { - id: "test-user-id", + id: "test-user", username: "TestUser", email: "test@coder.com", created_at: "", } -export const MockProject: Project = { - id: "project-id", +export const MockOrganization: Organization = { + id: "test-org", + name: "Test Organization", created_at: "", updated_at: "", - organization_id: "test-org", - name: "Test Project", - provisioner: "test-provisioner", - active_version_id: "", } export const MockProvisioner: Provisioner = { @@ -26,11 +23,14 @@ export const MockProvisioner: Provisioner = { name: "Test Provisioner", } -export const MockOrganization: Organization = { - id: "test-org", - name: "Test Organization", +export const MockProject: Project = { + id: "test-project", created_at: "", updated_at: "", + organization_id: MockOrganization.id, + name: "Test Project", + provisioner: MockProvisioner.id, + active_version_id: "", } export const MockWorkspace: Workspace = { @@ -38,6 +38,6 @@ export const MockWorkspace: Workspace = { name: "Test-Workspace", created_at: "", updated_at: "", - project_id: "project-id", - owner_id: "test-user-id", + project_id: MockProject.id, + owner_id: MockUser.id, } diff --git a/site/src/test_helpers/handlers.ts b/site/src/test_helpers/handlers.ts index c7ae6df9481d9..5e558a4818e34 100644 --- a/site/src/test_helpers/handlers.ts +++ b/site/src/test_helpers/handlers.ts @@ -2,9 +2,26 @@ import { rest } from "msw" import * as M from "./entities" export const handlers = [ + // organizations + rest.get("/api/v2/organizations/:organizationId", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockOrganization)) + }), + rest.get("/api/v2/organizations/:organizationId/projects/:projectId", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockProject)) + }), + + // projects + rest.get("/api/v2/projects/:projectId", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockProject)) + }), + + // users rest.post("/api/v2/users/me/workspaces", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockWorkspace)) }), + rest.get("/api/v2/users/me/organizations/:organizationId", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockOrganization)) + }), rest.post("/api/v2/users/login", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockSessionToken)) }), @@ -17,4 +34,9 @@ export const handlers = [ rest.get("/api/v2/users/me/keys", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockAPIKey)) }), + + // workspaces + rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => { + return res(ctx.status(200), ctx.json(M.MockWorkspace)) + }), ] diff --git a/site/src/util/redirect.test.ts b/site/src/util/redirect.test.ts new file mode 100644 index 0000000000000..c4be13828680f --- /dev/null +++ b/site/src/util/redirect.test.ts @@ -0,0 +1,20 @@ +import { embedRedirect, retrieveRedirect } from "./redirect" + +describe("redirect helper functions", () => { + describe("embedRedirect", () => { + it("embeds the page to return to in the URL", () => { + const result = embedRedirect("/workspaces", "/page") + expect(result).toEqual("/page?redirect=%2Fworkspaces") + }) + it("defaults to navigating to the login page", () => { + const result = embedRedirect("/workspaces") + expect(result).toEqual("/login?redirect=%2Fworkspaces") + }) + }) + describe("retrieveRedirect", () => { + it("retrieves the page to return to from the URL", () => { + const result = retrieveRedirect("?redirect=%2Fworkspaces") + expect(result).toEqual("/workspaces") + }) + }) +}) diff --git a/site/src/util/redirect.ts b/site/src/util/redirect.ts new file mode 100644 index 0000000000000..fa0616684396e --- /dev/null +++ b/site/src/util/redirect.ts @@ -0,0 +1,21 @@ +/** + * Creates a url containing a page to navigate to now, and embedding another + * URL in the query string so you can return to it later. + * @param navigateTo page to navigate to now (by default, /login) + * @param returnTo page to redirect to later (for instance, after logging in) + * @returns URL containing a redirect query parameter + */ +export const embedRedirect = (returnTo: string, navigateTo = "/login"): string => + `${navigateTo}?redirect=${encodeURIComponent(returnTo)}` + +/** + * Retrieves a url from the query string of the current URL + * @param search the query string in the current URL + * @returns the URL to redirect to + */ +export const retrieveRedirect = (search: string): string => { + const defaultRedirect = "/" + const searchParams = new URLSearchParams(search) + const redirect = searchParams.get("redirect") + return redirect ? redirect : defaultRedirect +}