├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── LICENSE ├── README.md ├── auth-email ├── cli.js ├── package.json ├── postcss.config.js ├── scaffold │ ├── .env.local │ ├── fauna-client.js │ ├── fauna-client.ts │ ├── happyauth │ │ ├── index.js │ │ ├── index.tsx │ │ ├── server.js │ │ └── server.ts │ ├── jsconfig.json │ └── pages │ │ ├── _app.js │ │ ├── _app.tsx │ │ ├── api │ │ └── auth │ │ │ ├── [...params].js │ │ │ └── [...params].ts │ │ ├── change-password.js │ │ ├── change-password.tsx │ │ ├── confirm-account.js │ │ ├── confirm-account.tsx │ │ ├── example.js │ │ ├── example.tsx │ │ ├── forgot-password.js │ │ ├── forgot-password.tsx │ │ ├── login.js │ │ ├── login.tsx │ │ ├── reset-password.js │ │ ├── reset-password.tsx │ │ ├── signup.js │ │ └── signup.tsx ├── src-app │ ├── api │ │ ├── change-password.spec.ts │ │ ├── change-password.ts │ │ ├── confirm-account.spec.ts │ │ ├── confirm-account.ts │ │ ├── connect.ts │ │ ├── forgot-password.spec.ts │ │ ├── forgot-password.ts │ │ ├── index.ts │ │ ├── login.spec.ts │ │ ├── login.ts │ │ ├── logout.spec.ts │ │ ├── logout.ts │ │ ├── oauth.spec.ts │ │ ├── oauth.ts │ │ ├── resend-confirmation-email.spec.ts │ │ ├── resend-confirmation-email.ts │ │ ├── reset-password.spec.ts │ │ ├── reset-password.ts │ │ ├── signup.spec.ts │ │ ├── signup.ts │ │ ├── tokencontent.spec.ts │ │ └── tokencontent.ts │ ├── components │ │ ├── forms.tsx │ │ ├── messages.tsx │ │ └── social-logins.tsx │ ├── drivers │ │ └── fauna.ts │ ├── index.tsx │ ├── jest.jsdom.config.js │ ├── jest.node.config.js │ ├── jest │ │ ├── css-mock.ts │ │ ├── setup-files-after-env.ts │ │ ├── setup-jest.ts │ │ ├── utils.jsdom.tsx │ │ └── utils.node.ts │ ├── pages │ │ ├── change-password.spec.tsx │ │ ├── change-password.tsx │ │ ├── confirm-account.spec.tsx │ │ ├── confirm-account.tsx │ │ ├── forgot-password.spec.tsx │ │ ├── forgot-password.tsx │ │ ├── login.spec.tsx │ │ ├── login.tsx │ │ ├── reset-password.spec.tsx │ │ ├── reset-password.tsx │ │ ├── signup.spec.tsx │ │ └── signup.tsx │ ├── tailwind.css │ ├── tsconfig.build.json │ ├── tsconfig.json │ └── types.d.ts ├── src-cli │ ├── db.ts │ ├── files.ts │ ├── index.ts │ ├── init.ts │ ├── prompts.ts │ ├── random-secret.ts │ └── tsconfig.json ├── tailwind.config.js └── webpack.config.js ├── demo ├── .gitignore ├── README.md ├── fauna-client.ts ├── happyauth │ ├── index.tsx │ └── server.ts ├── next-env.d.ts ├── package.json ├── pages │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ └── [...params].ts │ │ └── hello.js │ ├── change-password.tsx │ ├── confirm-account.tsx │ ├── forgot-password.tsx │ ├── index.tsx │ ├── login.tsx │ ├── reset-password.tsx │ └── signup.tsx ├── public │ ├── favicon.ico │ └── vercel.svg └── tsconfig.json ├── package.json ├── pristine-typescript ├── .gitignore ├── README.md ├── next-env.d.ts ├── package.json ├── pages │ ├── api │ │ └── hello.js │ └── index.js ├── public │ ├── favicon.ico │ └── vercel.svg └── tsconfig.json ├── pristine ├── .gitignore ├── README.md ├── package.json ├── pages │ ├── api │ │ └── hello.js │ └── index.js └── public │ ├── favicon.ico │ └── vercel.svg ├── starter-fauna-typescript ├── .gitignore ├── README.md ├── fauna-client.ts ├── happyauth │ ├── index.tsx │ └── server.ts ├── next-env.d.ts ├── package.json ├── pages │ ├── _app.tsx │ ├── api │ │ ├── auth │ │ │ └── [...params].ts │ │ └── hello.js │ ├── change-password.tsx │ ├── confirm-account.tsx │ ├── forgot-password.tsx │ ├── index.tsx │ ├── login.tsx │ ├── reset-password.tsx │ └── signup.tsx ├── public │ ├── favicon.ico │ └── vercel.svg └── tsconfig.json ├── starter-fauna ├── .gitignore ├── README.md ├── fauna-client.js ├── happyauth │ ├── index.js │ └── server.js ├── jsconfig.json ├── package.json ├── pages │ ├── _app.js │ ├── api │ │ ├── auth │ │ │ └── [...params].js │ │ └── hello.js │ ├── change-password.js │ ├── confirm-account.js │ ├── forgot-password.js │ ├── index.js │ ├── login.js │ ├── reset-password.js │ └── signup.js └── public │ ├── favicon.ico │ └── vercel.svg └── yarn.lock /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, 2 | # build the source code and run tests. 3 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 4 | 5 | name: Node.js CI 6 | 7 | on: 8 | push: 9 | branches: [master, next] 10 | pull_request: 11 | branches: [master, next] 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [12.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: yarn install --frozen-lockfile 28 | - run: yarn test 29 | env: 30 | CI: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # misc 9 | .DS_Store 10 | 11 | # debug 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | auth-email/README.md 17 | dist-app 18 | dist-cli 19 | auth-email/index.js 20 | auth-email/index.d.ts 21 | auth-email/api/ 22 | auth-email/pages/ 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 HappyKit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /auth-email/cli.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | // This file is necessary since the cli needs executable permissions (chmod +x). 4 | // TypeScript will not keep the executable permissions during compilation. 5 | // 6 | // Tihs file is executable (chmod +x) and is never touched by TypeScript. 7 | // This allows us to use TypeScript to build the CLI. 8 | 9 | require("./dist-cli/index.js") 10 | -------------------------------------------------------------------------------- /auth-email/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@happykit/auth-email", 3 | "version": "1.0.0-alpha.5", 4 | "main": "index.js", 5 | "types": "index.d.ts", 6 | "author": "Dominik Ferber (http://dferber.de/)", 7 | "license": "MIT", 8 | "sideEffects": false, 9 | "bin": { 10 | "auth-email": "cli.js" 11 | }, 12 | "scripts": { 13 | "test": "jest", 14 | "dev": "webpack --config webpack.config.js --watch", 15 | "prebuild": "cp ../README.md .", 16 | "build": "NODE_ENV=production webpack --config webpack.config.js", 17 | "pub": "yarn publish --access=public" 18 | }, 19 | "files": [ 20 | "api", 21 | "dist-app", 22 | "dist-cli", 23 | "pages", 24 | "scaffold", 25 | "cli.js", 26 | "index.d.ts", 27 | "index.js" 28 | ], 29 | "dependencies": { 30 | "@types/classnames": "2.2.10", 31 | "@types/cookie": "0.4.0", 32 | "@types/find-root": "1.1.1", 33 | "@types/fs-extra": "9.0.1", 34 | "@types/inquirer": "6.5.0", 35 | "@types/jsonwebtoken": "8.5.0", 36 | "@types/klaw": "3.0.1", 37 | "@types/lodash.mapvalues": "4.6.6", 38 | "@types/ms": "0.7.31", 39 | "@types/simple-oauth2": "2.5.3", 40 | "@xstate/fsm": "1.4.0", 41 | "@xstate/react": "0.8.1", 42 | "chalk": "4.1.0", 43 | "classnames": "2.2.6", 44 | "commander": "5.1.0", 45 | "cookie": "0.4.1", 46 | "find-root": "1.1.0", 47 | "fs-extra": "9.0.1", 48 | "inquirer": "7.2.0", 49 | "jsonwebtoken": "8.5.1", 50 | "klaw": "3.0.0", 51 | "lodash.mapvalues": "4.6.0", 52 | "ms": "2.1.2", 53 | "open": "7.0.4", 54 | "path": "0.12.7", 55 | "query-string": "6.13.1", 56 | "simple-oauth2": "3.4.0", 57 | "xstate": "4.10.0" 58 | }, 59 | "devDependencies": { 60 | "@fullhuman/postcss-purgecss": "2.3.0", 61 | "@testing-library/jest-dom": "5.10.1", 62 | "@testing-library/react": "10.4.1", 63 | "@types/test-listen": "1.1.0", 64 | "autoprefixer": "9.8.4", 65 | "clean-webpack-plugin": "3.0.0", 66 | "css-loader": "3.6.0", 67 | "isomorphic-unfetch": "3.0.0", 68 | "jest": "26.1.0", 69 | "jest-fetch-mock": "3.0.3", 70 | "next-server": "9.0.5", 71 | "postcss-import": "12.0.1", 72 | "postcss-loader": "3.0.0", 73 | "tailwindcss": "1.4.6", 74 | "test-listen": "1.1.0", 75 | "to-string-loader": "1.1.6", 76 | "ts-jest": "26.1.1", 77 | "ts-loader": "7.0.5", 78 | "typescript": "3.9.5", 79 | "webpack": "4.43.0", 80 | "webpack-cli": "3.3.12" 81 | }, 82 | "peerDependencies": { 83 | "faunadb": "^2.14.1", 84 | "next": "^9.4.0", 85 | "react": "^16.13.1" 86 | }, 87 | "jest": { 88 | "projects": [ 89 | "/src-app/jest.jsdom.config.js", 90 | "/src-app/jest.node.config.js" 91 | ] 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /auth-email/postcss.config.js: -------------------------------------------------------------------------------- 1 | const purgecss = require("@fullhuman/postcss-purgecss") 2 | 3 | const plugins = [ 4 | require("postcss-import"), 5 | require("tailwindcss"), 6 | require("autoprefixer"), 7 | ] 8 | 9 | if (process.env.NODE_ENV === "production") { 10 | plugins.push( 11 | purgecss({ 12 | content: ["./src-app/**/*.tsx"], 13 | extractors: [ 14 | { 15 | extractor: (content) => content.match(/[\w-/:]+(? New Key" 4 | # and create a new server key. 5 | FAUNA_SERVER_KEY="" 6 | # A random secret to sign your tokens. 7 | # We automatically created a random secret when creating this file. 8 | # You can keep it, or you can replace it with your own. 9 | # Note that existing users will be signed out whenever you change the secret. 10 | HAPPYAUTH_TOKEN_SECRET="" 11 | # The base url used when sending links via email, e.g. 12 | # "https://example.com" (do not include a path or trailing slash) 13 | PRODUCTION_BASE_URL="" 14 | -------------------------------------------------------------------------------- /auth-email/scaffold/fauna-client.js: -------------------------------------------------------------------------------- 1 | import faunadb from "faunadb" 2 | 3 | export const faunaClient = new faunadb.Client({ 4 | secret: process.env.FAUNA_SERVER_KEY, 5 | }) 6 | 7 | export const q = faunadb.query 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/fauna-client.ts: -------------------------------------------------------------------------------- 1 | import faunadb from "faunadb" 2 | 3 | export const faunaClient = new faunadb.Client({ 4 | secret: process.env.FAUNA_SERVER_KEY!, 5 | }) 6 | 7 | export const q = faunadb.query 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/happyauth/index.js: -------------------------------------------------------------------------------- 1 | import { createUseAuth, AuthProvider } from "@happykit/auth-email" 2 | 3 | export const publicConfig = { 4 | baseUrl: (() => { 5 | if (process.env.VERCEL_GITHUB_COMMIT_REF === "master") 6 | return process.env.PRODUCTION_BASE_URL 7 | if (process.env.NODE_ENV === "production") 8 | return `https://${process.env.VERCEL_URL}` 9 | return "http://localhost:3000" 10 | })(), 11 | identityProviders: {}, 12 | // Possible configuration: 13 | // redirects: { 14 | // afterSignIn: "/?afterSigIn=true", 15 | // afterSignOut: "/?afterSignOut=true", 16 | // afterChangePassword: "/?afterChangePassword=true", 17 | // afterResetPassword: "/?afterResetPassword=true", 18 | // }, 19 | } 20 | 21 | /* you can probably leave these as they are */ 22 | export { AuthProvider } 23 | export const useAuth = createUseAuth(publicConfig) 24 | -------------------------------------------------------------------------------- /auth-email/scaffold/happyauth/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createUseAuth, 3 | AuthProvider, 4 | PublicConfig, 5 | BaseTokenData, 6 | } from "@happykit/auth-email" 7 | 8 | export interface TokenData extends BaseTokenData { 9 | /* define your additional token data here */ 10 | } 11 | 12 | export const publicConfig: PublicConfig = { 13 | baseUrl: (() => { 14 | if (process.env.VERCEL_GITHUB_COMMIT_REF === "master") 15 | return process.env.PRODUCTION_BASE_URL! 16 | if (process.env.NODE_ENV === "production") 17 | return `https://${process.env.VERCEL_URL}` 18 | return "http://localhost:3000" 19 | })(), 20 | identityProviders: {}, 21 | // Possible configuration: 22 | // redirects: { 23 | // afterSignIn: "/?afterSigIn=true", 24 | // afterSignOut: "/?afterSignOut=true", 25 | // afterChangePassword: "/?afterChangePassword=true", 26 | // afterResetPassword: "/?afterResetPassword=true", 27 | // }, 28 | } 29 | 30 | /* you can probably leave these as they are */ 31 | export { AuthProvider } 32 | export const useAuth = createUseAuth(publicConfig) 33 | export type Auth = ReturnType 34 | -------------------------------------------------------------------------------- /auth-email/scaffold/happyauth/server.js: -------------------------------------------------------------------------------- 1 | import { 2 | createGetServerSideAuth, 3 | sendConfirmAccountMailToConsole, 4 | sendForgotPasswordMailToConsole, 5 | createFaunaEmailDriver, 6 | } from "@happykit/auth-email/api" 7 | import { faunaClient } from "fauna-client" 8 | 9 | export const serverConfig = { 10 | tokenSecret: process.env.HAPPYAUTH_TOKEN_SECRET, 11 | cookieName: "happyauth", 12 | secure: process.env.NODE_ENV === "production", 13 | identityProviders: {}, 14 | triggers: { 15 | sendConfirmAccountMail: sendConfirmAccountMailToConsole, 16 | sendForgotPasswordMail: sendForgotPasswordMailToConsole, 17 | }, 18 | driver: createFaunaEmailDriver(faunaClient), 19 | } 20 | 21 | /* you can probably leave these as they are */ 22 | export const getServerSideAuth = createGetServerSideAuth(serverConfig) 23 | -------------------------------------------------------------------------------- /auth-email/scaffold/happyauth/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createGetServerSideAuth, 3 | ServerConfig, 4 | sendConfirmAccountMailToConsole, 5 | sendForgotPasswordMailToConsole, 6 | createFaunaEmailDriver, 7 | } from "@happykit/auth-email/api" 8 | import { TokenData } from "." 9 | import { faunaClient } from "fauna-client" 10 | 11 | export const serverConfig: ServerConfig = { 12 | tokenSecret: process.env.HAPPYAUTH_TOKEN_SECRET!, 13 | cookieName: "happyauth", 14 | secure: process.env.NODE_ENV === "production", 15 | identityProviders: {}, 16 | triggers: { 17 | sendConfirmAccountMail: sendConfirmAccountMailToConsole, 18 | sendForgotPasswordMail: sendForgotPasswordMailToConsole, 19 | }, 20 | driver: createFaunaEmailDriver(faunaClient), 21 | } 22 | 23 | /* you can probably leave these as they are */ 24 | export type AuthState = ReturnType 25 | export const getServerSideAuth = createGetServerSideAuth( 26 | serverConfig, 27 | ) 28 | -------------------------------------------------------------------------------- /auth-email/scaffold/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/_app.js: -------------------------------------------------------------------------------- 1 | import { AuthProvider } from "happyauth" 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default MyApp 12 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from "next/app" 2 | import { AuthProvider } from "happyauth" 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default MyApp 13 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/api/auth/[...params].js: -------------------------------------------------------------------------------- 1 | import { createAuthRouteHandler } from "@happykit/auth-email/api" 2 | import { publicConfig } from "happyauth" 3 | import { serverConfig, getServerSideAuth } from "happyauth/server" 4 | 5 | // You can use the triggers to customize the server behaviour. 6 | // 7 | // Alternatively, you can completely override individual functions by creating 8 | // files for their routes /api/auth/.ts, e.g. /api/auth/login.ts 9 | export default createAuthRouteHandler({ 10 | publicConfig, 11 | serverConfig, 12 | getServerSideAuth, 13 | }) 14 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/api/auth/[...params].ts: -------------------------------------------------------------------------------- 1 | import { createAuthRouteHandler } from "@happykit/auth-email/api" 2 | import { publicConfig, TokenData } from "happyauth" 3 | import { serverConfig, getServerSideAuth } from "happyauth/server" 4 | 5 | // You can use the triggers to customize the server behaviour. 6 | // 7 | // Alternatively, you can completely override individual functions by creating 8 | // files for their routes /api/auth/.ts, e.g. /api/auth/login.ts 9 | export default createAuthRouteHandler({ 10 | publicConfig, 11 | serverConfig, 12 | getServerSideAuth, 13 | }) 14 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/change-password.js: -------------------------------------------------------------------------------- 1 | import { ChangePassword } from "@happykit/auth-email/pages/change-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ChangePasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/change-password.tsx: -------------------------------------------------------------------------------- 1 | import { ChangePassword } from "@happykit/auth-email/pages/change-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ChangePasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/confirm-account.js: -------------------------------------------------------------------------------- 1 | import { ConfirmAccount } from "@happykit/auth-email/pages/confirm-account" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ConfirmAccountPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/confirm-account.tsx: -------------------------------------------------------------------------------- 1 | import { ConfirmAccount } from "@happykit/auth-email/pages/confirm-account" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ConfirmAccountPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/example.js: -------------------------------------------------------------------------------- 1 | // Example of how to use HappyAuth. 2 | // 3 | // You can replace your existing pages/index.js file this one to test 4 | // your HappyAuth setup. 5 | // 6 | // This file can be deleted. 7 | import * as React from "react" 8 | import Head from "next/head" 9 | import Link from "next/link" 10 | import { useAuth } from "happyauth" 11 | import { getServerSideAuth } from "happyauth/server" 12 | 13 | export const getServerSideProps = async ({ req }) => { 14 | const initialAuth = getServerSideAuth(req) 15 | return { props: { initialAuth } } 16 | } 17 | 18 | const Example = (props) => { 19 | const auth = useAuth(props.initialAuth) 20 | 21 | return ( 22 | 23 | 24 | 28 | 29 |
30 |
31 |
32 |

33 | HappyAuth 34 |

35 |

36 | Demo 37 |

38 |
39 |
40 |

41 | This pink page was created 42 | automatically, so you can explore HappyAuth. You would replace 43 | this page with your own application. 44 |

45 |

46 | All the authentication pages with{" "} 47 | purple buttons are set up 48 | for you already. You can keep using them, or replace them with 49 | your own! 50 |

51 |
52 | {auth.state === "signedIn" ? ( 53 |
54 |
55 |
56 |
57 |
58 |
59 | You are signed in 60 |
61 |
62 |
63 |
64 |
65 |
66 | 75 |
76 | 83 |
84 |
85 |
86 | ) : ( 87 |
88 |
89 |
90 |
91 |
92 |
93 | Start with 94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 102 | 103 | 106 | 107 | 108 |
109 |
110 | 111 | 112 | 115 | 116 | 117 |
118 |
119 |
120 | )} 121 |
122 |
123 |
124 | ) 125 | } 126 | 127 | export default Example 128 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/example.tsx: -------------------------------------------------------------------------------- 1 | // Example of how to use HappyAuth. 2 | // 3 | // You can replace your existing pages/index.tsx file this one to test 4 | // your HappyAuth setup. 5 | // 6 | // This file can be deleted. 7 | import * as React from "react" 8 | import { GetServerSideProps } from "next" 9 | import Head from "next/head" 10 | import Link from "next/link" 11 | import { useAuth } from "happyauth" 12 | import { getServerSideAuth, AuthState } from "happyauth/server" 13 | 14 | export const getServerSideProps: GetServerSideProps = async ({ req }) => { 15 | const initialAuth = getServerSideAuth(req) 16 | return { props: { initialAuth } } 17 | } 18 | 19 | const Example = (props: { initialAuth: AuthState }) => { 20 | const auth = useAuth(props.initialAuth) 21 | 22 | return ( 23 | 24 | 25 | 29 | 30 |
31 |
32 |
33 |

34 | HappyAuth 35 |

36 |

37 | Demo 38 |

39 |
40 |
41 |

42 | This application was created with HappyAuth. You would replace 43 | this index page with your own application. 44 |

45 |

46 | All the authentication pages with{" "} 47 | purple buttons are set up 48 | for you already. You can keep using them, or replace them with 49 | your own! 50 |

51 |
52 | {auth.state === "signedIn" ? ( 53 |
54 |
55 |
56 |
57 |
58 |
59 | You are signed in 60 |
61 |
62 |
63 |
64 |
65 |
66 | 75 |
76 | 83 |
84 |
85 |
86 | ) : ( 87 |
88 |
89 |
90 |
91 |
92 |
93 | Start with 94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 102 | 103 | 106 | 107 | 108 |
109 |
110 | 111 | 112 | 115 | 116 | 117 |
118 |
119 |
120 | )} 121 |
122 |
123 |
124 | ) 125 | } 126 | 127 | export default Example 128 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/forgot-password.js: -------------------------------------------------------------------------------- 1 | import { ForgotPassword } from "@happykit/auth-email/pages/forgot-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ForgotPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPassword } from "@happykit/auth-email/pages/forgot-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ForgotPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/login.js: -------------------------------------------------------------------------------- 1 | import { Login } from "@happykit/auth-email/pages/login" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function LoginPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { Login } from "@happykit/auth-email/pages/login" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function LoginPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/reset-password.js: -------------------------------------------------------------------------------- 1 | import { ResetPassword } from "@happykit/auth-email/pages/reset-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ResetPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPassword } from "@happykit/auth-email/pages/reset-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ResetPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/signup.js: -------------------------------------------------------------------------------- 1 | import { Signup } from "@happykit/auth-email/pages/signup" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function SignupPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/scaffold/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import { Signup } from "@happykit/auth-email/pages/signup" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function SignupPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /auth-email/src-app/api/change-password.spec.ts: -------------------------------------------------------------------------------- 1 | import { createChangePassword } from "./change-password" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | createAuthCookie, 5 | fetch, 6 | createApi, 7 | handler, 8 | } from "../jest/utils.node" 9 | 10 | let options = createAuthRouteHandlerOptions() 11 | let changePassword = createChangePassword(options) 12 | 13 | afterEach(() => { 14 | options = createAuthRouteHandlerOptions() 15 | changePassword = createChangePassword(options) 16 | }) 17 | 18 | test("when tokenSecret is missing", () => { 19 | expect(() => 20 | createChangePassword({ 21 | ...options, 22 | serverConfig: { 23 | ...options.serverConfig, 24 | tokenSecret: "", 25 | }, 26 | }), 27 | ).toThrow("HappyAuth: Missing token secret") 28 | }) 29 | 30 | test("when not authenticated", async () => { 31 | const [url, close] = await createApi(changePassword) 32 | const response = await fetch(url) 33 | expect(response.status).toBe(200) 34 | 35 | const data = await response.json() 36 | expect(data).toEqual({ error: { code: "unauthorized" } }) 37 | close() 38 | }) 39 | 40 | test( 41 | "when authenticated and sending correct passwords", 42 | handler( 43 | () => { 44 | options.serverConfig.driver.changeEmailUserPassword = jest.fn( 45 | (userId: string, currentPassword: string, newPassword: string) => 46 | Promise.resolve(), 47 | ) 48 | return createChangePassword(options) 49 | }, 50 | async (url) => { 51 | const authCookie = createAuthCookie(options, { userId: "1" }) 52 | const response = await fetch(url, { 53 | method: "POST", 54 | headers: { Cookie: authCookie, "content-type": "application/json" }, 55 | body: JSON.stringify({ 56 | currentPassword: "hunter2", 57 | newPassword: "hunter3", 58 | }), 59 | }) 60 | expect(response.status).toBe(200) 61 | 62 | expect( 63 | options.serverConfig.driver.changeEmailUserPassword, 64 | ).toHaveBeenCalledWith("1", "hunter2", "hunter3") 65 | 66 | const data = await response.json() 67 | expect(data).toEqual({ data: { ok: true } }) 68 | }, 69 | ), 70 | ) 71 | 72 | test( 73 | "when authenticated and sending incorrect current password", 74 | handler( 75 | () => { 76 | options.serverConfig.driver.changeEmailUserPassword = jest.fn(() => { 77 | throw new Error("authentication failed") 78 | }) 79 | return createChangePassword(options) 80 | }, 81 | async (url) => { 82 | const authCookie = createAuthCookie(options, { userId: "1" }) 83 | const response = await fetch(url, { 84 | method: "POST", 85 | headers: { Cookie: authCookie, "content-type": "application/json" }, 86 | body: JSON.stringify({ 87 | currentPassword: "hunter2", 88 | newPassword: "hunter3", 89 | }), 90 | }) 91 | 92 | expect(response.status).toBe(200) 93 | 94 | expect( 95 | options.serverConfig.driver.changeEmailUserPassword, 96 | ).toHaveBeenCalledWith("1", "hunter2", "hunter3") 97 | 98 | const data = await response.json() 99 | expect(data).toEqual({ error: { code: "authentication failed" } }) 100 | }, 101 | ), 102 | ) 103 | -------------------------------------------------------------------------------- /auth-email/src-app/api/change-password.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { 3 | unauthorized, 4 | authenticationFailed, 5 | unexpectedError, 6 | jwtExpired, 7 | ok, 8 | AuthRouteHandlerOptions, 9 | } from "." 10 | 11 | export function createChangePassword(options: AuthRouteHandlerOptions) { 12 | if (!options.serverConfig.tokenSecret) 13 | throw new Error("HappyAuth: Missing token secret") 14 | 15 | return async function changePassword( 16 | req: NextApiRequest, 17 | res: NextApiResponse, 18 | ): Promise { 19 | const auth = options.getServerSideAuth(req) 20 | if (auth.value !== "signedIn") return unauthorized(res) 21 | const { currentPassword, newPassword } = req.body 22 | 23 | if (typeof currentPassword !== "string") { 24 | res.status(200).json({ 25 | error: { 26 | code: "invalid current password", 27 | message: "Invalid current password.", 28 | }, 29 | }) 30 | return 31 | } 32 | 33 | if (typeof newPassword !== "string") { 34 | res.status(200).json({ 35 | error: { 36 | code: "invalid new password", 37 | message: "Invalid new password.", 38 | }, 39 | }) 40 | return 41 | } 42 | 43 | try { 44 | if (!currentPassword) { 45 | res.status(200).json({ 46 | error: { 47 | code: "missing current password", 48 | message: "Current password must be provided.", 49 | }, 50 | }) 51 | return 52 | } 53 | 54 | if (!newPassword) { 55 | res.status(200).json({ 56 | error: { 57 | code: "missing new password", 58 | message: "New password must be provided.", 59 | }, 60 | }) 61 | return 62 | } 63 | 64 | // update user by storing new password 65 | await options.serverConfig.driver.changeEmailUserPassword( 66 | auth.context.tokenData.userId, 67 | currentPassword.trim(), 68 | newPassword.trim(), 69 | ) 70 | 71 | ok(res) 72 | } catch (error) { 73 | if (error.message === "jwt expired") { 74 | jwtExpired(res) 75 | } else if ((error.message = "authentication failed")) { 76 | authenticationFailed(res) 77 | } else { 78 | console.log(error) 79 | unexpectedError(res, error) 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /auth-email/src-app/api/confirm-account.spec.ts: -------------------------------------------------------------------------------- 1 | import { createConfirmAccount } from "./confirm-account" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | } from "../jest/utils.node" 7 | import jwt from "jsonwebtoken" 8 | 9 | let options = createAuthRouteHandlerOptions() 10 | let confirmAccount = createConfirmAccount(options) 11 | 12 | afterEach(() => { 13 | options = createAuthRouteHandlerOptions() 14 | confirmAccount = createConfirmAccount(options) 15 | }) 16 | 17 | test("when tokenSecret is missing", () => { 18 | expect(() => 19 | createConfirmAccount({ 20 | ...options, 21 | serverConfig: { 22 | ...options.serverConfig, 23 | tokenSecret: "", 24 | }, 25 | }), 26 | ).toThrow("HappyAuth: Missing token secret") 27 | }) 28 | 29 | test( 30 | "when request body is missing", 31 | handler( 32 | () => confirmAccount, 33 | async (url) => { 34 | const response = await fetch(url, { 35 | method: "POST", 36 | headers: { "content-type": "application/json" }, 37 | body: JSON.stringify({}), 38 | }) 39 | expect(response.status).toBe(500) 40 | 41 | const data = await response.json() 42 | expect(data).toEqual({ error: { code: "token missing" } }) 43 | }, 44 | ), 45 | ) 46 | 47 | test( 48 | "when token verification fails", 49 | handler( 50 | () => { 51 | options.serverConfig.driver.confirmAccount = jest.fn(async () => false) 52 | return createConfirmAccount(options) 53 | }, 54 | async (url) => { 55 | const response = await fetch(url, { 56 | method: "POST", 57 | headers: { "content-type": "application/json" }, 58 | body: JSON.stringify({ token: "invalid-token" }), 59 | }) 60 | expect(response.status).toBe(500) 61 | const data = await response.json() 62 | expect(data).toEqual({ 63 | error: { 64 | code: "unexpected error", 65 | message: "jwt malformed", 66 | }, 67 | }) 68 | }, 69 | ), 70 | ) 71 | 72 | test( 73 | "when account confirmation fails", 74 | handler( 75 | () => { 76 | options.serverConfig.driver.confirmAccount = jest.fn(async () => false) 77 | return createConfirmAccount(options) 78 | }, 79 | async (url) => { 80 | const token = jwt.sign( 81 | { userId: "fake-user-id" }, 82 | options.serverConfig.tokenSecret, 83 | ) 84 | const response = await fetch(url, { 85 | method: "POST", 86 | headers: { "content-type": "application/json" }, 87 | body: JSON.stringify({ token }), 88 | }) 89 | expect(response.status).toBe(200) 90 | const data = await response.json() 91 | expect(data).toEqual({ 92 | error: { code: "no user or user in invalid state" }, 93 | }) 94 | }, 95 | ), 96 | ) 97 | 98 | test( 99 | "when account confirmation succeeds", 100 | handler( 101 | () => { 102 | options.serverConfig.driver.confirmAccount = jest.fn(async () => true) 103 | return createConfirmAccount(options) 104 | }, 105 | async (url) => { 106 | const token = jwt.sign( 107 | { userId: "fake-user-id" }, 108 | options.serverConfig.tokenSecret, 109 | ) 110 | const response = await fetch(url, { 111 | method: "POST", 112 | headers: { "content-type": "application/json" }, 113 | body: JSON.stringify({ token }), 114 | }) 115 | expect(response.status).toBe(200) 116 | const data = await response.json() 117 | expect(data).toEqual({ data: { ok: true } }) 118 | expect(response.headers.get("Set-Cookie")).toMatch( 119 | new RegExp( 120 | `^${options.serverConfig.cookieName}=(\s+|\.)+; Path=/; HttpOnly; SameSite=Lax, syncAuthState=login; Path=/; SameSite=Lax$`, 121 | ), 122 | ) 123 | }, 124 | ), 125 | ) 126 | -------------------------------------------------------------------------------- /auth-email/src-app/api/confirm-account.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { BaseTokenData, Provider, AccountStatus } from ".." 3 | import { 4 | AuthRouteHandlerOptions, 5 | serializeAuthCookie, 6 | unexpectedError, 7 | ok, 8 | } from "." 9 | import jwt from "jsonwebtoken" 10 | 11 | export function createConfirmAccount(options: AuthRouteHandlerOptions) { 12 | if (!options.serverConfig.tokenSecret) 13 | throw new Error("HappyAuth: Missing token secret") 14 | 15 | return async function confirmAccount( 16 | req: NextApiRequest, 17 | res: NextApiResponse, 18 | ) { 19 | const { token } = req.body 20 | 21 | if (!token) { 22 | res.status(500).json({ error: { code: "token missing" } }) 23 | return 24 | } 25 | 26 | try { 27 | const data = jwt.verify( 28 | token, 29 | options.serverConfig.tokenSecret, 30 | ) as BaseTokenData 31 | 32 | const confirmed = await options.serverConfig.driver.confirmAccount( 33 | data.userId, 34 | ) 35 | 36 | if (!confirmed) { 37 | res 38 | .status(200) 39 | .json({ error: { code: "no user or user in invalid state" } }) 40 | return 41 | } else { 42 | const userId = data.userId 43 | const additionalTokenContent = options.serverConfig.triggers 44 | .fetchAdditionalTokenContent 45 | ? await options.serverConfig.triggers.fetchAdditionalTokenContent({ 46 | userId, 47 | }) 48 | : {} 49 | 50 | const serializedCookie = serializeAuthCookie( 51 | options.serverConfig, 52 | { 53 | userId, 54 | ...additionalTokenContent, 55 | provider: Provider.email, 56 | accountStatus: AccountStatus.confirmed, 57 | }, 58 | { rememberMe: false }, 59 | ) 60 | 61 | res.setHeader("Set-Cookie", serializedCookie) 62 | ok(res) 63 | } 64 | } catch (error) { 65 | unexpectedError(res, error) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /auth-email/src-app/api/connect.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { ok, AuthRouteHandlerOptions } from "." 3 | 4 | // Placeholder file for the HappyKit facing API 5 | export function createConnect(options: AuthRouteHandlerOptions) { 6 | return async function connect(req: NextApiRequest, res: NextApiResponse) { 7 | ok(res) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /auth-email/src-app/api/forgot-password.spec.ts: -------------------------------------------------------------------------------- 1 | import { createForgotPassword } from "./forgot-password" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | } from "../jest/utils.node" 7 | import jwt from "jsonwebtoken" 8 | 9 | let options = createAuthRouteHandlerOptions() 10 | let forgotPassword = createForgotPassword(options) 11 | 12 | afterEach(() => { 13 | options = createAuthRouteHandlerOptions() 14 | forgotPassword = createForgotPassword(options) 15 | }) 16 | 17 | test("when tokenSecret is missing", () => { 18 | expect(() => 19 | createForgotPassword({ 20 | ...options, 21 | serverConfig: { 22 | ...options.serverConfig, 23 | tokenSecret: "", 24 | }, 25 | }), 26 | ).toThrow("HappyAuth: Missing token secret") 27 | }) 28 | 29 | test( 30 | "when request body is missing email", 31 | handler( 32 | () => forgotPassword, 33 | async (url) => { 34 | const response = await fetch(url, { 35 | method: "POST", 36 | headers: { "content-type": "application/json" }, 37 | body: JSON.stringify({}), 38 | }) 39 | expect(response.status).toBe(200) 40 | 41 | const data = await response.json() 42 | expect(data).toEqual({ 43 | error: { 44 | code: "invalid email", 45 | message: "Email must be provided as a string.", 46 | }, 47 | }) 48 | }, 49 | ), 50 | ) 51 | 52 | test( 53 | "when request body contains an empty email", 54 | handler( 55 | () => forgotPassword, 56 | async (url) => { 57 | const response = await fetch(url, { 58 | method: "POST", 59 | headers: { "content-type": "application/json" }, 60 | body: JSON.stringify({ email: " " }), 61 | }) 62 | expect(response.status).toBe(200) 63 | 64 | const data = await response.json() 65 | expect(data).toEqual({ 66 | error: { 67 | code: "missing email", 68 | message: "Email must be provided.", 69 | }, 70 | }) 71 | }, 72 | ), 73 | ) 74 | 75 | test( 76 | "when the email does not match a user", 77 | handler( 78 | () => { 79 | options.serverConfig.driver.getUserIdByEmail = jest.fn(async () => null) 80 | options.serverConfig.triggers.sendForgotPasswordMail = jest.fn() 81 | return createForgotPassword(options) 82 | }, 83 | async (url) => { 84 | const response = await fetch(url, { 85 | method: "POST", 86 | headers: { "content-type": "application/json" }, 87 | body: JSON.stringify({ email: "user@test.com" }), 88 | }) 89 | expect(response.status).toBe(200) 90 | const body = await response.json() 91 | expect( 92 | options.serverConfig.triggers.sendForgotPasswordMail, 93 | ).not.toHaveBeenCalled() 94 | expect(body).toEqual({ data: { ok: true } }) 95 | }, 96 | ), 97 | ) 98 | 99 | test( 100 | "when the email matches a user", 101 | handler( 102 | () => { 103 | options.serverConfig.driver.getUserIdByEmail = jest.fn( 104 | async () => "fake-user-id", 105 | ) 106 | options.serverConfig.triggers.sendForgotPasswordMail = jest.fn() 107 | return createForgotPassword(options) 108 | }, 109 | async (url) => { 110 | const response = await fetch(url, { 111 | method: "POST", 112 | headers: { "content-type": "application/json" }, 113 | body: JSON.stringify({ email: "user@test.com" }), 114 | }) 115 | expect(response.status).toBe(200) 116 | const body = await response.json() 117 | expect( 118 | options.serverConfig.triggers.sendForgotPasswordMail, 119 | ).toHaveBeenCalledWith( 120 | "user@test.com", 121 | expect.stringMatching( 122 | new RegExp( 123 | `^${options.publicConfig.baseUrl}/reset-password#token=(\s|\.)+`, 124 | ), 125 | ), 126 | ) 127 | expect(body).toEqual({ data: { ok: true } }) 128 | }, 129 | ), 130 | ) 131 | 132 | test( 133 | "when an unexpected error happens during getUserIdByEmail", 134 | handler( 135 | () => { 136 | options.serverConfig.driver.getUserIdByEmail = jest.fn(async () => { 137 | throw new Error("hmm") 138 | }) 139 | options.serverConfig.triggers.sendForgotPasswordMail = jest.fn() 140 | return createForgotPassword(options) 141 | }, 142 | async (url) => { 143 | const response = await fetch(url, { 144 | method: "POST", 145 | headers: { "content-type": "application/json" }, 146 | body: JSON.stringify({ email: "user@test.com" }), 147 | }) 148 | expect(response.status).toBe(500) 149 | const body = await response.json() 150 | expect(body).toEqual({ 151 | error: { code: "unexpected error", message: "hmm" }, 152 | }) 153 | }, 154 | ), 155 | ) 156 | -------------------------------------------------------------------------------- /auth-email/src-app/api/forgot-password.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken" 2 | import { NextApiRequest, NextApiResponse } from "next" 3 | import { ok, unexpectedError, AuthRouteHandlerOptions } from "." 4 | 5 | export type SendForgotPasswordMail = ( 6 | email: string, 7 | link: string, 8 | ) => Promise 9 | 10 | export const sendForgotPasswordMailToConsole: SendForgotPasswordMail = async ( 11 | email, 12 | link, 13 | ) => { 14 | console.log( 15 | [ 16 | "", 17 | "***********************************************************************", 18 | `To: ${email}`, 19 | "***********************************************************************", 20 | "", 21 | "Hello,", 22 | "", 23 | "somebody requested a reset of your password.", 24 | "Click the link below to reset it:", 25 | link, 26 | "", 27 | "Cheers", 28 | "", 29 | "***********************************************************************", 30 | "", 31 | ].join("\n"), 32 | ) 33 | } 34 | 35 | const delay = (ms = 200) => new Promise((resolve) => setTimeout(resolve, ms)) 36 | 37 | export function createForgotPassword(options: AuthRouteHandlerOptions) { 38 | if (!options.serverConfig.tokenSecret) 39 | throw new Error("HappyAuth: Missing token secret") 40 | 41 | return async function forgotPassword( 42 | req: NextApiRequest, 43 | res: NextApiResponse, 44 | ) { 45 | const { email } = req.body 46 | 47 | if (typeof email !== "string") { 48 | res.status(200).json({ 49 | error: { 50 | code: "invalid email", 51 | message: "Email must be provided as a string.", 52 | }, 53 | }) 54 | return 55 | } 56 | 57 | if (email.trim() === "") { 58 | res.status(200).json({ 59 | error: { 60 | code: "missing email", 61 | message: "Email must be provided.", 62 | }, 63 | }) 64 | return 65 | } 66 | 67 | try { 68 | const userId = await options.serverConfig.driver.getUserIdByEmail(email) 69 | 70 | const forgotPasswordDelay = delay() 71 | 72 | if (userId) { 73 | new Promise((resolve, reject) => { 74 | jwt.sign( 75 | { userId }, 76 | options.serverConfig.tokenSecret, 77 | { expiresIn: "1h" }, 78 | (err, resetJwt) => { 79 | if (err) return reject(err) 80 | 81 | const link = `${options.publicConfig.baseUrl}/reset-password#token=${resetJwt}` 82 | resolve( 83 | options.serverConfig.triggers.sendForgotPasswordMail( 84 | email, 85 | link, 86 | ), 87 | ) 88 | }, 89 | ) 90 | }) 91 | } 92 | 93 | // take roughly the same time no matter whether login succeeds or not to 94 | // prevent user enumeration attacks 95 | await forgotPasswordDelay 96 | 97 | ok(res) 98 | } catch (error) { 99 | unexpectedError(res, error) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /auth-email/src-app/api/login.spec.ts: -------------------------------------------------------------------------------- 1 | import { createLogin } from "./login" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | } from "../jest/utils.node" 7 | import { AccountStatus } from ".." 8 | 9 | let options = createAuthRouteHandlerOptions() 10 | let login = createLogin(options) 11 | 12 | afterEach(() => { 13 | options = createAuthRouteHandlerOptions() 14 | login = createLogin(options) 15 | }) 16 | 17 | test( 18 | "when request body is missing email", 19 | handler( 20 | () => login, 21 | async (url) => { 22 | const response = await fetch(url, { 23 | method: "POST", 24 | headers: { "content-type": "application/json" }, 25 | body: JSON.stringify({}), 26 | }) 27 | expect(response.status).toBe(200) 28 | 29 | const body = await response.json() 30 | expect(body).toEqual({ 31 | error: { 32 | code: "invalid email", 33 | message: "Invalid email.", 34 | }, 35 | }) 36 | }, 37 | ), 38 | ) 39 | 40 | test( 41 | "when request body is missing password", 42 | handler( 43 | () => login, 44 | async (url) => { 45 | const response = await fetch(url, { 46 | method: "POST", 47 | headers: { "content-type": "application/json" }, 48 | body: JSON.stringify({ email: "user@test.com" }), 49 | }) 50 | expect(response.status).toBe(200) 51 | 52 | const body = await response.json() 53 | expect(body).toEqual({ 54 | error: { 55 | code: "invalid password", 56 | message: "Invalid password.", 57 | }, 58 | }) 59 | }, 60 | ), 61 | ) 62 | 63 | test( 64 | "when request body contains empty email", 65 | handler( 66 | () => login, 67 | async (url) => { 68 | const response = await fetch(url, { 69 | method: "POST", 70 | headers: { "content-type": "application/json" }, 71 | body: JSON.stringify({ email: "", password: "x" }), 72 | }) 73 | expect(response.status).toBe(200) 74 | 75 | const body = await response.json() 76 | expect(body).toEqual({ 77 | error: { 78 | code: "missing email or password", 79 | message: "Email and password must be provided.", 80 | }, 81 | }) 82 | }, 83 | ), 84 | ) 85 | 86 | test( 87 | "when request body contains empty password", 88 | handler( 89 | () => login, 90 | async (url) => { 91 | const response = await fetch(url, { 92 | method: "POST", 93 | headers: { "content-type": "application/json" }, 94 | body: JSON.stringify({ email: "user@test.com", password: "" }), 95 | }) 96 | expect(response.status).toBe(200) 97 | 98 | const body = await response.json() 99 | expect(body).toEqual({ 100 | error: { 101 | code: "missing email or password", 102 | message: "Email and password must be provided.", 103 | }, 104 | }) 105 | }, 106 | ), 107 | ) 108 | 109 | test( 110 | "when account is not confirmed", 111 | handler( 112 | () => { 113 | options.serverConfig.driver.attemptEmailPasswordLogin = jest.fn( 114 | async () => ({ 115 | success: true as true, 116 | data: { 117 | userId: "1", 118 | accountStatus: AccountStatus.unconfirmed, 119 | }, 120 | }), 121 | ) 122 | return createLogin(options) 123 | }, 124 | async (url) => { 125 | const response = await fetch(url, { 126 | method: "POST", 127 | headers: { "content-type": "application/json" }, 128 | body: JSON.stringify({ email: "user@test.com", password: "x" }), 129 | }) 130 | expect(response.status).toBe(200) 131 | 132 | const body = await response.json() 133 | expect(body).toEqual({ 134 | error: { 135 | code: "account not confirmed", 136 | message: 137 | "Your account is not confirmed yet. You need to confirm it before you can sign in.", 138 | }, 139 | }) 140 | }, 141 | ), 142 | ) 143 | 144 | test( 145 | "when credentials are invalid", 146 | handler( 147 | () => { 148 | options.serverConfig.driver.attemptEmailPasswordLogin = jest.fn( 149 | async () => { 150 | const loginError = { 151 | success: false as false, 152 | reason: "authentication failed" as "authentication failed", 153 | } 154 | return loginError 155 | }, 156 | ) 157 | return createLogin(options) 158 | }, 159 | async (url) => { 160 | const response = await fetch(url, { 161 | method: "POST", 162 | headers: { "content-type": "application/json" }, 163 | body: JSON.stringify({ email: "user@test.com", password: "x" }), 164 | }) 165 | expect(response.status).toBe(200) 166 | 167 | const body = await response.json() 168 | expect(body).toEqual({ error: { code: "authentication failed" } }) 169 | }, 170 | ), 171 | ) 172 | 173 | test( 174 | "when login is successful", 175 | handler( 176 | () => { 177 | options.serverConfig.driver.attemptEmailPasswordLogin = jest.fn( 178 | async () => ({ 179 | success: true as true, 180 | data: { 181 | userId: "1", 182 | accountStatus: AccountStatus.confirmed, 183 | }, 184 | }), 185 | ) 186 | return createLogin(options) 187 | }, 188 | async (url) => { 189 | const response = await fetch(url, { 190 | method: "POST", 191 | headers: { "content-type": "application/json" }, 192 | body: JSON.stringify({ email: "user@test.com", password: "x" }), 193 | }) 194 | expect(response.status).toBe(200) 195 | 196 | const body = await response.json() 197 | expect(response.headers.get("Set-Cookie")).toMatch( 198 | new RegExp( 199 | `^${options.serverConfig.cookieName}=(\s+|\.)+; Path=/; HttpOnly; SameSite=Lax, syncAuthState=login; Path=/; SameSite=Lax$`, 200 | ), 201 | ) 202 | expect( 203 | options.serverConfig.triggers.fetchAdditionalTokenContent, 204 | ).toHaveBeenCalledWith({ 205 | userId: "1", 206 | }) 207 | expect(body).toEqual({ data: { ok: true } }) 208 | }, 209 | ), 210 | ) 211 | -------------------------------------------------------------------------------- /auth-email/src-app/api/login.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { AccountStatus, Provider } from ".." 3 | import { 4 | serializeAuthCookie, 5 | ok, 6 | authenticationFailed, 7 | unexpectedError, 8 | AuthRouteHandlerOptions, 9 | } from "." 10 | import { Token } from "simple-oauth2" 11 | 12 | export type FetchAdditionalTokenContent = (options: { 13 | userId: string 14 | // only defined when using an oauth flow 15 | oauthToken?: Token 16 | }) => object 17 | 18 | // consumers can either use the default, or they can use 19 | // createEmailLogin to customize the default 20 | export function createLogin(options: AuthRouteHandlerOptions) { 21 | return async function login(req: NextApiRequest, res: NextApiResponse) { 22 | const { email, password, rememberMe } = req.body 23 | 24 | if (typeof email !== "string") { 25 | res.status(200).json({ 26 | error: { 27 | code: "invalid email", 28 | message: "Invalid email.", 29 | }, 30 | }) 31 | return 32 | } 33 | 34 | if (typeof password !== "string") { 35 | res.status(200).json({ 36 | error: { 37 | code: "invalid password", 38 | message: "Invalid password.", 39 | }, 40 | }) 41 | return 42 | } 43 | 44 | if (!email || !password) { 45 | res.status(200).json({ 46 | error: { 47 | code: "missing email or password", 48 | message: "Email and password must be provided.", 49 | }, 50 | }) 51 | return 52 | } 53 | 54 | try { 55 | const loginRes = await options.serverConfig.driver.attemptEmailPasswordLogin( 56 | email, 57 | password, 58 | ) 59 | 60 | if (!loginRes.success) return authenticationFailed(res) 61 | 62 | if (loginRes.data.accountStatus !== AccountStatus.confirmed) { 63 | res.status(200).json({ 64 | error: { 65 | code: "account not confirmed", 66 | message: 67 | "Your account is not confirmed yet. You need to confirm it before you can sign in.", 68 | }, 69 | }) 70 | return 71 | } 72 | 73 | const additionalTokenContent = options.serverConfig.triggers 74 | .fetchAdditionalTokenContent 75 | ? await options.serverConfig.triggers.fetchAdditionalTokenContent({ 76 | userId: loginRes.data.userId, 77 | }) 78 | : {} 79 | 80 | const serializedCookie = serializeAuthCookie( 81 | options.serverConfig, 82 | { 83 | userId: loginRes.data.userId, 84 | ...additionalTokenContent, 85 | provider: Provider.email, 86 | accountStatus: loginRes.data.accountStatus, 87 | }, 88 | { rememberMe: Boolean(rememberMe) }, 89 | ) 90 | 91 | res.setHeader("Set-Cookie", serializedCookie) 92 | ok(res) 93 | } catch (error) { 94 | unexpectedError(res, error) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /auth-email/src-app/api/logout.spec.ts: -------------------------------------------------------------------------------- 1 | import { createLogout } from "./logout" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | } from "../jest/utils.node" 7 | 8 | let options = createAuthRouteHandlerOptions() 9 | let logout = createLogout(options) 10 | 11 | afterEach(() => { 12 | options = createAuthRouteHandlerOptions() 13 | logout = createLogout(options) 14 | }) 15 | 16 | test( 17 | "when request body is missing email", 18 | handler( 19 | () => logout, 20 | async (url) => { 21 | const response = await fetch(url) 22 | expect(response.status).toBe(200) 23 | 24 | const body = await response.json() 25 | expect(body).toEqual({ data: { ok: true } }) 26 | expect(response.headers.get("Set-Cookie")).toMatch( 27 | new RegExp( 28 | `^${options.serverConfig.cookieName}=; Max-Age=-1; Path=/; HttpOnly; SameSite=Lax$`, 29 | ), 30 | ) 31 | }, 32 | ), 33 | ) 34 | -------------------------------------------------------------------------------- /auth-email/src-app/api/logout.ts: -------------------------------------------------------------------------------- 1 | import cookie from "cookie" 2 | import { NextApiRequest, NextApiResponse } from "next" 3 | import { ok, AuthRouteHandlerOptions } from "." 4 | 5 | export function createLogout(options: AuthRouteHandlerOptions) { 6 | return async function logout(req: NextApiRequest, res: NextApiResponse) { 7 | // Clear cookie no matter what. 8 | // The jwt contains a fauna secret which can be used to act as the user. 9 | // We want to remove that from the client in any case. 10 | const serializedCookie = cookie.serialize( 11 | options.serverConfig.cookieName, 12 | "", 13 | { 14 | sameSite: "lax", 15 | secure: options.serverConfig.secure, 16 | maxAge: -1, 17 | httpOnly: true, 18 | path: "/", 19 | }, 20 | ) 21 | res.setHeader("Set-Cookie", serializedCookie) 22 | ok(res) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /auth-email/src-app/api/resend-confirmation-email.spec.ts: -------------------------------------------------------------------------------- 1 | import { createResendConfirmationEmail } from "./resend-confirmation-email" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | } from "../jest/utils.node" 7 | 8 | let options = createAuthRouteHandlerOptions() 9 | let resendConfirmationEmail = createResendConfirmationEmail(options) 10 | 11 | afterEach(() => { 12 | options = createAuthRouteHandlerOptions() 13 | resendConfirmationEmail = createResendConfirmationEmail(options) 14 | }) 15 | 16 | test("when tokenSecret is missing", () => { 17 | expect(() => 18 | createResendConfirmationEmail({ 19 | ...options, 20 | serverConfig: { 21 | ...options.serverConfig, 22 | tokenSecret: "", 23 | }, 24 | }), 25 | ).toThrow("HappyAuth: Missing token secret") 26 | }) 27 | 28 | test( 29 | "when request body is missing email", 30 | handler( 31 | () => resendConfirmationEmail, 32 | async (url) => { 33 | const response = await fetch(url, { 34 | method: "POST", 35 | headers: { "content-type": "application/json" }, 36 | body: JSON.stringify({}), 37 | }) 38 | expect(response.status).toBe(200) 39 | 40 | const data = await response.json() 41 | expect(data).toEqual({ 42 | error: { code: "invalid email", message: "Invalid email." }, 43 | }) 44 | }, 45 | ), 46 | ) 47 | 48 | test( 49 | "when request email is empty", 50 | handler( 51 | () => resendConfirmationEmail, 52 | async (url) => { 53 | const response = await fetch(url, { 54 | method: "POST", 55 | headers: { "content-type": "application/json" }, 56 | body: JSON.stringify({ email: " " }), 57 | }) 58 | expect(response.status).toBe(200) 59 | 60 | const data = await response.json() 61 | expect(data).toEqual({ 62 | error: { code: "missing email", message: "Email must be provided." }, 63 | }) 64 | }, 65 | ), 66 | ) 67 | 68 | test( 69 | "when no user exists for that email", 70 | handler( 71 | () => { 72 | options.serverConfig.driver.getUserIdByEmail = jest.fn(async () => null) 73 | return createResendConfirmationEmail(options) 74 | }, 75 | async (url) => { 76 | const response = await fetch(url, { 77 | method: "POST", 78 | headers: { "content-type": "application/json" }, 79 | body: JSON.stringify({ email: "user@test.com" }), 80 | }) 81 | expect(response.status).toBe(200) 82 | 83 | const data = await response.json() 84 | expect(data).toEqual({ data: { ok: true } }) 85 | }, 86 | ), 87 | ) 88 | 89 | test( 90 | "when a user exists for that email", 91 | handler( 92 | () => { 93 | options.serverConfig.driver.getUserIdByEmail = jest.fn(async () => "1") 94 | return createResendConfirmationEmail(options) 95 | }, 96 | async (url) => { 97 | const response = await fetch(url, { 98 | method: "POST", 99 | headers: { "content-type": "application/json" }, 100 | body: JSON.stringify({ email: "user@test.com" }), 101 | }) 102 | expect(response.status).toBe(200) 103 | 104 | const data = await response.json() 105 | expect(data).toEqual({ data: { ok: true } }) 106 | }, 107 | ), 108 | ) 109 | -------------------------------------------------------------------------------- /auth-email/src-app/api/resend-confirmation-email.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { ok, unexpectedError, AuthRouteHandlerOptions } from "." 3 | import jwt from "jsonwebtoken" 4 | 5 | export function createResendConfirmationEmail( 6 | options: AuthRouteHandlerOptions, 7 | ) { 8 | if (!options.serverConfig.tokenSecret) 9 | throw new Error("HappyAuth: Missing token secret") 10 | 11 | return async function resendConfirmationEmail( 12 | req: NextApiRequest, 13 | res: NextApiResponse, 14 | ) { 15 | const { email } = req.body 16 | 17 | if (typeof email !== "string") { 18 | res.status(200).json({ 19 | error: { 20 | code: "invalid email", 21 | message: "Invalid email.", 22 | }, 23 | }) 24 | return 25 | } 26 | 27 | if (email.trim() === "") { 28 | res.status(200).json({ 29 | error: { 30 | code: "missing email", 31 | message: "Email must be provided.", 32 | }, 33 | }) 34 | return 35 | } 36 | 37 | try { 38 | const userId = await options.serverConfig.driver.getUserIdByEmail( 39 | email.trim().toLowerCase(), 40 | ) 41 | 42 | if (!userId) { 43 | // We don't give any information whether the user exists or not 44 | ok(res) 45 | return 46 | } 47 | 48 | // TODO only send confirmation email for unconfirmed accounts? 49 | 50 | const confirmJwt = jwt.sign( 51 | { userId }, 52 | options.serverConfig.tokenSecret, 53 | { 54 | expiresIn: "1h", 55 | }, 56 | ) 57 | const link = `${options.publicConfig.baseUrl}/confirm-account#token=${confirmJwt}` 58 | await options.serverConfig.triggers.sendConfirmAccountMail(email, link) 59 | ok(res) 60 | } catch (error) { 61 | unexpectedError(res, error) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /auth-email/src-app/api/reset-password.spec.ts: -------------------------------------------------------------------------------- 1 | import { createResetPassword } from "./reset-password" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | } from "../jest/utils.node" 7 | import jwt from "jsonwebtoken" 8 | 9 | let options = createAuthRouteHandlerOptions() 10 | let resetPassword = createResetPassword(options) 11 | 12 | afterEach(() => { 13 | options = createAuthRouteHandlerOptions() 14 | resetPassword = createResetPassword(options) 15 | }) 16 | 17 | test("when tokenSecret is missing", () => { 18 | expect(() => 19 | createResetPassword({ 20 | ...options, 21 | serverConfig: { 22 | ...options.serverConfig, 23 | tokenSecret: "", 24 | }, 25 | }), 26 | ).toThrow("HappyAuth: Missing token secret") 27 | }) 28 | 29 | test( 30 | "when request body is missing token", 31 | handler( 32 | () => resetPassword, 33 | async (url) => { 34 | const response = await fetch(url, { 35 | method: "POST", 36 | headers: { "content-type": "application/json" }, 37 | body: JSON.stringify({}), 38 | }) 39 | expect(response.status).toBe(200) 40 | 41 | const data = await response.json() 42 | expect(data).toEqual({ 43 | error: { code: "invalid token", message: "Invalid token." }, 44 | }) 45 | }, 46 | ), 47 | ) 48 | 49 | test( 50 | "when request body is missing password", 51 | handler( 52 | () => resetPassword, 53 | async (url) => { 54 | const response = await fetch(url, { 55 | method: "POST", 56 | headers: { "content-type": "application/json" }, 57 | body: JSON.stringify({ token: "asdf" }), 58 | }) 59 | expect(response.status).toBe(200) 60 | 61 | const data = await response.json() 62 | expect(data).toEqual({ 63 | error: { code: "invalid password", message: "Invalid password." }, 64 | }) 65 | }, 66 | ), 67 | ) 68 | 69 | test( 70 | "when token is empty", 71 | handler( 72 | () => resetPassword, 73 | async (url) => { 74 | const response = await fetch(url, { 75 | method: "POST", 76 | headers: { "content-type": "application/json" }, 77 | body: JSON.stringify({ token: "", password: "hunter2" }), 78 | }) 79 | expect(response.status).toBe(200) 80 | 81 | const data = await response.json() 82 | expect(data).toEqual({ 83 | error: { code: "missing token", message: "Token must be provided." }, 84 | }) 85 | }, 86 | ), 87 | ) 88 | 89 | test( 90 | "when password is empty", 91 | handler( 92 | () => resetPassword, 93 | async (url) => { 94 | const response = await fetch(url, { 95 | method: "POST", 96 | headers: { "content-type": "application/json" }, 97 | body: JSON.stringify({ token: "fake-token", password: "" }), 98 | }) 99 | expect(response.status).toBe(200) 100 | 101 | const data = await response.json() 102 | expect(data).toEqual({ 103 | error: { 104 | code: "missing password", 105 | message: "Password must be provided.", 106 | }, 107 | }) 108 | }, 109 | ), 110 | ) 111 | 112 | test( 113 | "when token is invalid", 114 | handler( 115 | () => resetPassword, 116 | async (url) => { 117 | const response = await fetch(url, { 118 | method: "POST", 119 | headers: { "content-type": "application/json" }, 120 | body: JSON.stringify({ token: "fake-token", password: "hunter2" }), 121 | }) 122 | expect(response.status).toBe(500) 123 | 124 | const data = await response.json() 125 | expect(data).toEqual({ 126 | error: { code: "unexpected error", message: "jwt malformed" }, 127 | }) 128 | }, 129 | ), 130 | ) 131 | 132 | test( 133 | "when reset succeeds", 134 | handler( 135 | () => resetPassword, 136 | async (url) => { 137 | const response = await fetch(url, { 138 | method: "POST", 139 | headers: { "content-type": "application/json" }, 140 | body: JSON.stringify({ 141 | token: jwt.sign({ userId: "1" }, options.serverConfig.tokenSecret), 142 | password: "hunter2", 143 | }), 144 | }) 145 | expect(response.status).toBe(200) 146 | expect( 147 | options.serverConfig.triggers.fetchAdditionalTokenContent, 148 | ).toHaveBeenCalledWith({ 149 | userId: "1", 150 | }) 151 | expect(response.headers.get("Set-Cookie")).toMatch( 152 | new RegExp( 153 | `^${options.serverConfig.cookieName}=(\\s|.)+; Path=/; HttpOnly; SameSite=Lax`, 154 | ), 155 | ) 156 | 157 | const data = await response.json() 158 | expect(data).toEqual({ data: { ok: true } }) 159 | }, 160 | ), 161 | ) 162 | -------------------------------------------------------------------------------- /auth-email/src-app/api/reset-password.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import jwt from "jsonwebtoken" 3 | import { AccountStatus, Provider } from ".." 4 | import { 5 | serializeAuthCookie, 6 | ok, 7 | jwtExpired, 8 | unexpectedError, 9 | AuthRouteHandlerOptions, 10 | } from "." 11 | 12 | export function createResetPassword(options: AuthRouteHandlerOptions) { 13 | if (!options.serverConfig.tokenSecret) 14 | throw new Error("HappyAuth: Missing token secret") 15 | 16 | return async function resetPassword( 17 | req: NextApiRequest, 18 | res: NextApiResponse, 19 | ) { 20 | const { token, password } = req.body 21 | 22 | if (typeof token !== "string") { 23 | res.status(200).json({ 24 | error: { 25 | code: "invalid token", 26 | message: "Invalid token.", 27 | }, 28 | }) 29 | return 30 | } 31 | 32 | if (typeof password !== "string") { 33 | res.status(200).json({ 34 | error: { 35 | code: "invalid password", 36 | message: "Invalid password.", 37 | }, 38 | }) 39 | return 40 | } 41 | 42 | if (!token) { 43 | res.status(200).json({ 44 | error: { 45 | code: "missing token", 46 | message: "Token must be provided.", 47 | }, 48 | }) 49 | return 50 | } 51 | 52 | if (!password) { 53 | res.status(200).json({ 54 | error: { 55 | code: "missing password", 56 | message: "Password must be provided.", 57 | }, 58 | }) 59 | return 60 | } 61 | 62 | try { 63 | const data = jwt.verify(token, options.serverConfig.tokenSecret) as { 64 | userId: string 65 | } 66 | 67 | const userId = data.userId 68 | await options.serverConfig.driver.updateEmailUserPassword( 69 | userId, 70 | password.trim(), 71 | ) 72 | 73 | const additionalTokenContent = options.serverConfig.triggers 74 | .fetchAdditionalTokenContent 75 | ? await options.serverConfig.triggers.fetchAdditionalTokenContent({ 76 | userId, 77 | }) 78 | : {} 79 | 80 | const serializedCookie = serializeAuthCookie( 81 | options.serverConfig, 82 | { 83 | userId, 84 | ...additionalTokenContent, 85 | provider: Provider.email, 86 | accountStatus: AccountStatus.confirmed, 87 | }, 88 | { rememberMe: false }, 89 | ) 90 | 91 | res.setHeader("Set-Cookie", serializedCookie) 92 | ok(res) 93 | } catch (error) { 94 | if (error.message === "jwt expired") { 95 | jwtExpired(res) 96 | } else { 97 | unexpectedError(res, error) 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /auth-email/src-app/api/signup.spec.ts: -------------------------------------------------------------------------------- 1 | import { createSignup } from "./signup" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | } from "../jest/utils.node" 7 | 8 | let options = createAuthRouteHandlerOptions() 9 | let signup = createSignup(options) 10 | 11 | afterEach(() => { 12 | options = createAuthRouteHandlerOptions() 13 | signup = createSignup(options) 14 | }) 15 | 16 | test("when tokenSecret is missing", () => { 17 | expect(() => 18 | createSignup({ 19 | ...options, 20 | serverConfig: { 21 | ...options.serverConfig, 22 | tokenSecret: "", 23 | }, 24 | }), 25 | ).toThrow("HappyAuth: Missing token secret") 26 | }) 27 | 28 | test( 29 | "when request body is missing email", 30 | handler( 31 | () => signup, 32 | async (url) => { 33 | const response = await fetch(url, { 34 | method: "POST", 35 | headers: { "content-type": "application/json" }, 36 | body: JSON.stringify({}), 37 | }) 38 | expect(response.status).toBe(200) 39 | 40 | const data = await response.json() 41 | expect(data).toEqual({ 42 | error: { code: "invalid email", message: "Invalid email." }, 43 | }) 44 | }, 45 | ), 46 | ) 47 | 48 | test( 49 | "when request body is missing password", 50 | handler( 51 | () => signup, 52 | async (url) => { 53 | const response = await fetch(url, { 54 | method: "POST", 55 | headers: { "content-type": "application/json" }, 56 | body: JSON.stringify({ email: "user@test.com" }), 57 | }) 58 | expect(response.status).toBe(200) 59 | 60 | const data = await response.json() 61 | expect(data).toEqual({ 62 | error: { code: "invalid password", message: "Invalid password." }, 63 | }) 64 | }, 65 | ), 66 | ) 67 | 68 | test( 69 | "when email is missing", 70 | handler( 71 | () => signup, 72 | async (url) => { 73 | const response = await fetch(url, { 74 | method: "POST", 75 | headers: { "content-type": "application/json" }, 76 | body: JSON.stringify({ email: "", password: "hunter2" }), 77 | }) 78 | expect(response.status).toBe(200) 79 | 80 | const data = await response.json() 81 | expect(data).toEqual({ 82 | error: { 83 | code: "missing email or password", 84 | message: "Email and password must be provided.", 85 | }, 86 | }) 87 | }, 88 | ), 89 | ) 90 | 91 | test( 92 | "when password is missing", 93 | handler( 94 | () => signup, 95 | async (url) => { 96 | const response = await fetch(url, { 97 | method: "POST", 98 | headers: { "content-type": "application/json" }, 99 | body: JSON.stringify({ email: "user@test.com", password: "" }), 100 | }) 101 | expect(response.status).toBe(200) 102 | 103 | const data = await response.json() 104 | expect(data).toEqual({ 105 | error: { 106 | code: "missing email or password", 107 | message: "Email and password must be provided.", 108 | }, 109 | }) 110 | }, 111 | ), 112 | ) 113 | 114 | test( 115 | "when signup is successful", 116 | handler( 117 | () => { 118 | options.serverConfig.driver.createEmailUser = jest.fn(async () => ({ 119 | success: true as true, 120 | data: { userId: "1" }, 121 | })) 122 | options.serverConfig.triggers.sendConfirmAccountMail = jest.fn() 123 | return createSignup(options) 124 | }, 125 | async (url) => { 126 | const response = await fetch(url, { 127 | method: "POST", 128 | headers: { "content-type": "application/json" }, 129 | body: JSON.stringify({ email: "user@test.com", password: "hunter2" }), 130 | }) 131 | expect(response.status).toBe(200) 132 | 133 | const data = await response.json() 134 | expect( 135 | options.serverConfig.triggers.sendConfirmAccountMail, 136 | ).toHaveBeenCalledWith( 137 | "user@test.com", 138 | expect.stringMatching( 139 | new RegExp("^http://localhost:3000/confirm-account#token=ey"), 140 | ), 141 | ) 142 | expect(data).toEqual({ data: { ok: true } }) 143 | }, 144 | ), 145 | ) 146 | 147 | test( 148 | "when user exists already", 149 | handler( 150 | () => { 151 | options.serverConfig.driver.createEmailUser = jest.fn(async () => { 152 | const creationError: { 153 | success: false 154 | reason: "instance not unique" 155 | } = { 156 | success: false, 157 | reason: "instance not unique", 158 | } 159 | return creationError 160 | }) 161 | options.serverConfig.triggers.sendConfirmAccountMail = jest.fn() 162 | return createSignup(options) 163 | }, 164 | async (url) => { 165 | const response = await fetch(url, { 166 | method: "POST", 167 | headers: { "content-type": "application/json" }, 168 | body: JSON.stringify({ email: "user@test.com", password: "hunter2" }), 169 | }) 170 | expect(response.status).toBe(200) 171 | 172 | const data = await response.json() 173 | expect( 174 | options.serverConfig.triggers.sendConfirmAccountMail, 175 | ).not.toHaveBeenCalled() 176 | expect(data).toEqual({ data: { ok: true } }) 177 | }, 178 | ), 179 | ) 180 | 181 | test( 182 | "when user creation fails", 183 | handler( 184 | () => { 185 | options.serverConfig.driver.createEmailUser = jest.fn(async () => { 186 | throw new Error("custom error") 187 | }) 188 | options.serverConfig.triggers.sendConfirmAccountMail = jest.fn() 189 | return createSignup(options) 190 | }, 191 | async (url) => { 192 | const response = await fetch(url, { 193 | method: "POST", 194 | headers: { "content-type": "application/json" }, 195 | body: JSON.stringify({ email: "user@test.com", password: "hunter2" }), 196 | }) 197 | expect(response.status).toBe(500) 198 | 199 | const data = await response.json() 200 | expect( 201 | options.serverConfig.triggers.sendConfirmAccountMail, 202 | ).not.toHaveBeenCalled() 203 | expect(data).toEqual({ error: { code: "unexpected error" } }) 204 | }, 205 | ), 206 | ) 207 | -------------------------------------------------------------------------------- /auth-email/src-app/api/signup.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { ok, unexpectedError, AuthRouteHandlerOptions } from "." 3 | import jwt from "jsonwebtoken" 4 | 5 | export type SendConfirmAccountMail = ( 6 | email: string, 7 | link: string, 8 | ) => Promise 9 | 10 | export const sendConfirmAccountMailToConsole: SendConfirmAccountMail = async ( 11 | email, 12 | link, 13 | ) => { 14 | console.log( 15 | [ 16 | "", 17 | "***********************************************************************", 18 | `To: ${email}`, 19 | "***********************************************************************", 20 | "", 21 | "Welcome,", 22 | "", 23 | "your account has been created.", 24 | "", 25 | "Click the link below to activate it:", 26 | link, 27 | "", 28 | "Cheers", 29 | "", 30 | "PS: If you did not sign up, you can simply ignore this email.", 31 | "", 32 | "***********************************************************************", 33 | "", 34 | ].join("\n"), 35 | ) 36 | } 37 | 38 | const delay = (ms = 200) => new Promise((resolve) => setTimeout(resolve, ms)) 39 | 40 | export function createSignup(options: AuthRouteHandlerOptions) { 41 | if (!options.serverConfig.tokenSecret) 42 | throw new Error("HappyAuth: Missing token secret") 43 | 44 | return async function signup(req: NextApiRequest, res: NextApiResponse) { 45 | const { email, password } = req.body 46 | 47 | if (typeof email !== "string") { 48 | res.status(200).json({ 49 | error: { 50 | code: "invalid email", 51 | message: "Invalid email.", 52 | }, 53 | }) 54 | return 55 | } 56 | 57 | if (typeof password !== "string") { 58 | res.status(200).json({ 59 | error: { 60 | code: "invalid password", 61 | message: "Invalid password.", 62 | }, 63 | }) 64 | return 65 | } 66 | 67 | if (!email || !password) { 68 | res.status(200).json({ 69 | error: { 70 | code: "missing email or password", 71 | message: "Email and password must be provided.", 72 | }, 73 | }) 74 | return 75 | } 76 | 77 | try { 78 | const lowercasedEmail = email.trim().toLowerCase() 79 | const trimmedPassword = password.trim() 80 | 81 | const creationDelay = delay() 82 | const response = await options.serverConfig.driver.createEmailUser( 83 | lowercasedEmail, 84 | trimmedPassword, 85 | ) 86 | // take same time for creation 87 | await creationDelay 88 | 89 | if (!response.success) { 90 | // We send an "ok" back anyways, since we don't want to give any 91 | // information about the potential existance of that account. 92 | ok(res) 93 | return 94 | } 95 | 96 | const confirmJwt = jwt.sign( 97 | { userId: response.data.userId }, 98 | options.serverConfig.tokenSecret, 99 | { expiresIn: "1h" }, 100 | ) 101 | const link = `${options.publicConfig.baseUrl}/confirm-account#token=${confirmJwt}` 102 | 103 | // answer request immediately, then continue sending the mail 104 | ok(res) 105 | await options.serverConfig.triggers.sendConfirmAccountMail(email, link) 106 | } catch (error) { 107 | unexpectedError(res) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /auth-email/src-app/api/tokencontent.spec.ts: -------------------------------------------------------------------------------- 1 | import { createTokenContent } from "./tokencontent" 2 | import { 3 | createAuthRouteHandlerOptions, 4 | fetch, 5 | handler, 6 | createAuthCookie, 7 | } from "../jest/utils.node" 8 | 9 | let options = createAuthRouteHandlerOptions() 10 | let tokenContent = createTokenContent(options) 11 | 12 | afterEach(() => { 13 | options = createAuthRouteHandlerOptions() 14 | tokenContent = createTokenContent(options) 15 | }) 16 | 17 | test( 18 | "when authenticated", 19 | handler( 20 | () => tokenContent, 21 | async (url) => { 22 | const payload = { userId: "1", foo: true } 23 | const response = await fetch(url, { 24 | headers: { Cookie: createAuthCookie(options, payload) }, 25 | }) 26 | expect(response.status).toBe(200) 27 | 28 | const body = await response.json() 29 | expect(body).toEqual({ 30 | data: expect.objectContaining({ 31 | value: "signedIn", 32 | context: { 33 | tokenData: expect.objectContaining(payload), 34 | error: null, 35 | }, 36 | }), 37 | }) 38 | }, 39 | ), 40 | ) 41 | 42 | test( 43 | "when not authenticated", 44 | handler( 45 | () => tokenContent, 46 | async (url) => { 47 | const response = await fetch(url) 48 | expect(response.status).toBe(200) 49 | 50 | const data = await response.json() 51 | expect(data).toEqual({ 52 | data: { 53 | context: { 54 | error: null, 55 | tokenData: null, 56 | }, 57 | value: "signedOut", 58 | }, 59 | }) 60 | }, 61 | ), 62 | ) 63 | -------------------------------------------------------------------------------- /auth-email/src-app/api/tokencontent.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { AuthRouteHandlerOptions } from "." 3 | 4 | export function createTokenContent(options: AuthRouteHandlerOptions) { 5 | return async (req: NextApiRequest, res: NextApiResponse) => { 6 | const auth = options.getServerSideAuth(req) 7 | res.status(200).json(auth ? { data: auth } : { data: null }) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /auth-email/src-app/components/forms.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import classNames from "classnames" 3 | import { StateMachine, EventObject } from "@xstate/fsm" 4 | import mapValues from "lodash.mapvalues" 5 | 6 | export const InputGroup: React.FunctionComponent< 7 | { 8 | id: Exclude["id"], undefined> 9 | label: string 10 | error?: string | null 11 | touched?: boolean 12 | } & React.InputHTMLAttributes 13 | > = ({ id, label, error, touched, ...inputProps }) => { 14 | return ( 15 | 29 | ) 30 | } 31 | 32 | export function hasValidationErrors(values: object) { 33 | return Object.values(values).some(Boolean) 34 | } 35 | 36 | export function inputAssigner< 37 | V, 38 | P, 39 | TC extends { values: V }, 40 | TE extends EventObject & { payload?: P } 41 | >(validate: (values: TC["values"]) => object): StateMachine.Assigner { 42 | return (context, event) => { 43 | const values = { ...context.values, ...event.payload } 44 | const validationErrors = validate(values) 45 | return { ...context, values, validationErrors } 46 | } 47 | } 48 | 49 | export function touchedAssigner< 50 | T, 51 | V, 52 | P, 53 | TC extends { touched: T; values: V }, 54 | TE extends EventObject & { payload?: P } 55 | >(validate: (values: TC["values"]) => object): StateMachine.Assigner { 56 | return (context, event) => { 57 | const touched = { ...context.touched, ...event.payload } 58 | const validationErrors = validate(context.values) 59 | return { ...context, touched, validationErrors } 60 | } 61 | } 62 | 63 | export function submitAssigner< 64 | T extends { [key: string]: boolean }, 65 | V, 66 | P, 67 | TC extends { touched: T; values: V }, 68 | TE extends EventObject & { payload?: P } 69 | >(validate: (values: TC["values"]) => object): StateMachine.Assigner { 70 | return (context, event) => { 71 | const touched = mapValues(context.touched, () => true) 72 | const validationErrors = validate(context.values) 73 | return { ...context, touched, validationErrors } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /auth-email/src-app/components/messages.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | export const ErrorMessage: React.FunctionComponent<{ title: string }> = ( 4 | props, 5 | ) => ( 6 |
10 |

{props.title}

11 | {props.children} 12 |
13 | ) 14 | 15 | export const WarningMessage: React.FunctionComponent<{ title: string }> = ( 16 | props, 17 | ) => ( 18 |
22 |

{props.title}

23 | {props.children} 24 |
25 | ) 26 | 27 | export const SuccessMessage: React.FunctionComponent<{ title?: string }> = ( 28 | props, 29 | ) => ( 30 |
34 | {props.title &&

{props.title}

} 35 | {props.children} 36 |
37 | ) 38 | -------------------------------------------------------------------------------- /auth-email/src-app/components/social-logins.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { PublicConfig } from ".." 3 | import Link from "next/link" 4 | 5 | export const SocialLogins: React.FunctionComponent<{ 6 | identityProviders: PublicConfig["identityProviders"] 7 | }> = (props) => ( 8 | 9 |
10 |
11 |
12 |
13 |
14 | Or continue with 15 |
16 |
17 |
18 |
19 |
20 |
21 | {Object.entries(props.identityProviders).map(([key, value]) => ( 22 | 31 | ))} 32 |
33 |
34 | ) 35 | -------------------------------------------------------------------------------- /auth-email/src-app/drivers/fauna.ts: -------------------------------------------------------------------------------- 1 | import faunadb from "faunadb" 2 | import { AccountStatus } from ".." 3 | import { query as q } from "faunadb" 4 | import { Driver } from "../api" 5 | 6 | export function createFaunaEmailDriver(faunaClient: faunadb.Client): Driver { 7 | return { 8 | attemptEmailPasswordLogin: async (email, password) => { 9 | try { 10 | const loginRes: { 11 | userRef: { id: string } 12 | accountStatus: AccountStatus 13 | } = await faunaClient.query( 14 | q.Let( 15 | { 16 | loginData: q.Login(q.Match(q.Index("users_by_email"), email), { 17 | password, 18 | }), 19 | }, 20 | { 21 | userRef: q.Select(["instance"], q.Var("loginData")), 22 | accountStatus: q.Select( 23 | ["data", "accountStatus"], 24 | q.Get(q.Select("instance", q.Var("loginData"))), 25 | ), 26 | }, 27 | ), 28 | ) 29 | return { 30 | success: true, 31 | data: { 32 | userId: loginRes.userRef.id, 33 | accountStatus: loginRes.accountStatus, 34 | }, 35 | } 36 | } catch (error) { 37 | if (error.message === "authentication failed") 38 | return { success: false, reason: "authentication failed" } 39 | 40 | throw error 41 | } 42 | }, 43 | createEmailUser: async (email, password) => { 44 | try { 45 | const user: { ref: { id: string } } = await faunaClient.query( 46 | q.Create(q.Collection("User"), { 47 | credentials: { password }, 48 | data: { 49 | email, 50 | created: q.Now(), 51 | // We do not need an "updated" timestamp, as fauna includes 52 | // a "ts" on all documents. 53 | // 54 | // See "ts" here: 55 | // https://docs.fauna.com/fauna/current/api/fql/functions/get 56 | accountStatus: AccountStatus.unconfirmed, 57 | }, 58 | }), 59 | ) 60 | return { success: true, data: { userId: user.ref.id } } 61 | } catch (error) { 62 | if (error.message === "instance not unique") 63 | return { success: false, reason: "instance not unique" } 64 | 65 | throw error 66 | } 67 | }, 68 | getUserIdByEmail: async (email: string) => { 69 | const response: string | false = await faunaClient.query( 70 | q.Let( 71 | { 72 | match: q.Match(q.Index("users_by_email"), email.toLowerCase()), 73 | }, 74 | q.If( 75 | q.Exists(q.Var("match")), 76 | q.Select(["ref", "id"], q.Get(q.Var("match"))), 77 | false, 78 | ), 79 | ), 80 | ) 81 | return response || null 82 | }, 83 | updateEmailUserPassword: async (userId, password) => { 84 | // update user by storing new password 85 | // https://docs.fauna.com/fauna/current/tutorials/authentication/user#change_password 86 | await faunaClient.query( 87 | q.Update(q.Ref(q.Collection("User"), userId), { 88 | credentials: { password }, 89 | }), 90 | ) 91 | }, 92 | // update user by storing new password 93 | // https://docs.fauna.com/fauna/current/tutorials/authentication/user#change_password 94 | changeEmailUserPassword: async (userId, currentPassword, newPassword) => { 95 | await faunaClient.query( 96 | q.Do( 97 | // Login will throw an error in case the password is invalid. 98 | // This then skips the Update in case the currentPassword is invalid. 99 | q.Login(q.Ref(q.Collection("User"), userId), { 100 | password: currentPassword, 101 | }), 102 | q.Update(q.Ref(q.Collection("User"), userId), { 103 | credentials: { password: newPassword }, 104 | }), 105 | ), 106 | ) 107 | }, 108 | confirmAccount: async (userId) => { 109 | return faunaClient.query( 110 | q.Let( 111 | { userRef: q.Ref(q.Collection("User"), userId) }, 112 | q.If( 113 | q.Exists(q.Var("userRef")), 114 | q.Let( 115 | { 116 | accountStatus: q.Select( 117 | ["data", "accountStatus"], 118 | q.Get(q.Var("userRef")), 119 | ), 120 | }, 121 | q.If( 122 | q.Or( 123 | q.Equals(q.Var("accountStatus"), AccountStatus.confirmed), 124 | q.Equals(q.Var("accountStatus"), AccountStatus.unconfirmed), 125 | ), 126 | q.Do( 127 | q.Update(q.Var("userRef"), { 128 | data: { accountStatus: AccountStatus.confirmed }, 129 | }), 130 | true, 131 | ), 132 | false, 133 | ), 134 | ), 135 | false, 136 | ), 137 | ), 138 | ) 139 | }, 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /auth-email/src-app/jest.jsdom.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | automock: false, 7 | 8 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 9 | moduleNameMapper: { 10 | "\\.css$": "/jest/css-mock.ts", 11 | // "\\.(css|less)$": "/__mocks__/styleMock.js" 12 | }, 13 | 14 | // A preset that is used as a base for Jest's configuration 15 | preset: "ts-jest", 16 | 17 | // The paths to modules that run some code to configure or set up the testing environment before each test 18 | setupFiles: ["./jest/setup-jest.ts"], 19 | 20 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 21 | setupFilesAfterEnv: ["./jest/setup-files-after-env.ts"], 22 | 23 | // The test environment that will be used for testing 24 | testEnvironment: "jest-environment-jsdom", 25 | 26 | // The glob patterns Jest uses to detect test files 27 | testMatch: ["**/*.spec.tsx"], 28 | 29 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 30 | testURL: "http://localhost:3000", 31 | } 32 | -------------------------------------------------------------------------------- /auth-email/src-app/jest.node.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | automock: false, 7 | 8 | // A preset that is used as a base for Jest's configuration 9 | preset: "ts-jest", 10 | 11 | // The test environment that will be used for testing 12 | testEnvironment: "node", 13 | 14 | // The glob patterns Jest uses to detect test files 15 | testMatch: ["**/*.spec.ts"], 16 | } 17 | -------------------------------------------------------------------------------- /auth-email/src-app/jest/css-mock.ts: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /auth-email/src-app/jest/setup-files-after-env.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom" 2 | import fetchMock from "jest-fetch-mock" 3 | 4 | beforeEach(() => { 5 | fetchMock.resetMocks() 6 | }) 7 | -------------------------------------------------------------------------------- /auth-email/src-app/jest/setup-jest.ts: -------------------------------------------------------------------------------- 1 | require("jest-fetch-mock").enableMocks() 2 | -------------------------------------------------------------------------------- /auth-email/src-app/jest/utils.jsdom.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { render } from "@testing-library/react" 3 | import { PublicConfig, AuthProvider } from ".." 4 | 5 | export const publicConfig: PublicConfig = { 6 | baseUrl: "http://localhost:3000", 7 | identityProviders: {}, 8 | } 9 | 10 | type P = Parameters 11 | export function renderApp(ui: P[0], options?: P[1]): ReturnType { 12 | return render({ui}, options) 13 | } 14 | 15 | export const signedInTokenContentResponse = { 16 | data: { 17 | value: "signedIn", 18 | context: { 19 | tokenData: { 20 | userId: "266962171104068102", 21 | provider: "email", 22 | accountStatus: "confirmed", 23 | iat: 1591028529, 24 | exp: 2195828529, 25 | }, 26 | error: null, 27 | }, 28 | }, 29 | } 30 | export const signedOutTokenContentResponse = { 31 | data: { 32 | value: "signedOut", 33 | context: { 34 | tokenData: null, 35 | error: null, 36 | }, 37 | }, 38 | } 39 | 40 | export * from "@testing-library/react" 41 | -------------------------------------------------------------------------------- /auth-email/src-app/jest/utils.node.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import http from "http" 3 | import listen from "test-listen" 4 | import { apiResolver } from "next-server/dist/server/api-utils" 5 | import fetch from "isomorphic-unfetch" 6 | import { PublicConfig } from ".." 7 | import jwt from "jsonwebtoken" 8 | import { 9 | AuthRouteHandlerOptions, 10 | ServerConfig, 11 | createGetServerSideAuth, 12 | } from "../api" 13 | 14 | export { fetch } 15 | 16 | export const publicConfig: PublicConfig = { 17 | baseUrl: "http://localhost:3000", 18 | identityProviders: {}, 19 | } 20 | 21 | export const serverConfig: ServerConfig = { 22 | cookieName: "happyauth-test", 23 | identityProviders: {}, 24 | secure: false, 25 | tokenSecret: "fake-token-secret", 26 | triggers: { 27 | sendConfirmAccountMail: jest.fn(), 28 | sendForgotPasswordMail: jest.fn(), 29 | fetchAdditionalTokenContent: jest.fn(), 30 | }, 31 | driver: { 32 | attemptEmailPasswordLogin: jest.fn(), 33 | changeEmailUserPassword: jest.fn(), 34 | confirmAccount: jest.fn(), 35 | createEmailUser: jest.fn(), 36 | getUserIdByEmail: jest.fn(), 37 | updateEmailUserPassword: jest.fn(), 38 | }, 39 | } 40 | 41 | export function createAuthRouteHandlerOptions( 42 | defaultServerConfig: ServerConfig = serverConfig, 43 | ): AuthRouteHandlerOptions { 44 | return { 45 | getServerSideAuth: createGetServerSideAuth(defaultServerConfig), 46 | publicConfig, 47 | serverConfig: { 48 | ...defaultServerConfig, 49 | driver: { 50 | attemptEmailPasswordLogin: jest.fn(), 51 | changeEmailUserPassword: jest.fn(), 52 | confirmAccount: jest.fn(), 53 | createEmailUser: jest.fn(), 54 | getUserIdByEmail: jest.fn(), 55 | updateEmailUserPassword: jest.fn(), 56 | }, 57 | triggers: { 58 | sendConfirmAccountMail: jest.fn(), 59 | sendForgotPasswordMail: jest.fn(), 60 | fetchAdditionalTokenContent: jest.fn(), 61 | }, 62 | }, 63 | } 64 | } 65 | 66 | // Usage: 67 | // test("when not authenticated", async () => { 68 | // const [url, close] = await createApi(changePassword) 69 | // const response = await fetch(url) 70 | // expect(response.status).toBe(200) 71 | // const data = await response.json() 72 | // expect(data).toEqual({ error: { code: "unauthorized" } }) 73 | // close() 74 | // }) 75 | export async function createApi( 76 | handler: (req: NextApiRequest, res: NextApiResponse) => Promise, 77 | params?: any, 78 | ): Promise<[string, () => void]> { 79 | const requestHandler: http.RequestListener = (req, res) => 80 | apiResolver(req as NextApiRequest, res as NextApiResponse, params, handler) 81 | 82 | const server = http.createServer(requestHandler) 83 | const url = await listen(server) 84 | return [ 85 | url, 86 | () => { 87 | server.close() 88 | }, 89 | ] 90 | } 91 | 92 | export function handler( 93 | prepare: 94 | | (() => (req: NextApiRequest, res: NextApiResponse) => Promise) 95 | | { 96 | handle: () => ( 97 | req: NextApiRequest, 98 | res: NextApiResponse, 99 | ) => Promise 100 | params?: any 101 | }, 102 | testFn: (url: string) => Promise, 103 | ) { 104 | return async () => { 105 | const apiHandler = await (typeof prepare === "function" 106 | ? prepare() 107 | : prepare.handle()) 108 | const [url, close] = await createApi( 109 | apiHandler, 110 | typeof prepare === "function" ? undefined : prepare.params, 111 | ) 112 | try { 113 | await testFn(url) 114 | close() 115 | } catch (e) { 116 | close() 117 | throw e 118 | } 119 | } 120 | } 121 | 122 | export function createAuthCookie( 123 | options: AuthRouteHandlerOptions, 124 | payload: object, 125 | ) { 126 | const value = jwt.sign(payload, options.serverConfig.tokenSecret) 127 | return `${options.serverConfig.cookieName}=${value}` 128 | } 129 | -------------------------------------------------------------------------------- /auth-email/src-app/pages/change-password.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ChangePassword } from "./change-password" 3 | import fetchMock from "jest-fetch-mock" 4 | import { createUseAuth } from ".." 5 | import { 6 | renderApp, 7 | publicConfig, 8 | signedInTokenContentResponse, 9 | fireEvent, 10 | screen, 11 | } from "../jest/utils.jsdom" 12 | 13 | const useAuth = createUseAuth(publicConfig) 14 | let ChangePasswordPage = () => { 15 | const auth = useAuth() 16 | return 17 | } 18 | 19 | test("when signed out", async () => { 20 | fetchMock.mockResponses([ 21 | JSON.stringify({ error: { code: "no content" } }), 22 | { status: 200 }, 23 | ]) 24 | 25 | renderApp() 26 | 27 | // screen.debug() 28 | 29 | expect(await screen.findByText(/Change your password/)).toBeInTheDocument() 30 | expect(await screen.findByText(/Not signed in/)).toBeInTheDocument() 31 | expect( 32 | await screen.findByText(/You must be signed in to change your password/), 33 | ).toBeInTheDocument() 34 | }) 35 | 36 | test("when signed in", async () => { 37 | fetchMock.mockResponses([ 38 | JSON.stringify(signedInTokenContentResponse), 39 | { status: 200 }, 40 | ]) 41 | 42 | renderApp() 43 | 44 | expect(await screen.findByText(/Change your password/)).toBeInTheDocument() 45 | expect(await screen.queryByText(/Not signed in/)).not.toBeInTheDocument() 46 | }) 47 | 48 | test("when changing successfully", async () => { 49 | fetchMock.mockResponses( 50 | [JSON.stringify(signedInTokenContentResponse), { status: 200 }], 51 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 52 | ) 53 | 54 | renderApp() 55 | 56 | const currentPasswordInput = await screen.findByLabelText(/Current Password/) 57 | fireEvent.click(currentPasswordInput) 58 | fireEvent.change(currentPasswordInput, { target: { value: "hunter2" } }) 59 | fireEvent.blur(currentPasswordInput) 60 | 61 | const newPasswordInput = await screen.findByLabelText(/New Password/) 62 | fireEvent.click(newPasswordInput) 63 | fireEvent.change(newPasswordInput, { target: { value: "hunter3" } }) 64 | fireEvent.blur(newPasswordInput) 65 | 66 | const submitButton = await screen.findByText(/Change password/) 67 | fireEvent.click(submitButton) 68 | 69 | expect( 70 | await screen.findByText(/Your password was changed successfully/), 71 | ).toBeInTheDocument() 72 | 73 | expect(fetchMock).toHaveBeenCalledWith("/api/auth/change-password", { 74 | body: '{"currentPassword":"hunter2","newPassword":"hunter3"}', 75 | headers: { "Content-Type": "application/json" }, 76 | method: "POST", 77 | }) 78 | }) 79 | 80 | test("when attempting change with invalid password", async () => { 81 | fetchMock.mockResponses( 82 | [JSON.stringify(signedInTokenContentResponse), { status: 200 }], 83 | [ 84 | JSON.stringify({ error: { code: "authentication failed" } }), 85 | { status: 200 }, 86 | ], 87 | ) 88 | 89 | renderApp() 90 | 91 | const currentPasswordInput = await screen.findByLabelText(/Current Password/) 92 | fireEvent.click(currentPasswordInput) 93 | fireEvent.change(currentPasswordInput, { target: { value: "wrong" } }) 94 | fireEvent.blur(currentPasswordInput) 95 | 96 | const newPasswordInput = await screen.findByLabelText(/New Password/) 97 | fireEvent.click(newPasswordInput) 98 | fireEvent.change(newPasswordInput, { target: { value: "hunter3" } }) 99 | fireEvent.blur(newPasswordInput) 100 | 101 | const submitButton = await screen.findByText(/Change password/) 102 | fireEvent.click(submitButton) 103 | 104 | expect( 105 | await screen.queryByText(/Your password was changed successfully/), 106 | ).not.toBeInTheDocument() 107 | 108 | expect(await screen.findByText(/Invalid password/)).toBeInTheDocument() 109 | expect( 110 | await screen.findByText(/The current password did not match/), 111 | ).toBeInTheDocument() 112 | 113 | expect(fetchMock).toHaveBeenCalledWith("/api/auth/change-password", { 114 | body: '{"currentPassword":"wrong","newPassword":"hunter3"}', 115 | headers: { "Content-Type": "application/json" }, 116 | method: "POST", 117 | }) 118 | }) 119 | 120 | test("when making mistakes while filling the form", async () => { 121 | fetchMock.mockResponses([ 122 | JSON.stringify(signedInTokenContentResponse), 123 | { status: 200 }, 124 | ]) 125 | 126 | renderApp() 127 | 128 | const currentPasswordInput = await screen.findByLabelText(/Current Password/) 129 | fireEvent.click(currentPasswordInput) 130 | fireEvent.blur(currentPasswordInput) 131 | 132 | expect( 133 | await screen.findByText(/The current password is missing/), 134 | ).toBeInTheDocument() 135 | 136 | fireEvent.click(currentPasswordInput) 137 | fireEvent.change(currentPasswordInput, { target: { value: "h" } }) 138 | fireEvent.blur(currentPasswordInput) 139 | 140 | expect( 141 | await screen.queryByText(/The current password is missing/), 142 | ).not.toBeInTheDocument() 143 | 144 | const newPasswordInput = await screen.findByLabelText(/New Password/) 145 | fireEvent.click(newPasswordInput) 146 | fireEvent.blur(newPasswordInput) 147 | 148 | expect( 149 | await screen.findByText(/The new password must be at least 3 characters/), 150 | ).toBeInTheDocument() 151 | 152 | fireEvent.click(newPasswordInput) 153 | fireEvent.change(newPasswordInput, { target: { value: "hunter3" } }) 154 | fireEvent.blur(newPasswordInput) 155 | 156 | expect( 157 | await screen.queryByText(/The new password must be at least 3 characters/), 158 | ).not.toBeInTheDocument() 159 | }) 160 | 161 | test("when submitting without filling the form", async () => { 162 | fetchMock.mockResponses([ 163 | JSON.stringify(signedInTokenContentResponse), 164 | { status: 200 }, 165 | ]) 166 | 167 | renderApp() 168 | 169 | const submitButton = await screen.findByText(/Change password/) 170 | fireEvent.click(submitButton) 171 | 172 | expect( 173 | await screen.findByText(/The current password is missing/), 174 | ).toBeInTheDocument() 175 | expect( 176 | await screen.findByText(/The new password must be at least 3 characters/), 177 | ).toBeInTheDocument() 178 | 179 | expect(fetchMock).toHaveBeenCalledTimes(1) 180 | }) 181 | -------------------------------------------------------------------------------- /auth-email/src-app/pages/confirm-account.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ConfirmAccount } from "./confirm-account" 3 | import fetchMock from "jest-fetch-mock" 4 | import { createUseAuth } from ".." 5 | import { 6 | renderApp, 7 | publicConfig, 8 | signedInTokenContentResponse, 9 | screen, 10 | } from "../jest/utils.jsdom" 11 | 12 | const useAuth = createUseAuth(publicConfig) 13 | let ConfirmAccountPage = () => { 14 | const auth = useAuth() 15 | return 16 | } 17 | 18 | afterEach(() => { 19 | window.location.hash = "" 20 | }) 21 | 22 | test("when opening without a token", async () => { 23 | fetchMock.mockResponses([ 24 | JSON.stringify({ error: { code: "no content" } }), 25 | { status: 200 }, 26 | ]) 27 | 28 | window.location.hash = "" 29 | renderApp() 30 | 31 | expect( 32 | await screen.findByText(/Confirming your account\.\.\./), 33 | ).toBeInTheDocument() 34 | }) 35 | 36 | test("when token confirmation fails", async () => { 37 | fetchMock.mockResponses([ 38 | JSON.stringify({ error: { code: "unexpected error" } }), 39 | { status: 500 }, 40 | ]) 41 | 42 | window.location.hash = "#token=broken" 43 | renderApp() 44 | 45 | expect( 46 | await screen.findByText(/Account confirmation failed/), 47 | ).toBeInTheDocument() 48 | }) 49 | 50 | test("when token confirmation succeeds", async () => { 51 | fetchMock.mockResponses( 52 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 53 | [JSON.stringify(signedInTokenContentResponse), { status: 200 }], 54 | ) 55 | 56 | window.location.hash = "#token=working" 57 | renderApp() 58 | 59 | expect(await screen.findByText(/Account confirmed/)).toBeInTheDocument() 60 | }) 61 | -------------------------------------------------------------------------------- /auth-email/src-app/pages/confirm-account.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { PublicConfig, HappyApiResponse, Auth, BaseTokenData } from ".." 3 | import queryString from "query-string" 4 | import Link from "next/link" 5 | import Head from "next/head" 6 | import tailwind from "../tailwind.css" 7 | import { SuccessMessage, ErrorMessage } from "../components/messages" 8 | 9 | enum STATE { 10 | confirming = "confirming", 11 | failed = "failed", 12 | confirmed = "confirmed", 13 | } 14 | 15 | export function ConfirmAccount(props: { 16 | auth: Auth 17 | publicConfig: PublicConfig 18 | }) { 19 | const [state, setState] = React.useState(STATE.confirming) 20 | 21 | React.useEffect(() => { 22 | const parsed = queryString.parse(window.location.hash) 23 | 24 | const token = Array.isArray(parsed.token) ? parsed.token[0] : parsed.token 25 | if (!token) return 26 | if (!props.auth.confirmAccount) return 27 | 28 | props.auth 29 | .confirmAccount(token) 30 | .then((response) => { 31 | if (response.data) setState(STATE.confirmed) 32 | else setState(STATE.failed) 33 | }) 34 | .catch(() => { 35 | setState(STATE.failed) 36 | }) 37 | }, [setState, props.auth]) 38 | 39 | if (state === "confirming") 40 | return ( 41 | 42 | 43 | 44 | 45 |
46 |
47 |
48 |

49 | Confirming your account... 50 |

51 |
52 |
53 |
54 |
55 | ) 56 | 57 | if (state === "failed") 58 | return ( 59 | 60 | 61 | 62 | 63 |
64 |
65 |
66 |

67 | Account confirmation failed 68 |

69 |
70 | 71 |

There was an error

72 |
73 |
74 |
75 |
76 | ) 77 | 78 | return ( 79 | 80 | 81 | 82 | 83 |
84 |
85 |
86 |

87 | Account confirmed 88 |

89 |

90 | You are now signed in 91 |

92 |
93 | 94 | 95 | 96 |

97 | Your account was confirmed and you have been signed in 98 | automatically. 99 |

100 |
101 |
102 | 105 | 106 | 113 | 114 | 115 |
116 |
117 |
118 |
119 |
120 | ) 121 | } 122 | -------------------------------------------------------------------------------- /auth-email/src-app/pages/forgot-password.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ForgotPassword } from "./forgot-password" 3 | import fetchMock from "jest-fetch-mock" 4 | import { createUseAuth } from ".." 5 | import { 6 | renderApp, 7 | publicConfig, 8 | signedOutTokenContentResponse, 9 | fireEvent, 10 | screen, 11 | } from "../jest/utils.jsdom" 12 | 13 | const useAuth = createUseAuth(publicConfig) 14 | let ForgotPasswordPage = () => { 15 | const auth = useAuth() 16 | return 17 | } 18 | 19 | test("when changing successfully", async () => { 20 | fetchMock.mockResponses( 21 | [JSON.stringify(signedOutTokenContentResponse), { status: 200 }], 22 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 23 | ) 24 | 25 | renderApp() 26 | 27 | const emailInput = await screen.findByLabelText(/Email address/) 28 | fireEvent.click(emailInput) 29 | fireEvent.change(emailInput, { target: { value: "user@test.com" } }) 30 | fireEvent.blur(emailInput) 31 | 32 | const submitButton = await screen.findByText(/Reset my password/) 33 | fireEvent.click(submitButton) 34 | 35 | expect(await screen.findByText(/Email sent/)).toBeInTheDocument() 36 | 37 | expect(fetchMock).toHaveBeenCalledWith("/api/auth/forgot-password", { 38 | body: '{"email":"user@test.com"}', 39 | headers: { "Content-Type": "application/json" }, 40 | method: "POST", 41 | }) 42 | }) 43 | 44 | test("when making mistakes while filling the form", async () => { 45 | fetchMock.mockResponses( 46 | [JSON.stringify(signedOutTokenContentResponse), { status: 200 }], 47 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 48 | ) 49 | 50 | renderApp() 51 | 52 | const emailInput = await screen.findByLabelText(/Email address/) 53 | fireEvent.click(emailInput) 54 | fireEvent.blur(emailInput) 55 | 56 | expect( 57 | await screen.findByText(/The email address is missing/), 58 | ).toBeInTheDocument() 59 | 60 | fireEvent.click(emailInput) 61 | fireEvent.change(emailInput, { target: { value: "h" } }) 62 | fireEvent.blur(emailInput) 63 | 64 | expect( 65 | await screen.findByText(/The email address doesn't seem valid/), 66 | ).toBeInTheDocument() 67 | 68 | let submitButton = await screen.findByText(/Reset my password/) 69 | fireEvent.click(submitButton) 70 | 71 | expect(fetchMock).toHaveBeenCalledTimes(1) 72 | 73 | fireEvent.click(emailInput) 74 | fireEvent.change(emailInput, { target: { value: "user@test.com" } }) 75 | fireEvent.blur(emailInput) 76 | 77 | expect( 78 | await screen.queryByText(/The email address is missing/), 79 | ).not.toBeInTheDocument() 80 | expect( 81 | await screen.queryByText(/The email address doesn't seem valid/), 82 | ).not.toBeInTheDocument() 83 | 84 | fireEvent.click(submitButton) 85 | expect(await screen.findByText(/Email sent/)).toBeInTheDocument() 86 | 87 | expect(fetchMock).toHaveBeenCalledTimes(2) 88 | }) 89 | 90 | test("when submitting without filling the form", async () => { 91 | fetchMock.mockResponses([ 92 | JSON.stringify(signedOutTokenContentResponse), 93 | { status: 200 }, 94 | ]) 95 | 96 | renderApp() 97 | 98 | const submitButton = await screen.findByText(/Reset my password/) 99 | fireEvent.click(submitButton) 100 | 101 | expect( 102 | await screen.findByText(/The email address is missing/), 103 | ).toBeInTheDocument() 104 | 105 | expect(fetchMock).toHaveBeenCalledTimes(1) 106 | }) 107 | -------------------------------------------------------------------------------- /auth-email/src-app/pages/reset-password.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { ResetPassword } from "./reset-password" 3 | import fetchMock from "jest-fetch-mock" 4 | import { createUseAuth } from ".." 5 | import { 6 | renderApp, 7 | publicConfig, 8 | signedOutTokenContentResponse, 9 | fireEvent, 10 | screen, 11 | } from "../jest/utils.jsdom" 12 | 13 | const useAuth = createUseAuth(publicConfig) 14 | let ResetPasswordPage = () => { 15 | const auth = useAuth() 16 | return 17 | } 18 | 19 | test("when token is missing", async () => { 20 | fetchMock.mockResponses([ 21 | JSON.stringify(signedOutTokenContentResponse), 22 | { status: 200 }, 23 | ]) 24 | renderApp() 25 | 26 | expect(await screen.findByText(/Invalid link/)).toBeInTheDocument() 27 | }) 28 | 29 | test("when token is present", async () => { 30 | fetchMock.mockResponses([ 31 | JSON.stringify(signedOutTokenContentResponse), 32 | { status: 200 }, 33 | ]) 34 | window.location.href = "#token=abc" 35 | renderApp() 36 | 37 | expect(await screen.findByText(/Reset your password/)).toBeInTheDocument() 38 | expect(await screen.queryByText(/Invalid link/)).not.toBeInTheDocument() 39 | }) 40 | 41 | test("setting a new password", async () => { 42 | fetchMock.mockResponses( 43 | [JSON.stringify(signedOutTokenContentResponse), { status: 200 }], 44 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 45 | ) 46 | window.location.href = "#token=abc" 47 | renderApp() 48 | 49 | const newPasswordInput = await screen.findByLabelText(/New Password/) 50 | fireEvent.click(newPasswordInput) 51 | fireEvent.change(newPasswordInput, { target: { value: "hunter2" } }) 52 | fireEvent.blur(newPasswordInput) 53 | 54 | const submitButton = await screen.findByText("Reset my password") 55 | fireEvent.click(submitButton) 56 | 57 | expect(await screen.queryByText(/Invalid link/)).not.toBeInTheDocument() 58 | expect(await screen.findByText("Password reset")).toBeInTheDocument() 59 | 60 | expect(fetchMock).toHaveBeenCalledWith("/api/auth/reset-password", { 61 | body: '{"token":"abc","password":"hunter2"}', 62 | headers: { "Content-Type": "application/json" }, 63 | method: "POST", 64 | }) 65 | }) 66 | 67 | test("when making mistakes while setting new password", async () => { 68 | fetchMock.mockResponses( 69 | [JSON.stringify(signedOutTokenContentResponse), { status: 200 }], 70 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 71 | ) 72 | window.location.href = "#token=abc" 73 | renderApp() 74 | 75 | const newPasswordInput = await screen.findByLabelText(/New Password/) 76 | fireEvent.click(newPasswordInput) 77 | fireEvent.blur(newPasswordInput) 78 | expect(await screen.findByText(/The password is missing/)).toBeInTheDocument() 79 | 80 | const submitButton = await screen.findByText("Reset my password") 81 | fireEvent.click(submitButton) 82 | expect(fetchMock).toHaveBeenCalledTimes(1) 83 | 84 | fireEvent.click(newPasswordInput) 85 | fireEvent.change(newPasswordInput, { target: { value: "h" } }) 86 | fireEvent.blur(newPasswordInput) 87 | expect( 88 | await screen.findByText( 89 | /The password should at least be three characters long/, 90 | ), 91 | ).toBeInTheDocument() 92 | 93 | fireEvent.click(submitButton) 94 | expect(fetchMock).toHaveBeenCalledTimes(1) 95 | 96 | fireEvent.click(newPasswordInput) 97 | fireEvent.change(newPasswordInput, { target: { value: "hunter2" } }) 98 | fireEvent.blur(newPasswordInput) 99 | 100 | fireEvent.click(submitButton) 101 | 102 | expect(await screen.queryByText(/Invalid link/)).not.toBeInTheDocument() 103 | expect(await screen.findByText("Password reset")).toBeInTheDocument() 104 | 105 | expect(fetchMock).toHaveBeenCalledWith("/api/auth/reset-password", { 106 | body: '{"token":"abc","password":"hunter2"}', 107 | headers: { "Content-Type": "application/json" }, 108 | method: "POST", 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /auth-email/src-app/pages/signup.spec.tsx: -------------------------------------------------------------------------------- 1 | const mockPush = jest.fn() 2 | jest.mock("next/router", () => { 3 | return { push: mockPush } 4 | }) 5 | 6 | import * as React from "react" 7 | import { Signup } from "./signup" 8 | import fetchMock from "jest-fetch-mock" 9 | import { createUseAuth } from ".." 10 | import { 11 | renderApp, 12 | publicConfig, 13 | signedOutTokenContentResponse, 14 | fireEvent, 15 | screen, 16 | } from "../jest/utils.jsdom" 17 | 18 | const useAuth = createUseAuth(publicConfig) 19 | let SignupPage = () => { 20 | const auth = useAuth() 21 | return 22 | } 23 | 24 | test("when signing up successfully", async () => { 25 | fetchMock.mockResponses( 26 | [JSON.stringify(signedOutTokenContentResponse), { status: 200 }], 27 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 28 | ) 29 | 30 | renderApp() 31 | 32 | const emailInput = await screen.findByLabelText(/Email address/) 33 | fireEvent.click(emailInput) 34 | fireEvent.change(emailInput, { target: { value: "user@test.com" } }) 35 | fireEvent.blur(emailInput) 36 | 37 | const passwordInput = await screen.findByLabelText(/Password/) 38 | fireEvent.click(passwordInput) 39 | fireEvent.change(passwordInput, { target: { value: "hunter2" } }) 40 | fireEvent.blur(passwordInput) 41 | 42 | const submitButton = await screen.findByText("Sign up") 43 | fireEvent.click(submitButton) 44 | 45 | // TODO verify that Router.push gets called 46 | expect(await screen.findByText("Account created")).toBeInTheDocument() 47 | 48 | expect(fetchMock).toHaveBeenCalledWith("/api/auth/signup", { 49 | body: '{"email":"user@test.com","password":"hunter2"}', 50 | headers: { "Content-Type": "application/json" }, 51 | method: "POST", 52 | }) 53 | }) 54 | 55 | test("when making mistakes while filling the form", async () => { 56 | fetchMock.mockResponses( 57 | [JSON.stringify(signedOutTokenContentResponse), { status: 200 }], 58 | [JSON.stringify({ data: { ok: true } }), { status: 200 }], 59 | ) 60 | renderApp() 61 | 62 | const emailInput = await screen.findByLabelText(/Email address/) 63 | fireEvent.click(emailInput) 64 | fireEvent.blur(emailInput) 65 | 66 | expect( 67 | await screen.findByText(/The email address is missing/), 68 | ).toBeInTheDocument() 69 | 70 | fireEvent.click(emailInput) 71 | fireEvent.change(emailInput, { target: { value: "h" } }) 72 | fireEvent.blur(emailInput) 73 | 74 | expect( 75 | await screen.findByText(/The email address doesn't seem valid/), 76 | ).toBeInTheDocument() 77 | 78 | fireEvent.click(emailInput) 79 | fireEvent.change(emailInput, { target: { value: "user@test.com" } }) 80 | fireEvent.blur(emailInput) 81 | 82 | expect( 83 | await screen.queryByText(/The email address doesn't seem valid/), 84 | ).not.toBeInTheDocument() 85 | 86 | expect( 87 | await screen.queryByText(/The email address is missing/), 88 | ).not.toBeInTheDocument() 89 | 90 | const passwordInput = await screen.findByLabelText(/Password/) 91 | fireEvent.click(passwordInput) 92 | fireEvent.blur(passwordInput) 93 | 94 | expect(await screen.findByText(/The password is missing/)).toBeInTheDocument() 95 | 96 | fireEvent.click(passwordInput) 97 | fireEvent.change(passwordInput, { target: { value: "h" } }) 98 | fireEvent.blur(passwordInput) 99 | 100 | expect( 101 | await screen.findByText( 102 | /The password should at least be three characters long/, 103 | ), 104 | ).toBeInTheDocument() 105 | 106 | fireEvent.click(passwordInput) 107 | fireEvent.change(passwordInput, { target: { value: "hunter2" } }) 108 | fireEvent.blur(passwordInput) 109 | 110 | expect( 111 | await screen.queryByText(/The password is missing/), 112 | ).not.toBeInTheDocument() 113 | expect( 114 | await screen.queryByText( 115 | /The password should at least be three characters long/, 116 | ), 117 | ).not.toBeInTheDocument() 118 | 119 | const submitButton = await screen.findByText("Sign up") 120 | fireEvent.click(submitButton) 121 | 122 | expect(await screen.findByText("Account created")).toBeInTheDocument() 123 | 124 | expect(fetchMock).toHaveBeenCalledWith("/api/auth/signup", { 125 | body: '{"email":"user@test.com","password":"hunter2"}', 126 | headers: { "Content-Type": "application/json" }, 127 | method: "POST", 128 | }) 129 | }) 130 | 131 | test("when submitting without filling the form", async () => { 132 | fetchMock.mockResponses([ 133 | JSON.stringify(signedOutTokenContentResponse), 134 | { status: 200 }, 135 | ]) 136 | renderApp() 137 | 138 | const submitButton = await screen.findByText("Sign up") 139 | fireEvent.click(submitButton) 140 | 141 | expect( 142 | await screen.findByText(/The email address is missing/), 143 | ).toBeInTheDocument() 144 | 145 | expect(await screen.findByText(/The password is missing/)).toBeInTheDocument() 146 | 147 | expect(fetchMock).toHaveBeenCalledTimes(1) 148 | }) 149 | -------------------------------------------------------------------------------- /auth-email/src-app/tailwind.css: -------------------------------------------------------------------------------- 1 | /* purgecss start ignore */ 2 | @import "tailwindcss/base"; 3 | @import "tailwindcss/components"; 4 | /* purgecss end ignore */ 5 | 6 | @import "tailwindcss/utilities"; 7 | -------------------------------------------------------------------------------- /auth-email/src-app/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | // This config file (tsconfig.build.json) is used for the build process. 3 | // The only difference is that we exclude the test files. 4 | "extends": "./tsconfig.json", 5 | "exclude": ["node_modules", "**/*.spec.ts", "**/*.spec.tsx"] 6 | } 7 | -------------------------------------------------------------------------------- /auth-email/src-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // This config file (tsconfig.json) is used by tests. 3 | "compilerOptions": { 4 | /* Basic Options */ 5 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 11 | "declaration": true /* Generates corresponding '.d.ts' file. */, 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "../dist-app" /* Redirect output structure to the directory. */, 16 | "rootDir": "." /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 17 | // "composite": true, /* Enable project compilation */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true /* Enable all strict type-checking options. */, 26 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | "strictNullChecks": true /* Enable strict null checks. */, 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | 51 | /* Source Map Options */ 52 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 53 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 54 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 55 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 56 | "resolveJsonModule": true 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | }, 62 | "exclude": ["node_modules"] 63 | } 64 | -------------------------------------------------------------------------------- /auth-email/src-app/types.d.ts: -------------------------------------------------------------------------------- 1 | // enable importing css into typescript 2 | // https://stackoverflow.com/a/41946697 3 | declare module "*.css" { 4 | const content: string 5 | export default content 6 | } 7 | -------------------------------------------------------------------------------- /auth-email/src-cli/db.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander" 2 | import faunadb, { query as q } from "faunadb" 3 | import chalk from "chalk" 4 | import inquirer from "inquirer" 5 | import * as prompts from "./prompts" 6 | 7 | export async function initDb({ faunaSecret }: { faunaSecret: string }) { 8 | const serverClient = new faunadb.Client({ secret: faunaSecret }) 9 | try { 10 | await serverClient.query( 11 | // TODO Currently the index will not get created in case the User 12 | // collection already exists, as the creation will abort. 13 | q.Let( 14 | { 15 | collection: q.CreateCollection({ 16 | name: "User", 17 | history_days: 30, 18 | ttl_days: null, 19 | }), 20 | }, 21 | q.CreateIndex({ 22 | name: "users_by_email", 23 | // permissions: { read: "public" }, 24 | source: q.Select("ref", q.Var("collection")), 25 | terms: [{ field: ["data", "email"] }], 26 | unique: true, 27 | }), 28 | ), 29 | ) 30 | console.log(chalk.green("Created User collection and users_by_email index")) 31 | } catch (e) { 32 | console.log(chalk.red("Creation failed"), "\n") 33 | if (e.message === "instance already exists") { 34 | console.log( 35 | "Seems like either the User collection or the users_by_email index exist already.", 36 | ) 37 | console.log( 38 | "You can verify that on the FaunaDB Dashboard https://dashboard.fauna.com/.", 39 | ) 40 | console.log("If they exist, you don't need to run this script again.") 41 | } else { 42 | console.error(e) 43 | } 44 | } 45 | } 46 | 47 | async function init() { 48 | const { faunaSecret } = await inquirer.prompt([prompts.faunaSecret]) 49 | 50 | return initDb({ faunaSecret }) 51 | } 52 | 53 | const db = new Command("db") 54 | db.description("Database subcommand") 55 | 56 | db.command("init").description("Initialize database").action(init) 57 | 58 | db.command("validate") 59 | .description("Initialize database") 60 | .action(() => { 61 | console.log("This command is not built yet") 62 | }) 63 | 64 | export default db 65 | -------------------------------------------------------------------------------- /auth-email/src-cli/index.ts: -------------------------------------------------------------------------------- 1 | import { program, Command } from "commander" 2 | import open from "open" 3 | import createInit from "./init" 4 | import files from "./files" 5 | import db from "./db" 6 | import createRandomSecret from "./random-secret" 7 | 8 | program.name("auth").action(() => { 9 | console.log(program.helpInformation()) 10 | }) 11 | 12 | program.usage("[command]") 13 | 14 | // yarn auth-email init 15 | createInit(program as Command) 16 | 17 | // yarn auth-email random-secret 18 | createRandomSecret(program as Command) 19 | 20 | program 21 | .command("docs") 22 | .description(`Open the documentation`) 23 | .action(() => { 24 | open("https://docs.happykit.dev/") 25 | }) 26 | 27 | program 28 | .command("repo") 29 | .description(`Open the repository`) 30 | .action(() => { 31 | open("https://github.com/happykit/auth-email") 32 | }) 33 | 34 | // yarn auth-email files init 35 | // yarn auth-email files clean 36 | // yarn auth-email files eject 37 | program.addCommand(files) 38 | 39 | // yarn auth-email db init 40 | // yarn auth-email db validate 41 | program.addCommand(db) 42 | 43 | program.parse(process.argv) 44 | -------------------------------------------------------------------------------- /auth-email/src-cli/init.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander" 2 | import inquirer from "inquirer" 3 | import chalk from "chalk" 4 | import { initFiles } from "./files" 5 | import { initDb } from "./db" 6 | import * as prompts from "./prompts" 7 | 8 | async function init() { 9 | const { faunaSecret } = await inquirer.prompt([prompts.faunaSecret]) 10 | 11 | try { 12 | await initDb({ faunaSecret }) 13 | await initFiles({ faunaSecret }) 14 | } catch (e) { 15 | console.error(chalk.red("Error"), e.message) 16 | } 17 | } 18 | 19 | export default function createInit(program: Command) { 20 | program 21 | // the init command is a shortcut for executing 22 | // yarn auth db init 23 | // yarn auth files init 24 | .command("init") 25 | .description(`Initialize database and files`) 26 | .action(init) 27 | } 28 | -------------------------------------------------------------------------------- /auth-email/src-cli/prompts.ts: -------------------------------------------------------------------------------- 1 | import inquirer from "inquirer" 2 | 3 | export const faunaSecret: inquirer.PasswordQuestion = { 4 | type: "password", 5 | name: "faunaSecret", 6 | message: "FaunaDB Server Key", 7 | validate: (input) => { 8 | if (input.trim().length === 0) return "Key must be provided" 9 | 10 | if (!input.startsWith("fn")) 11 | return `This looks like an invalid FaunaDB key. They usually start with "fn", but this one doesn't.` 12 | 13 | return true 14 | }, 15 | mask: "*", 16 | } 17 | -------------------------------------------------------------------------------- /auth-email/src-cli/random-secret.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "commander" 2 | import crypto from "crypto" 3 | 4 | function randomSecret() { 5 | return crypto.randomBytes(32).toString("hex") 6 | } 7 | 8 | export default function createRandomSecret(program: Command) { 9 | program 10 | // the init command is a shortcut for executing 11 | // yarn auth db init 12 | // yarn auth files init 13 | .command("random-secret") 14 | .description(`Generate a random secret`) 15 | .action(() => { 16 | console.log(randomSecret()) 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /auth-email/src-cli/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 10 | "declaration": true /* Generates corresponding '.d.ts' file. */, 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "../dist-cli" /* Redirect output structure to the directory. */, 15 | "rootDir": "." /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | "strictNullChecks": true /* Enable strict null checks. */, 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | "resolveJsonModule": true 56 | 57 | /* Experimental Options */ 58 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 59 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 60 | }, 61 | "exclude": ["node_modules"] 62 | } 63 | -------------------------------------------------------------------------------- /auth-email/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: false, 3 | } 4 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | ## demo 2 | 3 | This folder contains the demo you see at [auth-email-demo.now.sh](https://auth-email-demo.now.sh/). 4 | 5 | It is basically the same as `starter-fauna-typescript` with some slight adjustments. 6 | -------------------------------------------------------------------------------- /demo/fauna-client.ts: -------------------------------------------------------------------------------- 1 | import faunadb from "faunadb" 2 | 3 | export const faunaClient = new faunadb.Client({ 4 | secret: process.env.FAUNA_SERVER_KEY!, 5 | }) 6 | 7 | export const q = faunadb.query 8 | -------------------------------------------------------------------------------- /demo/happyauth/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createUseAuth, 3 | AuthProvider, 4 | PublicConfig, 5 | BaseTokenData, 6 | } from "@happykit/auth-email" 7 | 8 | export interface TokenData extends BaseTokenData { 9 | /* define your additional token data here */ 10 | } 11 | 12 | export const publicConfig: PublicConfig = { 13 | baseUrl: (() => { 14 | if (process.env.VERCEL_GITHUB_COMMIT_REF === "master") 15 | return process.env.PRODUCTION_BASE_URL! 16 | if (process.env.NODE_ENV === "production") 17 | return `https://${process.env.VERCEL_URL}` 18 | return "http://localhost:3000" 19 | })(), 20 | identityProviders: { 21 | github: { name: "GitHub" }, 22 | }, 23 | // Possible configuration: 24 | // redirects: { 25 | // afterSignIn: "/?afterSigIn=true", 26 | // afterSignOut: "/?afterSignOut=true", 27 | // afterChangePassword: "/?afterChangePassword=true", 28 | // afterResetPassword: "/?afterResetPassword=true", 29 | // }, 30 | } 31 | 32 | /* you can probably leave these as they are */ 33 | export { AuthProvider } 34 | export const useAuth = createUseAuth(publicConfig) 35 | export type Auth = ReturnType 36 | -------------------------------------------------------------------------------- /demo/happyauth/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createGetServerSideAuth, 3 | createFaunaEmailDriver, 4 | ServerConfig, 5 | } from "@happykit/auth-email/api" 6 | import { TokenData } from "." 7 | import { 8 | SendConfirmAccountMail, 9 | SendForgotPasswordMail, 10 | } from "@happykit/auth-email/api" 11 | import createMailgun from "mailgun-js" 12 | import { faunaClient, q } from "fauna-client" 13 | 14 | const mailgun = createMailgun({ 15 | apiKey: process.env.MAILGUN_API_KEY!, 16 | publicApiKey: process.env.MAILGUN_API_KEY!, 17 | domain: "mg.happykit.dev", 18 | // the mailgun host (default: 'api.mailgun.net'). Note that if you are using 19 | // the EU region the host should be set to 'api.eu.mailgun.net' 20 | // https://www.npmjs.com/package/mailgun-js 21 | host: "api.eu.mailgun.net", 22 | }) 23 | 24 | const sendConfirmAccountMail: SendConfirmAccountMail = (email, link) => { 25 | return new Promise((resolve) => { 26 | const data = { 27 | from: process.env.SENDMAIL_SENDER_EMAIL_ADDRESS, 28 | to: email, 29 | subject: "Welcome to the HappyKit Demo", 30 | html: [ 31 | `Welcome,`, 32 | ``, 33 | `your account has been created.`, 34 | ``, 35 | `Click the link below to activate it:`, 36 | `${link}`, 37 | ``, 38 | `PS: If you did not sign up, you can simply ignore this email.`, 39 | ``, 40 | `Cheers`, 41 | ].join("\n"), 42 | } 43 | mailgun.messages().send(data, () => { 44 | resolve() 45 | }) 46 | }) 47 | } 48 | 49 | const sendForgotPasswordMail: SendForgotPasswordMail = (email, link) => { 50 | return new Promise((resolve) => { 51 | const data = { 52 | from: process.env.SENDMAIL_SENDER_EMAIL_ADDRESS, 53 | to: email, 54 | subject: "Reset your password", 55 | html: [ 56 | `Hello,`, 57 | ``, 58 | `somebody requested a reset of your password.`, 59 | `Click the link below to reset it:`, 60 | `${link}`, 61 | ``, 62 | `Cheers`, 63 | ].join("\n"), 64 | } 65 | mailgun.messages().send(data, () => { 66 | resolve() 67 | }) 68 | }) 69 | } 70 | 71 | export const serverConfig: ServerConfig = { 72 | tokenSecret: process.env.HAPPYAUTH_TOKEN_SECRET!, 73 | cookieName: "happyauth", 74 | secure: process.env.NODE_ENV === "production", 75 | identityProviders: { 76 | github: { 77 | credentials: { 78 | client: { 79 | id: process.env.OAUTH_GITHUB_ID!, 80 | secret: process.env.OAUTH_GITHUB_SECRET!, 81 | }, 82 | auth: { 83 | tokenHost: "https://github.com", 84 | tokenPath: "/login/oauth/access_token", 85 | authorizePath: "/login/oauth/authorize", 86 | }, 87 | }, 88 | scope: "notifications", 89 | upsertUser: async (token) => { 90 | const response = await fetch("https://api.github.com/user", { 91 | headers: { 92 | Authorization: `token ${token.access_token}`, 93 | }, 94 | }) 95 | const content = await response.json() 96 | const userId = await faunaClient.query( 97 | q.Let( 98 | { 99 | match: q.Match( 100 | q.Index("users_by_email"), 101 | content.email.toLowerCase(), 102 | ), 103 | }, 104 | q.If( 105 | q.Exists(q.Var("match")), 106 | q.Select(["ref", "id"], q.Get(q.Var("match"))), 107 | q.Select( 108 | ["ref", "id"], 109 | q.Create(q.Collection("User"), { 110 | data: { 111 | email: content.email.toLowerCase(), 112 | created: q.Now(), 113 | accountStatus: "external", 114 | }, 115 | }), 116 | ), 117 | ), 118 | ), 119 | ) 120 | 121 | // TODO upsert user attributes 122 | 123 | return userId 124 | }, 125 | }, 126 | }, 127 | triggers: { 128 | sendConfirmAccountMail, 129 | sendForgotPasswordMail, 130 | }, 131 | driver: createFaunaEmailDriver(faunaClient), 132 | } 133 | 134 | /* you can probably leave these as they are */ 135 | export type AuthState = ReturnType 136 | export const getServerSideAuth = createGetServerSideAuth( 137 | serverConfig, 138 | ) 139 | -------------------------------------------------------------------------------- /demo/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@happykit/auth-email": "1.0.0-alpha.4", 12 | "@types/mailgun-js": "0.22.7", 13 | "@types/node": "14.0.14", 14 | "@types/react": "16.9.41", 15 | "faunadb": "2.14.2", 16 | "mailgun-js": "0.22.0", 17 | "next": "9.4.4", 18 | "react": "16.13.1", 19 | "react-dom": "16.13.1", 20 | "typescript": "3.9.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /demo/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from "next/app" 2 | import { AuthProvider } from "happyauth" 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default MyApp 13 | -------------------------------------------------------------------------------- /demo/pages/api/auth/[...params].ts: -------------------------------------------------------------------------------- 1 | import { createAuthRouteHandler } from "@happykit/auth-email/api" 2 | import { publicConfig, TokenData } from "happyauth" 3 | import { serverConfig, getServerSideAuth } from "happyauth/server" 4 | 5 | // You can use the triggers to customize the server behaviour. 6 | // 7 | // Alternatively, you can completely override individual functions by creating 8 | // files for their routes /api/auth/.ts, e.g. /api/auth/login.ts 9 | export default createAuthRouteHandler({ 10 | publicConfig, 11 | serverConfig, 12 | getServerSideAuth, 13 | }) 14 | -------------------------------------------------------------------------------- /demo/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.statusCode = 200 5 | res.json({ name: 'John Doe' }) 6 | } 7 | -------------------------------------------------------------------------------- /demo/pages/change-password.tsx: -------------------------------------------------------------------------------- 1 | import { ChangePassword } from "@happykit/auth-email/pages/change-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ChangePasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /demo/pages/confirm-account.tsx: -------------------------------------------------------------------------------- 1 | import { ConfirmAccount } from "@happykit/auth-email/pages/confirm-account" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ConfirmAccountPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /demo/pages/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPassword } from "@happykit/auth-email/pages/forgot-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ForgotPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /demo/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { Login } from "@happykit/auth-email/pages/login" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function LoginPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /demo/pages/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPassword } from "@happykit/auth-email/pages/reset-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ResetPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /demo/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import { Signup } from "@happykit/auth-email/pages/signup" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function SignupPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happykit/auth-email/f9ab5c0dc0594f046c60539c97565b0d8916e6ef/demo/public/favicon.ico -------------------------------------------------------------------------------- /demo/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "jsx": "preserve" 19 | }, 20 | "exclude": ["node_modules"], 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 22 | } 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "happyauth-email", 3 | "private": true, 4 | "license": "MIT", 5 | "workspaces": [ 6 | "demo", 7 | "pristine", 8 | "pristine-typescript", 9 | "starter-fauna", 10 | "starter-fauna-typescript", 11 | "auth-email" 12 | ], 13 | "scripts": { 14 | "test": "jest", 15 | "build": "yarn workspace @happykit/auth-email build", 16 | "pub": "yarn workspace @happykit/auth-email publish --access=public" 17 | }, 18 | "dependencies": { 19 | "wsrun": "5.2.1" 20 | }, 21 | "devDependencies": { 22 | "faunadb": "2.14.2", 23 | "next": "9.4.4", 24 | "prettier": "2.0.5", 25 | "react": "16.13.1", 26 | "react-dom": "16.13.1" 27 | }, 28 | "prettier": { 29 | "semi": false, 30 | "tabWidth": 2, 31 | "singleQuote": false, 32 | "trailingComma": "all" 33 | }, 34 | "jest": { 35 | "projects": [ 36 | "/auth-email/src-app/jest.jsdom.config.js", 37 | "/auth-email/src-app/jest.node.config.js" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pristine-typescript/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /pristine-typescript/README.md: -------------------------------------------------------------------------------- 1 | ## pristine 2 | 3 | > If you're looking for a HappyAuth example, check out the **starter-fauna-typescript** folder 4 | 5 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 6 | 7 | ## What this folder contains 8 | 9 | This `pristine` folder contains a newly set up Next.js project. HappyAuth is not set up here. 10 | 11 | ## What this folder is for 12 | 13 | This folder can be used to test the `yarn auth-email init` command for TypeScript during development. 14 | 15 | ## How to use this folder 16 | 17 | Always remove / reset any created files after testing your commands here. You can use `yarn auth-email files clean` to delete files created by the CLI. Keep this folder. 18 | -------------------------------------------------------------------------------- /pristine-typescript/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /pristine-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pristine-typescript", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "9.4.4", 12 | "react": "16.13.1", 13 | "react-dom": "16.13.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pristine-typescript/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.statusCode = 200 5 | res.json({ name: 'John Doe' }) 6 | } 7 | -------------------------------------------------------------------------------- /pristine-typescript/pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | 3 | export default function Home() { 4 | return ( 5 |
6 | 7 | Create Next App 8 | 9 | 10 | 11 |
12 |

13 | Welcome to Next.js! 14 |

15 | 16 |

17 | Get started by editing pages/index.js 18 |

19 | 20 | 49 |
50 | 51 | 61 | 62 | 192 | 193 | 207 |
208 | ) 209 | } 210 | -------------------------------------------------------------------------------- /pristine-typescript/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happykit/auth-email/f9ab5c0dc0594f046c60539c97565b0d8916e6ef/pristine-typescript/public/favicon.ico -------------------------------------------------------------------------------- /pristine-typescript/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /pristine-typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "strict": false, 12 | "forceConsistentCasingInFileNames": true, 13 | "noEmit": true, 14 | "esModuleInterop": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "jsx": "preserve" 20 | }, 21 | "exclude": [ 22 | "node_modules" 23 | ], 24 | "include": [ 25 | "next-env.d.ts", 26 | "**/*.ts", 27 | "**/*.tsx" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /pristine/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /pristine/README.md: -------------------------------------------------------------------------------- 1 | ## pristine 2 | 3 | > If you're looking for a HappyAuth example, check out the **starter-fauna** folder 4 | 5 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 6 | 7 | ## What this folder contains 8 | 9 | This `pristine` folder contains a newly set up Next.js project. HappyAuth is not set up here. 10 | 11 | ## What this folder is for 12 | 13 | This folder can be used to test the `yarn auth-email init` command during development. 14 | 15 | ## How to use this folder 16 | 17 | Always remove / reset any created files after testing your commands here. You can use `yarn auth-email files clean` to delete files created by the CLI. Keep this folder. 18 | -------------------------------------------------------------------------------- /pristine/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pristine", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "next": "9.4.4", 12 | "react": "16.13.1", 13 | "react-dom": "16.13.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pristine/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.statusCode = 200 5 | res.json({ name: 'John Doe' }) 6 | } 7 | -------------------------------------------------------------------------------- /pristine/pages/index.js: -------------------------------------------------------------------------------- 1 | import Head from 'next/head' 2 | 3 | export default function Home() { 4 | return ( 5 |
6 | 7 | Create Next App 8 | 9 | 10 | 11 |
12 |

13 | Welcome to Next.js! 14 |

15 | 16 |

17 | Get started by editing pages/index.js 18 |

19 | 20 | 49 |
50 | 51 | 61 | 62 | 192 | 193 | 207 |
208 | ) 209 | } 210 | -------------------------------------------------------------------------------- /pristine/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happykit/auth-email/f9ab5c0dc0594f046c60539c97565b0d8916e6ef/pristine/public/favicon.ico -------------------------------------------------------------------------------- /pristine/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /starter-fauna-typescript/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /starter-fauna-typescript/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) using the [starter-fauna-typescript](https://github.com/happykit/auth-email/tree/master/starter-fauna-typescript) setup. 2 | 3 | ## Setup 4 | 5 | ### Create a new FaunaDB 6 | 7 | Open [dashboard.fauna.com](https://dashboard.fauna.com/) to create a free FaunaDB instance. No credit card requried. 8 | 9 | Then use the Fauna web app to create a new "Server Key" (under "Security") and copy it. 10 | 11 | Finally, configure your FaunaDB by running the following command: 12 | 13 | ``` 14 | yarn auth-email db init 15 | ``` 16 | 17 | Copy your FaunaDB secret in when prompted. 18 | 19 | ### Environment variables 20 | 21 | Create a `.env.local` file. Next.js will load the environment variables automatically. 22 | 23 | Fill it with this content: 24 | 25 | ```bash 26 | # Your server key from fauna.com 27 | # Create a new database, 28 | # then go to "Security > New Key" 29 | # and create a new server key. 30 | FAUNA_SERVER_KEY="" 31 | # A random secret to sign your tokens. 32 | # We automatically created a random secret when creating this file. 33 | # You can keep it, or you can replace it with your own. 34 | # Note that existing users will be signed out whenever you change the secret. 35 | # 36 | # You can use "yarn auth-email random-secret" to create one. 37 | # Alternatively, you can just provide your own long random string. 38 | HAPPYAUTH_TOKEN_SECRET="" 39 | ``` 40 | 41 | ## Getting Started 42 | 43 | Now you can run the development server: 44 | 45 | ```bash 46 | npm run dev 47 | # or 48 | yarn dev 49 | ``` 50 | 51 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 52 | 53 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 54 | 55 | ## Email 56 | 57 | Out of the box, HappyAuth is configured to log all mails to the server console instead of sending them. You can provide your the triggers in `pages/api/auth/[...params].js` with your own functions to start sending real mails. 58 | 59 | ## Resources 60 | 61 | - [HappyKit site](https://happykit.dev/) 62 | - [Full documentation](https://docs.happykit.dev/) 63 | - [Repo](https://github.com/happykit/auth-email/) 64 | -------------------------------------------------------------------------------- /starter-fauna-typescript/fauna-client.ts: -------------------------------------------------------------------------------- 1 | import faunadb from "faunadb" 2 | 3 | export const faunaClient = new faunadb.Client({ 4 | secret: process.env.FAUNA_SERVER_KEY!, 5 | }) 6 | 7 | export const q = faunadb.query 8 | -------------------------------------------------------------------------------- /starter-fauna-typescript/happyauth/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createUseAuth, 3 | AuthProvider, 4 | PublicConfig, 5 | BaseTokenData, 6 | } from "@happykit/auth-email" 7 | 8 | export interface TokenData extends BaseTokenData { 9 | /* define your additional token data here */ 10 | } 11 | 12 | export const publicConfig: PublicConfig = { 13 | baseUrl: (() => { 14 | if (process.env.VERCEL_GITHUB_COMMIT_REF === "master") 15 | return process.env.PRODUCTION_BASE_URL! 16 | if (process.env.NODE_ENV === "production") 17 | return `https://${process.env.VERCEL_URL}` 18 | return "http://localhost:3000" 19 | })(), 20 | identityProviders: {}, 21 | // Possible configuration: 22 | // redirects: { 23 | // afterSignIn: "/?afterSigIn=true", 24 | // afterSignOut: "/?afterSignOut=true", 25 | // afterChangePassword: "/?afterChangePassword=true", 26 | // afterResetPassword: "/?afterResetPassword=true", 27 | // }, 28 | } 29 | 30 | /* you can probably leave these as they are */ 31 | export { AuthProvider } 32 | export const useAuth = createUseAuth(publicConfig) 33 | export type Auth = ReturnType 34 | -------------------------------------------------------------------------------- /starter-fauna-typescript/happyauth/server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createGetServerSideAuth, 3 | ServerConfig, 4 | sendConfirmAccountMailToConsole, 5 | sendForgotPasswordMailToConsole, 6 | createFaunaEmailDriver, 7 | } from "@happykit/auth-email/api" 8 | import { TokenData } from "." 9 | import { faunaClient } from "fauna-client" 10 | 11 | export const serverConfig: ServerConfig = { 12 | tokenSecret: process.env.HAPPYAUTH_TOKEN_SECRET!, 13 | cookieName: "happyauth", 14 | secure: process.env.NODE_ENV === "production", 15 | identityProviders: {}, 16 | triggers: { 17 | sendConfirmAccountMail: sendConfirmAccountMailToConsole, 18 | sendForgotPasswordMail: sendForgotPasswordMailToConsole, 19 | }, 20 | driver: createFaunaEmailDriver(faunaClient), 21 | } 22 | 23 | /* you can probably leave these as they are */ 24 | export type AuthState = ReturnType 25 | export const getServerSideAuth = createGetServerSideAuth( 26 | serverConfig, 27 | ) 28 | -------------------------------------------------------------------------------- /starter-fauna-typescript/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /starter-fauna-typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "starter-fauna-typescript", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@happykit/auth-email": "1.0.0-alpha.4", 12 | "@types/node": "14.0.14", 13 | "@types/react": "16.9.41", 14 | "faunadb": "2.14.2", 15 | "next": "9.4.4", 16 | "react": "16.13.1", 17 | "react-dom": "16.13.1", 18 | "typescript": "3.9.5" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { AppProps } from "next/app" 2 | import { AuthProvider } from "happyauth" 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ( 6 | 7 | 8 | 9 | ) 10 | } 11 | 12 | export default MyApp 13 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/api/auth/[...params].ts: -------------------------------------------------------------------------------- 1 | import { createAuthRouteHandler } from "@happykit/auth-email/api" 2 | import { publicConfig, TokenData } from "happyauth" 3 | import { serverConfig, getServerSideAuth } from "happyauth/server" 4 | 5 | // You can use the triggers to customize the server behaviour. 6 | // 7 | // Alternatively, you can completely override individual functions by creating 8 | // files for their routes /api/auth/.ts, e.g. /api/auth/login.ts 9 | export default createAuthRouteHandler({ 10 | publicConfig, 11 | serverConfig, 12 | getServerSideAuth, 13 | }) 14 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.statusCode = 200 5 | res.json({ name: 'John Doe' }) 6 | } 7 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/change-password.tsx: -------------------------------------------------------------------------------- 1 | import { ChangePassword } from "@happykit/auth-email/pages/change-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ChangePasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/confirm-account.tsx: -------------------------------------------------------------------------------- 1 | import { ConfirmAccount } from "@happykit/auth-email/pages/confirm-account" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ConfirmAccountPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/forgot-password.tsx: -------------------------------------------------------------------------------- 1 | import { ForgotPassword } from "@happykit/auth-email/pages/forgot-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ForgotPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/index.tsx: -------------------------------------------------------------------------------- 1 | // Example of how to use HappyAuth. 2 | // 3 | // You can replace your existing pages/index.tsx file this one to test 4 | // your HappyAuth setup. 5 | // 6 | // This file can be deleted. 7 | import * as React from "react" 8 | import { GetServerSideProps } from "next" 9 | import Head from "next/head" 10 | import Link from "next/link" 11 | import { useAuth } from "happyauth" 12 | import { getServerSideAuth, AuthState } from "happyauth/server" 13 | 14 | export const getServerSideProps: GetServerSideProps = async ({ req }) => { 15 | const initialAuth = getServerSideAuth(req) 16 | return { props: { initialAuth } } 17 | } 18 | 19 | const Index = (props: { initialAuth: AuthState }) => { 20 | const auth = useAuth(props.initialAuth) 21 | 22 | return ( 23 | 24 | @happykit/auth-email starter 25 | 26 | 30 | 31 |
32 |
33 |
34 |

35 | HappyAuth 36 |

37 |

38 | Demo 39 |

40 |
41 |
42 |

43 | This application was created with HappyAuth. You would replace 44 | this index page with your own application. 45 |

46 |

47 | All the authentication pages with{" "} 48 | purple buttons are set up 49 | for you already. You can keep using them, or replace them with 50 | your own! 51 |

52 |
53 | {auth.state === "signedIn" ? ( 54 |
87 | ) : ( 88 |
89 |
90 |
91 |
92 |
93 |
94 | Start with 95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 | 103 | 104 | 107 | 108 | 109 |
110 |
111 | 112 | 113 | 116 | 117 | 118 |
119 |
120 |
121 | )} 122 |
123 |
124 | 125 | ) 126 | } 127 | 128 | export default Index 129 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import { Login } from "@happykit/auth-email/pages/login" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function LoginPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/reset-password.tsx: -------------------------------------------------------------------------------- 1 | import { ResetPassword } from "@happykit/auth-email/pages/reset-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ResetPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna-typescript/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import { Signup } from "@happykit/auth-email/pages/signup" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function SignupPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna-typescript/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happykit/auth-email/f9ab5c0dc0594f046c60539c97565b0d8916e6ef/starter-fauna-typescript/public/favicon.ico -------------------------------------------------------------------------------- /starter-fauna-typescript/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /starter-fauna-typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "es5", 5 | "lib": ["dom", "dom.iterable", "esnext"], 6 | "allowJs": true, 7 | "skipLibCheck": true, 8 | "strict": false, 9 | "forceConsistentCasingInFileNames": true, 10 | "noEmit": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "jsx": "preserve" 19 | }, 20 | "exclude": ["node_modules"], 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 22 | } 23 | -------------------------------------------------------------------------------- /starter-fauna/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | 21 | # debug 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # local env files 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | -------------------------------------------------------------------------------- /starter-fauna/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) using the [starter-fauna](https://github.com/happykit/auth-email/tree/master/starter-fauna) setup. 2 | 3 | ## Setup 4 | 5 | ### Create a new FaunaDB 6 | 7 | Open [dashboard.fauna.com](https://dashboard.fauna.com/) to create a free FaunaDB instance. No credit card requried. 8 | 9 | Then use the Fauna web app to create a new "Server Key" (under "Security") and copy it. 10 | 11 | Finally, configure your FaunaDB by running the following command: 12 | 13 | ``` 14 | yarn auth-email db init 15 | ``` 16 | 17 | Copy your FaunaDB secret in when prompted. 18 | 19 | ### Environment variables 20 | 21 | Create a `.env.local` file. Next.js will load the environment variables automatically. 22 | 23 | Fill it with this content: 24 | 25 | ```bash 26 | # Your server key from fauna.com 27 | # Create a new database, 28 | # then go to "Security > New Key" 29 | # and create a new server key. 30 | FAUNA_SERVER_KEY="" 31 | # A random secret to sign your tokens. 32 | # We automatically created a random secret when creating this file. 33 | # You can keep it, or you can replace it with your own. 34 | # Note that existing users will be signed out whenever you change the secret. 35 | # 36 | # You can use "yarn auth-email random-secret" to create one. 37 | # Alternatively, you can just provide your own long random string. 38 | HAPPYAUTH_TOKEN_SECRET="" 39 | ``` 40 | 41 | ## Getting Started 42 | 43 | Now you can run the development server: 44 | 45 | ```bash 46 | npm run dev 47 | # or 48 | yarn dev 49 | ``` 50 | 51 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 52 | 53 | You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file. 54 | 55 | ## Email 56 | 57 | Out of the box, HappyAuth is configured to log all mails to the server console instead of sending them. You can provide your the triggers in `pages/api/auth/[...params].js` with your own functions to start sending real mails. 58 | 59 | ## Resources 60 | 61 | - [HappyKit site](https://happykit.dev/) 62 | - [Full documentation](https://docs.happykit.dev/) 63 | - [Repo](https://github.com/happykit/auth-email/) 64 | -------------------------------------------------------------------------------- /starter-fauna/fauna-client.js: -------------------------------------------------------------------------------- 1 | import faunadb from "faunadb" 2 | 3 | export const faunaClient = new faunadb.Client({ 4 | secret: process.env.FAUNA_SERVER_KEY, 5 | }) 6 | 7 | export const q = faunadb.query 8 | -------------------------------------------------------------------------------- /starter-fauna/happyauth/index.js: -------------------------------------------------------------------------------- 1 | import { createUseAuth, AuthProvider } from "@happykit/auth-email" 2 | 3 | export const publicConfig = { 4 | baseUrl: (() => { 5 | if (process.env.VERCEL_GITHUB_COMMIT_REF === "master") 6 | return process.env.PRODUCTION_BASE_URL 7 | if (process.env.NODE_ENV === "production") 8 | return `https://${process.env.VERCEL_URL}` 9 | return "http://localhost:3000" 10 | })(), 11 | identityProviders: {}, 12 | // Possible configuration: 13 | // redirects: { 14 | // afterSignIn: "/?afterSigIn=true", 15 | // afterSignOut: "/?afterSignOut=true", 16 | // afterChangePassword: "/?afterChangePassword=true", 17 | // afterResetPassword: "/?afterResetPassword=true", 18 | // }, 19 | } 20 | 21 | /* you can probably leave these as they are */ 22 | export { AuthProvider } 23 | export const useAuth = createUseAuth(publicConfig) 24 | -------------------------------------------------------------------------------- /starter-fauna/happyauth/server.js: -------------------------------------------------------------------------------- 1 | import { 2 | createGetServerSideAuth, 3 | sendConfirmAccountMailToConsole, 4 | sendForgotPasswordMailToConsole, 5 | createFaunaEmailDriver, 6 | } from "@happykit/auth-email/api" 7 | import { faunaClient } from "fauna-client" 8 | 9 | export const serverConfig = { 10 | tokenSecret: process.env.HAPPYAUTH_TOKEN_SECRET, 11 | cookieName: "happyauth", 12 | secure: process.env.NODE_ENV === "production", 13 | identityProviders: {}, 14 | triggers: { 15 | sendConfirmAccountMail: sendConfirmAccountMailToConsole, 16 | sendForgotPasswordMail: sendForgotPasswordMailToConsole, 17 | }, 18 | driver: createFaunaEmailDriver(faunaClient), 19 | } 20 | 21 | /* you can probably leave these as they are */ 22 | export const getServerSideAuth = createGetServerSideAuth(serverConfig) 23 | -------------------------------------------------------------------------------- /starter-fauna/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "." 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /starter-fauna/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "starter-fauna", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@happykit/auth-email": "1.0.0-alpha.4", 12 | "next": "9.4.4", 13 | "react": "16.13.1", 14 | "react-dom": "16.13.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /starter-fauna/pages/_app.js: -------------------------------------------------------------------------------- 1 | import { AuthProvider } from "happyauth" 2 | 3 | function MyApp({ Component, pageProps }) { 4 | return ( 5 | 6 | 7 | 8 | ) 9 | } 10 | 11 | export default MyApp 12 | -------------------------------------------------------------------------------- /starter-fauna/pages/api/auth/[...params].js: -------------------------------------------------------------------------------- 1 | import { createAuthRouteHandler } from "@happykit/auth-email/api" 2 | import { publicConfig } from "happyauth" 3 | import { serverConfig, getServerSideAuth } from "happyauth/server" 4 | 5 | // You can use the triggers to customize the server behaviour. 6 | // 7 | // Alternatively, you can completely override individual functions by creating 8 | // files for their routes /api/auth/.ts, e.g. /api/auth/login.ts 9 | export default createAuthRouteHandler({ 10 | publicConfig, 11 | serverConfig, 12 | getServerSideAuth, 13 | }) 14 | -------------------------------------------------------------------------------- /starter-fauna/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.statusCode = 200 5 | res.json({ name: 'John Doe' }) 6 | } 7 | -------------------------------------------------------------------------------- /starter-fauna/pages/change-password.js: -------------------------------------------------------------------------------- 1 | import { ChangePassword } from "@happykit/auth-email/pages/change-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ChangePasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna/pages/confirm-account.js: -------------------------------------------------------------------------------- 1 | import { ConfirmAccount } from "@happykit/auth-email/pages/confirm-account" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ConfirmAccountPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna/pages/forgot-password.js: -------------------------------------------------------------------------------- 1 | import { ForgotPassword } from "@happykit/auth-email/pages/forgot-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ForgotPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna/pages/index.js: -------------------------------------------------------------------------------- 1 | // Example of how to use HappyAuth. 2 | // 3 | // You can replace your existing pages/index.tsx file this one to test 4 | // your HappyAuth setup. 5 | // 6 | // This file can be deleted. 7 | import * as React from "react" 8 | import Head from "next/head" 9 | import Link from "next/link" 10 | import { useAuth } from "happyauth" 11 | import { getServerSideAuth } from "happyauth/server" 12 | 13 | export const getServerSideProps = async ({ req }) => { 14 | const initialAuth = getServerSideAuth(req) 15 | return { props: { initialAuth } } 16 | } 17 | 18 | const Index = (props) => { 19 | const auth = useAuth(props.initialAuth) 20 | 21 | return ( 22 | 23 | @happykit/auth-email starter 24 | 25 | 29 | 30 |
31 |
32 |
33 |

34 | HappyAuth 35 |

36 |

37 | Demo 38 |

39 |
40 |
41 |

42 | This pink page was created automatically, so you can explore 43 | HappyAuth. You would replace this page with your own application. 44 |

45 |

46 | All the authentication pages with{" "} 47 | purple buttons are set up 48 | for you already. You can keep using them, or replace them with 49 | your own! 50 |

51 |
52 | {auth.state === "signedIn" ? ( 53 |
86 | ) : ( 87 |
88 |
89 |
90 |
91 |
92 |
93 | Start with 94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 | 102 | 103 | 106 | 107 | 108 |
109 |
110 | 111 | 112 | 115 | 116 | 117 |
118 |
119 |
120 | )} 121 |
122 |
123 | 124 | ) 125 | } 126 | 127 | export default Index 128 | 129 | happykit.dev auth-email demo 130 | -------------------------------------------------------------------------------- /starter-fauna/pages/login.js: -------------------------------------------------------------------------------- 1 | import { Login } from "@happykit/auth-email/pages/login" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function LoginPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna/pages/reset-password.js: -------------------------------------------------------------------------------- 1 | import { ResetPassword } from "@happykit/auth-email/pages/reset-password" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function ResetPasswordPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna/pages/signup.js: -------------------------------------------------------------------------------- 1 | import { Signup } from "@happykit/auth-email/pages/signup" 2 | import { publicConfig, useAuth } from "happyauth" 3 | 4 | export default function SignupPage() { 5 | const auth = useAuth() 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /starter-fauna/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/happykit/auth-email/f9ab5c0dc0594f046c60539c97565b0d8916e6ef/starter-fauna/public/favicon.ico -------------------------------------------------------------------------------- /starter-fauna/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | --------------------------------------------------------------------------------