From b61a80c9a023b3734733fc644313b1430a037dd6 Mon Sep 17 00:00:00 2001 From: Ahmed Al-Taiar Date: Wed, 14 Aug 2024 12:35:57 -0400 Subject: [PATCH] Singular (admin) account system --- .env.defaults | 14 +- .env.example | 11 + .../20240810184713_user/migration.sql | 18 + api/db/migrations/migration_lock.toml | 3 + api/db/schema.prisma | 10 + api/package.json | 9 +- api/src/functions/auth.ts | 231 +++++++++ api/src/functions/graphql.ts | 6 + api/src/graphql/users.sdl.ts | 15 + api/src/lib/auth.ts | 127 ++++- api/src/lib/email.ts | 39 ++ api/src/services/users/users.ts | 36 ++ package.json | 5 +- redwood.toml | 10 +- web/package.json | 9 +- web/src/App.tsx | 11 +- web/src/Routes.tsx | 29 +- web/src/auth.ts | 5 + .../ThemeToggle/ThemeToggle.stories.tsx | 26 - .../ThemeToggle/ThemeToggle.test.tsx | 14 - .../components/ThemeToggle/ThemeToggle.tsx | 7 +- .../ToastNotification.stories.tsx | 26 - .../ToastNotification.test.tsx | 14 - .../ToastNotification/ToastNotification.tsx | 26 +- .../ToasterWrapper/ToasterWrapper.tsx | 20 + .../AccountbarLayout.stories.tsx | 13 + .../AccountbarLayout.test.tsx} | 6 +- .../AccountbarLayout/AccountbarLayout.tsx | 35 ++ web/src/layouts/NavbarLayout/NavbarLayout.tsx | 38 +- .../ForgotPasswordPage/ForgotPasswordPage.tsx | 83 +++ web/src/pages/HomePage/HomePage.stories.tsx | 13 - web/src/pages/LoginPage/LoginPage.tsx | 115 +++++ web/src/pages/NotFoundPage/NotFoundPage.tsx | 52 +- .../ResetPasswordPage/ResetPasswordPage.tsx | 119 +++++ web/src/pages/SignupPage/SignupPage.tsx | 163 ++++++ web/src/scaffold.css | 397 ++++++++++++++ yarn.lock | 483 ++++++++++-------- 37 files changed, 1796 insertions(+), 442 deletions(-) create mode 100644 api/db/migrations/20240810184713_user/migration.sql create mode 100644 api/db/migrations/migration_lock.toml create mode 100644 api/src/functions/auth.ts create mode 100644 api/src/graphql/users.sdl.ts create mode 100644 api/src/lib/email.ts create mode 100644 api/src/services/users/users.ts create mode 100644 web/src/auth.ts delete mode 100644 web/src/components/ThemeToggle/ThemeToggle.stories.tsx delete mode 100644 web/src/components/ThemeToggle/ThemeToggle.test.tsx delete mode 100644 web/src/components/ToastNotification/ToastNotification.stories.tsx delete mode 100644 web/src/components/ToastNotification/ToastNotification.test.tsx create mode 100644 web/src/components/ToasterWrapper/ToasterWrapper.tsx create mode 100644 web/src/layouts/AccountbarLayout/AccountbarLayout.stories.tsx rename web/src/{pages/HomePage/HomePage.test.tsx => layouts/AccountbarLayout/AccountbarLayout.test.tsx} (68%) create mode 100644 web/src/layouts/AccountbarLayout/AccountbarLayout.tsx create mode 100644 web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx delete mode 100644 web/src/pages/HomePage/HomePage.stories.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 create mode 100644 web/src/scaffold.css diff --git a/.env.defaults b/.env.defaults index 6c3f094..d4c199b 100644 --- a/.env.defaults +++ b/.env.defaults @@ -3,9 +3,6 @@ # system. Any custom values should go in .env and .env should *not* be checked # into version control. -# schema.prisma defaults -DATABASE_URL=file:./dev.db - # location of the test database for api service scenarios (defaults to ./.redwood/test.db if not set) # TEST_DATABASE_URL=file:./.redwood/test.db @@ -18,4 +15,15 @@ PRISMA_HIDE_UPDATE_MESSAGE=true # Ordered by how verbose they are: trace | debug | info | warn | error | silent # LOG_LEVEL=debug +NAME=Ahmed Al-Taiar + +GMAIL=example@gmail.com +GMAIL_SMTP_PASSWORD=chan geme xyza bcde + +# Must not end with "/" +DOMAIN_PROD=https://example.com +DOMAIN_DEV=http://localhost:8910 + DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio + +SESSION_SECRET=regenerate_me diff --git a/.env.example b/.env.example index 18a1759..b48244f 100644 --- a/.env.example +++ b/.env.example @@ -3,4 +3,15 @@ # PRISMA_HIDE_UPDATE_MESSAGE=true # LOG_LEVEL=trace +NAME=Firstname Lastname + +GMAIL=example@gmail.com +GMAIL_SMTP_PASSWORD=chan geme xyza bcde + +# Must not end with "/" +DOMAIN_PROD=https://example.com +DOMAIN_DEV=http://localhost:8910 + DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio + +SESSION_SECRET=regenerate_me diff --git a/api/db/migrations/20240810184713_user/migration.sql b/api/db/migrations/20240810184713_user/migration.sql new file mode 100644 index 0000000..6bcd44f --- /dev/null +++ b/api/db/migrations/20240810184713_user/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "hashedPassword" TEXT NOT NULL, + "salt" TEXT NOT NULL, + "resetToken" TEXT, + "resetTokenExpiresAt" TIMESTAMP(3), + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/api/db/migrations/migration_lock.toml b/api/db/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/api/db/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 80da5a1..80e79b9 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -13,3 +13,13 @@ generator client { provider = "prisma-client-js" binaryTargets = "native" } + +model User { + id Int @id @default(autoincrement()) + username String @unique + email String @unique + hashedPassword String + salt String + resetToken String? + resetTokenExpiresAt DateTime? +} diff --git a/api/package.json b/api/package.json index 250f70e..86a587c 100644 --- a/api/package.json +++ b/api/package.json @@ -3,7 +3,12 @@ "version": "0.0.0", "private": true, "dependencies": { - "@redwoodjs/api": "7.7.3", - "@redwoodjs/graphql-server": "7.7.3" + "@redwoodjs/api": "7.7.4", + "@redwoodjs/auth-dbauth-api": "7.7.4", + "@redwoodjs/graphql-server": "7.7.4", + "nodemailer": "^6.9.14" + }, + "devDependencies": { + "@types/nodemailer": "^6.4.15" } } diff --git a/api/src/functions/auth.ts b/api/src/functions/auth.ts new file mode 100644 index 0000000..977450f --- /dev/null +++ b/api/src/functions/auth.ts @@ -0,0 +1,231 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda' + +import { + DbAuthHandler, + PasswordValidationError, +} from '@redwoodjs/auth-dbauth-api' +import type { DbAuthHandlerOptions, UserType } from '@redwoodjs/auth-dbauth-api' + +import { cookieName } from 'src/lib/auth' +import { db } from 'src/lib/db' +import { censorEmail, sendEmail } from 'src/lib/email' + +export const handler = async ( + event: APIGatewayProxyEvent, + context: Context +) => { + const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = { + handler: async (user, resetToken) => { + const domain = + (process.env.NODE_ENV || 'development') == 'production' + ? process.env.DOMAIN_PROD + : process.env.DOMAIN_DEV + + const text = `If this wasn't you, please disregard this email. + +Hello, + +You are receiving this email because a password reset was requested. +Enter the following URL to begin resetting your password: + +${domain}/reset-password?resetToken=${resetToken} + ` + + const html = text.replaceAll('\n', '
') + + try { + await sendEmail({ + to: user.email, + subject: 'Password Reset Request', + text, + html, + }) + + return { + email: censorEmail(user.email), + } + } catch (error) { + return { + error: `Error: ${error.code} ${error.responseCode}`, + } + } + }, + + // 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: 'Account 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 username and password are required', + usernameNotFound: 'Username ${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', + }, + + // 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: true, + + 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', + }, + } + + interface UserAttributes { + email: string + } + + const signupOptions: DbAuthHandlerOptions< + UserType, + UserAttributes + >['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: async ({ username, hashedPassword, salt, userAttributes }) => { + // Only one admin account should exist + if ((await db.user.count()) >= 1) + return { error: 'Admin account already exists' } + + if ( + new RegExp( + /^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/ + ).test(userAttributes.email) + ) + return db.user.create({ + data: { + username: username, + email: userAttributes.email, + hashedPassword: hashedPassword, + salt: salt, + }, + }) + else return { error: 'Invalid email' } + }, + + // 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 < 8) + throw new PasswordValidationError( + 'Password must be at least 8 characters' + ) + else return true + }, + + errors: { + // `field` will be either "username" or "password" + fieldMissing: '${field} is required', + usernameTaken: 'Username `${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: 'username', + hashedPassword: 'hashedPassword', + salt: 'salt', + resetToken: 'resetToken', + resetTokenExpiresAt: 'resetTokenExpiresAt', + }, + + // A list of fields on your user object that are safe to return to the + // client when invoking a handler that returns a user (like forgotPassword + // and signup). This list should be as small as possible to be sure not to + // leak any sensitive information to the client. + allowedUserFields: ['id', 'email'], + + // 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: { + attributes: { + 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', + }, + name: cookieName, + }, + + 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..e9c53e2 100644 --- a/api/src/functions/graphql.ts +++ b/api/src/functions/graphql.ts @@ -1,13 +1,19 @@ +import { createAuthDecoder } 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 { cookieName, getCurrentUser } from 'src/lib/auth' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' +const authDecoder = createAuthDecoder(cookieName) + export const handler = createGraphQLHandler({ + authDecoder, + getCurrentUser, loggerConfig: { logger, options: {} }, directives, sdls, diff --git a/api/src/graphql/users.sdl.ts b/api/src/graphql/users.sdl.ts new file mode 100644 index 0000000..7db79bd --- /dev/null +++ b/api/src/graphql/users.sdl.ts @@ -0,0 +1,15 @@ +export const schema = gql` + type User { + id: Int! + username: String! + email: String! + hashedPassword: String! + salt: String! + resetToken: String + resetTokenExpiresAt: DateTime + } + + type Query { + userCount: Int! @skipAuth + } +` diff --git a/api/src/lib/auth.ts b/api/src/lib/auth.ts index 4b0b887..d6af4c5 100644 --- a/api/src/lib/auth.ts +++ b/api/src/lib/auth.ts @@ -1,32 +1,117 @@ +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 name of the cookie that dbAuth sets * - * See https://redwoodjs.com/docs/authentication for more info. + * %port% will be replaced with the port the api server is running on. + * If you have multiple RW apps running on the same host, you'll need to + * make sure they all use unique cookie names */ -export const isAuthenticated = () => { - return true +export const cookieName = 'session_%port%' + +/** + * 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: + * + * 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 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 }, + }) } -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') { + // roles to check is a string, currentUser.roles is a string + if (typeof currentUserRoles === '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 } -export const getCurrentUser = async () => { - throw new Error( - 'Auth is not set up yet. See https://redwoodjs.com/docs/authentication ' + - 'to get started' - ) +/** + * 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/api/src/lib/email.ts b/api/src/lib/email.ts new file mode 100644 index 0000000..246b58d --- /dev/null +++ b/api/src/lib/email.ts @@ -0,0 +1,39 @@ +import * as nodemailer from 'nodemailer' + +interface Options { + to: string | string[] + subject: string + html: string + text: string +} + +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.GMAIL, + pass: process.env.GMAIL_SMTP_PASSWORD, + }, +}) + +export async function sendEmail({ to, subject, text, html }: Options) { + return await transporter.sendMail({ + from: `"${process.env.NAME} (noreply)" <${process.env.GMAIL}>`, + to: Array.isArray(to) ? to : [to], + subject, + text, + html, + }) +} + +export function censorEmail(email: string): string { + const [localPart, domain] = email.split('@') + + if (localPart.length <= 2) return `${localPart}@${domain}` + + const firstChar = localPart[0] + const lastChar = localPart[localPart.length - 1] + const middleLength = Math.min(localPart.length - 2, 7) + const middle = '∗'.repeat(middleLength) + + return `${firstChar}${middle}${lastChar}@${domain}` +} diff --git a/api/src/services/users/users.ts b/api/src/services/users/users.ts new file mode 100644 index 0000000..a708d87 --- /dev/null +++ b/api/src/services/users/users.ts @@ -0,0 +1,36 @@ +import type { QueryResolvers } from 'types/graphql' + +import { db } from 'src/lib/db' + +export const userCount: QueryResolvers['userCount'] = () => { + return db.user.count() +} + +// export const users: QueryResolvers['users'] = () => { +// return db.user.findMany() +// } + +// export const user: QueryResolvers['user'] = ({ id }) => { +// return db.user.findUnique({ +// where: { id }, +// }) +// } + +// export const createUser: MutationResolvers['createUser'] = ({ input }) => { +// return db.user.create({ +// data: input, +// }) +// } + +// export const updateUser: MutationResolvers['updateUser'] = ({ id, input }) => { +// return db.user.update({ +// data: input, +// where: { id }, +// }) +// } + +// export const deleteUser: MutationResolvers['deleteUser'] = ({ id }) => { +// return db.user.delete({ +// where: { id }, +// }) +// } diff --git a/package.json b/package.json index 7ec68f8..548bb23 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ ] }, "devDependencies": { - "@redwoodjs/core": "7.7.3", - "@redwoodjs/project-config": "7.7.3", + "@redwoodjs/auth-dbauth-setup": "7.7.4", + "@redwoodjs/core": "7.7.4", + "@redwoodjs/project-config": "7.7.4", "prettier-plugin-tailwindcss": "0.4.1" }, "eslintConfig": { diff --git a/redwood.toml b/redwood.toml index d2af1a9..1fc7205 100644 --- a/redwood.toml +++ b/redwood.toml @@ -6,13 +6,13 @@ # https://redwoodjs.com/docs/app-configuration-redwood-toml [web] - title = "Ahmed Al-Taiar" + title = "${NAME}" port = 8910 apiUrl = "/.redwood/functions" # You can customize graphql and dbauth urls individually too: see https://redwoodjs.com/docs/app-configuration-redwood-toml#api-paths - includeEnvironmentVariables = [ - # Add any ENV vars that should be available to the web side to this array - # See https://redwoodjs.com/docs/environment-variables#web - ] + includeEnvironmentVariables = ["NAME"] +[generate] + tests = false + stories = false [api] port = 8911 [browser] diff --git a/web/package.json b/web/package.json index e2b64dc..22c68db 100644 --- a/web/package.json +++ b/web/package.json @@ -13,14 +13,15 @@ "dependencies": { "@mdi/js": "^7.4.47", "@mdi/react": "^1.6.1", - "@redwoodjs/forms": "7.7.3", - "@redwoodjs/router": "7.7.3", - "@redwoodjs/web": "7.7.3", + "@redwoodjs/auth-dbauth-web": "7.7.4", + "@redwoodjs/forms": "7.7.4", + "@redwoodjs/router": "7.7.4", + "@redwoodjs/web": "7.7.4", "react": "18.2.0", "react-dom": "18.2.0" }, "devDependencies": { - "@redwoodjs/vite": "7.7.3", + "@redwoodjs/vite": "7.7.4", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.19", "autoprefixer": "^10.4.20", diff --git a/web/src/App.tsx b/web/src/App.tsx index 97fb5e0..f636dc8 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,14 +4,19 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' import FatalErrorPage from 'src/pages/FatalErrorPage' import Routes from 'src/Routes' +import './scaffold.css' +import { AuthProvider, useAuth } from './auth' + import './index.css' const App = () => ( - - - + + + + + ) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index 5a60174..7b98703 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -1,19 +1,28 @@ -// In this file, all Page components from 'src/pages` are auto-imported. Nested -// directories are supported, and should be uppercase. Each subdirectory will be -// prepended onto the component name. -// -// Examples: -// -// 'src/pages/HomePage/HomePage.js' -> HomePage -// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage - import { Router, Route, Set } from '@redwoodjs/router' +import { useAuth } from './auth' +import AccountbarLayout from './layouts/AccountbarLayout/AccountbarLayout' import NavbarLayout from './layouts/NavbarLayout/NavbarLayout' 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/ThemeToggle/ThemeToggle.stories.tsx b/web/src/components/ThemeToggle/ThemeToggle.stories.tsx deleted file mode 100644 index 910790e..0000000 --- a/web/src/components/ThemeToggle/ThemeToggle.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// 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 ThemeToggle from './ThemeToggle' - -const meta: Meta = { - component: ThemeToggle, - tags: ['autodocs'], -} - -export default meta - -type Story = StoryObj - -export const Primary: Story = {} diff --git a/web/src/components/ThemeToggle/ThemeToggle.test.tsx b/web/src/components/ThemeToggle/ThemeToggle.test.tsx deleted file mode 100644 index 09642ab..0000000 --- a/web/src/components/ThemeToggle/ThemeToggle.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { render } from '@redwoodjs/testing/web' - -import ThemeToggle from './ThemeToggle' - -// Improve this test with help from the Redwood Testing Doc: -// https://redwoodjs.com/docs/testing#testing-components - -describe('ThemeToggle', () => { - it('renders successfully', () => { - expect(() => { - render() - }).not.toThrow() - }) -}) diff --git a/web/src/components/ThemeToggle/ThemeToggle.tsx b/web/src/components/ThemeToggle/ThemeToggle.tsx index 18b3615..3a7bc0d 100644 --- a/web/src/components/ThemeToggle/ThemeToggle.tsx +++ b/web/src/components/ThemeToggle/ThemeToggle.tsx @@ -9,11 +9,8 @@ const ThemeToggle = () => { ) const handleToggle = (e) => { - if (e.target.checked) { - setTheme('dark') - } else { - setTheme('light') - } + if (e.target.checked) setTheme('dark') + else setTheme('light') } useEffect(() => { diff --git a/web/src/components/ToastNotification/ToastNotification.stories.tsx b/web/src/components/ToastNotification/ToastNotification.stories.tsx deleted file mode 100644 index 270318f..0000000 --- a/web/src/components/ToastNotification/ToastNotification.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// 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 ToastNotification from './ToastNotification' - -const meta: Meta = { - component: ToastNotification, - tags: ['autodocs'], -} - -export default meta - -type Story = StoryObj - -export const Primary: Story = {} diff --git a/web/src/components/ToastNotification/ToastNotification.test.tsx b/web/src/components/ToastNotification/ToastNotification.test.tsx deleted file mode 100644 index d87dfb3..0000000 --- a/web/src/components/ToastNotification/ToastNotification.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { render } from '@redwoodjs/testing/web' - -import ToastNotification from './ToastNotification' - -// Improve this test with help from the Redwood Testing Doc: -// https://redwoodjs.com/docs/testing#testing-components - -describe('Toast', () => { - it('renders successfully', () => { - expect(() => { - render() - }).not.toThrow() - }) -}) diff --git a/web/src/components/ToastNotification/ToastNotification.tsx b/web/src/components/ToastNotification/ToastNotification.tsx index 25d28a7..3a25567 100644 --- a/web/src/components/ToastNotification/ToastNotification.tsx +++ b/web/src/components/ToastNotification/ToastNotification.tsx @@ -37,22 +37,24 @@ const ToastNotification = ({ t, type, message }: Props) => {
- {iconElement} -

{message}

+
{iconElement}
+
+

{message}

+
{type !== 'loading' ? ( - - ) : ( - <> - )} +
+ +
+ ) : null}
) } diff --git a/web/src/components/ToasterWrapper/ToasterWrapper.tsx b/web/src/components/ToasterWrapper/ToasterWrapper.tsx new file mode 100644 index 0000000..1459c8e --- /dev/null +++ b/web/src/components/ToasterWrapper/ToasterWrapper.tsx @@ -0,0 +1,20 @@ +import { Toaster } from '@redwoodjs/web/dist/toast' + +import ToastNotification from '../ToastNotification/ToastNotification' + +const ToasterWrapper = () => ( + + {(t) => ( + + )} + +) + +export default ToasterWrapper diff --git a/web/src/layouts/AccountbarLayout/AccountbarLayout.stories.tsx b/web/src/layouts/AccountbarLayout/AccountbarLayout.stories.tsx new file mode 100644 index 0000000..aa37ce8 --- /dev/null +++ b/web/src/layouts/AccountbarLayout/AccountbarLayout.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import AccountbarLayout from './AccountbarLayout' + +const meta: Meta = { + component: AccountbarLayout, +} + +export default meta + +type Story = StoryObj + +export const Primary: Story = {} diff --git a/web/src/pages/HomePage/HomePage.test.tsx b/web/src/layouts/AccountbarLayout/AccountbarLayout.test.tsx similarity index 68% rename from web/src/pages/HomePage/HomePage.test.tsx rename to web/src/layouts/AccountbarLayout/AccountbarLayout.test.tsx index c684c7a..25c93a4 100644 --- a/web/src/pages/HomePage/HomePage.test.tsx +++ b/web/src/layouts/AccountbarLayout/AccountbarLayout.test.tsx @@ -1,14 +1,14 @@ import { render } from '@redwoodjs/testing/web' -import HomePage from './HomePage' +import AccountbarLayout from './AccountbarLayout' // Improve this test with help from the Redwood Testing Doc: // https://redwoodjs.com/docs/testing#testing-pages-layouts -describe('HomePage', () => { +describe('AccountbarLayout', () => { it('renders successfully', () => { expect(() => { - render() + render() }).not.toThrow() }) }) diff --git a/web/src/layouts/AccountbarLayout/AccountbarLayout.tsx b/web/src/layouts/AccountbarLayout/AccountbarLayout.tsx new file mode 100644 index 0000000..7c3bcd7 --- /dev/null +++ b/web/src/layouts/AccountbarLayout/AccountbarLayout.tsx @@ -0,0 +1,35 @@ +import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle' +import ToasterWrapper from 'src/components/ToasterWrapper/ToasterWrapper' + +type AccountbarLayoutProps = { + title: string + children?: React.ReactNode +} + +const AccountbarLayout = ({ title, children }: AccountbarLayoutProps) => { + return ( + <> + +
+
+
+

{title}

+
+
+

+ {title} +

+
+
+ +
+
+
+
+
{children}
+
+ + ) +} + +export default AccountbarLayout diff --git a/web/src/layouts/NavbarLayout/NavbarLayout.tsx b/web/src/layouts/NavbarLayout/NavbarLayout.tsx index f888d04..9e986fe 100644 --- a/web/src/layouts/NavbarLayout/NavbarLayout.tsx +++ b/web/src/layouts/NavbarLayout/NavbarLayout.tsx @@ -1,11 +1,11 @@ -import { mdiMenu } from '@mdi/js' +import { mdiMenu, mdiLogout } from '@mdi/js' import Icon from '@mdi/react' import { Link, routes } from '@redwoodjs/router' -import { Toaster } from '@redwoodjs/web/toast' +import { useAuth } from 'src/auth' import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle' -import ToastNotification from 'src/components/ToastNotification/ToastNotification' +import ToasterWrapper from 'src/components/ToasterWrapper/ToasterWrapper' interface NavbarRoute { name: string @@ -17,6 +17,8 @@ type NavbarLayoutProps = { } const NavbarLayout = ({ children }: NavbarLayoutProps) => { + const { isAuthenticated, logOut } = useAuth() + // TODO: populate with buttons to other page const navbarRoutes: NavbarRoute[] = [ { @@ -34,22 +36,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => { return ( <> - - {(t) => ( - - )} - +
@@ -76,7 +63,7 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => { to={routes.home()} className="btn btn-ghost hidden font-syne text-xl sm:flex" > - Ahmed Al-Taiar + {process.env.NAME}
@@ -84,13 +71,20 @@ const NavbarLayout = ({ children }: NavbarLayoutProps) => { to={routes.home()} className="btn btn-ghost font-syne text-xl sm:hidden" > - Ahmed Al-Taiar + {process.env.NAME}
{navbarButtons()}
-
+
+ {isAuthenticated ? ( + + ) : ( + <> + )}
diff --git a/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx new file mode 100644 index 0000000..68a690e --- /dev/null +++ b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react' + +import { mdiAccount } from '@mdi/js' +import Icon from '@mdi/react' + +import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' +import { toast } 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: { username: string }) => { + toast.loading('Processing...', { id: `processing` }) + + const response = await forgotPassword(data.username) + + if (response.error) toast.error(response.error, { id: `processing` }) + else { + toast.success( + `A link to reset your password was sent to ${response.email}`, + { id: `processing` } + ) + + navigate(routes.login()) + } + } + + return ( + <> + + +
+
+ + + +
+ Submit +
+ +
+ + ) +} + +export default ForgotPasswordPage diff --git a/web/src/pages/HomePage/HomePage.stories.tsx b/web/src/pages/HomePage/HomePage.stories.tsx deleted file mode 100644 index d9631ae..0000000 --- a/web/src/pages/HomePage/HomePage.stories.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' - -import HomePage from './HomePage' - -const meta: Meta = { - component: HomePage, -} - -export default meta - -type Story = StoryObj - -export const Primary: Story = {} diff --git a/web/src/pages/LoginPage/LoginPage.tsx b/web/src/pages/LoginPage/LoginPage.tsx new file mode 100644 index 0000000..0a96c07 --- /dev/null +++ b/web/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,115 @@ +import { useEffect, useRef } from 'react' + +import { mdiAccount, mdiKey } from '@mdi/js' +import Icon from '@mdi/react' + +import { + Form, + Label, + TextField, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { Metadata } from '@redwoodjs/web' +import { toast } 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.username, + password: data.password, + }) + + if (response.message) toast(response.message) + else if (response.error) toast.error(response.error) + else toast.success('Welcome back!') + } + + return ( + <> + + +
+
+ + + + + + +
+ Log In +
+ +
+ + ) +} + +export default LoginPage diff --git a/web/src/pages/NotFoundPage/NotFoundPage.tsx b/web/src/pages/NotFoundPage/NotFoundPage.tsx index 92ef916..0dbc3d2 100644 --- a/web/src/pages/NotFoundPage/NotFoundPage.tsx +++ b/web/src/pages/NotFoundPage/NotFoundPage.tsx @@ -1,44 +1,12 @@ +import { Link, routes } from '@redwoodjs/router' + +import ThemeToggle from 'src/components/ThemeToggle/ThemeToggle' + export default () => ( -
-