feat(dashboard,routes): add logged users dashboard (wip) + add routes prehandlers for security
+ 307
- 84
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1663370041613,
+  "_generatedAtUnix": 1663372097423,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

...
@@ -10,7 +10,7 @@
       "pathSourceMap": "./public/.islands/InstantRouterIndicator.bundle.js.map"
     },
     "SideMenu": {
-      "hash": "41d8972c9315c3cdeae9cf898b79d6cda1b6af62",
+      "hash": "5d01374da1cbee58e081b9022aa56f0624209e27",
       "pathSource": "./app/islands/SideMenu.tsx",
       "pathBundle": "./public/.islands/SideMenu.bundle.js",
       "pathSourceMap": "./public/.islands/SideMenu.bundle.js.map"

...
@@ -25,6 +25,10 @@
       "hash": "389861a6e7f9ff12026ddf5e91bdf4f06646116e",
       "pathSource": "./app/views/InternalErrorView.tsx"
     },
+    "DashboardView": {
+      "hash": "94d966b2d5954a66b0be59fda234c04e55c41d60",
+      "pathSource": "./app/views/auth/DashboardView.tsx"
+    },
     "LoginView": {
       "hash": "ca64e50d382088fd54ac2617a983d369c2fe8820",
       "pathSource": "./app/views/auth/LoginView.tsx"

app/components/Layout.tsx
@@ -4,36 +4,31 @@ import React, { FC } from "react";
 import styled, { css } from "styled-components";
 
 // app
-import type { SectionsWithPages, WithThemeSchemeProp } from "../types";
+import type { CommonViewProps, WithThemeSchemeProp } from "../types";
 import { NamedColors } from "../utils/style";
 
 import InstantRouterIndicator from "../islands/InstantRouterIndicator";
 import SideMenu from "../islands/SideMenu";
 import { PageHeader } from "./PageHeader";
 
-interface LayoutProps {
-  foo?: boolean;
-  currentPageSlug?: string;
-  currentSectionSlug?: string;
-  menuDefinition?: SectionsWithPages;
-  showSideExamples?: boolean;
+interface LayoutProps extends CommonViewProps {
   showSideMenu?: boolean;
 }
 
 const BRANDLINE_HEIGHT = 4;
 const HEADER_HEIGHT = 72;
 const SIDE_MENU_WIDTH = 320;
-const SIDE_EXAMPLES_WIDTH = 490;
-
-export const Layout: FC<LayoutProps & WithThemeSchemeProp> = ({
-  children,
-  menuDefinition,
-  currentPageSlug = undefined,
-  currentSectionSlug = undefined,
-  showSideExamples = false,
-  showSideMenu = menuDefinition != null,
-  themeScheme,
-}) => {
+
+export const Layout: FC<LayoutProps & WithThemeSchemeProp> = (commonProps) => {
+  const {
+    children,
+    menuDefinition,
+    currentPageSlug = undefined,
+    currentSectionSlug = undefined,
+    showSideMenu = menuDefinition != null,
+    themeScheme,
+  } = commonProps;
+
   const sharedProps = {
     themeScheme,
   };

...
@@ -72,7 +67,7 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = ({
         </div>
         {/*<StyledBrandLine {...sharedProps} />*/}
         <StyledPageHeaderWrapper {...sharedProps}>
-          <PageHeader themeScheme={themeScheme} />
+          <PageHeader commonProps={commonProps} themeScheme={themeScheme} />
         </StyledPageHeaderWrapper>
         <StyledPageWrapper>
           {menuDefinition && showSideMenu && (

...
@@ -81,6 +76,7 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = ({
               {...sharedProps}
             >
               <SideMenu
+                commonProps={commonProps}
                 currentSectionSlug={currentSectionSlug}
                 currentPageSlug={currentPageSlug}
                 menuDefinition={menuDefinition}

...
@@ -91,13 +87,6 @@ export const Layout: FC<LayoutProps & WithThemeSchemeProp> = ({
           <StyledChildrenWrapper {...sharedProps}>
             {children}
           </StyledChildrenWrapper>
-          {showSideExamples && (
-            <StyledSideExamplesWrapper {...sharedProps}>
-              <div style={{ width: "100%" }}>
-                <h3>Examples</h3>
-              </div>
-            </StyledSideExamplesWrapper>
-          )}
         </StyledPageWrapper>
         {/*<StyledPageFooterWrapper>
         <h4>Footer</h4>

...
@@ -218,24 +207,3 @@ const StyledChildrenWrapper = styled.div`
   flex: 1;
   width: 100%;
 `;
-
-const StyledSideExamplesWrapper = styled.aside<WithThemeSchemeProp>`
-  display: flex;
-  flex-flow: column nowrap;
-  justify-content: flex-start;
-  align-items: flex-start;
-
-  width: ${SIDE_EXAMPLES_WIDTH}px;
-  min-height: calc(100% - ${BRANDLINE_HEIGHT + HEADER_HEIGHT}px);
-
-  position: sticky;
-  top: ${HEADER_HEIGHT}px;
-
-  padding: 24px;
-  gap: 24px;
-
-  ${({ themeScheme }) => css`
-    background-color: ${NamedColors.SIDE_EXAMPLES[themeScheme]};
-    border-left: 1px solid ${NamedColors.BORDER_DEFAULT[themeScheme]};
-  `};
-`;

app/components/PageHeader.tsx
@@ -1,21 +1,78 @@
 // 3rd-party
-import React, { VFC } from "react";
+import React, { useMemo, VFC } from "react";
 import styled, { css } from "styled-components";
 // app
-import type { WithThemeSchemeProp } from "../types";
+import type { CommonProps, WithThemeSchemeProp } from "../types";
 import { Const } from "../const";
 import { NamedColors } from "../utils/style";
 // import { ReactMonolithLogo } from "./icons";
 
-interface PageHeaderProps {}
+interface PageHeaderProps extends CommonProps {}
 
 // const LOGO_HEIGHT = 36;
 const SIDE_MENU_WIDTH = 320;
 
 export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
+  commonProps,
   themeScheme,
 }) => {
   const invertThemeScheme = themeScheme === "light" ? "dark" : "light";
+
+  const pageHeader = useMemo(() => {
+    if (commonProps.authenticated) {
+      return (
+        <>
+          <StyledPageHeaderNav>
+            <a aria-label={"Explore Projects"} href={"/explore/projects"}>
+              Explore Projects
+            </a>
+          </StyledPageHeaderNav>
+          <StyledActionsArea>
+            <a
+              href={`/theme/${invertThemeScheme}`}
+              style={{ color: NamedColors.TEXT_MUTED[themeScheme] }}
+              title={`Click to enable ${
+                themeScheme === "light" ? "dark" : "light"
+              } mode`}
+            >
+              {`${themeScheme === "light" ? "Dark" : "Light"} mode`}
+            </a>
+            <a aria-label={"Log off your account"} href={"/auth/logout"}>
+              Logout
+            </a>
+          </StyledActionsArea>
+        </>
+      );
+    }
+
+    return (
+      <>
+        <StyledPageHeaderNav>
+          <a aria-label={"Explore Projects"} href={"/explore/projects"}>
+            Explore Projects
+          </a>
+        </StyledPageHeaderNav>
+        <StyledActionsArea>
+          <a
+            href={`/theme/${invertThemeScheme}`}
+            style={{ color: NamedColors.TEXT_MUTED[themeScheme] }}
+            title={`Click to enable ${
+              themeScheme === "light" ? "dark" : "light"
+            } mode`}
+          >
+            {`${themeScheme === "light" ? "Dark" : "Light"} mode`}
+          </a>
+          <a aria-label={"Register a new account"} href={"/auth/register"}>
+            Register
+          </a>
+          <a aria-label={"Login to your account"} href={"/auth/login"}>
+            Login
+          </a>
+        </StyledActionsArea>
+      </>
+    );
+  }, [commonProps.authenticated]);
+
   return (
     <StyledPageHeader themeScheme={themeScheme}>
       <StyledLogoArea themeScheme={themeScheme}>

...
@@ -24,28 +81,7 @@ export const PageHeader: VFC<PageHeaderProps & WithThemeSchemeProp> = ({
           <h1 style={{ margin: 0, marginLeft: 20 }}>{Const.APP_NAME}</h1>
         </a>
       </StyledLogoArea>
-      <StyledPageHeaderNav>
-        <a aria-label={"Explore Projects"} href={"/explore/projects"}>
-          Explore Projects
-        </a>
-      </StyledPageHeaderNav>
-      <StyledActionsArea>
-        <a
-          href={`/theme/${invertThemeScheme}`}
-          style={{ color: NamedColors.TEXT_MUTED[themeScheme] }}
-          title={`Click to enable ${
-            themeScheme === "light" ? "dark" : "light"
-          } mode`}
-        >
-          {`${themeScheme === "light" ? "Dark" : "Light"} mode`}
-        </a>
-        <a aria-label={"Register a new account"} href={"/auth/register"}>
-          Register
-        </a>
-        <a aria-label={"Login to your account"} href={"/auth/login"}>
-          Login
-        </a>
-      </StyledActionsArea>
+      {pageHeader}
     </StyledPageHeader>
   );
 };

new file
app/controllers/auth/getDashboardView.ts
@@ -0,0 +1,26 @@
+// 3rd-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute } from "../../routes";
+import DashboardView, {
+  DashboardViewProps,
+} from "../../views/auth/DashboardView";
+import { makeUsersService } from "../../services/users";
+
+const getDashboardView: ReqHandler = async (request, reply) => {
+  const { authenticated, curr_user_uid } = request.session.data;
+  if (authenticated === false || curr_user_uid == null) {
+    reply.redirect(307, request.namedViewsPathMap[AppRoute.AUTH_LOGIN]);
+    return reply;
+  }
+
+  const usersService = makeUsersService({ request });
+  const currentUser = await usersService.getUserById(curr_user_uid);
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler<DashboardViewProps>(DashboardView.name, {
+    currentUser,
+  });
+};
+
+export default getDashboardView;

app/controllers/auth/index.ts
@@ -1,9 +1,11 @@
+import { default as getDashboardView } from "./getDashboardView";
 import { default as getLoginView } from "./getLoginView";
 import { default as getRegisterView } from "./getRegisterView";
 import { default as postLoginAction } from "./postLoginAction";
 import { default as postRegisterAction } from "./postRegisterAction";
 
 export const AuthController = {
+  getDashboardView,
   getLoginView,
   getRegisterView,
   postLoginAction,

app/controllers/auth/postLoginAction.ts
@@ -71,7 +71,7 @@ const postLoginView: ReqHandler = async (request, reply) => {
 
   console.log(`Logged user with id: ${userId}`);
 
-  reply.redirect(request.namedViewsPathMap[AppRoute.HOME]);
+  reply.redirect(302, request.namedViewsPathMap[AppRoute.USER_DASHBOARD]);
   return reply;
 };
 

app/controllers/auth/postRegisterAction.ts
@@ -84,7 +84,7 @@ const postRegisterView: ReqHandler = async (request, reply) => {
   request.session.data.curr_user_uid = userId;
   request.session.data.curr_user_username = username;
 
-  reply.redirect(request.namedViewsPathMap[AppRoute.HOME]);
+  reply.redirect(302, request.namedViewsPathMap[AppRoute.USER_DASHBOARD]);
   return reply;
 };
 

app/islands/SideMenu.tsx
@@ -3,12 +3,16 @@ import type { ReactIsland } from "@ethicdevs/react-monolith";
 import React, { useCallback, useState } from "react";
 import styled, { css } from "styled-components";
 // app
-import type { SectionsWithPages, WithThemeSchemeProp } from "../types";
+import type {
+  CommonProps,
+  SectionsWithPages,
+  WithThemeSchemeProp,
+} from "../types";
 import { MenuDivider } from "../components/MenuDivider";
 import { MenuItem } from "../components/MenuItem";
 import { NamedColors } from "../utils/style";
 
-interface SideMenuProps {
+interface SideMenuProps extends CommonProps {
   foo?: boolean;
   currentPageSlug?: string;
   currentSectionSlug?: string;

@@ -4,6 +4,8 @@ import { AppRouter, AppRouterGroup, Router } from "@ethicdevs/react-monolith";
 // 3rd-party
 import { FastifySchema } from "fastify";
 import React from "react";
+// app
+import { authenticatedOrLogin, guestOrRedirect } from "./utils/server";
 // app controllers
 import { AuthController } from "./controllers/auth";
 import * as HomeController from "./controllers/HomeController";

...
@@ -16,6 +18,7 @@ export enum AppRoute {
   AUTH_REGISTER_ACTION = "auth.register.action",
   AUTH_LOGIN = "auth.login",
   AUTH_LOGIN_ACTION = "auth.login.action",
+  USER_DASHBOARD = "user.dashboard",
 }
 
 export interface AppRoutesParams extends IRouteParams {

...
@@ -38,6 +41,7 @@ export interface AppRoutesParams extends IRouteParams {
       password: string;
     };
   };
+  [AppRoute.USER_DASHBOARD]: undefined;
 }
 
 export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {

...
@@ -80,18 +84,13 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
       },
     },
   },
+  [AppRoute.USER_DASHBOARD]: undefined,
 };
 
 const RootAppRouter: AppRouter = () => (
   <Router.Root>
     <></>
     <Router.Group type={AppRouterGroup.API}>
-      <Router.Route
-        name={AppRoute.HOME}
-        method={"GET"}
-        path={"/"}
-        handler={HomeController.getHomeView}
-      />
       <Router.Route
         name={AppRoute.SET_THEME}
         method={"GET"}

...
@@ -99,10 +98,20 @@ const RootAppRouter: AppRouter = () => (
         schema={AppRoutesSchemas[AppRoute.SET_THEME]}
         handler={ThemeController.getTheme}
       />
+      {/* --- */}
+      <Router.Route
+        name={AppRoute.HOME}
+        method={"GET"}
+        path={"/"}
+        preHandler={guestOrRedirect("/dashboard")}
+        handler={HomeController.getHomeView}
+      />
+      {/* --- */}
       <Router.Route
         name={AppRoute.AUTH_REGISTER}
         method={"GET"}
         path={"/auth/register"}
+        preHandler={guestOrRedirect("/dashboard")}
         handler={AuthController.getRegisterView}
       />
       <Router.Route

...
@@ -110,12 +119,15 @@ const RootAppRouter: AppRouter = () => (
         method={"POST"}
         path={"/auth/register"}
         schema={AppRoutesSchemas[AppRoute.AUTH_REGISTER_ACTION]}
+        preHandler={guestOrRedirect("/dashboard")}
         handler={AuthController.postRegisterAction}
       />
+      {/* --- */}
       <Router.Route
         name={AppRoute.AUTH_LOGIN}
         method={"GET"}
         path={"/auth/login"}
+        preHandler={guestOrRedirect("/dashboard")}
         handler={AuthController.getLoginView}
       />
       <Router.Route

...
@@ -123,8 +135,17 @@ const RootAppRouter: AppRouter = () => (
         method={"POST"}
         path={"/auth/login"}
         schema={AppRoutesSchemas[AppRoute.AUTH_LOGIN_ACTION]}
+        preHandler={guestOrRedirect("/dashboard")}
         handler={AuthController.postLoginAction}
       />
+      {/* --- */}
+      <Router.Route
+        name={AppRoute.USER_DASHBOARD}
+        method={"GET"}
+        path={"/dashboard"}
+        preHandler={authenticatedOrLogin()}
+        handler={AuthController.getDashboardView}
+      />
     </Router.Group>
   </Router.Root>
 );

new file
app/services/users/getUserByEmailAddress.ts
@@ -0,0 +1,24 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import { User } from "@prisma/client";
+// app
+import type { UsersServiceDeps } from "./types";
+
+const getUserByEmailAddress: ServiceMethodFactory<
+  UsersServiceDeps,
+  [string],
+  Promise<User | null>
+> = ({ request }) => {
+  return async (emailAddress) => {
+    const user = await request.prisma.user.findUnique({
+      where: {
+        email: emailAddress,
+      },
+    });
+
+    return user;
+  };
+};
+
+export default getUserByEmailAddress;

new file
app/services/users/getUserById.ts
@@ -0,0 +1,24 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import { User } from "@prisma/client";
+// app
+import type { UsersServiceDeps } from "./types";
+
+const getUserById: ServiceMethodFactory<
+  UsersServiceDeps,
+  [string],
+  Promise<User | null>
+> = ({ request }) => {
+  return async (userId) => {
+    const user = await request.prisma.user.findUnique({
+      where: {
+        id: userId,
+      },
+    });
+
+    return user;
+  };
+};
+
+export default getUserById;

new file
app/services/users/getUserByUsername.ts
@@ -0,0 +1,24 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import { User } from "@prisma/client";
+// app
+import type { UsersServiceDeps } from "./types";
+
+const getUserByUsername: ServiceMethodFactory<
+  UsersServiceDeps,
+  [string],
+  Promise<User | null>
+> = ({ request }) => {
+  return async (username) => {
+    const user = await request.prisma.user.findUnique({
+      where: {
+        username,
+      },
+    });
+
+    return user;
+  };
+};
+
+export default getUserByUsername;

new file
app/services/users/index.ts
@@ -0,0 +1,14 @@
+// 1st-party
+import { makeService } from "@ethicdevs/react-monolith";
+// service
+import type { UsersServiceAPI, UsersServiceDeps } from "./types";
+// service methods
+import { default as makeGetUserByEmailAddress } from "./getUserByEmailAddress";
+import { default as makeGetUserById } from "./getUserById";
+import { default as makeGetUserByUsername } from "./getUserByUsername";
+
+export const makeUsersService = makeService<UsersServiceAPI, UsersServiceDeps>({
+  getUserByEmailAddress: makeGetUserByEmailAddress,
+  getUserById: makeGetUserById,
+  getUserByUsername: makeGetUserByUsername,
+});

new file
app/services/users/types.ts
@@ -0,0 +1,17 @@
+// 1st-party
+import { ServiceApiContract } from "@ethicdevs/react-monolith";
+// 3rd-party
+import { FastifyRequest } from "fastify";
+// generated via script[generate:prisma]
+import { User } from "@prisma/client";
+// app
+
+export interface UsersServiceAPI extends ServiceApiContract {
+  getUserById(userId: string): Promise<User | null>;
+  getUserByUsername(username: string): Promise<User | null>;
+  getUserByEmailAddress(emailAddress: string): Promise<User | null>;
+}
+
+export interface UsersServiceDeps {
+  request: FastifyRequest;
+}

@@ -70,6 +70,7 @@ export type SectionsWithPages = {
 };
 
 export interface CommonViewProps {
+  authenticated: boolean;
   currentSectionSlug: string | undefined;
   currentPageSlug: string | undefined;
   menuDefinition: SectionsWithPages;

new file
app/utils/server/authenticatedOrLogin.ts
@@ -0,0 +1,10 @@
+import type { preHandlerHookHandler } from "fastify";
+// app
+import { AppRoute } from "../../routes";
+
+export const authenticatedOrLogin =
+  (): preHandlerHookHandler => async (request, reply) => {
+    if (request.session.data.authenticated === false) {
+      reply.redirect(302, request.namedViewsPathMap[AppRoute.AUTH_LOGIN]);
+    }
+  };

new file
app/utils/server/authenticatedOrRedirect.ts
@@ -0,0 +1,9 @@
+import type { preHandlerHookHandler } from "fastify";
+
+export const authenticatedOrRedirect =
+  (redirectToPath: string, statusCode: number = 302): preHandlerHookHandler =>
+  async (request, reply) => {
+    if (request.session.data.authenticated === false) {
+      reply.redirect(statusCode, redirectToPath);
+    }
+  };

new file
app/utils/server/guestOrRedirect.ts
@@ -0,0 +1,9 @@
+import type { preHandlerHookHandler } from "fastify";
+
+export const guestOrRedirect =
+  (redirectToPath: string, statusCode: number = 302): preHandlerHookHandler =>
+  async (request, reply) => {
+    if (request.session.data.authenticated) {
+      reply.redirect(statusCode, redirectToPath);
+    }
+  };

app/utils/server/index.ts
@@ -1,7 +1,10 @@
 // All exports in this file must not be called from the client-side.
 
+export { authenticatedOrLogin } from "./authenticatedOrLogin";
+export { authenticatedOrRedirect } from "./authenticatedOrRedirect";
 export { getDocFileContent } from "./getDocFileContent";
 export { getEnv } from "./getEnv";
+export { guestOrRedirect } from "./guestOrRedirect";
 export { localAppDomainPreHandler } from "./localAppDomainPreHandler";
 export { makeRequestHandler } from "./makeRequestHandler";
 export { sessionSetupPreHandler } from "./sessionSetupPreHandler";

app/utils/server/makeRequestHandler.ts
@@ -21,6 +21,7 @@ export const makeRequestHandler = {
         const viewProps: T & { commonProps: CommonViewProps } = {
           ...props,
           commonProps: {
+            authenticated: request.session.data.authenticated,
             title: props?.title,
             currentSectionSlug: sectionSlug,
             currentPageSlug: pageSlug,

new file
app/views/auth/DashboardView.tsx
@@ -0,0 +1,26 @@
+// 1st-party
+import type { ReactView } from "@ethicdevs/react-monolith";
+// 3rd-party
+import React from "react";
+// generated via script[generate:prisma]
+import type { User } from "@prisma/client";
+// app
+import type { CommonProps } from "../../types";
+import { Layout, PageWrapper } from "../../components";
+
+export interface DashboardViewProps extends CommonProps {
+  currentUser: User;
+}
+
+const DashboardView: ReactView<DashboardViewProps> = ({ commonProps }) => {
+  return (
+    <Layout {...commonProps} showSideMenu={false}>
+      <PageWrapper>
+        <h1>Hey, welcome!</h1>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+DashboardView.displayName = "DashboardView";
+export default DashboardView;