Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[legacy-framework] Add user signup/login/logout to new app template #834

Merged
merged 5 commits into from Aug 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/main.yml
Expand Up @@ -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') }}
Expand Down Expand Up @@ -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') }}
Expand Down
25 changes: 12 additions & 13 deletions examples/auth/db/schema.prisma
Expand Up @@ -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?
}
}
2 changes: 1 addition & 1 deletion packages/cli/bin/run
@@ -1,3 +1,3 @@
#!/usr/bin/env node

require('@blitzjs/cli').run()
require("@blitzjs/cli").run()
4 changes: 2 additions & 2 deletions packages/cli/src/commands/new.ts
Expand Up @@ -72,8 +72,8 @@ export class New extends Command {
await generator.run()
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)

Expand Down
43 changes: 43 additions & 0 deletions 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 (
<div>
<h1>Login</h1>

<Form<LoginInputType>
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(),
}
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
<LabeledTextField name="password" label="Password" placeholder="Password" type="password" />
</Form>
</div>
)
}

export default LoginForm
34 changes: 34 additions & 0 deletions 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<User, "hashedPassword">
}
15 changes: 15 additions & 0 deletions 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
}
5 changes: 5 additions & 0 deletions 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()
}
19 changes: 19 additions & 0 deletions 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
}
22 changes: 22 additions & 0 deletions 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 (
<>
<Head>
<title>Log In</title>
<link rel="icon" href="/favicon.ico" />
</Head>

<div>
<LoginForm onSuccess={() => router.push("/")} />
</div>
</>
)
}

export default SignupPage
51 changes: 51 additions & 0 deletions 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 (
<>
<Head>
<title>Sign Up</title>
<link rel="icon" href="/favicon.ico" />
</Head>

<div>
<h1>Create an Account</h1>

<Form<SignupInputType>
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()}
}
}
}}
>
<LabeledTextField name="email" label="Email" placeholder="Email" />
<LabeledTextField
name="password"
label="Password"
placeholder="Password"
type="password"
/>
</Form>
</div>
</>
)
}

export default SignupPage
13 changes: 13 additions & 0 deletions 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<typeof SignupInput>

export const LoginInput = z.object({
email: z.string().email(),
password: z.string(),
})
export type LoginInputType = z.infer<typeof LoginInput>
62 changes: 62 additions & 0 deletions 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<FormValues> = {
/** All your form fields */
children: ReactNode
/** Text to display in the submit button */
submitText: string
onSubmit: FinalFormProps<FormValues>["onSubmit"]
initialValues?: FinalFormProps<FormValues>["initialValues"]
schema?: z.ZodType<any, any>
} & Omit<PropsWithoutRef<JSX.IntrinsicElements["form"]>, "onSubmit">

export function Form<FormValues extends Record<string, unknown>>({
children,
submitText,
schema,
initialValues,
onSubmit,
...props
}: FormProps<FormValues>) {
return (
<FinalForm<FormValues>
initialValues={initialValues}
validate={(values) => {
if (!schema) return
try {
schema.parse(values)
} catch (error) {
return error.formErrors.fieldErrors
}
}}
onSubmit={onSubmit}
render={({handleSubmit, submitting, submitError}) => (
<form onSubmit={handleSubmit} className="form" {...props}>
{/* Form fields supplied as children are rendered here */}
{children}

{submitError && (
<div role="alert" style={{color: "red"}}>
{submitError}
</div>
)}

<button type="submit" disabled={submitting}>
{submitText}
</button>

<style global jsx>{`
.form > * + * {
margin-top: 1rem;
}
`}</style>
</form>
)}
/>
)
}

export default Form