diff --git a/api/db/migrations/20241003153016_add_authentication_db_auth/migration.sql b/api/db/migrations/20241003153016_add_authentication_db_auth/migration.sql new file mode 100644 index 0000000..3d19685 --- /dev/null +++ b/api/db/migrations/20241003153016_add_authentication_db_auth/migration.sql @@ -0,0 +1,24 @@ +-- 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, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserExample_email_key" ON "UserExample"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/api/db/migrations/migration_lock.toml b/api/db/migrations/migration_lock.toml new file mode 100644 index 0000000..e5e5c47 --- /dev/null +++ b/api/db/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 8c86666..29b3072 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -22,3 +22,16 @@ model UserExample { email String @unique name String? } + +model User { + id Int @id @default(autoincrement()) + email String @unique + firstName String? + lastName String? + hashedPassword String? + salt String? + resetToken String? + resetTokenExpiresAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/api/package.json b/api/package.json index 6eb8208..a3a7232 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.3.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..667c57a --- /dev/null +++ b/api/src/functions/auth.ts @@ -0,0 +1,207 @@ +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, + 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..e212e34 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 !== '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.") + } } diff --git a/package.json b/package.json index 7e55fcd..8697eac 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ ] }, "devDependencies": { + "@redwoodjs/auth-dbauth-setup": "8.3.0", "@redwoodjs/core": "8.3.0", "@redwoodjs/project-config": "8.3.0" }, @@ -23,5 +24,8 @@ "packageManager": "yarn@4.4.0", "resolutions": { "@storybook/react-dom-shim@npm:7.6.17": "https://verdaccio.tobbe.dev/@storybook/react-dom-shim/-/react-dom-shim-8.0.8.tgz" + }, + "dependencies": { + "crypto-js": "^4.2.0" } } diff --git a/web/package.json b/web/package.json index d8209fd..2e64933 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ ] }, "dependencies": { + "@redwoodjs/auth-dbauth-web": "8.3.0", "@redwoodjs/forms": "8.3.0", "@redwoodjs/router": "8.3.0", "@redwoodjs/web": "8.3.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 340c30b..56f13b6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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) => ( - {children} + + {children} + ) diff --git a/web/src/Routes.tsx b/web/src/Routes.tsx index e26b6aa..dedc6a9 100644 --- a/web/src/Routes.tsx +++ b/web/src/Routes.tsx @@ -9,8 +9,15 @@ import { Router, Route } from '@redwoodjs/router' +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/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..3bd063c --- /dev/null +++ b/web/src/pages/LoginPage/LoginPage.tsx @@ -0,0 +1,143 @@ +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! + +
+
+ + Login with GitHub + +
+ + ) +} + +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/web/src/scaffold.css b/web/src/scaffold.css new file mode 100644 index 0000000..3a6a215 --- /dev/null +++ b/web/src/scaffold.css @@ -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; +} diff --git a/yarn.lock b/yarn.lock index 92a5d77..3a42b8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4342,6 +4342,45 @@ __metadata: languageName: node linkType: hard +"@redwoodjs/auth-dbauth-api@npm:8.3.0": + version: 8.3.0 + resolution: "@redwoodjs/auth-dbauth-api@npm:8.3.0" + dependencies: + "@redwoodjs/project-config": "npm:8.3.0" + base64url: "npm:3.0.1" + md5: "npm:2.3.0" + uuid: "npm:10.0.0" + checksum: 10c0/35ed29c19cad11e65cc43ed77c37055ece043895c06a82e491db123b45e40d9710c8ed114b86384647b0ade658a03289b7346a9fa0f3d07ca4157104d134b35f + languageName: node + linkType: hard + +"@redwoodjs/auth-dbauth-setup@npm:8.3.0": + version: 8.3.0 + resolution: "@redwoodjs/auth-dbauth-setup@npm:8.3.0" + dependencies: + "@babel/runtime-corejs3": "npm:7.25.6" + "@prisma/internals": "npm:5.20.0" + "@redwoodjs/cli-helpers": "npm:8.3.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/6a5df110e0db68a2509d03e959492687bd42c0c293f6c56986285f1f8fc5121bc92a04805f340b524ac39f620f8befa5ea9db5c8938023483c79402273fd1c21 + languageName: node + linkType: hard + +"@redwoodjs/auth-dbauth-web@npm:8.3.0": + version: 8.3.0 + resolution: "@redwoodjs/auth-dbauth-web@npm:8.3.0" + dependencies: + "@babel/runtime-corejs3": "npm:7.25.6" + "@redwoodjs/auth": "npm:8.3.0" + "@simplewebauthn/browser": "npm:7.4.0" + core-js: "npm:3.38.1" + checksum: 10c0/8d06aca942a0a2381454eac2c2850f4500afe46477870dd6f8cbcf8026a0b64f54d4788053806e2392ebc4e29eb27f3e0820db648c49e2220fa183ca8fe06bb3 + languageName: node + linkType: hard + "@redwoodjs/auth@npm:8.3.0": version: 8.3.0 resolution: "@redwoodjs/auth@npm:8.3.0" @@ -5096,6 +5135,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" @@ -6311,6 +6366,7 @@ __metadata: resolution: "api@workspace:api" dependencies: "@redwoodjs/api": "npm:8.3.0" + "@redwoodjs/auth-dbauth-api": "npm:8.3.0" "@redwoodjs/graphql-server": "npm:8.3.0" languageName: unknown linkType: soft @@ -6918,6 +6974,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" @@ -7417,6 +7480,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" @@ -8025,6 +8095,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" @@ -8044,6 +8121,13 @@ __metadata: languageName: node linkType: hard +"crypto-js@npm:^4.2.0": + version: 4.2.0 + resolution: "crypto-js@npm:4.2.0" + checksum: 10c0/8fbdf9d56f47aea0794ab87b0eb9833baf80b01a7c5c1b0edc7faf25f662fb69ab18dc2199e2afcac54670ff0cd9607a9045a3f7a80336cccd18d77a55b9fdf0 + languageName: node + linkType: hard + "crypto-random-string@npm:^1.0.0": version: 1.0.0 resolution: "crypto-random-string@npm:1.0.0" @@ -11029,6 +11113,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" @@ -12834,6 +12925,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" @@ -15107,8 +15209,10 @@ __metadata: version: 0.0.0-use.local resolution: "root-workspace-0b6124@workspace:." dependencies: + "@redwoodjs/auth-dbauth-setup": "npm:8.3.0" "@redwoodjs/core": "npm:8.3.0" "@redwoodjs/project-config": "npm:8.3.0" + crypto-js: "npm:^4.2.0" languageName: unknown linkType: soft @@ -16521,11 +16625,11 @@ __metadata: "typescript@patch:typescript@npm%3A5.6.2#optional!builtin": version: 5.6.2 - resolution: "typescript@patch:typescript@npm%3A5.6.2#optional!builtin::version=5.6.2&hash=74658d" + resolution: "typescript@patch:typescript@npm%3A5.6.2#optional!builtin::version=5.6.2&hash=8c6c40" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/e6c1662e4852e22fe4bbdca471dca3e3edc74f6f1df043135c44a18a7902037023ccb0abdfb754595ca9028df8920f2f8492c00fc3cbb4309079aae8b7de71cd + checksum: 10c0/94eb47e130d3edd964b76da85975601dcb3604b0c848a36f63ac448d0104e93819d94c8bdf6b07c00120f2ce9c05256b8b6092d23cf5cf1c6fa911159e4d572f languageName: node linkType: hard @@ -16995,6 +17099,7 @@ __metadata: version: 0.0.0-use.local resolution: "web@workspace:web" dependencies: + "@redwoodjs/auth-dbauth-web": "npm:8.3.0" "@redwoodjs/forms": "npm:8.3.0" "@redwoodjs/router": "npm:8.3.0" "@redwoodjs/vite": "npm:8.3.0"