feat(auth): add login feature and complete registration
+ 220
- 3
@@ -1,5 +1,5 @@
 {
-  "_generatedAtUnix": 1663364284973,
+  "_generatedAtUnix": 1663370041613,
   "_hashAlgorithm": "sha1",
   "_version": 2,
   "islands": {

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

new file
app/controllers/auth/getLoginView.ts
@@ -0,0 +1,11 @@
+// 3rd-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import LoginView, { LoginViewProps } from "../../views/auth/LoginView";
+
+const getLoginView: ReqHandler = (request, reply) => {
+  const reqHandler = reply.makeRequestHandler(request, reply);
+  return reqHandler<LoginViewProps>(LoginView.name, {});
+};
+
+export default getLoginView;

app/controllers/auth/index.ts
@@ -1,9 +1,11 @@
-// import { default as getLoginView } from "./getLoginView";
+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 = {
-  // getLoginView,
+  getLoginView,
   getRegisterView,
+  postLoginAction,
   postRegisterAction,
 };

new file
app/controllers/auth/postLoginAction.ts
@@ -0,0 +1,78 @@
+// 3rd-party
+import type { ReqHandler } from "@ethicdevs/react-monolith";
+// app
+import { AppRoute, AppRoutesParams } from "../../routes";
+import LoginView, { LoginViewProps } from "../../views/auth/LoginView";
+import { makeAuthService } from "../../services/auth";
+
+const postLoginView: ReqHandler = async (request, reply) => {
+  const authService = makeAuthService({
+    cryptoService: request.cryptoService,
+    request,
+  });
+
+  const reqHandler = reply.makeRequestHandler(request, reply);
+
+  const { email_address: emailAddress, password } =
+    request.body as AppRoutesParams[AppRoute.AUTH_LOGIN_ACTION]["body"];
+
+  const initialValues = { emailAddress };
+
+  if (request.validationError != null) {
+    const { message: errorMessage } = request.validationError;
+
+    return reqHandler<LoginViewProps>(LoginView.name, {
+      errorMessage,
+      initialValues,
+    });
+  }
+
+  if (emailAddress.trim() === "") {
+    return reqHandler<LoginViewProps>(LoginView.name, {
+      errorMessage: "Please provide a non-empty email address.",
+      initialValues: { emailAddress },
+    });
+  }
+
+  if (password.trim() === "") {
+    return reqHandler<LoginViewProps>(LoginView.name, {
+      errorMessage: "Please provide a non-empty password.",
+      initialValues: { emailAddress },
+    });
+  }
+
+  if ((await authService.isExistingEmailAddress(emailAddress)) === false) {
+    return reqHandler<LoginViewProps>(LoginView.name, {
+      errorMessage:
+        "Invalid credentials. Please verify your input and try again.",
+      initialValues: { emailAddress },
+    });
+  }
+
+  const [isLoginAllowed, user] = await authService.shouldAllowUserLogin(
+    emailAddress,
+    password
+  );
+
+  if (isLoginAllowed === false || user == null) {
+    return reqHandler<LoginViewProps>(LoginView.name, {
+      errorMessage:
+        "Invalid credentials. Please verify your input and try again.",
+      initialValues: { emailAddress },
+    });
+  }
+
+  const { avatarUri, role, id: userId, username } = user;
+  request.session.data.authenticated = true;
+  request.session.data.curr_user_avatar_uri = avatarUri;
+  request.session.data.curr_user_role = role;
+  request.session.data.curr_user_uid = userId;
+  request.session.data.curr_user_username = username;
+
+  console.log(`Logged user with id: ${userId}`);
+
+  reply.redirect(request.namedViewsPathMap[AppRoute.HOME]);
+  return reply;
+};
+
+export default postLoginView;

@@ -15,6 +15,7 @@ export enum AppRoute {
   AUTH_REGISTER = "auth.register",
   AUTH_REGISTER_ACTION = "auth.register.action",
   AUTH_LOGIN = "auth.login",
+  AUTH_LOGIN_ACTION = "auth.login.action",
 }
 
 export interface AppRoutesParams extends IRouteParams {

...
@@ -31,6 +32,12 @@ export interface AppRoutesParams extends IRouteParams {
     };
   };
   [AppRoute.AUTH_LOGIN]: undefined;
+  [AppRoute.AUTH_LOGIN_ACTION]: {
+    body: {
+      email_address: string;
+      password: string;
+    };
+  };
 }
 
 export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {

...
@@ -62,6 +69,17 @@ export const AppRoutesSchemas: Record<AppRoute, undefined | FastifySchema> = {
     },
   },
   [AppRoute.AUTH_LOGIN]: undefined,
+  [AppRoute.AUTH_LOGIN_ACTION]: {
+    body: {
+      type: "object",
+      required: ["email_address", "password"],
+      additionalProperties: false,
+      properties: {
+        email_address: { type: "string" },
+        password: { type: "string" },
+      },
+    },
+  },
 };
 
 const RootAppRouter: AppRouter = () => (

...
@@ -94,6 +112,19 @@ const RootAppRouter: AppRouter = () => (
         schema={AppRoutesSchemas[AppRoute.AUTH_REGISTER_ACTION]}
         handler={AuthController.postRegisterAction}
       />
+      <Router.Route
+        name={AppRoute.AUTH_LOGIN}
+        method={"GET"}
+        path={"/auth/login"}
+        handler={AuthController.getLoginView}
+      />
+      <Router.Route
+        name={AppRoute.AUTH_LOGIN_ACTION}
+        method={"POST"}
+        path={"/auth/login"}
+        schema={AppRoutesSchemas[AppRoute.AUTH_LOGIN_ACTION]}
+        handler={AuthController.postLoginAction}
+      />
     </Router.Group>
   </Router.Root>
 );

app/services/auth/index.ts
@@ -6,6 +6,7 @@ import type { AuthServiceAPI, AuthServiceDeps } from "./types";
 import { default as makeCreateUser } from "./createUser";
 import { default as makeIsExistingUsername } from "./isExistingUsername";
 import { default as makeIsExistingEmailAddress } from "./isExistingEmailAddress";
+import { default as makeShouldAllowUserLogin } from "./shouldAllowUserLogin";
 
 export const makeAuthService = makeService<AuthServiceAPI, AuthServiceDeps>({
   createUser: makeCreateUser,

...
@@ -15,5 +16,6 @@ export const makeAuthService = makeService<AuthServiceAPI, AuthServiceDeps>({
   findUserByUid: () => () => undefined,
   findUserByUsername: () => () => undefined,
   sendUserEmailAddressV8nEmail: () => () => undefined,
+  shouldAllowUserLogin: makeShouldAllowUserLogin,
   validateUserEmailAddress: () => () => Promise.resolve(undefined),
 });

new file
app/services/auth/shouldAllowUserLogin.ts
@@ -0,0 +1,27 @@
+// 1st-party
+import type { ServiceMethodFactory } from "@ethicdevs/react-monolith";
+// generated via script[generate:prisma]
+import { User } from "@prisma/client";
+// app
+import type { AuthServiceDeps } from "./types";
+
+const makeShouldAllowUserLogin: ServiceMethodFactory<
+  AuthServiceDeps,
+  [string, string],
+  Promise<[boolean, User | null]>
+> = ({ cryptoService, request }) => {
+  return async (emailAddress, password) => {
+    const passwordHash = cryptoService.computeHash(password);
+    const matchingUser = await request.prisma.user.findUnique({
+      where: {
+        email: emailAddress,
+      },
+    });
+    if (matchingUser == null) return [false, null];
+    const shouldAllowLogin = matchingUser.hashedPassword === passwordHash;
+    if (shouldAllowLogin === false) return [false, null];
+    return [true, matchingUser];
+  };
+};
+
+export default makeShouldAllowUserLogin;

app/services/auth/types.ts
@@ -24,6 +24,10 @@ export interface AuthServiceAPI extends ServiceApiContract {
   findUserByUsername(username: string): void;
   findUserByUid(userUid: string): void;
   sendUserEmailAddressV8nEmail(args: unknown[]): void;
+  shouldAllowUserLogin(
+    emailAddress: string,
+    password: string
+  ): Promise<[boolean, User | null]>;
   validateUserEmailAddress(
     userUid: string,
     emailAddress: string

new file
app/views/auth/LoginView.tsx
@@ -0,0 +1,58 @@
+import type { ReactView } from "@ethicdevs/react-monolith";
+import React from "react";
+// app
+import type { CommonProps } from "../../types";
+import { Button, Layout, PageWrapper } from "../../components";
+
+export interface LoginViewProps extends CommonProps {
+  errorMessage?: null | string;
+  initialValues?: {
+    emailAddress?: string;
+    password?: string;
+  };
+}
+
+const LoginView: ReactView<LoginViewProps> = ({
+  commonProps,
+  errorMessage = undefined,
+  initialValues = undefined,
+}) => {
+  return (
+    <Layout {...commonProps} showSideMenu={false}>
+      <PageWrapper>
+        {errorMessage && (
+          <div className={"error_message"}>
+            <p>{errorMessage}</p>
+          </div>
+        )}
+        <form action={`/auth/login`} method={"POST"}>
+          {/* Email Address */}
+          <div>
+            <label htmlFor={"username"}>Email Address:</label>
+            <input
+              type={"text"}
+              name={"email_address"}
+              placeholder={"Enter your email address..."}
+              defaultValue={initialValues?.emailAddress}
+            />
+          </div>
+          {/* Password */}
+          <div>
+            <label htmlFor={"username"}>Password:</label>
+            <input
+              type={"password"}
+              name={"password"}
+              placeholder={"Enter your password..."}
+              defaultValue={initialValues?.password}
+            />
+          </div>
+          {/* Submit Button */}
+          <Button type={"submit"}>Log me In!</Button>
+        </form>
+      </PageWrapper>
+    </Layout>
+  );
+};
+
+LoginView.displayName = "LoginView";
+export default LoginView;