From 64736977cd7915b5de6ee7f76e2a5bb5792bff0f Mon Sep 17 00:00:00 2001 From: Graeme Ross Date: Tue, 15 Oct 2024 20:11:05 +0100 Subject: [PATCH] login/logout functionality --- .../20241015183559_dbauth/migration.sql | 35 +++ api/db/schema.prisma | 39 +-- api/package.json | 1 + api/src/functions/auth.ts | 208 ++++++++++++++++ api/src/functions/graphql.ts | 6 + api/src/lib/auth.ts | 131 ++++++++-- package.json | 1 + web/package.json | 1 + web/src/App.tsx | 6 +- web/src/Routes.tsx | 9 +- web/src/auth.ts | 5 + web/src/layouts/ClientLayout/ClientLayout.tsx | 225 ++++++++++-------- .../ForgotPasswordPage/ForgotPasswordPage.tsx | 94 ++++++++ web/src/pages/LoginPage/LoginPage.tsx | 133 +++++++++++ .../ResetPasswordPage/ResetPasswordPage.tsx | 121 ++++++++++ web/src/pages/SignupPage/SignupPage.tsx | 126 ++++++++++ yarn.lock | 97 ++++++++ 17 files changed, 1107 insertions(+), 131 deletions(-) create mode 100644 api/db/migrations/20241015183559_dbauth/migration.sql create mode 100644 api/src/functions/auth.ts create mode 100644 web/src/auth.ts 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/20241015183559_dbauth/migration.sql b/api/db/migrations/20241015183559_dbauth/migration.sql new file mode 100644 index 0000000..953b24a --- /dev/null +++ b/api/db/migrations/20241015183559_dbauth/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - You are about to drop the column `contactAddressId` on the `User` table. All the data in the column will be lost. + - Added the required column `hashedPassword` to the `User` table without a default value. This is not possible if the table is not empty. + - Added the required column `salt` to the `User` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateTable +CREATE TABLE "UserToContactAddress" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "userId" TEXT NOT NULL, + "contactAddressId" TEXT NOT NULL, + CONSTRAINT "UserToContactAddress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "UserToContactAddress_contactAddressId_fkey" FOREIGN KEY ("contactAddressId") REFERENCES "ContactAddress" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "hashedPassword" TEXT NOT NULL, + "salt" TEXT NOT NULL, + "resetToken" TEXT, + "resetTokenExpiresAt" DATETIME +); +INSERT INTO "new_User" ("email", "id", "userId") SELECT "email", "id", "userId" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 0cdeb80..7ceea93 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -30,25 +30,36 @@ model Account { } model ContactAddress { - id String @id @default(cuid()) - Name String // name of person who is using the pendant - Address1 String - Address2 String - Address3 String - Town String - PostalCode String - Account Account[] - User User[] + id String @id @default(cuid()) + Name String // name of person who is using the pendant + Address1 String + Address2 String + Address3 String + Town String + PostalCode String + Account Account[] + UserToContactAddress UserToContactAddress[] } model User { - id String @id @default(cuid()) - userId String @unique - email String + id String @id @default(cuid()) + userId String @unique + email String + hashedPassword String + salt String + resetToken String? + resetTokenExpiresAt DateTime? + accounts Account[] + roles Role[] + UserToContactAddress UserToContactAddress[] +} + +model UserToContactAddress { + id Int @id + user User @relation(fields: [userId], references: [id]) address ContactAddress @relation(fields: [contactAddressId], references: [id]) + userId String contactAddressId String - accounts Account[] - roles Role[] } model Role { diff --git a/api/package.json b/api/package.json index 6eb8208..3a81087 100644 --- a/api/package.json +++ b/api/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@redwoodjs/api": "8.3.0", + "@redwoodjs/auth-dbauth-api": "8.4.0", "@redwoodjs/graphql-server": "8.3.0" } } diff --git a/api/src/functions/auth.ts b/api/src/functions/auth.ts new file mode 100644 index 0000000..1991507 --- /dev/null +++ b/api/src/functions/auth.ts @@ -0,0 +1,208 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda' + +import { DbAuthHandler } 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' + +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. + // + // Note that this return value is sent to the client in *plain text* + // so don't include anything you wouldn't want prying eyes to see. The + // `user` here has been sanitized to only include the fields listed in + // `allowedUserFields` so it should be safe to return as-is. + handler: (user, _resetToken) => { + // TODO: Send user an email/message with a link to reset their password, + // including the `resetToken`. The URL should look something like: + // `http://localhost:8910/reset-password?resetToken=${resetToken}` + + 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: 'Username not found', + // if the user somehow gets around client validation + usernameRequired: 'Username 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 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: 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 { + name: 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: ({ + username, + hashedPassword, + salt, + userAttributes: _userAttributes, + }) => { + return db.user.create({ + data: { + email: username, + userId: username, + hashedPassword: hashedPassword, + salt: salt, + // name: userAttributes.name + }, + }) + }, + + // 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) => { + 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: 'email', + 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/lib/auth.ts b/api/src/lib/auth.ts index ef6d8c9..1e9ce5d 100644 --- a/api/src/lib/auth.ts +++ b/api/src/lib/auth.ts @@ -1,32 +1,121 @@ +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 !== 'string') { + 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 @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 } -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/package.json b/package.json index 1acf46a..e0554c6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ ] }, "devDependencies": { + "@redwoodjs/auth-dbauth-setup": "8.4.0", "@redwoodjs/core": "8.4.0", "@redwoodjs/project-config": "8.4.0", "prettier-plugin-tailwindcss": "^0.6.8" diff --git a/web/package.json b/web/package.json index fe2f6bb..96e9724 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ ] }, "dependencies": { + "@redwoodjs/auth-dbauth-web": "8.4.0", "@redwoodjs/forms": "8.4.0", "@redwoodjs/router": "8.4.0", "@redwoodjs/web": "8.4.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 6d20074..56f13b6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,6 +5,8 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' import FatalErrorPage from 'src/pages/FatalErrorPage' +import { AuthProvider, useAuth } from './auth' + import './index.css' import './scaffold.css' @@ -16,7 +18,9 @@ interface AppProps { const App = ({ children }: AppProps) => ( - {children} + + {children} + ) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index 2a59435..7b6aefd 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -9,13 +9,18 @@ import { Router, Route, Set } from '@redwoodjs/router' +import ClientLayout from 'src/layouts/ClientLayout/ClientLayout' import ScaffoldLayout from 'src/layouts/ScaffoldLayout' -import ClientLayout from 'src/layouts/ClientLayout/ClientLayout' +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/layouts/ClientLayout/ClientLayout.tsx b/web/src/layouts/ClientLayout/ClientLayout.tsx index 66080d2..e3d0e62 100644 --- a/web/src/layouts/ClientLayout/ClientLayout.tsx +++ b/web/src/layouts/ClientLayout/ClientLayout.tsx @@ -1,4 +1,6 @@ import { Link, routes } from '@redwoodjs/router' + +import { useAuth } from 'src/auth' import ThemeChanger from 'src/components/ThemeChanger/ThemeChanger' type ClientLayoutProps = { @@ -6,20 +8,35 @@ type ClientLayoutProps = { } const ClientLayout = ({ children }: ClientLayoutProps) => { + const { logOut } = useAuth() + return ( <>
{/* Page content here */} - +
- -
@@ -29,98 +46,120 @@ const ClientLayout = ({ children }: ClientLayoutProps) => {
-
-
- - - - 8 -
-
-
-
- 8 Items - Subtotal: $999 -
- +
+
+ + + + 8 +
+
+
+
+ 8 Items + Subtotal: $999 +
+ +
+
-
-
-
-
-
- Tailwind CSS Navbar component +
+
+
+ Tailwind CSS Navbar component +
+
+
- - -
-
{children}
- -
diff --git a/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx new file mode 100644 index 0000000..4d3f34f --- /dev/null +++ b/web/src/pages/ForgotPasswordPage/ForgotPasswordPage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react' + +import { Form, Label, TextField, Submit, FieldError } from '@redwoodjs/forms' +import { navigate, routes } from '@redwoodjs/router' +import { Metadata } 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 usernameRef = useRef(null) + useEffect(() => { + usernameRef?.current?.focus() + }, []) + + const onSubmit = async (data: { username: string }) => { + const response = await forgotPassword(data.username) + + 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..bf4e38e --- /dev/null +++ b/web/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,133 @@ +import { useEffect, useRef } from 'react' + +import { + Form, + Label, + TextField, + PasswordField, + Submit, + FieldError, +} from '@redwoodjs/forms' +import { Link, navigate, routes } from '@redwoodjs/router' +import { Metadata } 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 usernameRef = useRef(null) + useEffect(() => { + usernameRef.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 ( + <> + + +
+ +
+
+
+

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..191b39d --- /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 { Metadata } 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..e07f5ba --- /dev/null +++ b/web/src/pages/SignupPage/SignupPage.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef } from 'react' + +import { + Form, + Label, + TextField, + PasswordField, + FieldError, + Submit, +} from '@redwoodjs/forms' +import { Link, navigate, routes } from '@redwoodjs/router' +import { Metadata } 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 username box on page load + const usernameRef = useRef(null) + useEffect(() => { + usernameRef.current?.focus() + }, []) + + const onSubmit = async (data: Record) => { + const response = await signUp({ + username: data.username, + password: data.password, + }) + + 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/yarn.lock b/yarn.lock index c080a2d..c40a3f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4367,6 +4367,45 @@ __metadata: languageName: node linkType: hard +"@redwoodjs/auth-dbauth-api@npm:8.4.0": + version: 8.4.0 + resolution: "@redwoodjs/auth-dbauth-api@npm:8.4.0" + dependencies: + "@redwoodjs/project-config": "npm:8.4.0" + base64url: "npm:3.0.1" + md5: "npm:2.3.0" + uuid: "npm:10.0.0" + checksum: 10c0/3984773e14117b8fc5907efce22bbdb512aab3174a96216525a0c40bf40d9c90e7dca20331c9c3447df2d5ce9b53d8b99d38e1b653367474101f88c1e58ac739 + languageName: node + linkType: hard + +"@redwoodjs/auth-dbauth-setup@npm:8.4.0": + version: 8.4.0 + resolution: "@redwoodjs/auth-dbauth-setup@npm:8.4.0" + dependencies: + "@babel/runtime-corejs3": "npm:7.25.7" + "@prisma/internals": "npm:5.20.0" + "@redwoodjs/cli-helpers": "npm:8.4.0" + "@simplewebauthn/browser": "npm:7.4.0" + core-js: "npm:3.38.1" + prompts: "npm:2.4.2" + terminal-link: "npm:2.1.1" + checksum: 10c0/c2d4b528d55c503f3637f7587d7549c2ca0f0b9ab565657e566823db8cd4c04e03cb1395fe25b6edadf549db9d2222c3f913de4663d3a688b0786173214fa4cc + languageName: node + linkType: hard + +"@redwoodjs/auth-dbauth-web@npm:8.4.0": + version: 8.4.0 + resolution: "@redwoodjs/auth-dbauth-web@npm:8.4.0" + dependencies: + "@babel/runtime-corejs3": "npm:7.25.7" + "@redwoodjs/auth": "npm:8.4.0" + "@simplewebauthn/browser": "npm:7.4.0" + core-js: "npm:3.38.1" + checksum: 10c0/876fd9113e77809bb8c0fba0104f9f7b279694e283d963b4d061b12e965fef4855e43e8bcbcd3c599ad9ee187c89cb7c0c20c2c1b009afb75630b4fd8d52496c + languageName: node + linkType: hard + "@redwoodjs/auth@npm:8.4.0": version: 8.4.0 resolution: "@redwoodjs/auth@npm:8.4.0" @@ -5155,6 +5194,22 @@ __metadata: languageName: node linkType: hard +"@simplewebauthn/browser@npm:7.4.0": + version: 7.4.0 + resolution: "@simplewebauthn/browser@npm:7.4.0" + dependencies: + "@simplewebauthn/typescript-types": "npm:^7.4.0" + checksum: 10c0/cd69d51511e1bb75603b254b706194e8b7c3849e8f02fcb373cc8bb8c789df803a1bb900de7853c0cc63c0ad81fd56497ca63885638d566137afa387674099ad + languageName: node + linkType: hard + +"@simplewebauthn/typescript-types@npm:^7.4.0": + version: 7.4.0 + resolution: "@simplewebauthn/typescript-types@npm:7.4.0" + checksum: 10c0/b7aefd742d2f483531ff96509475571339660addba1f140883d8e489601d6a3a5b1c6759aa5ba27a9da5b502709aee9f060a4d4e57010f32c94eb5c42ef562a3 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.8 resolution: "@sinclair/typebox@npm:0.27.8" @@ -6370,6 +6425,7 @@ __metadata: resolution: "api@workspace:api" dependencies: "@redwoodjs/api": "npm:8.3.0" + "@redwoodjs/auth-dbauth-api": "npm:8.4.0" "@redwoodjs/graphql-server": "npm:8.3.0" languageName: unknown linkType: soft @@ -6995,6 +7051,13 @@ __metadata: languageName: node linkType: hard +"base64url@npm:3.0.1": + version: 3.0.1 + resolution: "base64url@npm:3.0.1" + checksum: 10c0/5ca9d6064e9440a2a45749558dddd2549ca439a305793d4f14a900b7256b5f4438ef1b7a494e1addc66ced5d20f5c010716d353ed267e4b769e6c78074991241 + languageName: node + linkType: hard + "binary-extensions@npm:^2.0.0": version: 2.3.0 resolution: "binary-extensions@npm:2.3.0" @@ -7502,6 +7565,13 @@ __metadata: languageName: node linkType: hard +"charenc@npm:0.0.2": + version: 0.0.2 + resolution: "charenc@npm:0.0.2" + checksum: 10c0/a45ec39363a16799d0f9365c8dd0c78e711415113c6f14787a22462ef451f5013efae8a28f1c058f81fc01f2a6a16955f7a5fd0cd56247ce94a45349c89877d8 + languageName: node + linkType: hard + "cheerio-select@npm:^2.1.0": version: 2.1.0 resolution: "cheerio-select@npm:2.1.0" @@ -8141,6 +8211,13 @@ __metadata: languageName: node linkType: hard +"crypt@npm:0.0.2": + version: 0.0.2 + resolution: "crypt@npm:0.0.2" + checksum: 10c0/adbf263441dd801665d5425f044647533f39f4612544071b1471962209d235042fb703c27eea2795c7c53e1dfc242405173003f83cf4f4761a633d11f9653f18 + languageName: node + linkType: hard + "crypto-browserify@npm:^3.11.0": version: 3.12.0 resolution: "crypto-browserify@npm:3.12.0" @@ -11211,6 +11288,13 @@ __metadata: languageName: node linkType: hard +"is-buffer@npm:~1.1.6": + version: 1.1.6 + resolution: "is-buffer@npm:1.1.6" + checksum: 10c0/ae18aa0b6e113d6c490ad1db5e8df9bdb57758382b313f5a22c9c61084875c6396d50bbf49315f5b1926d142d74dfb8d31b40d993a383e0a158b15fea7a82234 + languageName: node + linkType: hard + "is-callable@npm:^1.1.3, is-callable@npm:^1.1.4, is-callable@npm:^1.2.7": version: 1.2.7 resolution: "is-callable@npm:1.2.7" @@ -13014,6 +13098,17 @@ __metadata: languageName: node linkType: hard +"md5@npm:2.3.0": + version: 2.3.0 + resolution: "md5@npm:2.3.0" + dependencies: + charenc: "npm:0.0.2" + crypt: "npm:0.0.2" + is-buffer: "npm:~1.1.6" + checksum: 10c0/14a21d597d92e5b738255fbe7fe379905b8cb97e0a49d44a20b58526a646ec5518c337b817ce0094ca94d3e81a3313879c4c7b510d250c282d53afbbdede9110 + languageName: node + linkType: hard + "media-typer@npm:0.3.0": version: 0.3.0 resolution: "media-typer@npm:0.3.0" @@ -15483,6 +15578,7 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: + "@redwoodjs/auth-dbauth-setup": "npm:8.4.0" "@redwoodjs/core": "npm:8.4.0" "@redwoodjs/project-config": "npm:8.4.0" prettier-plugin-tailwindcss: "npm:^0.6.8" @@ -17448,6 +17544,7 @@ __metadata: version: 0.0.0-use.local resolution: "web@workspace:web" dependencies: + "@redwoodjs/auth-dbauth-web": "npm:8.4.0" "@redwoodjs/forms": "npm:8.4.0" "@redwoodjs/router": "npm:8.4.0" "@redwoodjs/vite": "npm:8.4.0"