From fcdacd844f615754171c45e6d72093fb77bed291 Mon Sep 17 00:00:00 2001 From: Ahmed Al-Taiar Date: Tue, 31 Oct 2023 18:41:57 -0400 Subject: [PATCH] Accounts system, no RBAC yet --- .../migration.sql | 14 ++ .../20231031125421_user_role/migration.sql | 12 ++ .../migration.sql | 26 +++ api/db/schema.prisma | 23 +++ api/package.json | 1 + api/src/functions/auth.ts | 185 ++++++++++++++++++ api/src/functions/graphql.ts | 4 + api/src/graphql/parts.sdl.ts | 4 +- api/src/lib/auth.ts | 119 +++++++++-- web/package.json | 1 + web/src/App.tsx | 10 +- web/src/Routes.tsx | 24 ++- web/src/auth.ts | 5 + .../NavbarAccountIcon.stories.tsx | 25 +++ .../NavbarAccountIcon.test.tsx | 14 ++ .../NavbarAccountIcon/NavbarAccountIcon.tsx | 47 +++++ web/src/layouts/NavbarLayout/NavbarLayout.tsx | 10 +- .../ForgotPasswordPage/ForgotPasswordPage.tsx | 96 +++++++++ web/src/pages/LoginPage/LoginPage.tsx | 136 +++++++++++++ .../ResetPasswordPage/ResetPasswordPage.tsx | 121 ++++++++++++ web/src/pages/SignupPage/SignupPage.tsx | 182 +++++++++++++++++ web/src/scaffold.css | 35 +--- yarn.lock | 78 +++++++- 23 files changed, 1112 insertions(+), 60 deletions(-) rename api/db/migrations/{20231027135109_create_part_schema => 20231031125027_user_and_part}/migration.sql (50%) create mode 100644 api/db/migrations/20231031125421_user_role/migration.sql create mode 100644 api/db/migrations/20231031145219_separate_name/migration.sql create mode 100644 api/src/functions/auth.ts create mode 100644 web/src/auth.ts create mode 100644 web/src/components/NavbarAccountIcon/NavbarAccountIcon.stories.tsx create mode 100644 web/src/components/NavbarAccountIcon/NavbarAccountIcon.test.tsx create mode 100644 web/src/components/NavbarAccountIcon/NavbarAccountIcon.tsx create mode 100644 web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx create mode 100644 web/src/pages/LoginPage/LoginPage.tsx create mode 100644 web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx create mode 100644 web/src/pages/SignupPage/SignupPage.tsx diff --git a/api/db/migrations/20231027135109_create_part_schema/migration.sql b/api/db/migrations/20231031125027_user_and_part/migration.sql similarity index 50% rename from api/db/migrations/20231027135109_create_part_schema/migration.sql rename to api/db/migrations/20231031125027_user_and_part/migration.sql index d96ec31..8a2a681 100644 --- a/api/db/migrations/20231027135109_create_part_schema/migration.sql +++ b/api/db/migrations/20231031125027_user_and_part/migration.sql @@ -7,3 +7,17 @@ CREATE TABLE "Part" ( "imageUrl" TEXT NOT NULL DEFAULT '/no_image.png', "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "name" TEXT, + "email" TEXT NOT NULL, + "hashedPassword" TEXT NOT NULL, + "salt" TEXT NOT NULL, + "resetToken" TEXT, + "resetTokenExpiresAt" DATETIME +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/api/db/migrations/20231031125421_user_role/migration.sql b/api/db/migrations/20231031125421_user_role/migration.sql new file mode 100644 index 0000000..676758b --- /dev/null +++ b/api/db/migrations/20231031125421_user_role/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "UserRole" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "userId" INTEGER, + CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserRole_name_userId_key" ON "UserRole"("name", "userId"); diff --git a/api/db/migrations/20231031145219_separate_name/migration.sql b/api/db/migrations/20231031145219_separate_name/migration.sql new file mode 100644 index 0000000..4326da6 --- /dev/null +++ b/api/db/migrations/20231031145219_separate_name/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `User` table. All the data in the column will be lost. + - Added the required column `firstName` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `lastName` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "hashedPassword" TEXT NOT NULL, + "salt" TEXT NOT NULL, + "resetToken" TEXT, + "resetTokenExpiresAt" DATETIME +); +INSERT INTO "new_User" ("email", "hashedPassword", "id", "resetToken", "resetTokenExpiresAt", "salt") SELECT "email", "hashedPassword", "id", "resetToken", "resetTokenExpiresAt", "salt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index d249aae..3c226f5 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -16,3 +16,26 @@ model Part { imageUrl String @default("/no_image.png") createdAt DateTime @default(now()) } + +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String @unique + hashedPassword String + salt String + resetToken String? + resetTokenExpiresAt DateTime? + userRoles UserRole[] +} + +model UserRole { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + name String + user User? @relation(fields: [userId], references: [id]) + userId Int? + + @@unique([name, userId]) +} diff --git a/api/package.json b/api/package.json index 6bc4a45..d5b2642 100644 --- a/api/package.json +++ b/api/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@redwoodjs/api": "6.3.2", + "@redwoodjs/auth-dbauth-api": "6.3.2", "@redwoodjs/graphql-server": "6.3.2", "filestack-js": "^3.27.0" } diff --git a/api/src/functions/auth.ts b/api/src/functions/auth.ts new file mode 100644 index 0000000..edf0041 --- /dev/null +++ b/api/src/functions/auth.ts @@ -0,0 +1,185 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda' + +import { + DbAuthHandler, + DbAuthHandlerOptions, + PasswordValidationError, +} from '@redwoodjs/auth-dbauth-api' + +import { db } from 'src/lib/db' + +export const handler = async ( + event: APIGatewayProxyEvent, + context: Context +) => { + const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = { + // handler() is invoked after verifying that a user was found with the given + // username. This is where you can send the user an email with a link to + // reset their password. With the default dbAuth routes and field names, the + // URL to reset the password will be: + // + // https://example.com/reset-password?resetToken=${user.resetToken} + // + // Whatever is returned from this function will be returned from + // the `forgotPassword()` function that is destructured from `useAuth()` + // You could use this return value to, for example, show the email + // address in a toast message so the user will know it worked and where + // to look for the email. + handler: (user) => { + // TODO: Forgot password link + return user + }, + + // How long the resetToken is valid for, in seconds (default is 24 hours) + expires: 60 * 60 * 24, + + errors: { + // for security reasons you may want to be vague here rather than expose + // the fact that the email address wasn't found (prevents fishing for + // valid email addresses) + usernameNotFound: 'Email not found', + // if the user somehow gets around client validation + usernameRequired: 'Email is required', + }, + } + + const loginOptions: DbAuthHandlerOptions['login'] = { + // handler() is called after finding the user that matches the + // username/password provided at login, but before actually considering them + // logged in. The `user` argument will be the user in the database that + // matched the username/password. + // + // If you want to allow this user to log in simply return the user. + // + // If you want to prevent someone logging in for another reason (maybe they + // didn't validate their email yet), throw an error and it will be returned + // by the `logIn()` function from `useAuth()` in the form of: + // `{ message: 'Error message' }` + handler: (user) => { + return user + }, + + errors: { + usernameOrPasswordMissing: 'Both email and password are required', + usernameNotFound: 'Email ${username} not found', + // For security reasons you may want to make this the same as the + // usernameNotFound error so that a malicious user can't use the error + // to narrow down if it's the username or password that's incorrect + incorrectPassword: 'Incorrect password for ${username}', + }, + + // How long a user will remain logged in, in seconds + expires: 60 * 60 * 24 * 365 * 10, + } + + const resetPasswordOptions: DbAuthHandlerOptions['resetPassword'] = { + // handler() is invoked after the password has been successfully updated in + // the database. Returning anything truthy will automatically log the user + // in. Return `false` otherwise, and in the Reset Password page redirect the + // user to the login page. + handler: (_user) => { + return true + }, + + // If `false` then the new password MUST be different from the current one + allowReusedPassword: false, + + errors: { + // the resetToken is valid, but expired + resetTokenExpired: 'resetToken is expired', + // no user was found with the given resetToken + resetTokenInvalid: 'resetToken is invalid', + // the resetToken was not present in the URL + resetTokenRequired: 'resetToken is required', + // new password is the same as the old password (apparently they did not forget it) + reusedPassword: 'Must choose a new password', + }, + } + + const signupOptions: DbAuthHandlerOptions['signup'] = { + // Whatever you want to happen to your data on new user signup. Redwood will + // check for duplicate usernames before calling this handler. At a minimum + // you need to save the `username`, `hashedPassword` and `salt` to your + // user table. `userAttributes` contains any additional object members that + // were included in the object given to the `signUp()` function you got + // from `useAuth()`. + // + // If you want the user to be immediately logged in, return the user that + // was created. + // + // If this handler throws an error, it will be returned by the `signUp()` + // function in the form of: `{ error: 'Error message' }`. + // + // If this returns anything else, it will be returned by the + // `signUp()` function in the form of: `{ message: 'String here' }`. + handler: ({ username, hashedPassword, salt, userAttributes }) => { + return db.user.create({ + data: { + email: username, + hashedPassword: hashedPassword, + salt: salt, + firstName: userAttributes.firstName, + lastName: userAttributes.lastName, + }, + }) + }, + + // Include any format checks for password here. Return `true` if the + // password is valid, otherwise throw a `PasswordValidationError`. + // Import the error along with `DbAuthHandler` from `@redwoodjs/api` above. + passwordValidation: (password) => { + if (password.length < 6) + throw new PasswordValidationError( + 'Password must be at least 6 characters' + ) + else return true + }, + + errors: { + // `field` will be either "username" or "password" + fieldMissing: '${field} is required', + usernameTaken: 'Email `${username}` already in use', + }, + } + + const authHandler = new DbAuthHandler(event, context, { + // Provide prisma db client + db: db, + + // The name of the property you'd call on `db` to access your user table. + // i.e. if your Prisma model is named `User` this value would be `user`, as in `db.user` + authModelAccessor: 'user', + + // A map of what dbAuth calls a field to what your database calls it. + // `id` is whatever column you use to uniquely identify a user (probably + // something like `id` or `userId` or even `email`) + authFields: { + id: 'id', + username: 'email', + hashedPassword: 'hashedPassword', + salt: 'salt', + resetToken: 'resetToken', + resetTokenExpiresAt: 'resetTokenExpiresAt', + }, + + // Specifies attributes on the cookie that dbAuth sets in order to remember + // who is logged in. See https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies + cookie: { + HttpOnly: true, + Path: '/', + SameSite: 'Strict', + Secure: process.env.NODE_ENV !== 'development', + + // If you need to allow other domains (besides the api side) access to + // the dbAuth session cookie: + // Domain: 'example.com', + }, + + forgotPassword: forgotPasswordOptions, + login: loginOptions, + resetPassword: resetPasswordOptions, + signup: signupOptions, + }) + + return await authHandler.invoke() +} diff --git a/api/src/functions/graphql.ts b/api/src/functions/graphql.ts index f395c3b..5d8db6a 100644 --- a/api/src/functions/graphql.ts +++ b/api/src/functions/graphql.ts @@ -1,13 +1,17 @@ +import { authDecoder } from '@redwoodjs/auth-dbauth-api' import { createGraphQLHandler } from '@redwoodjs/graphql-server' import directives from 'src/directives/**/*.{js,ts}' import sdls from 'src/graphql/**/*.sdl.{js,ts}' import services from 'src/services/**/*.{js,ts}' +import { getCurrentUser } from 'src/lib/auth' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' export const handler = createGraphQLHandler({ + authDecoder, + getCurrentUser, loggerConfig: { logger, options: {} }, directives, sdls, diff --git a/api/src/graphql/parts.sdl.ts b/api/src/graphql/parts.sdl.ts index 622f814..7395a1e 100644 --- a/api/src/graphql/parts.sdl.ts +++ b/api/src/graphql/parts.sdl.ts @@ -9,8 +9,8 @@ export const schema = gql` } type Query { - parts: [Part!]! @requireAuth - part(id: Int!): Part @requireAuth + parts: [Part!]! @skipAuth + part(id: Int!): Part @skipAuth } input CreatePartInput { diff --git a/api/src/lib/auth.ts b/api/src/lib/auth.ts index f98fe93..bd808d0 100644 --- a/api/src/lib/auth.ts +++ b/api/src/lib/auth.ts @@ -1,25 +1,112 @@ +import type { Decoded } from '@redwoodjs/api' +import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server' + +import { db } from './db' + /** - * Once you are ready to add authentication to your application - * you'll build out requireAuth() with real functionality. For - * now we just return `true` so that the calls in services - * have something to check against, simulating a logged - * in user that is allowed to access that service. + * The session object sent in as the first argument to getCurrentUser() will + * have a single key `id` containing the unique ID of the logged in user + * (whatever field you set as `authFields.id` in your auth function config). + * You'll need to update the call to `db` below if you use a different model + * name or unique field name, for example: * - * See https://redwoodjs.com/docs/authentication for more info. + * return await db.profile.findUnique({ where: { email: session.id } }) + * ───┬─── ──┬── + * model accessor ─┘ unique id field name ─┘ + * + * !! BEWARE !! Anything returned from this function will be available to the + * client--it becomes the content of `currentUser` on the web side (as well as + * `context.currentUser` on the api side). You should carefully add additional + * fields to the `select` object below once you've decided they are safe to be + * seen if someone were to open the Web Inspector in their browser. */ -export const isAuthenticated = () => { - return true +export const getCurrentUser = async (session: Decoded) => { + if (!session || typeof session.id !== 'number') { + throw new Error('Invalid session') + } + + return await db.user.findUnique({ + where: { id: session.id }, + select: { id: true, firstName: true }, + }) } -export const hasRole = ({ roles }) => { - return roles !== undefined +/** + * The user is authenticated if there is a currentUser in the context + * + * @returns {boolean} - If the currentUser is authenticated + */ +export const isAuthenticated = (): boolean => { + return !!context.currentUser } -// This is used by the redwood directive -// in ./api/src/directives/requireAuth +/** + * When checking role membership, roles can be a single value, a list, or none. + * You can use Prisma enums too (if you're using them for roles), just import your enum type from `@prisma/client` + */ +type AllowedRoles = string | string[] | undefined -// Roles are passed in by the requireAuth directive if you have auth setup -// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars -export const requireAuth = ({ roles }) => { - return isAuthenticated() +/** + * Checks if the currentUser is authenticated (and assigned one of the given roles) + * + * @param roles: {@link AllowedRoles} - Checks if the currentUser is assigned one of these roles + * + * @returns {boolean} - Returns true if the currentUser is logged in and assigned one of the given roles, + * or when no roles are provided to check against. Otherwise returns false. + */ +export const hasRole = (roles: AllowedRoles): boolean => { + if (!isAuthenticated()) { + return false + } + + const currentUserRoles = context.currentUser?.roles + + if (typeof roles === 'string') { + if (typeof currentUserRoles === 'string') { + // roles to check is a string, currentUser.roles is a string + return currentUserRoles === roles + } else if (Array.isArray(currentUserRoles)) { + // roles to check is a string, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => roles === allowedRole) + } + } + + if (Array.isArray(roles)) { + if (Array.isArray(currentUserRoles)) { + // roles to check is an array, currentUser.roles is an array + return currentUserRoles?.some((allowedRole) => + roles.includes(allowedRole) + ) + } else if (typeof currentUserRoles === 'string') { + // roles to check is an array, currentUser.roles is a string + return roles.some((allowedRole) => currentUserRoles === allowedRole) + } + } + + // roles not found + return false +} + +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param roles: {@link AllowedRoles} - When checking role membership, these roles grant access. + * + * @returns - If the currentUser is authenticated (and assigned one of the given roles) + * + * @throws {@link AuthenticationError} - If the currentUser is not authenticated + * @throws {@link ForbiddenError} If the currentUser is not allowed due to role permissions + * + * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples + */ +export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => { + if (!isAuthenticated()) { + throw new AuthenticationError("You don't have permission to do that.") + } + + if (roles && !hasRole(roles)) { + throw new ForbiddenError("You don't have access to do that.") + } } diff --git a/web/package.json b/web/package.json index bfe3a5d..c2a7286 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "dependencies": { "@mdi/js": "^7.3.67", "@mdi/react": "^1.6.1", + "@redwoodjs/auth-dbauth-web": "6.3.2", "@redwoodjs/forms": "6.3.2", "@redwoodjs/router": "6.3.2", "@redwoodjs/web": "6.3.2", diff --git a/web/src/App.tsx b/web/src/App.tsx index 5e7beac..65419d6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,15 +4,19 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' import FatalErrorPage from 'src/pages/FatalErrorPage' import Routes from 'src/Routes' +import { AuthProvider, useAuth } from './auth' + import './scaffold.css' import './index.css' const App = () => ( - - - + + + + + ) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index f7ce2cd..42cdee9 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -7,20 +7,28 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route, Set } from '@redwoodjs/router' +import { Router, Route, Set, Private } from '@redwoodjs/router' import NavbarLayout from 'src/layouts/NavbarLayout' import ScaffoldLayout from 'src/layouts/ScaffoldLayout' +import { useAuth } from './auth' + const Routes = () => { return ( - - - - - - - + + + + + + + + + + + + + diff --git a/web/src/auth.ts b/web/src/auth.ts new file mode 100644 index 0000000..143e75b --- /dev/null +++ b/web/src/auth.ts @@ -0,0 +1,5 @@ +import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web' + +const dbAuthClient = createDbAuthClient() + +export const { AuthProvider, useAuth } = createAuth(dbAuthClient) diff --git a/web/src/components/NavbarAccountIcon/NavbarAccountIcon.stories.tsx b/web/src/components/NavbarAccountIcon/NavbarAccountIcon.stories.tsx new file mode 100644 index 0000000..01336fc --- /dev/null +++ b/web/src/components/NavbarAccountIcon/NavbarAccountIcon.stories.tsx @@ -0,0 +1,25 @@ +// Pass props to your component by passing an `args` object to your story +// +// ```tsx +// export const Primary: Story = { +// args: { +// propName: propValue +// } +// } +// ``` +// +// See https://storybook.js.org/docs/react/writing-stories/args. + +import type { Meta, StoryObj } from '@storybook/react' + +import NavbarAccountIcon from './NavbarAccountIcon' + +const meta: Meta = { + component: NavbarAccountIcon, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/web/src/components/NavbarAccountIcon/NavbarAccountIcon.test.tsx b/web/src/components/NavbarAccountIcon/NavbarAccountIcon.test.tsx new file mode 100644 index 0000000..b1a6d52 --- /dev/null +++ b/web/src/components/NavbarAccountIcon/NavbarAccountIcon.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@redwoodjs/testing/web' + +import NavbarAccountIcon from './NavbarAccountIcon' + +// Improve this test with help from the Redwood Testing Doc: +// https://redwoodjs.com/docs/testing#testing-components + +describe('NavbarAccountIcon', () => { + it('renders successfully', () => { + expect(() => { + render() + }).not.toThrow() + }) +}) diff --git a/web/src/components/NavbarAccountIcon/NavbarAccountIcon.tsx b/web/src/components/NavbarAccountIcon/NavbarAccountIcon.tsx new file mode 100644 index 0000000..9a3cd1f --- /dev/null +++ b/web/src/components/NavbarAccountIcon/NavbarAccountIcon.tsx @@ -0,0 +1,47 @@ +import { mdiAccount, mdiLogout, mdiLogin } from '@mdi/js' +import Icon from '@mdi/react' + +import { Link, routes } from '@redwoodjs/router' + +import { useAuth } from 'src/auth' + +interface Props { + mobile: boolean + className?: string +} + +const NavbarAccountIcon = ({ mobile, className }: Props) => { + const { isAuthenticated, currentUser, logOut } = useAuth() + + return isAuthenticated ? ( +
+
+ + + +
+

+ Hello, {currentUser.firstName}! +

+ +
+
+
+ ) : ( +
+ + + +
+ ) +} + +export default NavbarAccountIcon diff --git a/web/src/layouts/NavbarLayout/NavbarLayout.tsx b/web/src/layouts/NavbarLayout/NavbarLayout.tsx index 93fa53c..92e714d 100644 --- a/web/src/layouts/NavbarLayout/NavbarLayout.tsx +++ b/web/src/layouts/NavbarLayout/NavbarLayout.tsx @@ -3,6 +3,7 @@ import Icon from '@mdi/react' import { Link, routes } from '@redwoodjs/router' +import NavbarAccountIcon from 'src/components/NavbarAccountIcon/NavbarAccountIcon' import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle' type NavBarLayoutProps = { @@ -14,7 +15,10 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => { <>
- + { */} +
{
  • -
    +
    { Parts Inventory

    +
  • {/*
  • diff --git a/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx new file mode 100644 index 0000000..1dce40c --- /dev/null +++ b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef } from 'react' + +import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const ForgotPasswordPage = () => { + const { isAuthenticated, forgotPassword } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + const emailRef = useRef(null) + useEffect(() => { + emailRef?.current?.focus() + }, []) + + const onSubmit = async (data: { email: string }) => { + const response = await forgotPassword(data.email) + + if (response.error) { + toast.error(response.error) + } else { + // The function `forgotPassword.handler` in api/src/functions/auth.js has + // been invoked, let the user know how to get the link to reset their + // password (sent in email, perhaps?) + toast.success( + 'A link to reset your password was sent to ' + response.email + ) + navigate(routes.login()) + } + } + + return ( + <> + + +
    + +
    +
    +
    +

    + Forgot Password +

    +
    + +
    +
    +
    +
    + + + + +
    + +
    + + Submit + +
    +
    +
    +
    +
    +
    +
    + + ) +} + +export default ForgotPasswordPage diff --git a/web/src/pages/LoginPage/LoginPage.tsx b/web/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..83ed82d --- /dev/null +++ b/web/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,136 @@ +import { useRef } from 'react' +import { useEffect } from 'react' + +import { + Form, + Label, + TextField, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { Link, navigate, routes } from '@redwoodjs/router' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const LoginPage = () => { + const { isAuthenticated, logIn } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + const emailRef = useRef(null) + useEffect(() => { + emailRef.current?.focus() + }, []) + + const onSubmit = async (data: Record) => { + const response = await logIn({ + username: data.email, + password: data.password, + }) + + if (response.message) { + toast(response.message) + } else if (response.error) { + toast.error(response.error) + } else { + toast.success('Welcome back!') + } + } + + return ( + <> + + +
    + +
    +
    +
    +

    Login

    +
    + +
    +
    +
    + + + + + + + + + + +
    + + Forgot Password? + +
    + +
    + + Login + +
    + +
    +
    +
    +
    + Don't have an account?{' '} + + Sign up! + +
    +
    +
    + + ) +} + +export default LoginPage diff --git a/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx b/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx new file mode 100644 index 0000000..c57d5c5 --- /dev/null +++ b/web/src/pages/ResetPasswordPage/ResetPasswordPage.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from 'react' + +import { + Form, + Label, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const ResetPasswordPage = ({ resetToken }: { resetToken: string }) => { + const { isAuthenticated, reauthenticate, validateResetToken, resetPassword } = + useAuth() + const [enabled, setEnabled] = useState(true) + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + useEffect(() => { + const validateToken = async () => { + const response = await validateResetToken(resetToken) + if (response.error) { + setEnabled(false) + toast.error(response.error) + } else { + setEnabled(true) + } + } + validateToken() + }, [resetToken, validateResetToken]) + + const passwordRef = useRef(null) + useEffect(() => { + passwordRef.current?.focus() + }, []) + + const onSubmit = async (data: Record) => { + const response = await resetPassword({ + resetToken, + password: data.password, + }) + + if (response.error) { + toast.error(response.error) + } else { + toast.success('Password changed!') + await reauthenticate() + navigate(routes.login()) + } + } + + return ( + <> + + +
    + +
    +
    +
    +

    + Reset Password +

    +
    + +
    +
    +
    +
    + + + + +
    + +
    + + Submit + +
    +
    +
    +
    +
    +
    +
    + + ) +} + +export default ResetPasswordPage diff --git a/web/src/pages/SignupPage/SignupPage.tsx b/web/src/pages/SignupPage/SignupPage.tsx new file mode 100644 index 0000000..933c84d --- /dev/null +++ b/web/src/pages/SignupPage/SignupPage.tsx @@ -0,0 +1,182 @@ +import { useRef } from 'react' +import { useEffect } from 'react' + +import { + Form, + Label, + TextField, + PasswordField, + FieldError, + Submit, +} from '@redwoodjs/forms' +import { Link, navigate, routes } from '@redwoodjs/router' +import { MetaTags } from '@redwoodjs/web' +import { toast, Toaster } from '@redwoodjs/web/toast' + +import { useAuth } from 'src/auth' + +const SignupPage = () => { + const { isAuthenticated, signUp } = useAuth() + + useEffect(() => { + if (isAuthenticated) { + navigate(routes.home()) + } + }, [isAuthenticated]) + + // focus on name box on page load + const nameRef = useRef(null) + useEffect(() => { + nameRef.current?.focus() + }, []) + + const onSubmit = async (data: Record) => { + console.log(data) + const response = await signUp({ + username: data.email, + password: data.password, + firstName: data.firstName, + lastName: data.lastName, + }) + + if (response.message) { + toast(response.message) + } else if (response.error) { + toast.error(response.error) + } else { + // user is signed in automatically + toast.success('Welcome!') + } + } + + return ( + <> + + +
    + +
    +
    +
    +

    Signup

    +
    + +
    +
    +
    +
    +
    + + + +
    + +
    + + + +
    +
    + + + + + + + + + +
    + + Sign Up + +
    + +
    +
    +
    +
    + Already have an account?{' '} + + Log in! + +
    +
    +
    + + ) +} + +export default SignupPage diff --git a/web/src/scaffold.css b/web/src/scaffold.css index ffa9142..82489e5 100644 --- a/web/src/scaffold.css +++ b/web/src/scaffold.css @@ -46,19 +46,19 @@ @apply bg-gray-100 p-4; } .rw-link { - @apply text-blue-400 underline; + @apply text-blue-400 underline font-inter; } .rw-link:hover { @apply text-blue-500; } .rw-forgot-link { - @apply mt-1 text-right text-xs text-gray-400 underline; + @apply mt-1 text-right text-xs text-gray-400 underline font-inter; } .rw-forgot-link:hover { @apply text-blue-500; } .rw-heading { - @apply font-semibold; + @apply font-semibold font-inter; } .rw-heading.rw-heading-primary { @apply text-xl; @@ -89,32 +89,11 @@ @apply mt-2 list-inside list-disc; } .rw-button { - @apply flex cursor-pointer justify-center rounded border-0 bg-gray-200 px-4 py-1 text-xs font-semibold uppercase leading-loose tracking-wide text-gray-500 no-underline transition duration-100; -} -.rw-button:hover { - @apply bg-gray-500 text-white; + @apply btn btn-primary font-inter } .rw-button.rw-button-small { @apply rounded-sm px-2 py-1 text-xs; } -.rw-button.rw-button-green { - @apply bg-green-500 text-white; -} -.rw-button.rw-button-green:hover { - @apply bg-green-700; -} -.rw-button.rw-button-blue { - @apply bg-blue-500 text-white; -} -.rw-button.rw-button-blue:hover { - @apply bg-blue-700; -} -.rw-button.rw-button-red { - @apply bg-red-500 text-white; -} -.rw-button.rw-button-red:hover { - @apply bg-red-700 text-white; -} .rw-button-icon { @apply mr-1 text-xl leading-5; } @@ -128,13 +107,13 @@ @apply mt-8; } .rw-label { - @apply mt-6 block text-left font-semibold text-gray-600; + @apply mt-6 block text-left font-semibold text-gray-600 font-inter; } .rw-label.rw-label-error { @apply text-red-600; } .rw-input { - @apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none; + @apply mt-2 block w-full rounded border border-gray-200 bg-white p-2 outline-none font-inter; } .rw-check-radio-items { @apply flex justify-items-center; @@ -157,7 +136,7 @@ box-shadow: 0 0 5px #c53030; } .rw-field-error { - @apply mt-1 block text-xs font-semibold uppercase text-red-600; + @apply mt-1 block text-xs font-semibold text-red-600 font-inter text-left; } .rw-table-wrapper-responsive { @apply overflow-x-auto; diff --git a/yarn.lock b/yarn.lock index 863ed8c..e2535bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4270,6 +4270,32 @@ __metadata: languageName: node linkType: hard +"@redwoodjs/auth-dbauth-api@npm:6.3.2": + version: 6.3.2 + resolution: "@redwoodjs/auth-dbauth-api@npm:6.3.2" + dependencies: + "@babel/runtime-corejs3": 7.22.15 + base64url: 3.0.1 + core-js: 3.32.2 + crypto-js: 4.1.1 + md5: 2.3.0 + uuid: 9.0.0 + checksum: f282ec993c7b25270cbc825070b736169e4924652e229948fd634e6835719e1133c9d623f81a0bd148b684f8382c7e94503f4a8d269c272d8a192d6d8f5aed23 + languageName: node + linkType: hard + +"@redwoodjs/auth-dbauth-web@npm:6.3.2": + version: 6.3.2 + resolution: "@redwoodjs/auth-dbauth-web@npm:6.3.2" + dependencies: + "@babel/runtime-corejs3": 7.22.15 + "@redwoodjs/auth": 6.3.2 + "@simplewebauthn/browser": 7.2.0 + core-js: 3.32.2 + checksum: 725297f0ba9935f80443d0a4aebb1e500db94c0761ebd79a189d9e835de9329a6ca62a707dec402085dcc8f6595392b1f2e00eecda7b13307b6437b8ccc26ff1 + languageName: node + linkType: hard + "@redwoodjs/auth@npm:6.3.2": version: 6.3.2 resolution: "@redwoodjs/auth@npm:6.3.2" @@ -4860,6 +4886,22 @@ __metadata: languageName: node linkType: hard +"@simplewebauthn/browser@npm:7.2.0": + version: 7.2.0 + resolution: "@simplewebauthn/browser@npm:7.2.0" + dependencies: + "@simplewebauthn/typescript-types": "*" + checksum: 7a12b06bb20e1f6214524603b5d030ec110c85dee1399946400315ee4fb9e1fcc79809a41851445b7f09513b56fb6e6fca5c78365e04dee7244e19b985672cb4 + languageName: node + linkType: hard + +"@simplewebauthn/typescript-types@npm:*": + version: 8.3.4 + resolution: "@simplewebauthn/typescript-types@npm:8.3.4" + checksum: 763384ad35c78d10b50b9108c3aee5e471cd4bf0d8ba3efb4a581901b57c1b5d3d425c2c86eb3f88823b4a86fd9d3d7d5bbb4d757c4eb8eeea427a4eb517a472 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -6698,6 +6740,7 @@ __metadata: resolution: "api@workspace:api" dependencies: "@redwoodjs/api": 6.3.2 + "@redwoodjs/auth-dbauth-api": 6.3.2 "@redwoodjs/graphql-server": 6.3.2 filestack-js: ^3.27.0 languageName: unknown @@ -7435,6 +7478,13 @@ __metadata: languageName: node linkType: hard +"base64url@npm:3.0.1": + version: 3.0.1 + resolution: "base64url@npm:3.0.1" + checksum: 5ca9d6064e9440a2a45749558dddd2549ca439a305793d4f14a900b7256b5f4438ef1b7a494e1addc66ced5d20f5c010716d353ed267e4b769e6c78074991241 + languageName: node + linkType: hard + "base@npm:^0.11.1": version: 0.11.2 resolution: "base@npm:0.11.2" @@ -8087,6 +8137,13 @@ __metadata: languageName: node linkType: hard +"charenc@npm:0.0.2": + version: 0.0.2 + resolution: "charenc@npm:0.0.2" + checksum: a45ec39363a16799d0f9365c8dd0c78e711415113c6f14787a22462ef451f5013efae8a28f1c058f81fc01f2a6a16955f7a5fd0cd56247ce94a45349c89877d8 + languageName: node + linkType: hard + "checkpoint-client@npm:1.1.27": version: 1.1.27 resolution: "checkpoint-client@npm:1.1.27" @@ -8966,6 +9023,13 @@ __metadata: languageName: node linkType: hard +"crypt@npm:0.0.2": + version: 0.0.2 + resolution: "crypt@npm:0.0.2" + checksum: adbf263441dd801665d5425f044647533f39f4612544071b1471962209d235042fb703c27eea2795c7c53e1dfc242405173003f83cf4f4761a633d11f9653f18 + languageName: node + linkType: hard + "crypto-browserify@npm:^3.11.0": version: 3.12.0 resolution: "crypto-browserify@npm:3.12.0" @@ -12886,7 +12950,7 @@ __metadata: languageName: node linkType: hard -"is-buffer@npm:^1.1.5": +"is-buffer@npm:^1.1.5, is-buffer@npm:~1.1.6": version: 1.1.6 resolution: "is-buffer@npm:1.1.6" checksum: ae18aa0b6e113d6c490ad1db5e8df9bdb57758382b313f5a22c9c61084875c6396d50bbf49315f5b1926d142d74dfb8d31b40d993a383e0a158b15fea7a82234 @@ -14831,6 +14895,17 @@ __metadata: languageName: node linkType: hard +"md5@npm:2.3.0": + version: 2.3.0 + resolution: "md5@npm:2.3.0" + dependencies: + charenc: 0.0.2 + crypt: 0.0.2 + is-buffer: ~1.1.6 + checksum: 14a21d597d92e5b738255fbe7fe379905b8cb97e0a49d44a20b58526a646ec5518c337b817ce0094ca94d3e81a3313879c4c7b510d250c282d53afbbdede9110 + languageName: node + linkType: hard + "mdn-data@npm:2.0.28": version: 2.0.28 resolution: "mdn-data@npm:2.0.28" @@ -20852,6 +20927,7 @@ __metadata: dependencies: "@mdi/js": ^7.3.67 "@mdi/react": ^1.6.1 + "@redwoodjs/auth-dbauth-web": 6.3.2 "@redwoodjs/forms": 6.3.2 "@redwoodjs/router": 6.3.2 "@redwoodjs/vite": 6.3.2