From a8f5853115ffbcd65642c4df0b70b58971327f25 Mon Sep 17 00:00:00 2001 From: Brandon Bayer Date: Tue, 4 Aug 2020 11:03:05 -0400 Subject: [PATCH 1/3] wip --- examples/auth/db/schema.prisma | 25 ++++---- packages/cli/src/commands/new.ts | 2 + .../app/app/auth/components/LoginForm.tsx | 43 +++++++++++++ .../generator/templates/app/app/auth/index.ts | 34 ++++++++++ .../templates/app/app/auth/mutations/login.ts | 15 +++++ .../app/app/auth/mutations/logout.ts | 5 ++ .../app/app/auth/mutations/signup.ts | 19 ++++++ .../templates/app/app/auth/pages/login.tsx | 22 +++++++ .../templates/app/app/auth/pages/signup.tsx | 51 +++++++++++++++ .../templates/app/app/auth/validations.ts | 13 ++++ .../templates/app/app/components/Form.tsx | 62 +++++++++++++++++++ .../app/app/components/LabeledTextField.tsx | 55 ++++++++++++++++ .../templates/app/app/pages/_app.tsx | 16 ++++- .../generator/templates/app/blitz.config.js | 14 +++-- .../generator/templates/app/db/schema.prisma | 28 +++++++-- packages/generator/templates/app/package.json | 5 +- 16 files changed, 384 insertions(+), 25 deletions(-) create mode 100644 packages/generator/templates/app/app/auth/components/LoginForm.tsx create mode 100644 packages/generator/templates/app/app/auth/index.ts create mode 100644 packages/generator/templates/app/app/auth/mutations/login.ts create mode 100644 packages/generator/templates/app/app/auth/mutations/logout.ts create mode 100644 packages/generator/templates/app/app/auth/mutations/signup.ts create mode 100644 packages/generator/templates/app/app/auth/pages/login.tsx create mode 100644 packages/generator/templates/app/app/auth/pages/signup.tsx create mode 100644 packages/generator/templates/app/app/auth/validations.ts create mode 100644 packages/generator/templates/app/app/components/Form.tsx create mode 100644 packages/generator/templates/app/app/components/LabeledTextField.tsx diff --git a/examples/auth/db/schema.prisma b/examples/auth/db/schema.prisma index f765e4815b..33505bd943 100644 --- a/examples/auth/db/schema.prisma +++ b/examples/auth/db/schema.prisma @@ -17,30 +17,29 @@ generator client { provider = "prisma-client-js" } - // -------------------------------------- model User { - id Int @default(autoincrement()) @id - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - name String? - email String @unique + id Int @default(autoincrement()) @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String? + email String @unique hashedPassword String? - role String @default("user") - sessions Session[] + role String @default("user") + sessions Session[] } model Session { - id Int @default(autoincrement()) @id - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @default(autoincrement()) @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt expiresAt DateTime? - handle String @unique + handle String @unique user User? @relation(fields: [userId], references: [id]) userId Int? hashedSessionToken String? antiCSRFToken String? publicData String? privateData String? -} +} \ No newline at end of file diff --git a/packages/cli/src/commands/new.ts b/packages/cli/src/commands/new.ts index f816b1d35f..e3f60e0bd3 100644 --- a/packages/cli/src/commands/new.ts +++ b/packages/cli/src/commands/new.ts @@ -5,6 +5,7 @@ import {AppGenerator} from "@blitzjs/generator" import chalk from "chalk" import hasbin from "hasbin" import {log} from "@blitzjs/display" +import {runMigrate} from "./db" const debug = require("debug")("blitz:new") import {PromptAbortedError} from "../errors/prompt-aborted" @@ -70,6 +71,7 @@ export class New extends Command { try { this.log("\n" + log.withBrand("Hang tight while we set up your new Blitz app!") + "\n") await generator.run() + await runMigrate() this.log("\n" + log.withBrand("Your new Blitz app is ready! Next steps:") + "\n") this.log(chalk.yellow(` 1. cd ${args.name}`)) this.log(chalk.yellow(` 2. blitz start`)) diff --git a/packages/generator/templates/app/app/auth/components/LoginForm.tsx b/packages/generator/templates/app/app/auth/components/LoginForm.tsx new file mode 100644 index 0000000000..92ee038962 --- /dev/null +++ b/packages/generator/templates/app/app/auth/components/LoginForm.tsx @@ -0,0 +1,43 @@ +import React from "react" +import {LabeledTextField} from "app/components/LabeledTextField" +import {Form, FORM_ERROR} from "app/components/Form" +import login from "app/auth/mutations/login" +import {LoginInput, LoginInputType} from "app/auth/validations" + +type LoginFormProps = { + onSuccess?: () => void +} + +export const LoginForm = (props: LoginFormProps) => { + return ( +
+

Login

+ + + submitText="Log In" + schema={LoginInput} + initialValues={{email: undefined, password: undefined}} + onSubmit={async (values) => { + try { + await login({email: values.email, password: values.password}) + props.onSuccess && props.onSuccess() + } catch (error) { + if (error.name === "AuthenticationError") { + return {[FORM_ERROR]: "Sorry, those credentials are invalid"} + } else { + return { + [FORM_ERROR]: + "Sorry, we had an unexpected error. Please try again. - " + error.toString(), + } + } + } + }} + > + + + +
+ ) +} + +export default LoginForm diff --git a/packages/generator/templates/app/app/auth/index.ts b/packages/generator/templates/app/app/auth/index.ts new file mode 100644 index 0000000000..b91612e260 --- /dev/null +++ b/packages/generator/templates/app/app/auth/index.ts @@ -0,0 +1,34 @@ +import {AuthenticationError} from "blitz" +import SecurePassword from "secure-password" +import db, {User} from "db" + +const SP = new SecurePassword() + +export const hashPassword = async (password: string) => { + const hashedBuffer = await SP.hash(Buffer.from(password)) + return hashedBuffer.toString("base64") +} +export const verifyPassword = async (hashedPassword: string, password: string) => { + return await SP.verify(Buffer.from(password), Buffer.from(hashedPassword, "base64")) +} + +export const authenticateUser = async (email: string, password: string) => { + const user = await db.user.findOne({where: {email}}) + + if (!user || !user.hashedPassword) throw new AuthenticationError() + + switch (await verifyPassword(user.hashedPassword, password)) { + case SecurePassword.VALID: + break + case SecurePassword.VALID_NEEDS_REHASH: + // Upgrade hashed password with a more secure hash + const improvedHash = await hashPassword(password) + await db.user.update({where: {id: user.id}, data: {hashedPassword: improvedHash}}) + break + default: + throw new AuthenticationError() + } + + delete user.hashedPassword + return user as Omit +} diff --git a/packages/generator/templates/app/app/auth/mutations/login.ts b/packages/generator/templates/app/app/auth/mutations/login.ts new file mode 100644 index 0000000000..c5436a5120 --- /dev/null +++ b/packages/generator/templates/app/app/auth/mutations/login.ts @@ -0,0 +1,15 @@ +import {SessionContext} from "blitz" +import {authenticateUser} from "app/auth" +import {LoginInput, LoginInputType} from "../validations" + +export default async function login(input: LoginInputType, ctx: {session?: SessionContext} = {}) { + // This throws an error if input is invalid + const {email, password} = LoginInput.parse(input) + + // This throws an error if credentials are invalid + const user = await authenticateUser(email, password) + + await ctx.session!.create({userId: user.id, roles: [user.role]}) + + return user +} diff --git a/packages/generator/templates/app/app/auth/mutations/logout.ts b/packages/generator/templates/app/app/auth/mutations/logout.ts new file mode 100644 index 0000000000..7f0de3ee95 --- /dev/null +++ b/packages/generator/templates/app/app/auth/mutations/logout.ts @@ -0,0 +1,5 @@ +import {SessionContext} from "blitz" + +export default async function logout(_ = null, ctx: {session?: SessionContext} = {}) { + return await ctx.session!.revoke() +} diff --git a/packages/generator/templates/app/app/auth/mutations/signup.ts b/packages/generator/templates/app/app/auth/mutations/signup.ts new file mode 100644 index 0000000000..1ab2d8d3d7 --- /dev/null +++ b/packages/generator/templates/app/app/auth/mutations/signup.ts @@ -0,0 +1,19 @@ +import db from "db" +import {SessionContext} from "blitz" +import {hashPassword} from "app/auth" +import {SignupInput, SignupInputType} from "app/auth/validations" + +export default async function signup(input: SignupInputType, ctx: {session?: SessionContext} = {}) { + // This throws an error if input is invalid + const {email, password} = SignupInput.parse(input) + + const hashedPassword = await hashPassword(password) + const user = await db.user.create({ + data: {email, hashedPassword, role: "user"}, + select: {id: true, name: true, email: true, role: true}, + }) + + await ctx.session!.create({userId: user.id, roles: [user.role]}) + + return user +} diff --git a/packages/generator/templates/app/app/auth/pages/login.tsx b/packages/generator/templates/app/app/auth/pages/login.tsx new file mode 100644 index 0000000000..aad2c0b617 --- /dev/null +++ b/packages/generator/templates/app/app/auth/pages/login.tsx @@ -0,0 +1,22 @@ +import React from "react" +import {Head, useRouter, BlitzPage} from "blitz" +import {LoginForm} from "app/auth/components/LoginForm" + +const SignupPage: BlitzPage = () => { + const router = useRouter() + + return ( + <> + + Log In + + + +
+ router.push("/")} /> +
+ + ) +} + +export default SignupPage diff --git a/packages/generator/templates/app/app/auth/pages/signup.tsx b/packages/generator/templates/app/app/auth/pages/signup.tsx new file mode 100644 index 0000000000..c961168360 --- /dev/null +++ b/packages/generator/templates/app/app/auth/pages/signup.tsx @@ -0,0 +1,51 @@ +import React from "react" +import {Head, useRouter, BlitzPage} from "blitz" +import {Form, FORM_ERROR} from "app/components/Form" +import {LabeledTextField} from "app/components/LabeledTextField" +import signup from "app/auth/mutations/signup" +import {SignupInput, SignupInputType} from "app/auth/validations" + +const SignupPage: BlitzPage = () => { + const router = useRouter() + + return ( + <> + + Sign Up + + + +
+

Create an Account

+ + + submitText="Create Account" + schema={SignupInput} + onSubmit={async (values) => { + try { + await signup({email: values.email, password: values.password}) + router.push("/") + } catch (error) { + if (error.code === "P2002" && error.meta?.target?.includes("email")) { + // This error comes from Prisma + return {email: "This email is already being used"} + } else { + return {[FORM_ERROR]: error.toString()} + } + } + }} + > + + + +
+ + ) +} + +export default SignupPage diff --git a/packages/generator/templates/app/app/auth/validations.ts b/packages/generator/templates/app/app/auth/validations.ts new file mode 100644 index 0000000000..61756b6818 --- /dev/null +++ b/packages/generator/templates/app/app/auth/validations.ts @@ -0,0 +1,13 @@ +import * as z from "zod" + +export const SignupInput = z.object({ + email: z.string().email(), + password: z.string().min(10).max(100), +}) +export type SignupInputType = z.infer + +export const LoginInput = z.object({ + email: z.string().email(), + password: z.string(), +}) +export type LoginInputType = z.infer diff --git a/packages/generator/templates/app/app/components/Form.tsx b/packages/generator/templates/app/app/components/Form.tsx new file mode 100644 index 0000000000..1abb0470b8 --- /dev/null +++ b/packages/generator/templates/app/app/components/Form.tsx @@ -0,0 +1,62 @@ +import React, {ReactNode, PropsWithoutRef} from "react" +import {Form as FinalForm, FormProps as FinalFormProps} from "react-final-form" +import * as z from "zod" +export {FORM_ERROR} from "final-form" + +type FormProps = { + /** All your form fields */ + children: ReactNode + /** Text to display in the submit button */ + submitText: string + onSubmit: FinalFormProps["onSubmit"] + initialValues?: FinalFormProps["initialValues"] + schema?: z.ZodType +} & Omit, "onSubmit"> + +export function Form>({ + children, + submitText, + schema, + initialValues, + onSubmit, + ...props +}: FormProps) { + return ( + + initialValues={initialValues} + validate={(values) => { + if (!schema) return + try { + schema.parse(values) + } catch (error) { + return error.formErrors.fieldErrors + } + }} + onSubmit={onSubmit} + render={({handleSubmit, submitting, submitError}) => ( +
+ {/* Form fields supplied as children are rendered here */} + {children} + + {submitError && ( +
+ {submitError} +
+ )} + + + + +
+ )} + /> + ) +} + +export default Form diff --git a/packages/generator/templates/app/app/components/LabeledTextField.tsx b/packages/generator/templates/app/app/components/LabeledTextField.tsx new file mode 100644 index 0000000000..e20ece1aa0 --- /dev/null +++ b/packages/generator/templates/app/app/components/LabeledTextField.tsx @@ -0,0 +1,55 @@ +import React, {PropsWithoutRef} from "react" +import {useField} from "react-final-form" + +export interface LabeledTextFieldProps extends PropsWithoutRef { + /** Field name. */ + name: string + /** Field label. */ + label: string + /** Field type. Doesn't include radio buttons and checkboxes */ + type?: "text" | "password" | "email" | "number" + outerProps?: PropsWithoutRef +} + +export const LabeledTextField = React.forwardRef( + ({name, label, outerProps, ...props}, ref) => { + const { + input, + meta: {touched, error, submitError, submitting}, + } = useField(name) + + return ( +
+ + + {touched && (error || submitError) && ( +
+ {error || submitError} +
+ )} + + +
+ ) + }, +) + +export default LabeledTextField diff --git a/packages/generator/templates/app/app/pages/_app.tsx b/packages/generator/templates/app/app/pages/_app.tsx index 8949f1206d..0be54b8643 100644 --- a/packages/generator/templates/app/app/pages/_app.tsx +++ b/packages/generator/templates/app/app/pages/_app.tsx @@ -1,6 +1,7 @@ import { AppProps, ErrorComponent } from "blitz" import { ErrorBoundary } from "react-error-boundary" import { queryCache } from "react-query" +import LoginForm from "app/auth/components/LoginForm" export default function App({ Component, pageProps }: AppProps) { return ( @@ -18,5 +19,18 @@ export default function App({ Component, pageProps }: AppProps) { } function RootErrorFallback({ error, resetErrorBoundary }) { - return + if (error.name === "AuthenticationError") { + return + } else if (error.name === "AuthorizationError") { + return ( + + ) + } else { + return ( + + ) + } } diff --git a/packages/generator/templates/app/blitz.config.js b/packages/generator/templates/app/blitz.config.js index 3b17b02ef1..5d1e4f303b 100644 --- a/packages/generator/templates/app/blitz.config.js +++ b/packages/generator/templates/app/blitz.config.js @@ -1,13 +1,17 @@ +const { sessionMiddleware, unstable_simpleRolesIsAuthorized } = require("@blitzjs/server") + module.exports = { + middleware: [ + sessionMiddleware({ + unstable_isAuthorized: unstable_simpleRolesIsAuthorized, + }), + ], + /* Uncomment this to customize the webpack config webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { // Note: we provide webpack above so you should not `require` it // Perform customizations to webpack config // Important: return the modified config return config }, - webpackDevMiddleware: (config) => { - // Perform customizations to webpack dev middleware config - // Important: return the modified config - return config - }, + */ } diff --git a/packages/generator/templates/app/db/schema.prisma b/packages/generator/templates/app/db/schema.prisma index a7ca37f36f..ec12a7f246 100644 --- a/packages/generator/templates/app/db/schema.prisma +++ b/packages/generator/templates/app/db/schema.prisma @@ -17,11 +17,29 @@ generator client { provider = "prisma-client-js" } - // -------------------------------------- -//model Project { -// id Int @default(autoincrement()) @id -// name String -//} +model User { + id Int @default(autoincrement()) @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String? + email String @unique + hashedPassword String? + role String @default("user") + sessions Session[] +} +model Session { + id Int @default(autoincrement()) @id + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + expiresAt DateTime? + handle String @unique + user User? @relation(fields: [userId], references: [id]) + userId Int? + hashedSessionToken String? + antiCSRFToken String? + publicData String? + privateData String? +} diff --git a/packages/generator/templates/app/package.json b/packages/generator/templates/app/package.json index c5fa8e8222..6089056d26 100644 --- a/packages/generator/templates/app/package.json +++ b/packages/generator/templates/app/package.json @@ -32,7 +32,10 @@ "blitz": "canary", "react": "0.0.0-experimental-33c3af284", "react-dom": "0.0.0-experimental-33c3af284", - "react-error-boundary": "2.x" + "react-error-boundary": "2.x", + "react-final-form": "6.5.1", + "secure-password": "4.x", + "zod": "1.x" }, "devDependencies": { "@types/react": "16.x", From 4241e6000d9d4ea3774930fbd6cbeeba5dd489cf Mon Sep 17 00:00:00 2001 From: Brandon Bayer Date: Tue, 4 Aug 2020 16:08:22 -0400 Subject: [PATCH 2/3] finish --- packages/cli/bin/run | 2 +- packages/cli/src/commands/new.ts | 6 +- .../templates/app/app/pages/index.tsx | 434 ++++++++++-------- packages/generator/templates/app/package.json | 1 + 4 files changed, 248 insertions(+), 195 deletions(-) diff --git a/packages/cli/bin/run b/packages/cli/bin/run index d0a3afa252..dd1f27dbef 100755 --- a/packages/cli/bin/run +++ b/packages/cli/bin/run @@ -1,3 +1,3 @@ #!/usr/bin/env node -require('@blitzjs/cli').run() +require("@blitzjs/cli").run() diff --git a/packages/cli/src/commands/new.ts b/packages/cli/src/commands/new.ts index e3f60e0bd3..2658aa5624 100644 --- a/packages/cli/src/commands/new.ts +++ b/packages/cli/src/commands/new.ts @@ -5,7 +5,6 @@ import {AppGenerator} from "@blitzjs/generator" import chalk from "chalk" import hasbin from "hasbin" import {log} from "@blitzjs/display" -import {runMigrate} from "./db" const debug = require("debug")("blitz:new") import {PromptAbortedError} from "../errors/prompt-aborted" @@ -71,11 +70,10 @@ export class New extends Command { try { this.log("\n" + log.withBrand("Hang tight while we set up your new Blitz app!") + "\n") await generator.run() - await runMigrate() this.log("\n" + log.withBrand("Your new Blitz app is ready! Next steps:") + "\n") this.log(chalk.yellow(` 1. cd ${args.name}`)) - this.log(chalk.yellow(` 2. blitz start`)) - this.log(chalk.yellow(` 3. You create new pages by placing components inside app/pages/\n`)) + this.log(chalk.yellow(` 2. blitz db migrate`)) + this.log(chalk.yellow(` 3. blitz start`)) } catch (err) { if (err instanceof PromptAbortedError) this.exit(0) diff --git a/packages/generator/templates/app/app/pages/index.tsx b/packages/generator/templates/app/app/pages/index.tsx index f0ad86fd9c..a82f314447 100644 --- a/packages/generator/templates/app/app/pages/index.tsx +++ b/packages/generator/templates/app/app/pages/index.tsx @@ -1,197 +1,251 @@ -import { Head, Link } from "blitz" - -const Home = () => ( -
- - __name__ - - - -
-
- blitz.js -
-

1. Run this command in your terminal:

-
-        blitz generate all project name:string
-      
-

2. Then run this command:

-
-        blitz db migrate
-      
- -

- 3. Go to{" "} - - /projects - -

-
+import { Head, Link, useSession } from "blitz" +import logout from "app/auth/mutations/logout" + +/* + * This file is just for a pleasant getting started page for your new app. + * You can delete everything in here and start from scratch if you like. + */ + +export default function Home() { + const session = useSession() + + return ( +
+ + __name__ + + + +
+
+ blitz.js +
+

+ Congrats! Your app is ready, including user sign-up and log-in. +

+
+ {session.userId ? ( + <> + +
+ User id: {session.userId} +
+ User role: {session.roles[0]} +
+ + ) : ( + <> + + + Sign Up + + + + + Login + + + + )} +
+

+ + To add a new model to your app,
+ run the following in your terminal: +
+

+
+          blitz generate all project name:string
+        
+
+          blitz db migrate
+        
+ +

+ Then go to{" "} + + /projects + +

+ +
+ +
-
- - - - + + - - -
-) - -export default Home + `} + + ) +} diff --git a/packages/generator/templates/app/package.json b/packages/generator/templates/app/package.json index 6089056d26..cfdb92103e 100644 --- a/packages/generator/templates/app/package.json +++ b/packages/generator/templates/app/package.json @@ -33,6 +33,7 @@ "react": "0.0.0-experimental-33c3af284", "react-dom": "0.0.0-experimental-33c3af284", "react-error-boundary": "2.x", + "final-form": "4.x", "react-final-form": "6.5.1", "secure-password": "4.x", "zod": "1.x" From 57186cfa74bdb0e8fb21fa9ed1f7c099788a2498 Mon Sep 17 00:00:00 2001 From: Brandon Bayer Date: Tue, 4 Aug 2020 16:37:53 -0400 Subject: [PATCH 3/3] try not caching node_modules --- .github/workflows/main.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cc1a7a2b94..290aaefd95 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -64,7 +64,6 @@ jobs: with: path: | ${{ steps.yarn-cache-dir-path.outputs.dir }} - **/node_modules /home/runner/.cache/Cypress C:\Users\runneradmin\AppData\Local\Cypress\Cache key: ${{ runner.os }}-yarn-v2-${{ hashFiles('yarn.lock') }} @@ -106,7 +105,6 @@ jobs: with: path: | ${{ steps.yarn-cache-dir-path.outputs.dir }} - **/node_modules /home/runner/.cache/Cypress C:\Users\runneradmin\AppData\Local\Cypress\Cache key: ${{ runner.os }}-yarn-v2-${{ hashFiles('yarn.lock') }}