user auth

This commit is contained in:
Michael Zhang 2024-12-04 01:57:49 -06:00
parent 84e368f0ed
commit 670fb5465e
22 changed files with 1805 additions and 26 deletions

View file

@ -0,0 +1,38 @@
-- CreateTable
CREATE TABLE "UserExample" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"name" TEXT
);
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"hashedPassword" TEXT NOT NULL,
"salt" TEXT NOT NULL,
"resetToken" TEXT,
"resetTokenExpiresAt" DATETIME,
"webAuthnChallenge" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "UserCredential" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" INTEGER NOT NULL,
"publicKey" BLOB NOT NULL,
"transports" TEXT,
"counter" BIGINT NOT NULL,
CONSTRAINT "UserCredential_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "UserExample_email_key" ON "UserExample"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_webAuthnChallenge_key" ON "User"("webAuthnChallenge");

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View file

@ -22,3 +22,25 @@ model UserExample {
email String @unique
name String?
}
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String
salt String
resetToken String?
resetTokenExpiresAt DateTime?
webAuthnChallenge String? @unique
credentials UserCredential[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserCredential {
id String @id
userId Int
user User @relation(fields: [userId], references: [id])
publicKey Bytes
transports String?
counter BigInt
}

View file

@ -4,6 +4,8 @@
"private": true,
"dependencies": {
"@redwoodjs/api": "8.4.1",
"@redwoodjs/graphql-server": "8.4.1"
"@redwoodjs/auth-dbauth-api": "8.4.1",
"@redwoodjs/graphql-server": "8.4.1",
"@simplewebauthn/server": "^7"
}
}

225
api/src/functions/auth.ts Normal file
View file

@ -0,0 +1,225 @@
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.
handler: (user) => {
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 logs 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 than 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,
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.
// ie. if your Prisma model is named `User` this value would be `user`, as in `db.user`
authModelAccessor: 'user',
// The name of the property you'd call on `db` to access your user credentials table.
// ie. if your Prisma model is named `UserCredential` this value would be `userCredential`, as in `db.userCredential`
credentialModelAccessor: 'userCredential',
// 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',
challenge: 'webAuthnChallenge',
},
// 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' ? true : false,
// 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,
// See https://redwoodjs.com/docs/authentication/dbauth#webauthn for options
webAuthn: {
enabled: true,
// How long to allow re-auth via WebAuthn in seconds (default is 10 years).
// The `login.expires` time denotes how many seconds before a user will be
// logged out, and this value is how long they'll be to continue to use a
// fingerprint/face scan to log in again. When this one expires they
// *must* re-enter username and password to authenticate (WebAuthn will
// then be re-enabled for this amount of time).
expires: 60 * 60 * 24 * 365 * 10,
name: 'Redwood Application',
domain:
process.env.NODE_ENV === 'development' ? 'localhost' : 'server.com',
origin:
process.env.NODE_ENV === 'development'
? 'http://localhost:8910'
: 'https://server.com',
type: 'platform',
timeout: 60000,
credentialFields: {
id: 'id',
userId: 'userId',
publicKey: 'publicKey',
transports: 'transports',
counter: 'counter',
},
},
})
return await authHandler.invoke()
}

View file

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

View file

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

View file

@ -3,7 +3,7 @@
"noEmit": true,
"allowJs": true,
"esModuleInterop": true,
"target": "ES2023",
"target": "ESNext",
"module": "Node16",
"moduleResolution": "Node16",
"skipLibCheck": false,

View file

@ -7,6 +7,7 @@
]
},
"devDependencies": {
"@redwoodjs/auth-dbauth-setup": "8.4.1",
"@redwoodjs/core": "8.4.1",
"@redwoodjs/project-config": "8.4.1"
},

View file

@ -11,9 +11,11 @@
]
},
"dependencies": {
"@redwoodjs/auth-dbauth-web": "8.4.1",
"@redwoodjs/forms": "8.4.1",
"@redwoodjs/router": "8.4.1",
"@redwoodjs/web": "8.4.1",
"@simplewebauthn/browser": "^7",
"react": "18.3.1",
"react-dom": "18.3.1"
},

View file

@ -5,7 +5,11 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo'
import FatalErrorPage from 'src/pages/FatalErrorPage'
import { AuthProvider, useAuth } from './auth'
import './index.css'
import './scaffold.css'
interface AppProps {
children?: ReactNode
@ -14,7 +18,9 @@ interface AppProps {
const App = ({ children }: AppProps) => (
<FatalErrorBoundary page={FatalErrorPage}>
<RedwoodProvider titleTemplate="%PageTitle | %AppTitle">
<RedwoodApolloProvider>{children}</RedwoodApolloProvider>
<AuthProvider>
<RedwoodApolloProvider useAuth={useAuth}>{children}</RedwoodApolloProvider>
</AuthProvider>
</RedwoodProvider>
</FatalErrorBoundary>
)

View file

@ -9,9 +9,16 @@
import { Router, Route } from '@redwoodjs/router'
import { useAuth } from './auth'
const Routes = () => {
return (
<Router>
<Router useAuth={useAuth}>
<Route path="/" page={HomePage} name="home" />
<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" />
<Route notfound page={NotFoundPage} />
</Router>
)

6
web/src/auth.ts Normal file
View file

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

View file

@ -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<HTMLInputElement>(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 (
<>
<Metadata 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="username"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Username
</Label>
<TextField
name="username"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={usernameRef}
validation={{
required: {
value: true,
message: 'Username is required',
},
}}
/>
<FieldError name="username" className="rw-field-error" />
</div>
<div className="rw-button-group">
<Submit className="rw-button rw-button-blue">Submit</Submit>
</div>
</Form>
</div>
</div>
</div>
</div>
</main>
</>
)
}
export default ForgotPasswordPage

View file

@ -0,0 +1,13 @@
import type { Meta, StoryObj } from '@storybook/react'
import HomePage from './HomePage'
const meta: Meta<typeof HomePage> = {
component: HomePage,
}
export default meta
type Story = StoryObj<typeof HomePage>
export const Primary: Story = {}

View file

@ -0,0 +1,14 @@
import { render } from '@redwoodjs/testing/web'
import HomePage from './HomePage'
// Improve this test with help from the Redwood Testing Doc:
// https://redwoodjs.com/docs/testing#testing-pages-layouts
describe('HomePage', () => {
it('renders successfully', () => {
expect(() => {
render(<HomePage />)
}).not.toThrow()
})
})

View file

@ -0,0 +1,27 @@
// import { Link, routes } from '@redwoodjs/router'
import { Metadata } from '@redwoodjs/web'
import { useAuth } from 'src/auth'
const HomePage = () => {
const authInfo = useAuth();
return (
<>
<Metadata title="Home" description="Home page" />
<h1>HomePage</h1>
Auth: {JSON.stringify(authInfo)}
<p>
Find me in <code>./web/src/pages/HomePage/HomePage.tsx</code>
</p>
{/*
My default route is named `home`, link to me with:
`<Link to={routes.home()}>Home</Link>`
*/}
</>
)
}
export default HomePage

View file

@ -0,0 +1,267 @@
import { useEffect, useRef, useState } 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 WELCOME_MESSAGE = 'Welcome back!'
const REDIRECT = routes.home()
const LoginPage = ({ type }) => {
const {
isAuthenticated,
client: webAuthn,
loading,
logIn,
reauthenticate,
} = useAuth()
const [shouldShowWebAuthn, setShouldShowWebAuthn] = useState(false)
const [showWebAuthn, setShowWebAuthn] = useState(
webAuthn.isEnabled() && type !== 'password'
)
// should redirect right after login or wait to show the webAuthn prompts?
useEffect(() => {
if (isAuthenticated && (!shouldShowWebAuthn || webAuthn.isEnabled())) {
navigate(REDIRECT)
}
}, [isAuthenticated, shouldShowWebAuthn])
// if WebAuthn is enabled, show the prompt as soon as the page loads
useEffect(() => {
if (!loading && !isAuthenticated && showWebAuthn) {
onAuthenticate()
}
}, [loading, isAuthenticated])
// focus on the username field as soon as the page loads
const usernameRef = useRef()
useEffect(() => {
usernameRef.current && usernameRef.current.focus()
}, [])
const onSubmit = async (data) => {
const webAuthnSupported = await webAuthn.isSupported()
if (webAuthnSupported) {
setShouldShowWebAuthn(true)
}
const response = await logIn({
username: data.username,
password: data.password,
})
if (response.message) {
// auth details good, but user not logged in
toast(response.message)
} else if (response.error) {
// error while authenticating
toast.error(response.error)
} else {
// user logged in
if (webAuthnSupported) {
setShowWebAuthn(true)
} else {
toast.success(WELCOME_MESSAGE)
}
}
}
const onAuthenticate = async () => {
try {
await webAuthn.authenticate()
await reauthenticate()
toast.success(WELCOME_MESSAGE)
navigate(REDIRECT)
} catch (e) {
if (e.name === 'WebAuthnDeviceNotFoundError') {
toast.error(
'Device not found, log in with Username/Password to continue'
)
setShowWebAuthn(false)
} else {
toast.error(e.message)
}
}
}
const onRegister = async () => {
try {
await webAuthn.register()
toast.success(WELCOME_MESSAGE)
navigate(REDIRECT)
} catch (e) {
toast.error(e.message)
}
}
const onSkip = () => {
toast.success(WELCOME_MESSAGE)
setShouldShowWebAuthn(false)
}
const AuthWebAuthnPrompt = () => {
return (
<div className="rw-webauthn-wrapper">
<h2>WebAuthn Login Enabled</h2>
<p>Log in with your fingerprint, face or PIN</p>
<div className="rw-button-group">
<button className="rw-button rw-button-blue" onClick={onAuthenticate}>
Open Authenticator
</button>
</div>
</div>
)
}
const RegisterWebAuthnPrompt = () => (
<div className="rw-webauthn-wrapper">
<h2>No more Passwords!</h2>
<p>
Depending on your device you can log in with your fingerprint, face or
PIN next time.
</p>
<div className="rw-button-group">
<button className="rw-button rw-button-blue" onClick={onRegister}>
Turn On
</button>
<button className="rw-button" onClick={onSkip}>
Skip for now
</button>
</div>
</div>
)
const PasswordForm = () => (
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<Label
name="username"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Username
</Label>
<TextField
name="username"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={usernameRef}
autoFocus
validation={{
required: {
value: true,
message: 'Username is required',
},
}}
/>
<FieldError name="username" 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: 'Password is required',
},
}}
/>
<div className="rw-forgot-link">
<Link to={routes.forgotPassword()} className="rw-forgot-link">
Forgot Password?
</Link>
</div>
<FieldError name="password" className="rw-field-error" />
<div className="rw-button-group">
<Submit className="rw-button rw-button-blue">Login</Submit>
</div>
</Form>
)
const formToRender = () => {
if (showWebAuthn) {
if (webAuthn.isEnabled()) {
return <AuthWebAuthnPrompt />
} else {
return <RegisterWebAuthnPrompt />
}
} else {
return <PasswordForm />
}
}
const linkToRender = () => {
if (showWebAuthn) {
if (webAuthn.isEnabled()) {
return (
<div className="rw-login-link">
<span>or login with </span>{' '}
<a href="?type=password" className="rw-link">
username and password
</a>
</div>
)
}
} else {
return (
<div className="rw-login-link">
<span>Don&apos;t have an account?</span>{' '}
<Link to={routes.signup()} className="rw-link">
Sign up!
</Link>
</div>
)
}
}
if (loading) {
return null
}
return (
<>
<Metadata 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">{formToRender()}</div>
</div>
</div>
{linkToRender()}
</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 { 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<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 (
<>
<Metadata 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: 'New Password is required',
},
}}
/>
<FieldError name="password" className="rw-field-error" />
</div>
<div className="rw-button-group">
<Submit
className="rw-button rw-button-blue"
disabled={!enabled}
>
Submit
</Submit>
</div>
</Form>
</div>
</div>
</div>
</div>
</main>
</>
)
}
export default ResetPasswordPage

View file

@ -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<HTMLInputElement>(null)
useEffect(() => {
usernameRef.current?.focus()
}, [])
const onSubmit = async (data: Record<string, string>) => {
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 (
<>
<Metadata title="Signup" />
<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">Signup</h2>
</header>
<div className="rw-segment-main">
<div className="rw-form-wrapper">
<Form onSubmit={onSubmit} className="rw-form-wrapper">
<Label
name="username"
className="rw-label"
errorClassName="rw-label rw-label-error"
>
Username
</Label>
<TextField
name="username"
className="rw-input"
errorClassName="rw-input rw-input-error"
ref={usernameRef}
validation={{
required: {
value: true,
message: 'Username is required',
},
}}
/>
<FieldError name="username" 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: 'Password is required',
},
}}
/>
<FieldError name="password" className="rw-field-error" />
<div className="rw-button-group">
<Submit className="rw-button rw-button-blue">
Sign Up
</Submit>
</div>
</Form>
</div>
</div>
</div>
<div className="rw-login-link">
<span>Already have an account?</span>{' '}
<Link to={routes.login()} className="rw-link">
Log in!
</Link>
</div>
</div>
</main>
</>
)
}
export default SignupPage

397
web/src/scaffold.css Normal file
View file

@ -0,0 +1,397 @@
/*
normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css
*/
.rw-scaffold *,
.rw-scaffold ::after,
.rw-scaffold ::before {
box-sizing: inherit;
}
.rw-scaffold main {
color: #4a5568;
display: block;
}
.rw-scaffold h1,
.rw-scaffold h2 {
margin: 0;
}
.rw-scaffold a {
background-color: transparent;
}
.rw-scaffold ul {
margin: 0;
padding: 0;
}
.rw-scaffold input {
font-family: inherit;
font-size: 100%;
overflow: visible;
}
.rw-scaffold input:-ms-input-placeholder {
color: #a0aec0;
}
.rw-scaffold input::-ms-input-placeholder {
color: #a0aec0;
}
.rw-scaffold input::placeholder {
color: #a0aec0;
}
.rw-scaffold table {
border-collapse: collapse;
}
/*
Style
*/
.rw-scaffold,
.rw-toast {
background-color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
}
.rw-header {
display: flex;
justify-content: space-between;
padding: 1rem 2rem 1rem 2rem;
}
.rw-main {
margin-left: 1rem;
margin-right: 1rem;
padding-bottom: 1rem;
}
.rw-segment {
border-radius: 0.5rem;
border-width: 1px;
border-color: #e5e7eb;
overflow: hidden;
width: 100%;
scrollbar-color: #a1a1aa transparent;
}
.rw-segment::-webkit-scrollbar {
height: initial;
}
.rw-segment::-webkit-scrollbar-track {
background-color: transparent;
border-color: #e2e8f0;
border-style: solid;
border-radius: 0 0 10px 10px;
border-width: 1px 0 0 0;
padding: 2px;
}
.rw-segment::-webkit-scrollbar-thumb {
background-color: #a1a1aa;
background-clip: content-box;
border: 3px solid transparent;
border-radius: 10px;
}
.rw-segment-header {
background-color: #e2e8f0;
color: #4a5568;
padding: 0.75rem 1rem;
}
.rw-segment-main {
background-color: #f7fafc;
padding: 1rem;
}
.rw-link {
color: #4299e1;
text-decoration: underline;
}
.rw-link:hover {
color: #2b6cb0;
}
.rw-forgot-link {
font-size: 0.75rem;
color: #a0aec0;
text-align: right;
margin-top: 0.1rem;
}
.rw-forgot-link:hover {
font-size: 0.75rem;
color: #4299e1;
}
.rw-heading {
font-weight: 600;
}
.rw-heading.rw-heading-primary {
font-size: 1.25rem;
}
.rw-heading.rw-heading-secondary {
font-size: 0.875rem;
}
.rw-heading .rw-link {
color: #4a5568;
text-decoration: none;
}
.rw-heading .rw-link:hover {
color: #1a202c;
text-decoration: underline;
}
.rw-cell-error {
font-size: 90%;
font-weight: 600;
}
.rw-form-wrapper {
box-sizing: border-box;
font-size: 0.875rem;
margin-top: -1rem;
}
.rw-cell-error,
.rw-form-error-wrapper {
padding: 1rem;
background-color: #fff5f5;
color: #c53030;
border-width: 1px;
border-color: #feb2b2;
border-radius: 0.25rem;
margin: 1rem 0;
}
.rw-form-error-title {
margin-top: 0;
margin-bottom: 0;
font-weight: 600;
}
.rw-form-error-list {
margin-top: 0.5rem;
list-style-type: disc;
list-style-position: inside;
}
.rw-button {
border: none;
color: #718096;
cursor: pointer;
display: flex;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 1rem;
text-transform: uppercase;
text-decoration: none;
letter-spacing: 0.025em;
border-radius: 0.25rem;
line-height: 2;
border: 0;
}
.rw-button:hover {
background-color: #718096;
color: #fff;
}
.rw-button.rw-button-small {
font-size: 0.75rem;
border-radius: 0.125rem;
padding: 0.25rem 0.5rem;
line-height: inherit;
}
.rw-button.rw-button-green {
background-color: #48bb78;
color: #fff;
}
.rw-button.rw-button-green:hover {
background-color: #38a169;
color: #fff;
}
.rw-button.rw-button-blue {
background-color: #3182ce;
color: #fff;
}
.rw-button.rw-button-blue:hover {
background-color: #2b6cb0;
}
.rw-button.rw-button-red {
background-color: #e53e3e;
color: #fff;
}
.rw-button.rw-button-red:hover {
background-color: #c53030;
}
.rw-button-icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 0.25rem;
}
.rw-button-group {
display: flex;
justify-content: center;
margin: 0.75rem 0.5rem;
}
.rw-button-group .rw-button {
margin: 0 0.25rem;
}
.rw-form-wrapper .rw-button-group {
margin-top: 2rem;
margin-bottom: 0;
}
.rw-label {
display: block;
margin-top: 1.5rem;
color: #4a5568;
font-weight: 600;
text-align: left;
}
.rw-label.rw-label-error {
color: #c53030;
}
.rw-input {
display: block;
margin-top: 0.5rem;
width: 100%;
padding: 0.5rem;
border-width: 1px;
border-style: solid;
border-color: #e2e8f0;
color: #4a5568;
border-radius: 0.25rem;
outline: none;
}
.rw-check-radio-item-none {
color: #4a5568;
}
.rw-check-radio-items {
display: flex;
justify-items: center;
}
.rw-input[type='checkbox'] {
display: inline;
width: 1rem;
margin-left: 0;
margin-right: 0.5rem;
margin-top: 0.25rem;
}
.rw-input[type='radio'] {
display: inline;
width: 1rem;
margin-left: 0;
margin-right: 0.5rem;
margin-top: 0.25rem;
}
.rw-input:focus {
border-color: #a0aec0;
}
.rw-input-error {
border-color: #c53030;
color: #c53030;
}
.rw-input-error:focus {
outline: none;
border-color: #c53030;
box-shadow: 0 0 5px #c53030;
}
.rw-field-error {
display: block;
margin-top: 0.25rem;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
color: #c53030;
}
.rw-table-wrapper-responsive {
overflow-x: auto;
}
.rw-table-wrapper-responsive .rw-table {
min-width: 48rem;
}
.rw-table {
table-layout: auto;
width: 100%;
font-size: 0.875rem;
}
.rw-table th,
.rw-table td {
padding: 0.75rem;
}
.rw-table td {
background-color: #ffffff;
color: #1a202c;
}
.rw-table tr:nth-child(odd) td,
.rw-table tr:nth-child(odd) th {
background-color: #f7fafc;
}
.rw-table thead tr {
color: #4a5568;
}
.rw-table th {
font-weight: 600;
text-align: left;
}
.rw-table thead th {
background-color: #e2e8f0;
text-align: left;
}
.rw-table tbody th {
text-align: right;
}
@media (min-width: 768px) {
.rw-table tbody th {
width: 20%;
}
}
.rw-table tbody tr {
border-top-width: 1px;
}
.rw-table input {
margin-left: 0;
}
.rw-table-actions {
display: flex;
justify-content: flex-end;
align-items: center;
height: 17px;
padding-right: 0.25rem;
}
.rw-table-actions .rw-button {
background-color: transparent;
}
.rw-table-actions .rw-button:hover {
background-color: #718096;
color: #fff;
}
.rw-table-actions .rw-button-blue {
color: #3182ce;
}
.rw-table-actions .rw-button-blue:hover {
background-color: #3182ce;
color: #fff;
}
.rw-table-actions .rw-button-red {
color: #e53e3e;
}
.rw-table-actions .rw-button-red:hover {
background-color: #e53e3e;
color: #fff;
}
.rw-text-center {
text-align: center;
}
.rw-login-container {
display: flex;
align-items: center;
justify-content: center;
width: 24rem;
margin: 4rem auto;
flex-wrap: wrap;
}
.rw-login-container .rw-form-wrapper {
width: 100%;
text-align: center;
}
.rw-login-link {
margin-top: 1rem;
color: #4a5568;
font-size: 90%;
text-align: center;
flex-basis: 100%;
}
.rw-webauthn-wrapper {
margin: 1.5rem 1rem 1rem;
line-height: 1.4;
}
.rw-webauthn-wrapper h2 {
font-size: 150%;
font-weight: bold;
margin-bottom: 1rem;
}

315
yarn.lock
View file

@ -1673,6 +1673,48 @@ __metadata:
languageName: node
linkType: hard
"@cbor-extract/cbor-extract-darwin-arm64@npm:2.2.0":
version: 2.2.0
resolution: "@cbor-extract/cbor-extract-darwin-arm64@npm:2.2.0"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@cbor-extract/cbor-extract-darwin-x64@npm:2.2.0":
version: 2.2.0
resolution: "@cbor-extract/cbor-extract-darwin-x64@npm:2.2.0"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@cbor-extract/cbor-extract-linux-arm64@npm:2.2.0":
version: 2.2.0
resolution: "@cbor-extract/cbor-extract-linux-arm64@npm:2.2.0"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@cbor-extract/cbor-extract-linux-arm@npm:2.2.0":
version: 2.2.0
resolution: "@cbor-extract/cbor-extract-linux-arm@npm:2.2.0"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@cbor-extract/cbor-extract-linux-x64@npm:2.2.0":
version: 2.2.0
resolution: "@cbor-extract/cbor-extract-linux-x64@npm:2.2.0"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@cbor-extract/cbor-extract-win32-x64@npm:2.2.0":
version: 2.2.0
resolution: "@cbor-extract/cbor-extract-win32-x64@npm:2.2.0"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@chevrotain/cst-dts-gen@npm:10.5.0":
version: 10.5.0
resolution: "@chevrotain/cst-dts-gen@npm:10.5.0"
@ -3382,6 +3424,13 @@ __metadata:
languageName: node
linkType: hard
"@hexagon/base64@npm:^1.1.25":
version: 1.1.28
resolution: "@hexagon/base64@npm:1.1.28"
checksum: 10c0/f90876938cda7c369444f9abcf268a9d93fe26269ae9a16a95fa1f294a5e8f9d172a77905fac205d315b4538ab4da90c866ba73cc035cc0fcf5a6062bb6691ca
languageName: node
linkType: hard
"@humanwhocodes/config-array@npm:^0.13.0":
version: 0.13.0
resolution: "@humanwhocodes/config-array@npm:0.13.0"
@ -4170,7 +4219,42 @@ __metadata:
languageName: node
linkType: hard
"@peculiar/asn1-schema@npm:^2.3.13, @peculiar/asn1-schema@npm:^2.3.8":
"@peculiar/asn1-android@npm:^2.3.3":
version: 2.3.13
resolution: "@peculiar/asn1-android@npm:2.3.13"
dependencies:
"@peculiar/asn1-schema": "npm:^2.3.13"
asn1js: "npm:^3.0.5"
tslib: "npm:^2.6.2"
checksum: 10c0/4253299c126ea948ded7c690c71a54e9b9623ec4768126314418cc6d5e634e2c90632845e387fe83412977b1c68273f9a03c6baae99a2cd79fd8a5ef069275ac
languageName: node
linkType: hard
"@peculiar/asn1-ecc@npm:^2.3.4":
version: 2.3.14
resolution: "@peculiar/asn1-ecc@npm:2.3.14"
dependencies:
"@peculiar/asn1-schema": "npm:^2.3.13"
"@peculiar/asn1-x509": "npm:^2.3.13"
asn1js: "npm:^3.0.5"
tslib: "npm:^2.6.2"
checksum: 10c0/01b9f4602c84c2bc4a4829a297983118265820d936b9fd9ad698fc35a8fb7a940f764036719874e42b548b4c3ff264d23628b26527e51c8f7a95f3e7ab8fc672
languageName: node
linkType: hard
"@peculiar/asn1-rsa@npm:^2.3.4":
version: 2.3.13
resolution: "@peculiar/asn1-rsa@npm:2.3.13"
dependencies:
"@peculiar/asn1-schema": "npm:^2.3.13"
"@peculiar/asn1-x509": "npm:^2.3.13"
asn1js: "npm:^3.0.5"
tslib: "npm:^2.6.2"
checksum: 10c0/e36370f2c3c3b400d890c965251530fbf1ca0781fc1fa4140774f8499aad7e3e3b466832f1ad63f8660ba829e049ae995d75384f07aa79db4e546998e25162e1
languageName: node
linkType: hard
"@peculiar/asn1-schema@npm:^2.3.13, @peculiar/asn1-schema@npm:^2.3.3, @peculiar/asn1-schema@npm:^2.3.8":
version: 2.3.13
resolution: "@peculiar/asn1-schema@npm:2.3.13"
dependencies:
@ -4181,6 +4265,19 @@ __metadata:
languageName: node
linkType: hard
"@peculiar/asn1-x509@npm:^2.3.13, @peculiar/asn1-x509@npm:^2.3.4":
version: 2.3.13
resolution: "@peculiar/asn1-x509@npm:2.3.13"
dependencies:
"@peculiar/asn1-schema": "npm:^2.3.13"
asn1js: "npm:^3.0.5"
ipaddr.js: "npm:^2.1.0"
pvtsutils: "npm:^1.3.5"
tslib: "npm:^2.6.2"
checksum: 10c0/3135b76ab9039d071e149d7e9be42217ae64f5e0c51038e91266e13e1b6b9e33a21f2866671032a11a3d780b76d337c39032e1af08f81df0fbfe1cd0100ffcfd
languageName: node
linkType: hard
"@peculiar/json-schema@npm:^1.1.12":
version: 1.1.12
resolution: "@peculiar/json-schema@npm:1.1.12"
@ -4384,6 +4481,45 @@ __metadata:
languageName: node
linkType: hard
"@redwoodjs/auth-dbauth-api@npm:8.4.1":
version: 8.4.1
resolution: "@redwoodjs/auth-dbauth-api@npm:8.4.1"
dependencies:
"@redwoodjs/project-config": "npm:8.4.1"
base64url: "npm:3.0.1"
md5: "npm:2.3.0"
uuid: "npm:10.0.0"
checksum: 10c0/cd0ded7bf030e948b73e0cb910ba482d08fbf38911199f5b39d74242977f725a48a0fb7a58c22547264e32ebc26d7086bc72b01c44b7dffe2edcb445d68b3fd2
languageName: node
linkType: hard
"@redwoodjs/auth-dbauth-setup@npm:8.4.1":
version: 8.4.1
resolution: "@redwoodjs/auth-dbauth-setup@npm:8.4.1"
dependencies:
"@babel/runtime-corejs3": "npm:7.25.7"
"@prisma/internals": "npm:5.20.0"
"@redwoodjs/cli-helpers": "npm:8.4.1"
"@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/d77c095fe412ca1c88bbcdb41e914631894f923fd9af184649cf31fbf4ec0418acfe69c977a6347fe3dc7399c2dff0b80abb81dd8015b9a95ce402d8e2419bf7
languageName: node
linkType: hard
"@redwoodjs/auth-dbauth-web@npm:8.4.1":
version: 8.4.1
resolution: "@redwoodjs/auth-dbauth-web@npm:8.4.1"
dependencies:
"@babel/runtime-corejs3": "npm:7.25.7"
"@redwoodjs/auth": "npm:8.4.1"
"@simplewebauthn/browser": "npm:7.4.0"
core-js: "npm:3.38.1"
checksum: 10c0/a6103083bed9661bed4ea2166bad9d133d30dcb9e3139222fa1893fa5e6ec8607b6413dc662e57bea2b89494772256a4377cf6649faf5e1ed43dd7decc163d6e
languageName: node
linkType: hard
"@redwoodjs/auth@npm:8.4.1":
version: 8.4.1
resolution: "@redwoodjs/auth@npm:8.4.1"
@ -5150,6 +5286,53 @@ __metadata:
languageName: node
linkType: hard
"@simplewebauthn/browser@npm:7.4.0, @simplewebauthn/browser@npm:^7":
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/iso-webcrypto@npm:^7.4.0":
version: 7.4.0
resolution: "@simplewebauthn/iso-webcrypto@npm:7.4.0"
dependencies:
"@simplewebauthn/typescript-types": "npm:^7.4.0"
"@types/node": "npm:^18.11.9"
checksum: 10c0/66a3eabb8fca5a8f779d428b358c8fc02dd2496f9cafda882f3b19562e5c9d21a8af3082f635c7ff0a1914e33a87817be0d16307f5327606149a52e854406cbb
languageName: node
linkType: hard
"@simplewebauthn/server@npm:^7":
version: 7.4.0
resolution: "@simplewebauthn/server@npm:7.4.0"
dependencies:
"@hexagon/base64": "npm:^1.1.25"
"@peculiar/asn1-android": "npm:^2.3.3"
"@peculiar/asn1-ecc": "npm:^2.3.4"
"@peculiar/asn1-rsa": "npm:^2.3.4"
"@peculiar/asn1-schema": "npm:^2.3.3"
"@peculiar/asn1-x509": "npm:^2.3.4"
"@simplewebauthn/iso-webcrypto": "npm:^7.4.0"
"@simplewebauthn/typescript-types": "npm:^7.4.0"
"@types/debug": "npm:^4.1.7"
"@types/node": "npm:^18.11.9"
cbor-x: "npm:^1.4.1"
cross-fetch: "npm:^3.1.5"
debug: "npm:^4.3.2"
checksum: 10c0/51858ad0bcfb55b96c8dd4a337ed93baf000ccf55cdf13f9f87c96e54c0fa80b0fb0eb96fc570d9e039a2526d770a1a21811a03a15f9ad23a02142ff9ba8ad6e
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"
@ -5720,6 +5903,15 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:^18.11.9":
version: 18.19.67
resolution: "@types/node@npm:18.19.67"
dependencies:
undici-types: "npm:~5.26.4"
checksum: 10c0/72b06802ac291c2e710bcf527b040f5490e1f85f26fdedad417e13ce3ed3aeb67e1bf3eef0ba5f581986bf361dcdc5f2d1229a9e284bf3dbd85db5c595e67bc6
languageName: node
linkType: hard
"@types/parse-json@npm:^4.0.0":
version: 4.0.2
resolution: "@types/parse-json@npm:4.0.2"
@ -6403,7 +6595,9 @@ __metadata:
resolution: "api@workspace:api"
dependencies:
"@redwoodjs/api": "npm:8.4.1"
"@redwoodjs/auth-dbauth-api": "npm:8.4.1"
"@redwoodjs/graphql-server": "npm:8.4.1"
"@simplewebauthn/server": "npm:^7"
languageName: unknown
linkType: soft
@ -7010,6 +7204,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"
@ -7395,6 +7596,49 @@ __metadata:
languageName: node
linkType: hard
"cbor-extract@npm:^2.2.0":
version: 2.2.0
resolution: "cbor-extract@npm:2.2.0"
dependencies:
"@cbor-extract/cbor-extract-darwin-arm64": "npm:2.2.0"
"@cbor-extract/cbor-extract-darwin-x64": "npm:2.2.0"
"@cbor-extract/cbor-extract-linux-arm": "npm:2.2.0"
"@cbor-extract/cbor-extract-linux-arm64": "npm:2.2.0"
"@cbor-extract/cbor-extract-linux-x64": "npm:2.2.0"
"@cbor-extract/cbor-extract-win32-x64": "npm:2.2.0"
node-gyp: "npm:latest"
node-gyp-build-optional-packages: "npm:5.1.1"
dependenciesMeta:
"@cbor-extract/cbor-extract-darwin-arm64":
optional: true
"@cbor-extract/cbor-extract-darwin-x64":
optional: true
"@cbor-extract/cbor-extract-linux-arm":
optional: true
"@cbor-extract/cbor-extract-linux-arm64":
optional: true
"@cbor-extract/cbor-extract-linux-x64":
optional: true
"@cbor-extract/cbor-extract-win32-x64":
optional: true
bin:
download-cbor-prebuilds: bin/download-prebuilds.js
checksum: 10c0/c36dec273f2114fcfe3b544d03d8bfddd2d537d114b9f94ba52a9366a8b852ea9725850e3d29ceda5df6894faeb37026e3bf2cb0d2bb4429f0a699fcfdfa1b8b
languageName: node
linkType: hard
"cbor-x@npm:^1.4.1":
version: 1.6.0
resolution: "cbor-x@npm:1.6.0"
dependencies:
cbor-extract: "npm:^2.2.0"
dependenciesMeta:
cbor-extract:
optional: true
checksum: 10c0/c6ab391e935a60c8a768080806f2c9aee01b2b124de68997e3e4cb700753757286860186094a92f510b595d7f8c77b3023d9125a05247afcbfea08cae45a0615
languageName: node
linkType: hard
"chalk@npm:4.1.2, chalk@npm:^4.0.0, chalk@npm:^4.1.0, chalk@npm:^4.1.1, chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
@ -7499,6 +7743,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"
@ -8098,6 +8349,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.1
resolution: "crypto-browserify@npm:3.12.1"
@ -8483,6 +8741,13 @@ __metadata:
languageName: node
linkType: hard
"detect-libc@npm:^2.0.1":
version: 2.0.3
resolution: "detect-libc@npm:2.0.3"
checksum: 10c0/88095bda8f90220c95f162bf92cad70bd0e424913e655c20578600e35b91edc261af27531cf160a331e185c0ced93944bc7e09939143225f56312d7fd800fdb7
languageName: node
linkType: hard
"detect-newline@npm:^3.0.0":
version: 3.1.0
resolution: "detect-newline@npm:3.1.0"
@ -11030,6 +11295,13 @@ __metadata:
languageName: node
linkType: hard
"ipaddr.js@npm:^2.1.0":
version: 2.2.0
resolution: "ipaddr.js@npm:2.2.0"
checksum: 10c0/e4ee875dc1bd92ac9d27e06cfd87cdb63ca786ff9fd7718f1d4f7a8ef27db6e5d516128f52d2c560408cbb75796ac2f83ead669e73507c86282d45f84c5abbb6
languageName: node
linkType: hard
"is-absolute@npm:^1.0.0":
version: 1.0.0
resolution: "is-absolute@npm:1.0.0"
@ -11104,6 +11376,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"
@ -12907,6 +13186,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"
@ -13358,6 +13648,19 @@ __metadata:
languageName: node
linkType: hard
"node-gyp-build-optional-packages@npm:5.1.1":
version: 5.1.1
resolution: "node-gyp-build-optional-packages@npm:5.1.1"
dependencies:
detect-libc: "npm:^2.0.1"
bin:
node-gyp-build-optional-packages: bin.js
node-gyp-build-optional-packages-optional: optional.js
node-gyp-build-optional-packages-test: build-test.js
checksum: 10c0/f9fad2061c48fb0fc90831cd11d6a7670d731d22a5b00c7d3441b43b4003543299ff64ff2729afe2cefd7d14928e560d469336e5bb00f613932ec2cd56b3665b
languageName: node
linkType: hard
"node-gyp@npm:latest":
version: 10.2.0
resolution: "node-gyp@npm:10.2.0"
@ -15239,6 +15542,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "root-workspace-0b6124@workspace:."
dependencies:
"@redwoodjs/auth-dbauth-setup": "npm:8.4.1"
"@redwoodjs/core": "npm:8.4.1"
"@redwoodjs/project-config": "npm:8.4.1"
languageName: unknown
@ -16688,6 +16992,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~5.26.4":
version: 5.26.5
resolution: "undici-types@npm:5.26.5"
checksum: 10c0/bb673d7876c2d411b6eb6c560e0c571eef4a01c1c19925175d16e3a30c4c428181fb8d7ae802a261f283e4166a0ac435e2f505743aa9e45d893f9a3df017b501
languageName: node
linkType: hard
"undici-types@npm:~6.19.2, undici-types@npm:~6.19.8":
version: 6.19.8
resolution: "undici-types@npm:6.19.8"
@ -17119,10 +17430,12 @@ __metadata:
version: 0.0.0-use.local
resolution: "web@workspace:web"
dependencies:
"@redwoodjs/auth-dbauth-web": "npm:8.4.1"
"@redwoodjs/forms": "npm:8.4.1"
"@redwoodjs/router": "npm:8.4.1"
"@redwoodjs/vite": "npm:8.4.1"
"@redwoodjs/web": "npm:8.4.1"
"@simplewebauthn/browser": "npm:^7"
"@types/react": "npm:^18.2.55"
"@types/react-dom": "npm:^18.2.19"
react: "npm:18.3.1"