1
0

Accounts system, no RBAC yet

This commit is contained in:
Ahmed Al-Taiar
2023-10-31 18:41:57 -04:00
parent 1eaf76fce2
commit fcdacd844f
23 changed files with 1112 additions and 60 deletions

View File

@ -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");

View File

@ -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");

View File

@ -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;

View File

@ -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])
}

View File

@ -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"
}

185
api/src/functions/auth.ts Normal file
View File

@ -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()
}

View File

@ -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,

View File

@ -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 {

View File

@ -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.")
}
}

View File

@ -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",

View File

@ -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 = () => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<RedwoodApolloProvider>
<AuthProvider>
<RedwoodApolloProvider useAuth={useAuth}>
<Routes />
</RedwoodApolloProvider>
</AuthProvider>
</RedwoodProvider>
</FatalErrorBoundary>
)

View File

@ -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 (
<Router>
<Router useAuth={useAuth}>
<Route path="/login" page={LoginPage} name="login" />
<Route path="/signup" page={SignupPage} name="signup" />
<Route path="/forgot-password" page={ForgotPasswordPage} name="forgotPassword" />
<Route path="/reset-password" page={ResetPasswordPage} name="resetPassword" />
<Private unauthenticated="home">
<Set wrap={ScaffoldLayout} title="Parts" titleTo="parts" buttonLabel="New Part" buttonTo="newPart">
<Route path="/admin/parts/new" page={PartNewPartPage} name="newPart" />
<Route path="/admin/parts/{id:Int}/edit" page={PartEditPartPage} name="editPart" />
<Route path="/admin/parts/{id:Int}" page={PartPartPage} name="part" />
<Route path="/admin/parts" page={PartPartsPage} name="parts" />
</Set>
</Private>
<Set wrap={NavbarLayout}>
<Route path="/" page={HomePage} name="home" />
</Set>

5
web/src/auth.ts Normal file
View File

@ -0,0 +1,5 @@
import { createDbAuthClient, createAuth } from '@redwoodjs/auth-dbauth-web'
const dbAuthClient = createDbAuthClient()
export const { AuthProvider, useAuth } = createAuth(dbAuthClient)

View File

@ -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<typeof NavbarAccountIcon> = {
component: NavbarAccountIcon,
}
export default meta
type Story = StoryObj<typeof NavbarAccountIcon>
export const Primary: Story = {}

View File

@ -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(<NavbarAccountIcon />)
}).not.toThrow()
})
})

View File

@ -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 ? (
<div className={className}>
<details
className={`dropdown dropdown-end ${
mobile ? 'space-y-2' : 'space-y-4'
}`}
>
<summary className="btn btn-ghost swap swap-rotate w-12 hover:shadow-lg">
<Icon path={mdiAccount} className="h-8 w-8 text-gray-500" />
</summary>
<div className="dropdown-content flex w-auto flex-row items-center space-x-3 rounded-xl bg-base-100 p-3 shadow-lg">
<p className="whitespace-nowrap font-inter text-lg">
Hello, {currentUser.firstName}!
</p>
<button className="btn btn-ghost" type="button" onClick={logOut}>
<Icon path={mdiLogout} className="h-7 w-7 text-gray-600" />
</button>
</div>
</details>
</div>
) : (
<div className={className}>
<Link to={routes.login()}>
<button className="btn btn-ghost" type="button" onClick={logOut}>
<Icon path={mdiLogin} className="h-8 w-8 text-gray-500" />
</button>
</Link>
</div>
)
}
export default NavbarAccountIcon

View File

@ -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) => {
<>
<div className="navbar bg-base-100 shadow-lg">
<div className="justify-start space-x-3">
<Icon path={mdiChip} className="ml-3 h-10 text-logo" />
<Icon
path={mdiChip}
className="ml-3 hidden h-10 text-logo md:block"
/>
<Link
to={routes.home()}
className="btn btn-ghost items-center hover:shadow-lg"
@ -36,6 +40,7 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
</li>
</ul> */}
<ThemeToggle />
<NavbarAccountIcon mobile={false} className="hidden lg:block" />
<div className="lg:hidden">
<input
id="mobile-menu-drawer"
@ -57,7 +62,7 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
</label>
<ul className="min-h-full w-80 space-y-3 bg-base-100 p-3 text-base-content shadow-lg">
<li>
<div className="flex justify-center space-x-3">
<div className="flex items-center justify-between">
<Icon path={mdiChip} className="ml-3 h-10 text-logo" />
<Link
to={routes.home()}
@ -67,6 +72,7 @@ const NavBarLayout = ({ children }: NavBarLayoutProps) => {
Parts Inventory
</p>
</Link>
<NavbarAccountIcon mobile={true} />
</div>
</li>
{/* <li>

View File

@ -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<HTMLInputElement>(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 (
<>
<MetaTags title="Forgot Password" />
<main className="rw-main">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Forgot Password
</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<div className="text-left">
<Label
name="email"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Email
</Label>
<TextField
name="email"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={emailRef}
validation={{
required: {
value: true,
message: 'Email is required',
},
}}
/>
<FieldError name="email" className="rw-field-error" />
</div>
<div className="rw-button-group">
<Submit className="btn btn-primary font-inter">
Submit
</Submit>
</div>
</Form>
</div>
</div>
</div>
</div>
</main>
</>
)
}
export default ForgotPasswordPage

View File

@ -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<HTMLInputElement>(null)
useEffect(() => {
emailRef.current?.focus()
}, [])
const onSubmit = async (data: Record<string, string>) => {
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 (
<>
<MetaTags title="Login" />
<main className="rw-main">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">Login</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<Label
name="email"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Email
</Label>
<TextField
name="email"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={emailRef}
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
<FieldError name="email" className="rw-field-error" />
<Label
name="password"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Password
</Label>
<PasswordField
name="password"
className="rw-input"
errorClassName="rw-input rw-input-error"
autoComplete="current-password"
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
<FieldError name="password" className="rw-field-error" />
<div className="rw-forgot-link">
<Link
to={routes.forgotPassword()}
className="rw-forgot-link"
>
Forgot Password?
</Link>
</div>
<div className="rw-button-group">
<Submit className="btn btn-primary font-inter">
Login
</Submit>
</div>
</Form>
</div>
</div>
</div>
<div className="rw-login-link">
<span className="font-inter">Don&apos;t have an account?</span>{' '}
<Link to={routes.signup()} className="rw-link">
Sign up!
</Link>
</div>
</div>
</main>
</>
)
}
export default LoginPage

View File

@ -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<HTMLInputElement>(null)
useEffect(() => {
passwordRef.current?.focus()
}, [])
const onSubmit = async (data: Record<string, string>) => {
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 (
<>
<MetaTags title="Reset Password" />
<main className="rw-main">
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">
Reset Password
</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<div className="text-left">
<Label
name="password"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
New Password
</Label>
<PasswordField
name="password"
autoComplete="new-password"
className="rw-input"
errorClassName="rw-input rw-input-error"
disabled={!enabled}
ref={passwordRef}
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
<FieldError name="password" className="rw-field-error" />
</div>
<div className="rw-button-group">
<Submit
className="btn btn-primary font-inter"
disabled={!enabled}
>
Submit
</Submit>
</div>
</Form>
</div>
</div>
</div>
</div>
</main>
</>
)
}
export default ResetPasswordPage

View File

@ -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<HTMLInputElement>(null)
useEffect(() => {
nameRef.current?.focus()
}, [])
const onSubmit = async (data: Record<string, string>) => {
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 (
<>
<MetaTags title="Signup" />
<main>
<Toaster toastOptions={{ className: 'rw-toast', duration: 6000 }} />
<div className="rw-scaffold rw-login-container">
<div className="rw-segment">
<header className="rw-segment-header">
<h2 className="rw-heading rw-heading-secondary">Signup</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<div className="flex justify-between space-x-3">
<div>
<Label
name="firstName"
className="mt-6 block text-left font-semibold text-gray-600"
errorClassName="rw-label rw-label-error"
>
First Name
</Label>
<TextField
name="firstName"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={nameRef}
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
<FieldError name="firstName" className="rw-field-error" />
</div>
<div>
<Label
name="lastName"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Last Name
</Label>
<TextField
name="lastName"
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
<FieldError name="lastName" className="rw-field-error" />
</div>
</div>
<Label
name="email"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Email
</Label>
<TextField
name="email"
className="rw-input"
errorClassName="rw-input rw-input-error"
validation={{
required: {
value: true,
message: 'Required',
},
pattern: {
value: new RegExp(
/^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-]+)(\.[a-zA-Z]{2,5}){1,2}$/
),
message: 'Email is not valid',
},
}}
/>
<FieldError name="email" className="rw-field-error" />
<Label
name="password"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Password
</Label>
<PasswordField
name="password"
className="rw-input"
errorClassName="rw-input rw-input-error"
autoComplete="current-password"
validation={{
required: {
value: true,
message: 'Required',
},
}}
/>
<FieldError name="password" className="rw-field-error" />
<div className="rw-button-group">
<Submit className="btn btn-primary font-inter">
Sign Up
</Submit>
</div>
</Form>
</div>
</div>
</div>
<div className="rw-login-link">
<span className="font-inter">Already have an account?</span>{' '}
<Link to={routes.login()} className="rw-link">
Log in!
</Link>
</div>
</div>
</main>
</>
)
}
export default SignupPage

View File

@ -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;

View File

@ -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