Merge pull request #14 from redwoodjs/dt-upgrade-rw-v37

Redwood v0.37 updates
This commit is contained in:
David Thyresson
2021-10-01 17:38:12 -04:00
committed by GitHub
17 changed files with 2206 additions and 1901 deletions

View File

@@ -3,6 +3,7 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@redwoodjs/api": "^0.36.0" "@redwoodjs/api": "^0.37.0",
"@redwoodjs/graphql-server": "^0.37.0"
} }
} }

View 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

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

View 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

View 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')
})
})

View File

@@ -1,10 +1,7 @@
import { import { createGraphQLHandler } from '@redwoodjs/graphql-server'
createGraphQLHandler,
makeMergedSchema,
makeServices,
} from '@redwoodjs/api'
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 services from 'src/services/**/*.{js,ts}'
import { getCurrentUser } from 'src/lib/auth' import { getCurrentUser } from 'src/lib/auth'
@@ -14,10 +11,10 @@ import { logger } from 'src/lib/logger'
export const handler = createGraphQLHandler({ export const handler = createGraphQLHandler({
loggerConfig: { logger, options: {} }, loggerConfig: { logger, options: {} },
getCurrentUser, getCurrentUser,
schema: makeMergedSchema({ directives,
schemas, sdls,
services: makeServices({ services }), services,
}),
onException: () => { onException: () => {
// Disconnect from your database with an unhandled exception. // Disconnect from your database with an unhandled exception.
db.$disconnect() db.$disconnect()

View File

@@ -8,7 +8,7 @@ export const schema = gql`
} }
type Query { type Query {
contacts: [Contact!]! contacts: [Contact!]! @skipAuth
} }
input CreateContactInput { input CreateContactInput {
@@ -24,6 +24,6 @@ export const schema = gql`
} }
type Mutation { type Mutation {
createContact(input: CreateContactInput!): Contact createContact(input: CreateContactInput!): Contact @skipAuth
} }
` `

View File

@@ -7,8 +7,8 @@ export const schema = gql`
} }
type Query { type Query {
posts: [Post!]! posts: [Post!]! @skipAuth
post(id: Int!): Post post(id: Int!): Post @skipAuth
} }
input CreatePostInput { input CreatePostInput {
@@ -22,8 +22,8 @@ export const schema = gql`
} }
type Mutation { type Mutation {
createPost(input: CreatePostInput!): Post! createPost(input: CreatePostInput!): Post! @requireAuth
updatePost(id: Int!, input: UpdatePostInput!): Post! updatePost(id: Int!, input: UpdatePostInput!): Post! @requireAuth
deletePost(id: Int!): Post! deletePost(id: Int!): Post! @requireAuth(roles: ["FOO"])
} }
` `

View File

@@ -1,104 +1,78 @@
// Define what you want `currentUser` to return throughout your app. For example, import {
// to return a real user from your database, you could do something like: AuthenticationError,
// ForbiddenError,
// export const getCurrentUser = async ({ email }) => { parseJWT,
// return await db.user.fineUnique({ where: { email } }) } from '@redwoodjs/graphql-server'
// } import { logger } from 'src/lib/logger'
// /**
// If you want to enforce role-based access ... * getCurrentUser returns the user information together with
// * an optional collection of roles used by requireAuth() to check
// You'll need to set the currentUser's roles attributes to the * if the user is authenticated or has role-based access
// collection of roles as defined by your app. *
// * @param decoded - The decoded access token containing user info and JWT claims like `sub`
// This allows requireAuth() on the api side and hasRole() in the useAuth() hook on the web side * @param { token, SupportedAuthTypes type } - The access token itself as well as the auth provider type
// to check if the user is assigned a given role or not. * @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
// How you set the currentUser's roles depends on your auth provider and its implementation. *
// * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
// For example, your decoded JWT may store `roles` in it namespaced `app_metadata`: */
// export const getCurrentUser = async (
// { decoded,
// 'https://example.com/app_metadata': { authorization: { roles: ['admin'] } }, { _token, _type },
// 'https://example.com/user_metadata': {}, { _event, _context }
// iss: 'https://app.us.auth0.com/', ) => {
// sub: 'email|1234', if (!decoded) {
// aud: [ logger.warn('Missing decoded user')
// 'https://example.com', return null
// '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/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, * The user is authenticated if there is a currentUser in the context
* whether or not they are assigned a role, and optionally raise an
* error if they're not.
* *
* @param {string=, string[]=} role - An optional role * @returns {boolean} - If the currentUser is authenticated
*
* @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,
* }
* }
*/ */
export const getCurrentUser = async (decoded, { _token, _type }) => { export const isAuthenticated = () => {
return { ...decoded, roles: parseJWT({ decoded }).roles } 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 * whether or not they are assigned a role, and optionally raise an
* error if they're not. * error if they're not.
* *
* @param {string=} roles - An optional role or list of roles * @param {string= | string[]=} roles - A single role or list of roles to check if the user belongs to
* @param {string[]=} roles - An optional list of roles
* @example
* *
* // checks if currentUser is authenticated * @returns - If the currentUser is authenticated (and assigned one of the given roles)
* requireAuth()
* *
* @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 * @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
* requireAuth({ role: 'admin' })
* requireAuth({ role: ['editor', 'author'] })
* requireAuth({ role: ['publisher'] })
*/ */
export const requireAuth = ({ role } = {}) => { export const requireAuth = ({ roles } = {}) => {
if (!context.currentUser) { if (!isAuthenticated) {
throw new AuthenticationError("You don't have permission to do that.") throw new AuthenticationError("You don't have permission to do that.")
} }
if ( if (!hasRole({ roles })) {
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))
) {
throw new ForbiddenError("You don't have access to do that.") throw new ForbiddenError("You don't have access to do that.")
} }
} }

View File

@@ -1,5 +1,5 @@
import { UserInputError } from '@redwoodjs/graphql-server'
import { db } from 'src/lib/db' import { db } from 'src/lib/db'
import { UserInputError } from '@redwoodjs/api'
const validate = (input) => { const validate = (input) => {
if (input.email && !input.email.match(/[^@]+@[^.]+\..+/)) { if (input.email && !input.email.match(/[^@]+@[^.]+\..+/)) {

View File

@@ -1,9 +1,11 @@
export const standard = defineScenario({ export const standard = defineScenario({
contact: { contact: {
john: { john: {
name: 'John Doe', data: {
email: 'john.doe@example.com', name: 'John Doe',
message: 'I love RedwoodJS', email: 'john.doe@example.com',
message: 'I love RedwoodJS',
}
}, },
}, },
}) })

View File

@@ -1,10 +1,4 @@
import { db } from 'src/lib/db' 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 = () => { export const posts = () => {
return db.post.findMany() return db.post.findMany()

View File

@@ -1,6 +1,10 @@
export const standard = defineScenario({ export const standard = defineScenario({
post: { post: {
one: { title: 'String', body: 'String' }, one: {
two: { title: 'String', body: 'String' }, data: { title: 'String', body: 'String' }
},
two: {
data: { title: 'String', body: 'String' }
},
}, },
}) })

View File

@@ -7,7 +7,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@redwoodjs/core": "^0.36.0" "@redwoodjs/core": "^0.37.0"
}, },
"eslintConfig": { "eslintConfig": {
"extends": "@redwoodjs/eslint-config" "extends": "@redwoodjs/eslint-config"

View File

@@ -13,10 +13,10 @@
] ]
}, },
"dependencies": { "dependencies": {
"@redwoodjs/auth": "^0.36.0", "@redwoodjs/auth": "^0.37.0",
"@redwoodjs/forms": "^0.36.0", "@redwoodjs/forms": "^0.37.0",
"@redwoodjs/router": "^0.36.0", "@redwoodjs/router": "^0.37.0",
"@redwoodjs/web": "^0.36.0", "@redwoodjs/web": "^0.37.0",
"netlify-identity-widget": "^1.9.1", "netlify-identity-widget": "^1.9.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^17.0.2", "react": "^17.0.2",

View File

@@ -34,77 +34,75 @@ const ContactPage = () => {
console.log(data) console.log(data)
} }
return ( return <>
<> <Toaster />
<Toaster /> <Form
<Form onSubmit={onSubmit}
onSubmit={onSubmit} config={{ mode: 'onBlur' }}
validation={{ mode: 'onBlur' }} error={error}
formMethods={formMethods}
>
<FormError
error={error} 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 Name
error={error} </Label>
wrapperClassName="py-4 px-6 rounded-lg bg-red-100 text-red-700" <TextField
listClassName="list-disc ml-4" name="name"
listItemClassName="" validation={{ required: true }}
/> className="border rounded-sm px-2 py-1 outline-none"
<Label errorClassName="border rounded-sm px-2 py-1 border-red-700 outline-none"
name="name" />
className="block text-gray-700 uppercase text-sm" <FieldError name="name" className="block text-red-700" />
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" />
<Label <Label
name="name" name="name"
className="block mt-8 text-gray-700 uppercase text-sm" className="block mt-8 text-gray-700 uppercase text-sm"
errorClassName="block mt-8 text-red-700 uppercase text-sm" errorClassName="block mt-8 text-red-700 uppercase text-sm"
> >
Email Email
</Label> </Label>
<TextField <TextField
name="email" name="email"
validation={{ validation={{
required: true, required: true,
}} }}
className="border rounded-sm px-2 py-1" className="border rounded-sm px-2 py-1"
errorClassName="border rounded-sm px-2 py-1 border-red-700 outline-none" errorClassName="border rounded-sm px-2 py-1 border-red-700 outline-none"
/> />
<FieldError name="email" className="block text-red-700" /> <FieldError name="email" className="block text-red-700" />
<Label <Label
name="name" name="name"
className="block mt-8 text-gray-700 uppercase text-sm" className="block mt-8 text-gray-700 uppercase text-sm"
errorClassName="block mt-8 text-red-700 uppercase text-sm" errorClassName="block mt-8 text-red-700 uppercase text-sm"
> >
Message Message
</Label> </Label>
<TextAreaField <TextAreaField
name="message" name="message"
validation={{ required: true }} validation={{ required: true }}
className="block border rounded-sm px-2 py-1" className="block border rounded-sm px-2 py-1"
errorClassName="block border rounded-sm px-2 py-1 border-red-700 outline-none" errorClassName="block border rounded-sm px-2 py-1 border-red-700 outline-none"
/> />
<FieldError name="message" className="block text-red-700" /> <FieldError name="message" className="block text-red-700" />
<Submit <Submit
className="block bg-blue-700 text-white mt-8 px-4 py-2 rounded" className="block bg-blue-700 text-white mt-8 px-4 py-2 rounded"
disabled={loading} disabled={loading}
> >
Save Save
</Submit> </Submit>
</Form> </Form>
</> </>;
)
} }
export default ContactPage export default ContactPage

3640
yarn.lock

File diff suppressed because it is too large Load Diff