Redwood v0.37 updates
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@redwoodjs/api": "^0.36.0"
|
||||
"@redwoodjs/api": "^0.37.0",
|
||||
"@redwoodjs/graphql-server": "^0.37.0"
|
||||
}
|
||||
}
|
||||
23
api/src/directives/requireAuth/requireAuth.js
Normal file
23
api/src/directives/requireAuth/requireAuth.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
import { createValidatorDirective } from '@redwoodjs/graphql-server'
|
||||
|
||||
import { requireAuth as applicationRequireAuth } from 'src/lib/auth'
|
||||
|
||||
export const schema = gql`
|
||||
"""
|
||||
Use to check whether or not a user is authenticated and is associated
|
||||
with an optional set of roles.
|
||||
"""
|
||||
directive @requireAuth(roles: [String]) on FIELD_DEFINITION
|
||||
`
|
||||
|
||||
const validate = ({ directiveArgs }) => {
|
||||
const { roles } = directiveArgs
|
||||
|
||||
applicationRequireAuth({ roles: roles })
|
||||
}
|
||||
|
||||
const requireAuth = createValidatorDirective(schema, validate)
|
||||
|
||||
export default requireAuth
|
||||
18
api/src/directives/requireAuth/requireAuth.test.js
Normal file
18
api/src/directives/requireAuth/requireAuth.test.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { mockRedwoodDirective, getDirectiveName } from '@redwoodjs/testing/api'
|
||||
|
||||
import requireAuth from './requireAuth'
|
||||
|
||||
describe('requireAuth directive', () => {
|
||||
it('declares the directive sdl as schema, with the correct name', () => {
|
||||
expect(requireAuth.schema).toBeTruthy()
|
||||
expect(getDirectiveName(requireAuth.schema)).toBe('requireAuth')
|
||||
})
|
||||
|
||||
it('requireAuth has stub implementation. Should not throw when current user', () => {
|
||||
// If you want to set values in context, pass it through e.g.
|
||||
// mockRedwoodDirective(requireAuth, { context: { currentUser: { id: 1, name: 'Lebron McGretzky' } }})
|
||||
const mockExecution = mockRedwoodDirective(requireAuth, { context: {} })
|
||||
|
||||
expect(mockExecution).not.toThrowError()
|
||||
})
|
||||
})
|
||||
16
api/src/directives/skipAuth/skipAuth.js
Normal file
16
api/src/directives/skipAuth/skipAuth.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
import { createValidatorDirective } from '@redwoodjs/graphql-server'
|
||||
|
||||
export const schema = gql`
|
||||
"""
|
||||
Use to skip authentication checks and allow public access.
|
||||
"""
|
||||
directive @skipAuth on FIELD_DEFINITION
|
||||
`
|
||||
|
||||
const skipAuth = createValidatorDirective(schema, () => {
|
||||
return
|
||||
})
|
||||
|
||||
export default skipAuth
|
||||
10
api/src/directives/skipAuth/skipAuth.test.js
Normal file
10
api/src/directives/skipAuth/skipAuth.test.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { getDirectiveName } from '@redwoodjs/testing/api'
|
||||
|
||||
import skipAuth from './skipAuth'
|
||||
|
||||
describe('skipAuth directive', () => {
|
||||
it('declares the directive sdl as schema, with the correct name', () => {
|
||||
expect(skipAuth.schema).toBeTruthy()
|
||||
expect(getDirectiveName(skipAuth.schema)).toBe('skipAuth')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
createGraphQLHandler,
|
||||
makeMergedSchema,
|
||||
makeServices,
|
||||
} from '@redwoodjs/api'
|
||||
import { createGraphQLHandler } from '@redwoodjs/graphql-server'
|
||||
|
||||
import schemas from 'src/graphql/**/*.{js,ts}'
|
||||
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'
|
||||
@@ -14,10 +11,10 @@ import { logger } from 'src/lib/logger'
|
||||
export const handler = createGraphQLHandler({
|
||||
loggerConfig: { logger, options: {} },
|
||||
getCurrentUser,
|
||||
schema: makeMergedSchema({
|
||||
schemas,
|
||||
services: makeServices({ services }),
|
||||
}),
|
||||
directives,
|
||||
sdls,
|
||||
services,
|
||||
|
||||
onException: () => {
|
||||
// Disconnect from your database with an unhandled exception.
|
||||
db.$disconnect()
|
||||
|
||||
@@ -8,7 +8,7 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Query {
|
||||
contacts: [Contact!]!
|
||||
contacts: [Contact!]! @skipAuth
|
||||
}
|
||||
|
||||
input CreateContactInput {
|
||||
@@ -24,6 +24,6 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createContact(input: CreateContactInput!): Contact
|
||||
createContact(input: CreateContactInput!): Contact @skipAuth
|
||||
}
|
||||
`
|
||||
|
||||
@@ -7,8 +7,8 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Query {
|
||||
posts: [Post!]!
|
||||
post(id: Int!): Post
|
||||
posts: [Post!]! @skipAuth
|
||||
post(id: Int!): Post @skipAuth
|
||||
}
|
||||
|
||||
input CreatePostInput {
|
||||
@@ -22,8 +22,8 @@ export const schema = gql`
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createPost(input: CreatePostInput!): Post!
|
||||
updatePost(id: Int!, input: UpdatePostInput!): Post!
|
||||
deletePost(id: Int!): Post!
|
||||
createPost(input: CreatePostInput!): Post! @requireAuth
|
||||
updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
|
||||
deletePost(id: Int!): Post! @requireAuth(roles: ["FOO"])
|
||||
}
|
||||
`
|
||||
|
||||
@@ -1,104 +1,78 @@
|
||||
// Define what you want `currentUser` to return throughout your app. For example,
|
||||
// to return a real user from your database, you could do something like:
|
||||
//
|
||||
// export const getCurrentUser = async ({ email }) => {
|
||||
// return await db.user.fineUnique({ where: { email } })
|
||||
// }
|
||||
//
|
||||
// If you want to enforce role-based access ...
|
||||
//
|
||||
// You'll need to set the currentUser's roles attributes to the
|
||||
// collection of roles as defined by your app.
|
||||
//
|
||||
// This allows requireAuth() on the api side and hasRole() in the useAuth() hook on the web side
|
||||
// to check if the user is assigned a given role or not.
|
||||
//
|
||||
// How you set the currentUser's roles depends on your auth provider and its implementation.
|
||||
//
|
||||
// For example, your decoded JWT may store `roles` in it namespaced `app_metadata`:
|
||||
//
|
||||
// {
|
||||
// 'https://example.com/app_metadata': { authorization: { roles: ['admin'] } },
|
||||
// 'https://example.com/user_metadata': {},
|
||||
// iss: 'https://app.us.auth0.com/',
|
||||
// sub: 'email|1234',
|
||||
// aud: [
|
||||
// 'https://example.com',
|
||||
// 'https://app.us.auth0.com/userinfo'
|
||||
// ],
|
||||
// iat: 1596481520,
|
||||
// exp: 1596567920,
|
||||
// azp: '1l0w6JXXXXL880T',
|
||||
// scope: 'openid profile email'
|
||||
// }
|
||||
//
|
||||
// The parseJWT utility will extract the roles from decoded token.
|
||||
//
|
||||
// The app_medata claim may or may not be namespaced based on the auth provider.
|
||||
// Note: Auth0 requires namespacing custom JWT claims
|
||||
//
|
||||
// Some providers, such as with Auth0, will set roles an authorization
|
||||
// attribute in app_metadata (namespaced or not):
|
||||
//
|
||||
// 'app_metadata': { authorization: { roles: ['publisher'] } }
|
||||
// 'https://example.com/app_metadata': { authorization: { roles: ['publisher'] } }
|
||||
//
|
||||
// Other providers may include roles simply within app_metadata:
|
||||
//
|
||||
// 'app_metadata': { roles: ['author'] }
|
||||
// 'https://example.com/app_metadata': { roles: ['author'] }
|
||||
//
|
||||
// And yet other may define roles as a custom claim at the root of the decoded token:
|
||||
//
|
||||
// roles: ['admin']
|
||||
//
|
||||
// The function `getCurrentUser` should return the user information
|
||||
// together with a collection of roles to check for role assignment:
|
||||
import {
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
parseJWT,
|
||||
} from '@redwoodjs/graphql-server'
|
||||
import { logger } from 'src/lib/logger'
|
||||
/**
|
||||
* getCurrentUser returns the user information together with
|
||||
* an optional collection of roles used by requireAuth() to check
|
||||
* if the user is authenticated or has role-based access
|
||||
*
|
||||
* @param decoded - The decoded access token containing user info and JWT claims like `sub`
|
||||
* @param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type
|
||||
* @param { APIGatewayEvent event, Context context } - An object which contains information from the invoker
|
||||
* such as headers and cookies, and the context information about the invocation such as IP Address
|
||||
*
|
||||
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
|
||||
*/
|
||||
export const getCurrentUser = async (
|
||||
decoded,
|
||||
{ _token, _type },
|
||||
{ _event, _context }
|
||||
) => {
|
||||
if (!decoded) {
|
||||
logger.warn('Missing decoded user')
|
||||
return null
|
||||
}
|
||||
|
||||
import { AuthenticationError, ForbiddenError, parseJWT } from '@redwoodjs/api'
|
||||
const { roles } = parseJWT({ decoded })
|
||||
|
||||
if (roles) {
|
||||
return { ...decoded, roles }
|
||||
}
|
||||
|
||||
return { ...decoded }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The user is authenticated if there is a currentUser in the context
|
||||
*
|
||||
* @param {string=, string[]=} role - An optional role
|
||||
*
|
||||
* @example - No role-based access control.
|
||||
*
|
||||
* export const getCurrentUser = async (decoded) => {
|
||||
* return await db.user.fineUnique({ where: { decoded.email } })
|
||||
* }
|
||||
*
|
||||
* @example - User info is conatined in the decoded token and roles extracted
|
||||
*
|
||||
* export const getCurrentUser = async (decoded, { _token, _type }) => {
|
||||
* return { ...decoded, roles: parseJWT({ decoded }).roles }
|
||||
* }
|
||||
*
|
||||
* @example - User record query by email with namespaced app_metadata roles
|
||||
*
|
||||
* export const getCurrentUser = async (decoded) => {
|
||||
* const currentUser = await db.user.fineUnique({ where: { email: decoded.email } })
|
||||
*
|
||||
* return {
|
||||
* ...currentUser,
|
||||
* roles: parseJWT({ decoded: decoded, namespace: NAMESPACE }).roles,
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @example - User record query by an identity with app_metadata roles
|
||||
*
|
||||
* const getCurrentUser = async (decoded) => {
|
||||
* const currentUser = await db.user.fineUnique({ where: { userIdentity: decoded.sub } })
|
||||
* return {
|
||||
* ...currentUser,
|
||||
* roles: parseJWT({ decoded: decoded }).roles,
|
||||
* }
|
||||
* }
|
||||
* @returns {boolean} - If the currentUser is authenticated
|
||||
*/
|
||||
export const getCurrentUser = async (decoded, { _token, _type }) => {
|
||||
return { ...decoded, roles: parseJWT({ decoded }).roles }
|
||||
export const isAuthenticated = () => {
|
||||
return !!context.currentUser
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the currentUser is authenticated (and assigned one of the given roles)
|
||||
*
|
||||
* @param {string= | string[]=} roles - A single role or list of roles to check if the user belongs to
|
||||
*
|
||||
* @returns {boolean} - Returns true if the currentUser is authenticated (and assigned one of the given roles)
|
||||
*/
|
||||
export const hasRole = ({ roles }) => {
|
||||
if (!isAuthenticated()) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
typeof roles !== 'undefined' &&
|
||||
typeof roles === 'string' &&
|
||||
context.currentUser.roles?.includes(roles)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
typeof roles !== 'undefined' &&
|
||||
Array.isArray(roles) &&
|
||||
context.currentUser.roles?.some((r) => roles.includes(r))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,39 +80,21 @@ export const getCurrentUser = async (decoded, { _token, _type }) => {
|
||||
* whether or not they are assigned a role, and optionally raise an
|
||||
* error if they're not.
|
||||
*
|
||||
* @param {string=} roles - An optional role or list of roles
|
||||
* @param {string[]=} roles - An optional list of roles
|
||||
|
||||
* @example
|
||||
* @param {string= | string[]=} roles - A single role or list of roles to check if the user belongs to
|
||||
*
|
||||
* // checks if currentUser is authenticated
|
||||
* requireAuth()
|
||||
* @returns - If the currentUser is authenticated (and assigned one of the given roles)
|
||||
*
|
||||
* @example
|
||||
* @throws {AuthenticationError} - If the currentUser is not authenticated
|
||||
* @throws {ForbiddenError} If the currentUser is not allowed due to role permissions
|
||||
*
|
||||
* // checks if currentUser is authenticated and assigned one of the given roles
|
||||
* requireAuth({ role: 'admin' })
|
||||
* requireAuth({ role: ['editor', 'author'] })
|
||||
* requireAuth({ role: ['publisher'] })
|
||||
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
|
||||
*/
|
||||
export const requireAuth = ({ role } = {}) => {
|
||||
if (!context.currentUser) {
|
||||
export const requireAuth = ({ roles } = {}) => {
|
||||
if (!isAuthenticated) {
|
||||
throw new AuthenticationError("You don't have permission to do that.")
|
||||
}
|
||||
|
||||
if (
|
||||
typeof role !== 'undefined' &&
|
||||
typeof role === 'string' &&
|
||||
!context.currentUser.roles?.includes(role)
|
||||
) {
|
||||
throw new ForbiddenError("You don't have access to do that.")
|
||||
}
|
||||
|
||||
if (
|
||||
typeof role !== 'undefined' &&
|
||||
Array.isArray(role) &&
|
||||
!context.currentUser.roles?.some((r) => role.includes(r))
|
||||
) {
|
||||
if (!hasRole({ roles })) {
|
||||
throw new ForbiddenError("You don't have access to do that.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UserInputError } from '@redwoodjs/graphql-server'
|
||||
import { db } from 'src/lib/db'
|
||||
import { UserInputError } from '@redwoodjs/api'
|
||||
|
||||
const validate = (input) => {
|
||||
if (input.email && !input.email.match(/[^@]+@[^.]+\..+/)) {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export const standard = defineScenario({
|
||||
contact: {
|
||||
john: {
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
message: 'I love RedwoodJS',
|
||||
data: {
|
||||
name: 'John Doe',
|
||||
email: 'john.doe@example.com',
|
||||
message: 'I love RedwoodJS',
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { db } from 'src/lib/db'
|
||||
import { requireAuth } from 'src/lib/auth'
|
||||
|
||||
// Used when the environment variable REDWOOD_SECURE_SERVICES=1
|
||||
export const beforeResolver = (rules) => {
|
||||
rules.add(requireAuth)
|
||||
}
|
||||
|
||||
export const posts = () => {
|
||||
return db.post.findMany()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export const standard = defineScenario({
|
||||
post: {
|
||||
one: { title: 'String', body: 'String' },
|
||||
two: { title: 'String', body: 'String' },
|
||||
one: {
|
||||
data: { title: 'String', body: 'String' }
|
||||
},
|
||||
two: {
|
||||
data: { title: 'String', body: 'String' }
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@redwoodjs/core": "^0.36.0"
|
||||
"@redwoodjs/core": "^0.37.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "@redwoodjs/eslint-config"
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@redwoodjs/auth": "^0.36.0",
|
||||
"@redwoodjs/forms": "^0.36.0",
|
||||
"@redwoodjs/router": "^0.36.0",
|
||||
"@redwoodjs/web": "^0.36.0",
|
||||
"@redwoodjs/auth": "^0.37.0",
|
||||
"@redwoodjs/forms": "^0.37.0",
|
||||
"@redwoodjs/router": "^0.37.0",
|
||||
"@redwoodjs/web": "^0.37.0",
|
||||
"netlify-identity-widget": "^1.9.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^17.0.2",
|
||||
|
||||
@@ -34,77 +34,75 @@ const ContactPage = () => {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
validation={{ mode: 'onBlur' }}
|
||||
return <>
|
||||
<Toaster />
|
||||
<Form
|
||||
onSubmit={onSubmit}
|
||||
config={{ mode: 'onBlur' }}
|
||||
error={error}
|
||||
formMethods={formMethods}
|
||||
>
|
||||
<FormError
|
||||
error={error}
|
||||
formMethods={formMethods}
|
||||
wrapperClassName="py-4 px-6 rounded-lg bg-red-100 text-red-700"
|
||||
listClassName="list-disc ml-4"
|
||||
listItemClassName=""
|
||||
/>
|
||||
<Label
|
||||
name="name"
|
||||
className="block text-gray-700 uppercase text-sm"
|
||||
errorClassName="block uppercase text-sm text-red-700"
|
||||
>
|
||||
<FormError
|
||||
error={error}
|
||||
wrapperClassName="py-4 px-6 rounded-lg bg-red-100 text-red-700"
|
||||
listClassName="list-disc ml-4"
|
||||
listItemClassName=""
|
||||
/>
|
||||
<Label
|
||||
name="name"
|
||||
className="block text-gray-700 uppercase text-sm"
|
||||
errorClassName="block uppercase text-sm text-red-700"
|
||||
>
|
||||
Name
|
||||
</Label>
|
||||
<TextField
|
||||
name="name"
|
||||
validation={{ required: true }}
|
||||
className="border rounded-sm px-2 py-1 outline-none"
|
||||
errorClassName="border rounded-sm px-2 py-1 border-red-700 outline-none"
|
||||
/>
|
||||
<FieldError name="name" className="block text-red-700" />
|
||||
Name
|
||||
</Label>
|
||||
<TextField
|
||||
name="name"
|
||||
validation={{ required: true }}
|
||||
className="border rounded-sm px-2 py-1 outline-none"
|
||||
errorClassName="border rounded-sm px-2 py-1 border-red-700 outline-none"
|
||||
/>
|
||||
<FieldError name="name" className="block text-red-700" />
|
||||
|
||||
<Label
|
||||
name="name"
|
||||
className="block mt-8 text-gray-700 uppercase text-sm"
|
||||
errorClassName="block mt-8 text-red-700 uppercase text-sm"
|
||||
>
|
||||
Email
|
||||
</Label>
|
||||
<TextField
|
||||
name="email"
|
||||
validation={{
|
||||
required: true,
|
||||
}}
|
||||
className="border rounded-sm px-2 py-1"
|
||||
errorClassName="border rounded-sm px-2 py-1 border-red-700 outline-none"
|
||||
/>
|
||||
<FieldError name="email" className="block text-red-700" />
|
||||
<Label
|
||||
name="name"
|
||||
className="block mt-8 text-gray-700 uppercase text-sm"
|
||||
errorClassName="block mt-8 text-red-700 uppercase text-sm"
|
||||
>
|
||||
Email
|
||||
</Label>
|
||||
<TextField
|
||||
name="email"
|
||||
validation={{
|
||||
required: true,
|
||||
}}
|
||||
className="border rounded-sm px-2 py-1"
|
||||
errorClassName="border rounded-sm px-2 py-1 border-red-700 outline-none"
|
||||
/>
|
||||
<FieldError name="email" className="block text-red-700" />
|
||||
|
||||
<Label
|
||||
name="name"
|
||||
className="block mt-8 text-gray-700 uppercase text-sm"
|
||||
errorClassName="block mt-8 text-red-700 uppercase text-sm"
|
||||
>
|
||||
Message
|
||||
</Label>
|
||||
<TextAreaField
|
||||
name="message"
|
||||
validation={{ required: true }}
|
||||
className="block border rounded-sm px-2 py-1"
|
||||
errorClassName="block border rounded-sm px-2 py-1 border-red-700 outline-none"
|
||||
/>
|
||||
<FieldError name="message" className="block text-red-700" />
|
||||
<Label
|
||||
name="name"
|
||||
className="block mt-8 text-gray-700 uppercase text-sm"
|
||||
errorClassName="block mt-8 text-red-700 uppercase text-sm"
|
||||
>
|
||||
Message
|
||||
</Label>
|
||||
<TextAreaField
|
||||
name="message"
|
||||
validation={{ required: true }}
|
||||
className="block border rounded-sm px-2 py-1"
|
||||
errorClassName="block border rounded-sm px-2 py-1 border-red-700 outline-none"
|
||||
/>
|
||||
<FieldError name="message" className="block text-red-700" />
|
||||
|
||||
<Submit
|
||||
className="block bg-blue-700 text-white mt-8 px-4 py-2 rounded"
|
||||
disabled={loading}
|
||||
>
|
||||
Save
|
||||
</Submit>
|
||||
</Form>
|
||||
</>
|
||||
)
|
||||
<Submit
|
||||
className="block bg-blue-700 text-white mt-8 px-4 py-2 rounded"
|
||||
disabled={loading}
|
||||
>
|
||||
Save
|
||||
</Submit>
|
||||
</Form>
|
||||
</>;
|
||||
}
|
||||
|
||||
export default ContactPage
|
||||
|
||||
Reference in New Issue
Block a user