├── .github └── workflows │ ├── firebase-deploy.yml │ └── nextjs-test.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── STEPS.md ├── firebase ├── .gitignore ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── package.json └── yarn.lock ├── nextjs ├── .eslintrc.json ├── .gitignore ├── README.md ├── __tests__ │ ├── backend │ │ ├── login.test.ts │ │ ├── logout.test.ts │ │ ├── resetPassword.test.ts │ │ ├── sendEmailVerification.test.ts │ │ └── signup.test.ts │ ├── index.test.tsx │ ├── login │ │ └── index.test.tsx │ ├── logout │ │ └── index.test.tsx │ ├── profile │ │ └── index.test.tsx │ ├── resetpassword │ │ └── index.test.tsx │ ├── signup │ │ ├── checkinbox.test.tsx │ │ └── index.test.tsx │ ├── snapshot.tsx │ ├── todo │ │ └── index.test.tsx │ └── util │ │ └── mockContext.ts ├── jest.config.js ├── jest.setup.js ├── next.config.js ├── package.json ├── postcss.config.js ├── public │ ├── example-profile-image.jpg │ ├── favicon.png │ ├── favicon.svg │ ├── icons │ │ └── trash-can.svg │ ├── landingpage1.jpg │ ├── logo.png │ └── logo.svg ├── src │ ├── auth │ │ └── useAuthState.ts │ ├── backend │ │ ├── Backend.ts │ │ ├── IBackend.ts │ │ ├── Login.ts │ │ ├── Logout.ts │ │ ├── Profile.ts │ │ ├── ResetPassword.ts │ │ ├── SendEmailVerification.ts │ │ ├── ServerSideUtil.ts │ │ ├── Signup.ts │ │ └── UserItem.ts │ ├── common │ │ └── util.ts │ ├── components │ │ ├── Alert.tsx │ │ ├── FieldErrorAlert.tsx │ │ ├── HeroSectionWithImage.tsx │ │ ├── Layout.tsx │ │ ├── LoginForm.tsx │ │ ├── Nav.tsx │ │ ├── ProfileForm.tsx │ │ ├── RequiresLoginNotice.tsx │ │ ├── ResetPasswordForm.tsx │ │ ├── SignupForm.tsx │ │ └── Toasts.tsx │ ├── context │ │ └── Context.ts │ ├── firebase │ │ ├── config.ts │ │ ├── errorCodes.ts │ │ └── init.ts │ ├── index.d.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api │ │ │ └── hello.ts │ │ ├── index.tsx │ │ ├── login │ │ │ └── index.tsx │ │ ├── profile │ │ │ └── index.tsx │ │ ├── resetpassword │ │ │ └── index.tsx │ │ ├── signup │ │ │ ├── checkinbox.tsx │ │ │ └── index.tsx │ │ └── todos │ │ │ └── index.tsx │ └── styles │ │ ├── Home.module.css │ │ ├── globals.css │ │ └── index.css ├── tailwind.config.js ├── tsconfig.json └── yarn.lock └── readmeassets ├── firestarter-screenshot-1-2.png ├── firestarter-screenshot-1.png ├── firestarter-screenshot-2.png ├── firestarter-screenshot-3.png └── logo.png /.github/workflows/firebase-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Firebase Rules and Functions 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | defaults: 8 | run: 9 | working-directory: firebase 10 | 11 | jobs: 12 | build: 13 | name: Build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Repo 17 | uses: actions/checkout@main 18 | - name: Install Dependencies 19 | run: yarn install --frozen-lockfile 20 | - name: Archive Production Artifact 21 | uses: actions/upload-artifact@v3 22 | with: 23 | name: dist 24 | path: | 25 | firebase 26 | !./firebase/node_modules 27 | deploy: 28 | name: Deploy 29 | needs: build 30 | runs-on: ubuntu-latest 31 | environment: Production 32 | steps: 33 | - name: Checkout Repo 34 | uses: actions/checkout@main 35 | - name: Download Artifact 36 | uses: actions/download-artifact@v3 37 | with: 38 | name: dist 39 | path: . 40 | - name: Deploy to Firebase 41 | uses: w9jds/firebase-action@v11.22.0 42 | with: 43 | args: deploy 44 | env: 45 | GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }} 46 | PROJECT_ID: ${{ vars.PROJECT_ID }} 47 | -------------------------------------------------------------------------------- /.github/workflows/nextjs-test.yml: -------------------------------------------------------------------------------- 1 | name: Tests (nextjs) 2 | 3 | on: 4 | push: 5 | 6 | defaults: 7 | run: 8 | working-directory: nextjs 9 | 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: 18.x 21 | - run: yarn install --frozen-lockfile 22 | - run: yarn build 23 | - run: yarn test:ci 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .firebaserc -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "css.lint.unknownAtRules": "ignore" 3 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Martin Capodici 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Firestarter Logo](readmeassets/logo.png?raw=true "Title") 2 | 3 | # Get your app idea off the ground - darn fast! 4 | 5 | Start off with a site like this: 6 | 7 | ![Screenshots from 25 March 2023](readmeassets/firestarter-screenshot-1-2.png?raw=true "Title") 8 | 9 | ... with Sign Up, Log in, Reset Password, Email Verification, CRUD template, modern design, unit tests (if you want to use them), and clean code. Just add your million dollar idea! 10 | 11 | 12 | ## Firestarter is: 13 | 14 | 📦 A starter kit for quickly building your next app. Side project or startup! 15 | 16 | 🎨 That uses Tailwind for clean and easily adapatable design and style. 17 | 18 | 🔥 That uses Firebase for a Serverless backend. No messing with machines! 19 | 20 | 🚀 That uses NextJS, for convenient packaging and deployment of code and pages. 21 | 22 | ⚛️ Uses ReactJS for responsive UI. 23 | 24 | 🆓 Free to use! MIT Licensed. 25 | 26 | ### How it works: 27 | 28 | 1. Create your own [Firebase](https://firebase.google.com/) account and project. 29 | 2. Sign up for free hosting at [Vercel](https://vercel.com/dashboard). 30 | 3. Fork this repo, and clone to your machine. 31 | 4. Set up the environment variables based on your Firebase account. 32 | 5. Link your forked repo to Vercel. 33 | 6. Well done you now have your app running in production! 34 | 7. Buy a custom domain name and link to your Vercel account, if you want to. 35 | 8. Customize by adding your own code and features, and/or removing any features you don't want from our examples. 36 | 37 | For detailed steps, see the [Setup docs](https://docs.firestarterapp.com/setup/) 38 | 39 | ## Firestarter is ideal for: 40 | 41 | * Side Projects 42 | * Open Source projects 43 | * Startups 44 | * Internal Tools 45 | * Any time you want to iterate on an idea quickly... 46 | 47 | ## Demo 48 | You can see the latest deployed demo here https://demo.firestarterapp.com you can sign up for an account and add some TODOs! Be aware that the data can be destroyed at any time, so only use it for playing. 49 | 50 | ## Setup 51 | 52 | If you want to start using and developing off Firestarter, see the [Setup docs](https://docs.firestarterapp.com/setup/) 53 | 54 | ## Documentation 55 | 56 | Once you are setup, you can read the [Documentation](https://docs.firestarterapp.com) to understand how the code works and see examples of how you can extend Firestarter to do what you need. 57 | 58 | ## Screenshots 59 | 60 | ![Screenshot of home page from 25 March 2023](readmeassets/firestarter-screenshot-1.png?raw=true "Title") 61 | ![Screenshot of todos page from 25 March 2023](readmeassets/firestarter-screenshot-2.png?raw=true "Title") 62 | ![Screenshot of todos page (mobile) from 25 March 2023](readmeassets/firestarter-screenshot-3.png?raw=true "Title") 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /STEPS.md: -------------------------------------------------------------------------------- 1 | # Steps taken to create this repo: 2 | 3 | * Created new repo in Github, MIT license. 4 | * Create NextJS app: 5 | 6 | ``` 7 | mkdir nextjs 8 | cd nextjs 9 | npx create-next-app@latest 10 | ``` 11 | 12 | Version: `create-next-app@13.1.6` 13 | 14 | Answers given: 15 | 16 | > ✔ What is your project named? … firestarter-nextjs 17 | > ✔ Would you like to use TypeScript with this project? … Yes 18 | > ✔ Would you like to use ESLint with this project? … Yes 19 | > ✔ Would you like to use `src/` directory with this project? … Yes 20 | > ✔ Would you like to use experimental `app/` directory with this project? … No 21 | > ✔ What import alias would you like configured? … @/* 22 | 23 | * Set up project in Vercel: 24 | * Log into Vercel 25 | * Add a new project 26 | * Use this repo 27 | * Choose root directory `firestarter-nextjs` 28 | * Click Deploy 29 | 30 | This will deploy off main, so use branches to avoid deployments. 31 | 32 | Note: this got deployed to `https://firestarter-three.vercel.app/` in my case. 33 | 34 | No code changes to repo. 35 | 36 | * Add testing 37 | 38 | ``` 39 | yarn add --dev @testing-library/jest-dom @testing-library/react @testing-library/user-event @types/testing-library__jest-dom jest jest-environment-jsdom 40 | ``` 41 | 42 | For good measure rearrange `package.json` so things like `typescript` are in `devDependencies` 43 | 44 | Copied various files from https://github.com/vercel/next.js/blob/canary/examples/with-jest/, adapting them. See [this PR](https://github.com/mcapodici/firestarter/pull/2) for details. 45 | 46 | Removed test that only passes for old next.js welcome page. Since the welcome page will go, no need to really get that working. 47 | 48 | * Add testing to Github action (.github/workflows/firestarter-nextjs-test.yml) 49 | * Add tailwind support 50 | 51 | * Create a Firebase Project 52 | * Copy the JSON of all cliend-side variables to these environment variables: 53 | 54 | ``` 55 | NEXT_PUBLIC_FIREBASE_API_KEY 56 | NEXT_PUBLIC_FIREBASE_AUTHDOMAIN 57 | NEXT_PUBLIC_FIREBASE_PROJECTID 58 | NEXT_PUBLIC_FIREBASE_STORAGEBUCKET 59 | NEXT_PUBLIC_FIREBASE_SENDERID 60 | NEXT_PUBLIC_FIREBASE_APPID 61 | ``` 62 | 63 | * In codespaces this can be set under {url_to_repo}/settings/secrets/codespaces/new 64 | * In vercel, se this under {url_to_project}/settings/environment-variables 65 | 66 | Note that none of this configuration is secret, it will be available in the bundled JS. 67 | 68 | * Enable Auth in Firebase portal - just Email/Password for now. 69 | * Enable Firestore in Firebase portal - https://console.firebase.google.com/project/fire-starter-demo/firestore 70 | 71 | Github Firebase Deployment Integration 72 | 73 | * Added permissions to project IAM role for Firebase Rules Admin and Service Account User (see https://github.com/marketplace/actions/github-action-for-firebase?version=v11.22.0) 74 | * Added environment secret GCP_SA_KEY and environment variable PROJECT_ID as per (see https://github.com/marketplace/actions/github-action-for-firebase?version=v11.22.0) 75 | 76 | -------------------------------------------------------------------------------- /firebase/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | 10 | # Firebase cache 11 | .firebase/ 12 | 13 | # Firebase config 14 | 15 | # Uncomment this if you'd like others to create their own Firebase project. 16 | # For a team working on the same Firebase project(s), it is recommended to leave 17 | # it commented so all members can deploy to the same project(s) in .firebaserc. 18 | # .firebaserc 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (http://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | -------------------------------------------------------------------------------- /firebase/firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /firebase/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /firebase/firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | 3 | // Help from https://stackoverflow.com/questions/52993123/firestore-security-rules-allow-user-to-create-doc-only-if-new-doc-id-is-same-as 4 | 5 | service cloud.firestore { 6 | 7 | function validateUser(user) { 8 | return user.keys().hasAll(['firstName', 'lastName']) && 9 | user.firstName is string && 10 | user.lastName is string && 11 | user.firstName.size() > 0 && 12 | user.firstName.size() <= 100 && 13 | user.lastName.size() > 0 && 14 | user.lastName.size() <= 100 15 | } 16 | 17 | function validateTodo(todo) { 18 | return todo.keys().hasAll(['uid', 'title', 'done']) && 19 | todo.uid is string && 20 | todo.title is string && 21 | todo.done is bool && 22 | todo.uid.size() > 0 && 23 | todo.title.size() > 0 && 24 | todo.title.size() <= 100 25 | } 26 | 27 | match /databases/{database}/documents { 28 | match /users/{userId} { 29 | // Allow users to read their own profile (doc id same as user id) 30 | allow read: if request.auth != null && request.auth.token.email_verified && request.auth.uid == userId; 31 | allow create, update: if request.auth != null && request.auth.token.email_verified && request.auth.uid == userId && validateUser(request.resource.data); 32 | } 33 | 34 | match /todo/{todo} { 35 | allow read, delete: if request.auth != null && request.auth.token.email_verified && request.auth.uid == resource.data.uid; 36 | allow create: if request.auth != null && request.auth.token.email_verified && request.auth.uid == request.resource.data.uid && validateTodo(request.resource.data); 37 | allow update: if request.auth != null && request.auth.token.email_verified && request.auth.uid == resource.data.uid && request.auth.uid == request.resource.data.uid && validateTodo(request.resource.data); 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /firebase/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firebase", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "devDependencies": { 7 | "firebase-tools": "^11.23.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /nextjs/.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 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /nextjs/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). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 18 | 19 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 20 | 21 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 22 | 23 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 24 | 25 | ## Learn More 26 | 27 | To learn more about Next.js, take a look at the following resources: 28 | 29 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 30 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 31 | 32 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 33 | 34 | ## Deploy on Vercel 35 | 36 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 37 | 38 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 39 | -------------------------------------------------------------------------------- /nextjs/__tests__/backend/login.test.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from '@/backend/Backend'; 2 | import { AUTH_INVALID_EMAIL, AUTH_USER_DISABLED, AUTH_USER_NOT_FOUND, AUTH_WRONG_PASSWORD } from '@/firebase/errorCodes'; 3 | import { FirebaseError } from '@firebase/util'; 4 | import { UserCredential } from 'firebase/auth'; 5 | 6 | const signInWithEmailAndPassword = jest.fn(); 7 | const login = new Backend().login; 8 | 9 | jest.mock('firebase/auth', 10 | () => ({ 11 | signInWithEmailAndPassword: (...args: any[]) => signInWithEmailAndPassword(...args) 12 | })); 13 | 14 | describe('Login Function', () => { 15 | beforeEach(() => { 16 | jest.resetAllMocks(); 17 | }) 18 | 19 | it('Works with successful login', async () => { 20 | let credential: UserCredential; 21 | const mockCredentials = { user: { uid: "123", emailVerified: true } } as any as UserCredential; 22 | signInWithEmailAndPassword.mockResolvedValue(mockCredentials); 23 | const result = await login('ben@example.com', 'fred1234!'); 24 | expect(result).toEqual({ result: 'success', uid: '123', emailVerified: true }); 25 | expect(signInWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred1234!'); 26 | }); 27 | 28 | describe('handles error code from Firebase:', () => { 29 | it(AUTH_INVALID_EMAIL, async () => { 30 | jest.spyOn(console, 'error').mockImplementation(); 31 | signInWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_INVALID_EMAIL, '')); 32 | const result = await login('ben@example.com', 'fred'); 33 | expect(result).toEqual({ result: 'user-not-found' }); 34 | expect(signInWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 35 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_INVALID_EMAIL, '')); 36 | }); 37 | 38 | it(AUTH_USER_NOT_FOUND, async () => { 39 | jest.spyOn(console, 'error').mockImplementation(); 40 | signInWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_USER_NOT_FOUND, '')); 41 | const result = await login('ben@example.com', 'fred'); 42 | expect(result).toEqual({ result: 'user-not-found' }); 43 | expect(signInWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 44 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_USER_NOT_FOUND, '')); 45 | }); 46 | 47 | it(AUTH_USER_DISABLED, async () => { 48 | jest.spyOn(console, 'error').mockImplementation(); 49 | signInWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_USER_DISABLED, '')); 50 | const result = await login('ben@example.com', 'fred'); 51 | expect(result).toEqual({ result: 'user-disabled' }); 52 | expect(signInWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 53 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_USER_DISABLED, '')); 54 | }); 55 | 56 | it(AUTH_WRONG_PASSWORD, async () => { 57 | jest.spyOn(console, 'error').mockImplementation(); 58 | signInWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_WRONG_PASSWORD, '')); 59 | const result = await login('ben@example.com', 'fred'); 60 | expect(result).toEqual({ result: 'wrong-password' }); 61 | expect(signInWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 62 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_WRONG_PASSWORD, '')); 63 | }); 64 | }); 65 | 66 | it('Handles an unexpected result from Firebase', async () => { 67 | jest.spyOn(console, 'error').mockImplementation(); 68 | signInWithEmailAndPassword.mockRejectedValue(new FirebaseError('auth/something-unencountered', 'Cosmic Radiation')); 69 | const result = await login('ben@example.com', 'fred'); 70 | expect(result).toEqual({ result: 'fail', message: 'Cosmic Radiation' }); 71 | expect(signInWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 72 | expect(console.error).toBeCalledWith(new FirebaseError('auth/something-unencountered', 'Cosmic Radiation')); 73 | }); 74 | 75 | it('Handles an unexpected error entirely', async () => { 76 | jest.spyOn(console, 'error').mockImplementation(); 77 | signInWithEmailAndPassword.mockRejectedValue('xyz'); 78 | const result = await login('ben@example.com', 'fred'); 79 | expect(result).toEqual({ result: 'fail', message: '' }); 80 | expect(signInWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 81 | expect(console.error).toBeCalledWith('xyz'); 82 | }); 83 | }); -------------------------------------------------------------------------------- /nextjs/__tests__/backend/logout.test.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from '@/backend/Backend'; 2 | 3 | const signOut = jest.fn(); 4 | const logout = new Backend().logout; 5 | 6 | jest.mock('firebase/auth', 7 | () => ({ 8 | signOut: (...args: any[]) => signOut(...args) 9 | })); 10 | 11 | describe('Logout Function', () => { 12 | beforeEach(() => { 13 | jest.resetAllMocks(); 14 | }) 15 | 16 | it('Works with successful logout', async () => { 17 | signOut.mockResolvedValue(undefined); 18 | const result = await logout(); 19 | expect(result.result).toBe('success'); 20 | expect(signOut).toBeCalled(); 21 | }); 22 | 23 | it('Handles an unexpected error entirely', async () => { 24 | jest.spyOn(console, 'error').mockImplementation(); 25 | signOut.mockRejectedValue('xyz'); 26 | const result = await logout(); 27 | expect(result.result).toBe('fail'); 28 | expect(signOut).toBeCalled(); 29 | expect(console.error).toBeCalledWith('xyz'); 30 | }); 31 | }); -------------------------------------------------------------------------------- /nextjs/__tests__/backend/resetPassword.test.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from '@/backend/Backend'; 2 | import { AUTH_INVALID_EMAIL, AUTH_USER_DISABLED, AUTH_USER_NOT_FOUND, AUTH_WRONG_PASSWORD } from '@/firebase/errorCodes'; 3 | import { FirebaseError } from '@firebase/util'; 4 | import { UserCredential } from 'firebase/auth'; 5 | 6 | const sendPasswordResetEmail = jest.fn(); 7 | const resetPassword = new Backend().resetPassword; 8 | 9 | jest.mock('firebase/auth', 10 | () => ({ 11 | sendPasswordResetEmail: (...args: any[]) => sendPasswordResetEmail(...args) 12 | })); 13 | 14 | describe('Reset Password Function', () => { 15 | beforeEach(() => { 16 | jest.resetAllMocks(); 17 | }) 18 | 19 | it('Works with successful login', async () => { 20 | sendPasswordResetEmail.mockResolvedValue(undefined); 21 | const result = await resetPassword('ben@example.com'); 22 | expect(result).toEqual({ result: 'success' }); 23 | expect(sendPasswordResetEmail).toBeCalledWith(undefined, 'ben@example.com'); 24 | }); 25 | 26 | describe('handles error code from Firebase:', () => { 27 | it(AUTH_INVALID_EMAIL, async () => { 28 | jest.spyOn(console, 'error').mockImplementation(); 29 | sendPasswordResetEmail.mockRejectedValue(new FirebaseError(AUTH_INVALID_EMAIL, '')); 30 | const result = await resetPassword('ben@example.com'); 31 | expect(result).toEqual({ result: 'user-not-found' }); 32 | expect(sendPasswordResetEmail).toBeCalledWith(undefined, 'ben@example.com'); 33 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_INVALID_EMAIL, '')); 34 | }); 35 | 36 | it(AUTH_USER_NOT_FOUND, async () => { 37 | jest.spyOn(console, 'error').mockImplementation(); 38 | sendPasswordResetEmail.mockRejectedValue(new FirebaseError(AUTH_USER_NOT_FOUND, '')); 39 | const result = await resetPassword('ben@example.com'); 40 | expect(result).toEqual({ result: 'user-not-found' }); 41 | expect(sendPasswordResetEmail).toBeCalledWith(undefined, 'ben@example.com'); 42 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_USER_NOT_FOUND, '')); 43 | }); 44 | }); 45 | 46 | it('Handles an unexpected result from Firebase', async () => { 47 | jest.spyOn(console, 'error').mockImplementation(); 48 | sendPasswordResetEmail.mockRejectedValue(new FirebaseError('auth/something-unencountered', 'Cosmic Radiation')); 49 | const result = await resetPassword('ben@example.com'); 50 | expect(result).toEqual({ result: 'fail', message: 'Cosmic Radiation' }); 51 | expect(sendPasswordResetEmail).toBeCalledWith(undefined, 'ben@example.com'); 52 | expect(console.error).toBeCalledWith(new FirebaseError('auth/something-unencountered', 'Cosmic Radiation')); 53 | }); 54 | 55 | it('Handles an unexpected error entirely', async () => { 56 | jest.spyOn(console, 'error').mockImplementation(); 57 | sendPasswordResetEmail.mockRejectedValue('xyz'); 58 | const result = await resetPassword('ben@example.com'); 59 | expect(result).toEqual({ result: 'fail', message: '' }); 60 | expect(sendPasswordResetEmail).toBeCalledWith(undefined, 'ben@example.com'); 61 | expect(console.error).toBeCalledWith('xyz'); 62 | }); 63 | }); -------------------------------------------------------------------------------- /nextjs/__tests__/backend/sendEmailVerification.test.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from "@/backend/Backend"; 2 | import { FirebaseError } from "@firebase/util"; 3 | import { User } from "firebase/auth"; 4 | 5 | const sendEmailVerificationFirebase = jest.fn(); 6 | const sendEmailVerification = new Backend().sendEmailVerification; 7 | 8 | jest.mock("firebase/auth", () => ({ 9 | sendEmailVerification: (...args: any[]) => 10 | sendEmailVerificationFirebase(...args), 11 | })); 12 | 13 | describe("Reset Password Function", () => { 14 | beforeEach(() => { 15 | jest.resetAllMocks(); 16 | }); 17 | 18 | it("Works with successful result", async () => { 19 | sendEmailVerificationFirebase.mockResolvedValue(undefined); 20 | const result = await sendEmailVerification({ uid: "123" } as User); 21 | expect(result).toEqual({ result: "success" }); 22 | expect(sendEmailVerificationFirebase).toBeCalledWith({ uid: "123" } as User); 23 | }); 24 | 25 | it("Handles an unexpected result from Firebase", async () => { 26 | jest.spyOn(console, 'error').mockImplementation(); 27 | sendEmailVerificationFirebase.mockRejectedValue( 28 | new FirebaseError("auth/something-unencountered", "Cosmic Radiation") 29 | ); 30 | const result = await sendEmailVerification({ uid: "123" } as User); 31 | expect(result).toEqual({ result: "fail", message: "Cosmic Radiation" }); 32 | expect(sendEmailVerificationFirebase).toBeCalledWith({ uid: "123" } as User); 33 | expect(console.error).toBeCalledWith(new FirebaseError('auth/something-unencountered', 'Cosmic Radiation')); 34 | }); 35 | 36 | it("Handles an unexpected error entirely", async () => { 37 | jest.spyOn(console, 'error').mockImplementation(); 38 | sendEmailVerificationFirebase.mockRejectedValue("xyz"); 39 | const result = await sendEmailVerification({ uid: "123" } as User); 40 | expect(result).toEqual({ result: "fail", message: "" }); 41 | expect(sendEmailVerificationFirebase).toBeCalledWith({ uid: "123" } as User); 42 | expect(console.error).toBeCalledWith('xyz'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /nextjs/__tests__/backend/signup.test.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from '@/backend/Backend'; 2 | import { AUTH_EMAIL_ALREADY_IN_USE, AUTH_INVALID_EMAIL, AUTH_OPERATION_NOT_ALLOWED, AUTH_WEAK_PASSWORD } from '@/firebase/errorCodes'; 3 | import { FirebaseError } from '@firebase/util'; 4 | import { UserCredential } from 'firebase/auth'; 5 | 6 | const createUserWithEmailAndPassword = jest.fn(); 7 | const sendEmailVerification = jest.fn(); 8 | const signup = new Backend().signup; 9 | 10 | jest.mock('firebase/auth', 11 | () => ({ 12 | createUserWithEmailAndPassword: (...args: any[]) => createUserWithEmailAndPassword(...args), 13 | sendEmailVerification: (...args: any[]) => sendEmailVerification(...args) 14 | })); 15 | 16 | const setDocMock = jest.fn(); 17 | jest.mock('firebase/firestore', 18 | () => ({ 19 | doc: () => null, 20 | setDoc: (...args: any[]) => setDocMock(args) 21 | })); 22 | 23 | describe('Signup Function', () => { 24 | beforeEach(() => { 25 | jest.resetAllMocks(); 26 | }) 27 | 28 | it('Works with successful signup', async () => { 29 | const mockCredentials = { user: { uid: "123" } } as any as UserCredential; 30 | createUserWithEmailAndPassword.mockResolvedValue(mockCredentials); 31 | setDocMock.mockResolvedValue(undefined); 32 | sendEmailVerification.mockResolvedValue(undefined); 33 | const result = await signup('ben@example.com', 'fred1234!', { firstName: 'ben', lastName: 'neb' }); 34 | expect(result).toEqual({ result: 'success', uid: '123' }); 35 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred1234!'); 36 | expect(sendEmailVerification).toBeCalledWith(mockCredentials.user); 37 | }); 38 | 39 | it('Works with partial successful signup', async () => { 40 | jest.spyOn(console, 'error').mockImplementation(); 41 | const mockCredentials = { user: { uid: "123" } } as any as UserCredential; 42 | createUserWithEmailAndPassword.mockResolvedValue(mockCredentials); 43 | setDocMock.mockRejectedValue('saving data about user is not working for some reason'); 44 | sendEmailVerification.mockResolvedValue(undefined); 45 | const result = await signup('ben@example.com', 'fred1234!', { firstName: 'ben', lastName: 'neb' }); 46 | expect(result).toEqual({ result: 'partial-success', uid: '123' }); 47 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred1234!'); 48 | expect(sendEmailVerification).toBeCalledWith(mockCredentials.user); 49 | expect(console.error).toBeCalledWith('saving data about user is not working for some reason'); 50 | }); 51 | 52 | describe('handles error code from Firebase:', () => { 53 | it(AUTH_WEAK_PASSWORD, async () => { 54 | jest.spyOn(console, 'error').mockImplementation(); 55 | createUserWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_WEAK_PASSWORD, '')); 56 | const result = await signup('ben@example.com', 'fred', { firstName: 'ben', lastName: 'neb' }); 57 | expect(result).toEqual({ result: 'weak-password' }); 58 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 59 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_WEAK_PASSWORD, '')); 60 | }); 61 | 62 | it(AUTH_OPERATION_NOT_ALLOWED, async () => { 63 | jest.spyOn(console, 'error').mockImplementation(); 64 | createUserWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_OPERATION_NOT_ALLOWED, '')); 65 | const result = await signup('ben@example.com', 'fred', { firstName: 'ben', lastName: 'neb' }); 66 | expect(result).toEqual({ result: 'accounts-not-enabled' }); 67 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 68 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_OPERATION_NOT_ALLOWED, '')); 69 | }); 70 | 71 | it(AUTH_EMAIL_ALREADY_IN_USE, async () => { 72 | jest.spyOn(console, 'error').mockImplementation(); 73 | createUserWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_EMAIL_ALREADY_IN_USE, '')); 74 | const result = await signup('ben@example.com', 'fred', { firstName: 'ben', lastName: 'neb' }); 75 | expect(result).toEqual({ result: 'email-in-use' }); 76 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 77 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_EMAIL_ALREADY_IN_USE, '')); 78 | }); 79 | 80 | it(AUTH_INVALID_EMAIL, async () => { 81 | jest.spyOn(console, 'error').mockImplementation(); 82 | createUserWithEmailAndPassword.mockRejectedValue(new FirebaseError(AUTH_INVALID_EMAIL, '')); 83 | const result = await signup('ben@example.com', 'fred', { firstName: 'ben', lastName: 'neb' }); 84 | expect(result).toEqual({ result: 'invalid-email' }); 85 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 86 | expect(console.error).toBeCalledWith(new FirebaseError(AUTH_INVALID_EMAIL, '')); 87 | }); 88 | }); 89 | 90 | it('Handles an unexpected result from Firebase', async () => { 91 | jest.spyOn(console, 'error').mockImplementation(); 92 | createUserWithEmailAndPassword.mockRejectedValue(new FirebaseError('auth/something-unencountered', 'Cosmic Radiation')); 93 | const result = await signup('ben@example.com', 'fred', { firstName: 'ben', lastName: 'neb' }); 94 | expect(result).toEqual({ result: 'fail', message: 'Cosmic Radiation' }); 95 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 96 | expect(console.error).toBeCalledWith(new FirebaseError('auth/something-unencountered', 'Cosmic Radiation')); 97 | }); 98 | 99 | it('Handles an unexpected error entirely', async () => { 100 | jest.spyOn(console, 'error').mockImplementation(); 101 | createUserWithEmailAndPassword.mockRejectedValue('xyz'); 102 | const result = await signup('ben@example.com', 'fred', { firstName: 'ben', lastName: 'neb' }); 103 | expect(result).toEqual({ result: 'fail', message: '' }); 104 | expect(createUserWithEmailAndPassword).toBeCalledWith(undefined, 'ben@example.com', 'fred'); 105 | expect(console.error).toBeCalledWith('xyz'); 106 | }); 107 | }); -------------------------------------------------------------------------------- /nextjs/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { fireEvent, getByText, render, screen } from '@testing-library/react' 2 | import Home from '@/pages/index' 3 | import mockRouter from 'next-router-mock'; 4 | import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; 5 | 6 | describe('Home', () => { 7 | beforeEach(() => render()); 8 | 9 | it('shows the hero text', () => { 10 | const heading = screen.getByRole('heading'); 11 | expect(heading).toHaveTextContent('The best offer for your business'); 12 | }); 13 | it('shows the hero image', () => { 14 | const image = screen.getByTestId('hero-image'); 15 | expect(image.getAttribute('src')).toBeDefined(); 16 | expect(image).toBeVisible(); 17 | }); 18 | }); 19 | 20 | describe('Navigation', () => { 21 | beforeEach(() => render(, { wrapper: MemoryRouterProvider })); 22 | it('is shown', () => { 23 | const nav = screen.getByRole('navigation'); 24 | ['Todos', 'Team', 'Projects', 'Login', 'Sign up for free'].forEach(t => expect(getByText(nav, t)).toBeVisible()); 25 | }); 26 | it('signup', () => { 27 | const signup = screen.getByText('Sign up for free'); 28 | fireEvent.click(signup); 29 | expect(mockRouter.asPath).toEqual('/signup'); 30 | }) 31 | }); 32 | -------------------------------------------------------------------------------- /nextjs/__tests__/login/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { Context } from '@/context/Context'; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; 5 | import mockRouter from 'next-router-mock'; 6 | import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; 7 | import { makeMockContext } from '__tests__/util/mockContext'; 8 | import Login from '@/pages/login'; 9 | 10 | jest.mock('next/router', () => require('next-router-mock')); 11 | 12 | describe('Login', () => { 13 | 14 | const mockContext = makeMockContext(); 15 | 16 | let user: UserEvent; 17 | 18 | beforeEach(() => { 19 | user = userEvent.setup(); 20 | render( 21 | 22 | 23 | , 24 | { wrapper: MemoryRouterProvider } 25 | ) 26 | }); 27 | 28 | afterEach(() => { 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | async function fillInAllFieldsValid() { 33 | await user.type(screen.getByPlaceholderText('Email address'), 'me@them.com'); 34 | await user.type(screen.getByPlaceholderText('Password'), 'password123'); 35 | } 36 | 37 | function expectNoBackendCall() { 38 | expect(mockContext.backend.login).not.toBeCalled(); 39 | } 40 | 41 | async function submitFormAndCheckAlertText(expectedAlert: string) { 42 | await user.click(screen.getByRole('button', { name: /Log in/i })); 43 | const alerts = await screen.findAllByRole("alert"); 44 | expect(alerts).toHaveLength(1); 45 | expect(alerts[0]).toHaveTextContent(expectedAlert); 46 | } 47 | 48 | it('correct form elements shown', () => { 49 | expect(screen.getByRole("heading", { name: "Log in" })).toBeInTheDocument(); 50 | expect(screen.getByPlaceholderText('Email address')).toBeInTheDocument(); 51 | const pw = screen.getByPlaceholderText('Password'); 52 | expect(pw).toBeInTheDocument(); 53 | expect(pw.attributes.getNamedItem('type')?.value).toBe('password'); 54 | }); 55 | 56 | describe('on valid input submission', () => { 57 | describe('with success response and verified user', () => { 58 | beforeEach(async () => { 59 | mockContext.backend.login.mockResolvedValue({ result: 'success', emailVerified: true }); 60 | await fillInAllFieldsValid(); 61 | await user.click(screen.getByRole('button', { name: /Log in/i })); 62 | }) 63 | it('submits the data correctly', async () => { 64 | expect(mockContext.backend.login).toBeCalledWith("me@them.com", "password123"); 65 | }); 66 | it('redirects to the home page', async () => { 67 | expect(mockRouter.asPath).toEqual('/'); 68 | }); 69 | it('shows success alert', async () => { 70 | expect(mockContext.addToast).toBeCalledTimes(1); 71 | expect(mockContext.addToast).toBeCalledWith('You are now logged in.', 'success'); 72 | }); 73 | }); 74 | 75 | describe('with success response and unverified user', () => { 76 | beforeEach(async () => { 77 | mockContext.backend.login.mockResolvedValue({ result: 'success', emailVerified: false }); 78 | await fillInAllFieldsValid(); 79 | await user.click(screen.getByRole('button', { name: /Log in/i })); 80 | }) 81 | it('submits the data correctly', async () => { 82 | expect(mockContext.backend.login).toBeCalledWith("me@them.com", "password123"); 83 | }); 84 | it('redirects to the check inbox page', async () => { 85 | expect(mockRouter.asPath).toEqual('/signup/checkinbox'); 86 | }); 87 | it('shows warning alert', async () => { 88 | expect(mockContext.addToast).toBeCalledTimes(1); 89 | expect(mockContext.addToast).toBeCalledWith('You need to verify your email.', 'warning'); 90 | }); 91 | }); 92 | }); 93 | 94 | it('validates the email address exists', async () => { 95 | await fillInAllFieldsValid(); 96 | await user.clear(screen.getByPlaceholderText('Email address')); 97 | await submitFormAndCheckAlertText("Email address is required"); 98 | expectNoBackendCall(); 99 | }); 100 | 101 | it('validates the password exists', async () => { 102 | await fillInAllFieldsValid(); 103 | await user.clear(screen.getByPlaceholderText('Password')); 104 | await submitFormAndCheckAlertText("Password is required"); 105 | expectNoBackendCall(); 106 | }); 107 | 108 | it('validates the email format', async () => { 109 | await fillInAllFieldsValid(); 110 | await user.clear(screen.getByPlaceholderText('Email address')); 111 | await user.type(screen.getByPlaceholderText('Email address'), 'methem'); 112 | await submitFormAndCheckAlertText("Email address is invalid"); 113 | expectNoBackendCall(); 114 | }); 115 | 116 | describe('handles firebase error return code', () => { 117 | it('user-not-found', async () => { 118 | mockContext.backend.login.mockResolvedValue({ result: 'user-not-found' }); 119 | await fillInAllFieldsValid(); 120 | await submitFormAndCheckAlertText('No user exists with this email'); 121 | }); 122 | 123 | it('wrong-password', async () => { 124 | mockContext.backend.login.mockResolvedValue({ result: 'wrong-password' }); 125 | await fillInAllFieldsValid(); 126 | await submitFormAndCheckAlertText('Password is incorrect'); 127 | }); 128 | 129 | it('user-disabled', async () => { 130 | mockContext.backend.login.mockResolvedValue({ result: 'user-disabled' }); 131 | await fillInAllFieldsValid(); 132 | await submitFormAndCheckAlertText('Your login has been disabled. Please contact support for assistance.'); 133 | }); 134 | 135 | it('fail', async () => { 136 | mockContext.backend.login.mockResolvedValue({ result: 'fail' }); 137 | await fillInAllFieldsValid(); 138 | await submitFormAndCheckAlertText("Sorry there was a server problem while logging in, please try again later."); 139 | }); 140 | }); 141 | 142 | }); 143 | -------------------------------------------------------------------------------- /nextjs/__tests__/logout/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { Context } from "@/context/Context"; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; 5 | import { MemoryRouterProvider } from "next-router-mock/MemoryRouterProvider"; 6 | import { makeMockContext } from "__tests__/util/mockContext"; 7 | import Index from "@/pages/index"; 8 | import { User } from "firebase/auth"; 9 | 10 | jest.mock("next/router", () => require("next-router-mock")); 11 | 12 | describe("Logout", () => { 13 | const mockContext = makeMockContext(); 14 | 15 | let user: UserEvent; 16 | 17 | function doRender(loggedIn: boolean) { 18 | user = userEvent.setup(); 19 | mockContext.user = loggedIn ? ({ uid: "123" } as User) : undefined; 20 | render( 21 | 22 | 23 | , 24 | { wrapper: MemoryRouterProvider } 25 | ); 26 | } 27 | 28 | it("if logged in, can log out", async () => { 29 | doRender(true); 30 | mockContext.backend.logout.mockResolvedValue(undefined); 31 | await user.click(screen.getByTitle(/user menu/i)); 32 | expect(screen.getByText(/log out/i)).toBeInTheDocument(); 33 | await user.click(screen.getByText(/log out/i)); 34 | expect(mockContext.backend.logout).toBeCalled(); 35 | // Note: We can't assert that the log out button has disappeared, as this is requires a change to authentication state 36 | // (picked up by useAuthState) which is used by _app.tsx, which is not under test here. 37 | }); 38 | 39 | it("if not logged in, can't log out", async () => { 40 | doRender(false); 41 | mockContext.backend.logout.mockResolvedValue(undefined); 42 | expect(screen.queryByText(/log out/i)).toBeNull(); 43 | }); 44 | 45 | afterEach(() => { 46 | jest.clearAllMocks(); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /nextjs/__tests__/profile/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen, waitFor } from "@testing-library/react"; 2 | import { Context } from "@/context/Context"; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; 5 | import mockRouter from "next-router-mock"; 6 | import { MemoryRouterProvider } from "next-router-mock/MemoryRouterProvider"; 7 | import { makeMockContext } from "__tests__/util/mockContext"; 8 | import Profile from "@/pages/profile"; 9 | import { User } from "firebase/auth"; 10 | 11 | jest.mock("next/router", () => require("next-router-mock")); 12 | 13 | describe("Profile", () => { 14 | const mockContext = makeMockContext(); 15 | 16 | let user: UserEvent; 17 | 18 | function init(resolveGetProfile: boolean = true) { 19 | if (resolveGetProfile) { 20 | // Mocks the situation where we get an immediate response for the profile, 21 | // from the server. 22 | mockContext.backend.getProfile.mockResolvedValue({ 23 | result: "success", 24 | item: { firstName: "Anthony", lastName: "Bean" }, 25 | }); 26 | } else { 27 | // Mocks the situation where we are waiting indefinitely for the profile to 28 | // load from the server: 29 | mockContext.backend.getProfile.mockReturnValue(new Promise(() => {})); 30 | } 31 | mockRouter.setCurrentUrl("/profile"); 32 | user = userEvent.setup(); 33 | mockContext.user = { uid: "123" } as User; 34 | render( 35 | 36 | 37 | , 38 | { wrapper: MemoryRouterProvider } 39 | ); 40 | } 41 | 42 | afterEach(() => { 43 | jest.clearAllMocks(); 44 | }); 45 | 46 | async function fillInAllFieldsValid() { 47 | await waitFor(() => { 48 | expect(screen.getByPlaceholderText("First name")).toBeInTheDocument(); 49 | }); 50 | await user.clear(screen.getByPlaceholderText("First name")); 51 | await user.clear(screen.getByPlaceholderText("First name")); // Because of https://github.com/testing-library/user-event/discussions/970 52 | await user.clear(screen.getByPlaceholderText("Last name")); 53 | await user.clear(screen.getByPlaceholderText("Last name")); 54 | await user.type(screen.getByPlaceholderText("First name"), "Ben"); 55 | await user.type(screen.getByPlaceholderText("Last name"), "Neb"); 56 | } 57 | 58 | function expectNoSaveCall() { 59 | expect(mockContext.backend.setProfile).not.toBeCalled(); 60 | } 61 | 62 | async function submitFormAndCheckAlertText(expectedAlert: string) { 63 | await user.click(screen.getByText("Save")); 64 | await waitFor(async () => 65 | expect(await screen.findAllByRole("alert")).toHaveLength(1) 66 | ); 67 | const alerts = await screen.findAllByRole("alert"); 68 | expect(alerts).toHaveLength(1); 69 | expect(alerts[0]).toHaveTextContent(expectedAlert); 70 | } 71 | 72 | it("shows loading symbols when loading", async () => { 73 | init(false); 74 | expect(screen.getByTitle("Loading Indicator")).toBeInTheDocument(); 75 | expect(screen.queryByPlaceholderText("First name")).toBeNull(); 76 | expect(screen.queryByPlaceholderText("Last name")).toBeNull(); 77 | }); 78 | 79 | it("correct form elements shown, with loaded values", async () => { 80 | init(); 81 | await waitFor(() => { 82 | expect(screen.getByPlaceholderText("First name")).toBeInTheDocument(); 83 | }); 84 | expect(mockContext.backend.getProfile).toBeCalledWith("123"); 85 | expect(screen.getByText("Your Profile")).toBeInTheDocument(); 86 | expect(screen.getByPlaceholderText("First name")).toBeInTheDocument(); 87 | expect(screen.getByPlaceholderText("Last name")).toBeInTheDocument(); 88 | await waitFor(() => { 89 | expect(screen.getByRole("form")).toHaveFormValues({ 90 | firstName: "Anthony", 91 | }); 92 | }); 93 | }); 94 | 95 | describe("on valid input submission", () => { 96 | describe("with success response", () => { 97 | beforeEach(async () => { 98 | mockContext.backend.setProfile.mockResolvedValue({ 99 | result: "success", 100 | uid: "123", 101 | }); 102 | init(); 103 | await fillInAllFieldsValid(); 104 | await user.click(screen.getByText("Save")); 105 | }); 106 | it("sends the submitted data to the signup service", async () => { 107 | expect(mockContext.backend.setProfile).toBeCalledWith("123", { 108 | firstName: "Ben", 109 | lastName: "Neb", 110 | }); 111 | }); 112 | it("no redirection", async () => { 113 | expect(mockRouter.asPath).toEqual("/profile"); 114 | }); 115 | it("shows success alert", async () => { 116 | expect(mockContext.addToast).toBeCalledTimes(1); 117 | expect(mockContext.addToast).toBeCalledWith( 118 | "Your changes have been saved", 119 | "success" 120 | ); 121 | }); 122 | }); 123 | }); 124 | 125 | it("validates the first name exists", async () => { 126 | init(); 127 | await fillInAllFieldsValid(); 128 | await user.clear(screen.getByPlaceholderText("First name")); 129 | await submitFormAndCheckAlertText("First name is required"); 130 | expectNoSaveCall(); 131 | }); 132 | 133 | it("validates the last name exists", async () => { 134 | init(); 135 | await fillInAllFieldsValid(); 136 | await user.clear(screen.getByPlaceholderText("Last name")); 137 | await submitFormAndCheckAlertText("Last name is required"); 138 | expectNoSaveCall(); 139 | }); 140 | 141 | describe("handles failed save", () => { 142 | it("fail", async () => { 143 | mockContext.backend.setProfile.mockResolvedValue({ 144 | result: "fail", 145 | message: "Error 500", 146 | }); 147 | init(); 148 | await fillInAllFieldsValid(); 149 | await user.click(screen.getByText("Save")); 150 | expect(mockContext.addToast).toBeCalledWith( 151 | "There was a problem. Your changes have not been saved. Please try again.", 152 | "danger" 153 | ); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /nextjs/__tests__/resetpassword/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from '@testing-library/react' 2 | import { Context } from '@/context/Context'; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; 5 | import mockRouter from 'next-router-mock'; 6 | import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; 7 | import { makeMockContext } from '__tests__/util/mockContext'; 8 | import ResetPassword from '@/pages/resetpassword'; 9 | 10 | jest.mock('next/router', () => require('next-router-mock')); 11 | 12 | describe('ResetPassword', () => { 13 | 14 | const mockContext = makeMockContext(); 15 | 16 | let user: UserEvent; 17 | 18 | beforeEach(() => { 19 | user = userEvent.setup(); 20 | render( 21 | 22 | 23 | , 24 | { wrapper: MemoryRouterProvider } 25 | ) 26 | }); 27 | 28 | afterEach(() => { 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | async function fillInAllFieldsValid() { 33 | await user.type(screen.getByPlaceholderText('Email address'), 'me@them.com'); 34 | } 35 | 36 | function expectNoBackendCall() { 37 | expect(mockContext.backend.resetPassword).not.toBeCalled(); 38 | } 39 | 40 | async function submitFormAndCheckAlertText(expectedAlert: string) { 41 | await user.click(screen.getByRole('button', { name: /Send Reset Password Link/i })); 42 | const alerts = await screen.findAllByRole("alert"); 43 | expect(alerts).toHaveLength(1); 44 | expect(alerts[0]).toHaveTextContent(expectedAlert); 45 | } 46 | 47 | it('correct form elements shown', () => { 48 | expect(screen.getByRole("heading", { name: "Reset Password" })).toBeInTheDocument(); 49 | expect(screen.getByRole("button", { name: "Send Reset Password Link" })).toBeInTheDocument(); 50 | expect(screen.getByPlaceholderText('Email address')).toBeInTheDocument(); 51 | }); 52 | 53 | describe('on valid input submission', () => { 54 | describe('with success response', () => { 55 | beforeEach(async () => { 56 | act(() => { 57 | mockRouter.setCurrentUrl('/resetpassword'); 58 | }); 59 | mockContext.backend.resetPassword.mockResolvedValue({ result: 'success' }); 60 | await fillInAllFieldsValid(); 61 | await user.click(screen.getByRole('button', { name: /Send Reset Password Link/i })); 62 | }) 63 | it('submits the data correctly', async () => { 64 | expect(mockContext.backend.resetPassword).toBeCalledWith("me@them.com"); 65 | }); 66 | it('stays on the same page', async () => { 67 | expect(mockRouter.asPath).toEqual('/resetpassword'); 68 | }); 69 | it('shows success alert', async () => { 70 | expect(mockContext.addToast).toBeCalledTimes(1); 71 | expect(mockContext.addToast).toBeCalledWith('Your password reset link has been sent.', 'success'); 72 | }); 73 | }); 74 | }); 75 | 76 | it('validates the email address exists', async () => { 77 | await fillInAllFieldsValid(); 78 | await user.clear(screen.getByPlaceholderText('Email address')); 79 | await submitFormAndCheckAlertText("Email address is required"); 80 | expectNoBackendCall(); 81 | }); 82 | 83 | it('validates the email format', async () => { 84 | await fillInAllFieldsValid(); 85 | await user.clear(screen.getByPlaceholderText('Email address')); 86 | await user.type(screen.getByPlaceholderText('Email address'), 'methem'); 87 | await submitFormAndCheckAlertText("Email address is invalid"); 88 | expectNoBackendCall(); 89 | }); 90 | 91 | describe('handles firebase error return code', () => { 92 | it('user-not-found', async () => { 93 | mockContext.backend.resetPassword.mockResolvedValue({ result: 'user-not-found' }); 94 | await fillInAllFieldsValid(); 95 | await submitFormAndCheckAlertText('No user exists with this email'); 96 | }); 97 | 98 | it('fail', async () => { 99 | mockContext.backend.resetPassword.mockResolvedValue({ result: 'fail' }); 100 | await fillInAllFieldsValid(); 101 | await submitFormAndCheckAlertText("Sorry there was a server problem while resetting the password, please try again later."); 102 | }); 103 | }); 104 | 105 | }); 106 | -------------------------------------------------------------------------------- /nextjs/__tests__/signup/checkinbox.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen, waitFor } from "@testing-library/react"; 2 | import { Context } from "@/context/Context"; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; 5 | import mockRouter from "next-router-mock"; 6 | import { MemoryRouterProvider } from "next-router-mock/MemoryRouterProvider"; 7 | import { makeMockContext } from "__tests__/util/mockContext"; 8 | import CheckInbox from "@/pages/signup/checkinbox"; 9 | import { User } from "firebase/auth"; 10 | 11 | jest.mock("next/router", () => require("next-router-mock")); 12 | 13 | describe("CheckInbox", () => { 14 | const mockContext = makeMockContext(); 15 | 16 | let user: UserEvent; 17 | 18 | const init = (withUser: boolean) => { 19 | user = userEvent.setup(); 20 | mockContext.user = withUser ? ({ uid: "123" } as User) : undefined; 21 | render( 22 | 23 | 24 | , 25 | { wrapper: MemoryRouterProvider } 26 | ); 27 | }; 28 | 29 | afterEach(() => { 30 | jest.clearAllMocks(); 31 | }); 32 | 33 | it("correct form elements shown when not logged in", () => { 34 | init(false); 35 | expect( 36 | screen.getByRole("heading", { name: "Check your inbox, please!" }) 37 | ).toBeInTheDocument(); 38 | expect( 39 | screen.queryByRole("button", { name: "Send confirmation email again" }) 40 | ).toBeNull(); 41 | expect( 42 | screen.getByText( 43 | "To complete sign up, open the email we have sent you, and click the confirmation link.", 44 | { exact: false } 45 | ) 46 | ).toBeInTheDocument(); 47 | }); 48 | 49 | it("correct form elements shown when logged in", () => { 50 | init(true); 51 | expect( 52 | screen.getByRole("heading", { name: "Check your inbox, please!" }) 53 | ).toBeInTheDocument(); 54 | expect( 55 | screen.getByRole("button", { name: "Send confirmation email again" }) 56 | ).toBeInTheDocument(); 57 | expect( 58 | screen.getByText( 59 | "To complete sign up, open the email we have sent you, and click the confirmation link.", 60 | { exact: false } 61 | ) 62 | ).toBeInTheDocument(); 63 | }); 64 | 65 | it("clicking send causes backend to be called", async () => { 66 | init(true); 67 | await user.click( 68 | screen.getByRole("button", { name: "Send confirmation email again" }) 69 | ); 70 | expect(mockContext.backend.sendEmailVerification).toBeCalledWith({ 71 | uid: "123", 72 | } as User); 73 | }); 74 | 75 | it("clicking send causes button to be disabled", async () => { 76 | init(true); 77 | await user.click( 78 | screen.getByRole("button", { name: "Send confirmation email again" }) 79 | ); 80 | waitFor(() => 81 | expect( 82 | screen.queryByRole("button", { name: "Send confirmation email again" }) 83 | ).toBeDisabled() 84 | ); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /nextjs/__tests__/signup/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react' 2 | import { Context } from '@/context/Context'; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; 5 | import mockRouter from 'next-router-mock'; 6 | import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; 7 | import { makeMockContext } from '__tests__/util/mockContext'; 8 | import Signup from '@/pages/signup'; 9 | 10 | jest.mock('next/router', () => require('next-router-mock')); 11 | 12 | describe('Signup', () => { 13 | 14 | const mockContext = makeMockContext(); 15 | 16 | let user: UserEvent; 17 | 18 | beforeEach(() => { 19 | user = userEvent.setup(); 20 | render( 21 | 22 | 23 | , 24 | { wrapper: MemoryRouterProvider } 25 | ) 26 | }); 27 | 28 | afterEach(() => { 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | async function fillInAllFieldsValid() { 33 | await user.type(screen.getByPlaceholderText('First name'), 'Ben'); 34 | await user.type(screen.getByPlaceholderText('Last name'), 'Neb'); 35 | await user.type(screen.getByPlaceholderText('Email address'), 'me@them.com'); 36 | await user.type(screen.getByPlaceholderText('Password'), 'password123'); 37 | } 38 | 39 | function expectNoSignupCall() { 40 | expect(mockContext.backend.signup).not.toBeCalled(); 41 | } 42 | 43 | async function submitFormAndCheckAlertText(expectedAlert: string) { 44 | await user.click(screen.getByText('Sign up')); 45 | const alerts = await screen.findAllByRole("alert"); 46 | expect(alerts).toHaveLength(1); 47 | expect(alerts[0]).toHaveTextContent(expectedAlert); 48 | } 49 | 50 | it('correct form elements shown', () => { 51 | expect(screen.getByText('Reap the benefits')).toBeInTheDocument(); 52 | expect(screen.getByPlaceholderText('First name')).toBeInTheDocument(); 53 | expect(screen.getByPlaceholderText('Last name')).toBeInTheDocument(); 54 | expect(screen.getByPlaceholderText('Email address')).toBeInTheDocument(); 55 | const pw = screen.getByPlaceholderText('Password'); 56 | expect(pw).toBeInTheDocument(); 57 | expect(pw.attributes.getNamedItem('type')?.value).toBe('password'); 58 | }); 59 | 60 | describe('on valid input submission', () => { 61 | describe('with success response', () => { 62 | beforeEach(async () => { 63 | mockContext.backend.signup.mockResolvedValue({ result: 'success', uid: '123' }); 64 | await fillInAllFieldsValid(); 65 | await user.click(screen.getByText('Sign up')); 66 | }) 67 | it('sends the submitted data to the signup service', async () => { 68 | expect(mockContext.backend.signup).toBeCalledWith("me@them.com", "password123", { firstName: 'Ben', lastName: 'Neb' }); 69 | }); 70 | it('redirects to the check inbox page', async () => { 71 | expect(mockRouter.asPath).toEqual('/signup/checkinbox'); 72 | }); 73 | it('shows no alert', async () => { 74 | expect(mockContext.addToast).toBeCalledTimes(0); 75 | }); 76 | }); 77 | 78 | describe('with partial success response', () => { 79 | beforeEach(async () => { 80 | mockContext.backend.signup.mockResolvedValue({ result: 'partial-success', uid: '123' }); 81 | await fillInAllFieldsValid(); 82 | await user.click(screen.getByText('Sign up')); 83 | }) 84 | it ('sends the submitted data to the signup service', async () => { 85 | expect(mockContext.backend.signup).toBeCalledWith("me@them.com", "password123", { firstName: 'Ben', lastName: 'Neb' }); 86 | }); 87 | it('redirects to the check inbox page', async () => { 88 | expect(mockRouter.asPath).toEqual('/signup/checkinbox'); 89 | }); 90 | it('shows partial success alert', async () => { 91 | expect(mockContext.addToast).toBeCalledTimes(1); 92 | expect(mockContext.addToast).toBeCalledWith('There was an issue trying to save your name to the profile, so you will need to do this again.', 'warning'); 93 | }); 94 | }); 95 | }); 96 | 97 | it('validates the first name exists', async () => { 98 | await fillInAllFieldsValid(); 99 | await user.clear(screen.getByPlaceholderText('First name')); 100 | await submitFormAndCheckAlertText("First name is required"); 101 | expectNoSignupCall(); 102 | }); 103 | 104 | it('validates the last name exists', async () => { 105 | await fillInAllFieldsValid(); 106 | await user.clear(screen.getByPlaceholderText('Last name')); 107 | await submitFormAndCheckAlertText("Last name is required"); 108 | expectNoSignupCall(); 109 | }); 110 | 111 | 112 | it('validates the email address exists', async () => { 113 | await fillInAllFieldsValid(); 114 | await user.clear(screen.getByPlaceholderText('Email address')); 115 | await submitFormAndCheckAlertText("Email address is required"); 116 | expectNoSignupCall(); 117 | }); 118 | 119 | it('validates the password exists', async () => { 120 | await fillInAllFieldsValid(); 121 | await user.clear(screen.getByPlaceholderText('Password')); 122 | await submitFormAndCheckAlertText("Password is required"); 123 | expectNoSignupCall(); 124 | }); 125 | 126 | it('validates the email format', async () => { 127 | await fillInAllFieldsValid(); 128 | await user.clear(screen.getByPlaceholderText('Email address')); 129 | await user.type(screen.getByPlaceholderText('Email address'), 'methem'); 130 | await submitFormAndCheckAlertText("Email address is invalid"); 131 | expectNoSignupCall(); 132 | }); 133 | 134 | describe('handles firebase error return code', () => { 135 | it('invalid-email', async () => { 136 | mockContext.backend.signup.mockResolvedValue({ result: 'invalid-email' }); 137 | await fillInAllFieldsValid(); 138 | await submitFormAndCheckAlertText("Email address is invalid"); 139 | }); 140 | 141 | it('accounts-not-enabled', async () => { 142 | mockContext.backend.signup.mockResolvedValue({ result: 'accounts-not-enabled' }); 143 | await fillInAllFieldsValid(); 144 | await submitFormAndCheckAlertText("Sorry there was a server problem while signing up, please try again later."); 145 | }); 146 | 147 | it('email-in-use', async () => { 148 | mockContext.backend.signup.mockResolvedValue({ result: 'email-in-use' }); 149 | await fillInAllFieldsValid(); 150 | await submitFormAndCheckAlertText("An account with this email already exists. Please pick another email, or try signing in."); 151 | }); 152 | 153 | it('fail', async () => { 154 | mockContext.backend.signup.mockResolvedValue({ result: 'fail', message: 'Error 500' }); 155 | await fillInAllFieldsValid(); 156 | await submitFormAndCheckAlertText("Sorry there was a server problem while signing up, please try again later."); 157 | }); 158 | 159 | it('weak-password', async () => { 160 | mockContext.backend.signup.mockResolvedValue({ result: 'weak-password' }); 161 | await fillInAllFieldsValid(); 162 | await submitFormAndCheckAlertText("Password doesn't meet the requirements. Password should have at least 6 characters."); 163 | }); 164 | }); 165 | 166 | }); 167 | -------------------------------------------------------------------------------- /nextjs/__tests__/snapshot.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import Home from '@/pages/index' 3 | 4 | it('renders homepage unchanged', () => { 5 | const { container } = render() 6 | // expect(container).toMatchSnapshot() 7 | }) 8 | -------------------------------------------------------------------------------- /nextjs/__tests__/todo/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, waitFor } from "@testing-library/react"; 2 | import { Context } from "@/context/Context"; 3 | import userEvent from "@testing-library/user-event"; 4 | import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; 5 | import { MemoryRouterProvider } from "next-router-mock/MemoryRouterProvider"; 6 | import { makeMockContext } from "__tests__/util/mockContext"; 7 | import Todos from "@/pages/todos"; 8 | import { User } from "firebase/auth"; 9 | import { Todo, WithId, WithUid } from "@/backend/IBackend"; 10 | import { act } from "react-dom/test-utils"; 11 | import mockRouter from "next-router-mock"; 12 | 13 | jest.mock("next/router", () => require("next-router-mock")); 14 | 15 | describe("Todos", () => { 16 | const mockContext = makeMockContext(); 17 | 18 | let human: UserEvent; 19 | 20 | async function renderWith( 21 | todos: (Todo & WithUid & WithId)[] = [], 22 | hasUser: boolean = true 23 | ) { 24 | await act(async () => { 25 | mockRouter.setCurrentUrl("/todos"); 26 | human = userEvent.setup(); 27 | mockContext.backend.getUserItems.mockResolvedValue({ 28 | result: "success", 29 | items: todos, 30 | }); 31 | mockContext.user = hasUser ? ({ uid: "123" } as User) : undefined; 32 | render( 33 | 34 | 35 | , 36 | { wrapper: MemoryRouterProvider } 37 | ); 38 | }); 39 | } 40 | 41 | afterEach(() => { 42 | jest.clearAllMocks(); 43 | }); 44 | 45 | it("correct form elements shown when not logged in", async () => { 46 | await renderWith([], false); 47 | expect(screen.getByRole("heading", { name: "Todos" })).toBeInTheDocument(); 48 | expect( 49 | screen.getByText(/This page requires you to be signed in/i) 50 | ).toBeInTheDocument(); 51 | expect(screen.queryByRole("button", { name: /Add Todo/i })).toBeNull(); 52 | }); 53 | 54 | it("correct form elements shown", async () => { 55 | await renderWith(); 56 | expect(screen.getByRole("heading", { name: "Todos" })).toBeInTheDocument(); 57 | expect( 58 | screen.getByPlaceholderText("E.g. buy shoelaces") 59 | ).toBeInTheDocument(); 60 | expect( 61 | screen.getByRole("button", { name: /Add Todo/i }) 62 | ).toBeInTheDocument(); 63 | }); 64 | 65 | it("it correctly shows an item that needs to be done", async () => { 66 | await renderWith([{ title: "my thing", done: false, uid: "123", id: "1" }]); 67 | await waitFor(() => { 68 | expect( 69 | screen.getByRole("cell", { name: /my thing/i }) 70 | ).toBeInTheDocument(); 71 | }); 72 | expect(screen.getByRole("cell", { name: /my thing/i })).not.toHaveClass( 73 | "line-through" 74 | ); 75 | expect(screen.queryAllByRole("row")).toHaveLength(2); // One header row and 1 data row 76 | }); 77 | 78 | it("it correctly shows an item that is done", async () => { 79 | await renderWith([{ title: "my thing", done: true, uid: "123", id: "1" }]); 80 | await waitFor(() => { 81 | expect( 82 | screen.getByRole("cell", { name: /my thing/i }) 83 | ).toBeInTheDocument(); 84 | }); 85 | expect(screen.getByRole("cell", { name: /my thing/i })).toHaveClass( 86 | "line-through" 87 | ); 88 | }); 89 | 90 | it("it can remove an item", async () => { 91 | await renderWith([ 92 | { title: "my thing", done: true, uid: "123", id: "1" }, 93 | { title: "your thing", done: false, uid: "123", id: "2" }, 94 | ]); 95 | await waitFor(() => { 96 | expect( 97 | screen.getByRole("cell", { name: /my thing/i }) 98 | ).toBeInTheDocument(); 99 | }); 100 | await human.click(screen.getByLabelText(/Remove 'my thing'/)); 101 | await waitFor(() => { 102 | expect(screen.queryByRole("cell", { name: /my thing/i })).toBeNull(); 103 | }); 104 | expect(mockContext.backend.deleteUserItem).toBeCalledWith("todo", "1"); 105 | }); 106 | 107 | it("it can toggle an item", async () => { 108 | await renderWith([ 109 | { title: "my thing", done: true, uid: "123", id: "1" }, 110 | { title: "your thing", done: false, uid: "123", id: "2" }, 111 | ]); 112 | await waitFor(() => { 113 | expect( 114 | screen.getByRole("cell", { name: /my thing/i }) 115 | ).toBeInTheDocument(); 116 | }); 117 | expect(screen.getByRole("cell", { name: /my thing/i })).toHaveClass( 118 | "line-through" 119 | ); 120 | await human.click(screen.getByLabelText(/Toggle 'my thing'/)); 121 | await waitFor(() => { 122 | expect(screen.getByRole("cell", { name: /my thing/i })).not.toHaveClass( 123 | "line-through" 124 | ); 125 | }); 126 | expect(mockContext.backend.setUserItem).toBeCalledWith("todo", "1", { 127 | done: false, 128 | }); 129 | await human.click(screen.getByLabelText(/Toggle 'my thing'/)); 130 | await waitFor(() => { 131 | expect(screen.getByRole("cell", { name: /my thing/i })).toHaveClass( 132 | "line-through" 133 | ); 134 | }); 135 | expect(mockContext.backend.setUserItem).toBeCalledWith("todo", "1", { 136 | done: true, 137 | }); 138 | }); 139 | 140 | it("it can add a todo", async () => { 141 | mockContext.backend.addUserItem.mockResolvedValue({ 142 | result: "success", 143 | id: "456", 144 | }); 145 | await renderWith([{ title: "my thing", done: true, uid: "123", id: "1" }]); 146 | await new Promise(process.nextTick); 147 | await human.type(screen.getByRole("textbox"), "Buy Milk"); 148 | await human.click(screen.getByText(/Add Todo/i)); 149 | await waitFor(() => { 150 | expect(screen.queryByRole("cell", { name: /Buy Milk/i })).not.toBeNull(); 151 | }); 152 | expect(screen.queryAllByRole("row")).toHaveLength(3); // One header row and 2 data rows 153 | expect(mockContext.backend.addUserItem).toBeCalledWith("todo", "123", { 154 | done: false, 155 | title: "Buy Milk", 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /nextjs/__tests__/util/mockContext.ts: -------------------------------------------------------------------------------- 1 | import { IBackend } from "@/backend/IBackend"; 2 | import { ContextInterface } from "@/context/Context"; 3 | import { User } from "firebase/auth"; 4 | 5 | export const makeMockContext = () => { 6 | 7 | const backendMocks = { 8 | signup: jest.fn(), 9 | login: jest.fn(), 10 | logout: jest.fn(), 11 | resetPassword: jest.fn(), 12 | setProfile: jest.fn(), 13 | getProfile: jest.fn(), 14 | addUserItem: jest.fn(), 15 | setUserItem: jest.fn(), 16 | getUserItems: jest.fn(), 17 | deleteUserItem: jest.fn(), 18 | sendEmailVerification: jest.fn() 19 | }; 20 | 21 | // Type check 22 | const backend = backendMocks as IBackend; 23 | 24 | const mocks = { 25 | backend: backendMocks, 26 | toasts: [], 27 | addToast: jest.fn(), 28 | user: undefined as User | undefined, 29 | authLoading: false 30 | }; 31 | 32 | // Type check 33 | const context = mocks as ContextInterface; 34 | 35 | return mocks; 36 | }; 37 | -------------------------------------------------------------------------------- /nextjs/jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest') 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: './', 6 | }) 7 | 8 | // Add any custom config to be passed to Jest 9 | const customJestConfig = { 10 | setupFilesAfterEnv: ['/jest.setup.js'], 11 | moduleNameMapper: { 12 | // Handle module aliases (this will be automatically configured for you soon) 13 | '^@/components/(.*)$': '/src/components/$1', 14 | '^@/context/(.*)$': '/src/context/$1', 15 | '^@/backend/(.*)$': '/src/backend/$1', 16 | '^@/firebase/(.*)$': '/src/firebase/$1', 17 | '^@/pages/(.*)$': '/src/pages/$1', 18 | '^@/common/(.*)$': '/src/common/$1', 19 | '^__tests__/(.*$)': '/__tests__/$1' 20 | }, 21 | testEnvironment: 'jest-environment-jsdom', 22 | testPathIgnorePatterns: ['/util'] 23 | } 24 | 25 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 26 | module.exports = createJestConfig(customJestConfig) 27 | -------------------------------------------------------------------------------- /nextjs/jest.setup.js: -------------------------------------------------------------------------------- 1 | // Optional: configure or set up a testing framework before each test. 2 | // If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js` 3 | 4 | // Used for __tests__/testing-library.js 5 | // Learn more: https://github.com/testing-library/jest-dom 6 | import "@testing-library/jest-dom/extend-expect"; 7 | import failOnConsole from "jest-fail-on-console"; 8 | 9 | import { configure } from "@testing-library/dom"; 10 | 11 | configure({ 12 | getElementError: (message, container) => { 13 | const error = new Error(message); 14 | error.name = "TestingLibraryElementError"; 15 | error.stack = null; 16 | return error; 17 | }, 18 | }); 19 | 20 | failOnConsole(); 21 | -------------------------------------------------------------------------------- /nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "firestarter-nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest --watch", 11 | "test:ci": "jest --ci" 12 | }, 13 | "dependencies": { 14 | "firebase": "^9.17.1", 15 | "next": "13.1.6", 16 | "react": "18.2.0", 17 | "react-dom": "18.2.0", 18 | "react-hook-form": "^7.43.1", 19 | "ts-pattern": "^4.1.4" 20 | }, 21 | "devDependencies": { 22 | "@fortawesome/fontawesome-svg-core": "^6.3.0", 23 | "@fortawesome/free-solid-svg-icons": "^6.3.0", 24 | "@fortawesome/react-fontawesome": "^0.2.0", 25 | "@next/font": "13.1.6", 26 | "@tailwindcss/aspect-ratio": "^0.4.2", 27 | "@testing-library/jest-dom": "^5.16.5", 28 | "@testing-library/react": "^13.4.0", 29 | "@testing-library/user-event": "^14.4.3", 30 | "@types/node": "18.11.18", 31 | "@types/react": "18.0.27", 32 | "@types/react-dom": "18.0.10", 33 | "@types/testing-library__jest-dom": "^5.14.5", 34 | "eslint": "8.33.0", 35 | "eslint-config-next": "13.1.6", 36 | "jest": "^29.4.1", 37 | "jest-environment-jsdom": "^29.4.1", 38 | "jest-fail-on-console": "^3.0.2", 39 | "next-router-mock": "^0.9.1", 40 | "postcss-preset-env": "^8.0.1", 41 | "tailwindcss": "^3.2.4", 42 | "typescript": "4.9.5" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /nextjs/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'tailwindcss', 4 | 'postcss-preset-env', 5 | ], 6 | } -------------------------------------------------------------------------------- /nextjs/public/example-profile-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/nextjs/public/example-profile-image.jpg -------------------------------------------------------------------------------- /nextjs/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/nextjs/public/favicon.png -------------------------------------------------------------------------------- /nextjs/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /nextjs/public/icons/trash-can.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nextjs/public/landingpage1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/nextjs/public/landingpage1.jpg -------------------------------------------------------------------------------- /nextjs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/nextjs/public/logo.png -------------------------------------------------------------------------------- /nextjs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | F i r e s t a r t e r -------------------------------------------------------------------------------- /nextjs/src/auth/useAuthState.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@/firebase/init'; 2 | import { onAuthStateChanged, User } from 'firebase/auth'; 3 | import { useEffect, useMemo, useState } from 'react'; 4 | 5 | /** General response from a hook where the result can be in a "loading" state. The 6 | * First element is the value, if obtained. 7 | * Second element indicates if we are in the loading state. 8 | * Third element is the error, if thrown. 9 | */ 10 | export declare type LoadingHook = [T | undefined, boolean, E | undefined]; 11 | 12 | export type AuthStateHook = LoadingHook; 13 | 14 | const useAuthState = (): AuthStateHook => { 15 | 16 | const [loading, setLoading] = useState(true); 17 | const [error, setError] = useState(); 18 | const [value, setValue] = useState(); 19 | 20 | useEffect(() => { 21 | const listener = onAuthStateChanged( 22 | auth, 23 | async (user) => { 24 | setValue(user || undefined); 25 | setLoading(false); 26 | }, 27 | setError 28 | ); 29 | 30 | return () => { 31 | listener(); 32 | }; 33 | }, []); 34 | 35 | return useMemo(() => [value, loading, error], [value, loading, error]); 36 | }; 37 | 38 | export default useAuthState; -------------------------------------------------------------------------------- /nextjs/src/backend/Backend.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AddResult, 3 | DeleteResult, 4 | EmailVerificationResult, 5 | GetItemResult, 6 | GetListResult, 7 | IBackend, 8 | LoginResult, 9 | LogoutResult, 10 | PasswordResetResult, 11 | Profile, 12 | SetResult, 13 | SignupResult, 14 | Todo, 15 | WithId, 16 | WithUid, 17 | } from "./IBackend"; 18 | import doSignUp from "./Signup"; 19 | import doLogin from "./Login"; 20 | import doLogout from "./Logout"; 21 | import doResetPassword from "./ResetPassword"; 22 | import doSendEmailVerification from "./SendEmailVerification"; 23 | import * as useritem from "./UserItem"; 24 | import * as profile from "./Profile"; 25 | import { User } from "firebase/auth"; 26 | 27 | export class Backend implements IBackend { 28 | async login(email: string, password: string): Promise { 29 | return await doLogin(email, password); 30 | } 31 | async logout(): Promise { 32 | return await doLogout(); 33 | } 34 | async signup( 35 | email: string, 36 | password: string, 37 | data: Profile 38 | ): Promise { 39 | return await doSignUp(email, password, data); 40 | } 41 | async resetPassword(email: string): Promise { 42 | return await doResetPassword(email); 43 | } 44 | async sendEmailVerification(user: User): Promise { 45 | return await doSendEmailVerification(user); 46 | } 47 | async getProfile(uid: string): Promise> { 48 | return await profile.getProfile(uid); 49 | } 50 | async setProfile(uid: string, data: Profile): Promise { 51 | return await profile.setProfile(uid, data); 52 | } 53 | async addUserItem(collectionName: string, uid: string, item: T): Promise { 54 | return await useritem.addUserItem(collectionName, uid, item); 55 | } 56 | async setUserItem(collectionName: string, id: string, item: Partial): Promise { 57 | return await useritem.setUserItem(collectionName, id, item); 58 | } 59 | async deleteUserItem(collectionName: string, id: string): Promise { 60 | return await useritem.deleteUserItem(collectionName, id); 61 | } 62 | async getUserItems(collectionName: string, uid: string): Promise> { 63 | return await useritem.getUserItems(collectionName, uid); 64 | } 65 | } -------------------------------------------------------------------------------- /nextjs/src/backend/IBackend.ts: -------------------------------------------------------------------------------- 1 | import { User } from "firebase/auth"; 2 | 3 | export type SignupResult = 4 | | { result: "success"; uid: string } 5 | 6 | /** 7 | * Represents success in creating user, but a failure to store user data 8 | */ 9 | | { result: "partial-success"; uid: string } 10 | | { result: "fail"; message: string } 11 | | { result: "weak-password" } 12 | | { result: "accounts-not-enabled" } 13 | | { result: "email-in-use" } 14 | | { result: "invalid-email" }; 15 | 16 | export type LoginResult = 17 | | { result: "success"; uid: string, emailVerified: boolean } 18 | | { result: "user-not-found" } 19 | | { result: "wrong-password" } 20 | | { result: "user-disabled" } 21 | | { result: "fail"; message: string }; 22 | 23 | export type PasswordResetResult = 24 | | { result: "success" } 25 | | { result: "user-not-found" } 26 | | { result: "fail"; message: string }; 27 | 28 | export type EmailVerificationResult = 29 | | { result: "success" } 30 | | { result: "fail"; message: string }; 31 | 32 | export type AddResult = 33 | | { result: "success"; id: string } 34 | | { result: "fail"; message: string }; 35 | 36 | export type SetResult = 37 | | { result: "success" } 38 | | { result: "fail"; message: string }; 39 | 40 | export type DeleteResult = 41 | | { result: "success" } 42 | | { result: "fail"; message: string }; 43 | 44 | export type GetListResult = 45 | | { result: "success"; items: T[] } 46 | | { result: "fail"; message: string }; 47 | 48 | export type GetItemResult = 49 | | { result: "success"; item: T } 50 | | { result: "fail"; message: string }; 51 | 52 | export type LogoutResult = { result: "success" } | { result: "fail" }; 53 | 54 | export interface Profile { 55 | firstName: string; 56 | lastName: string; 57 | } 58 | 59 | export interface Todo { 60 | title: string; 61 | done: boolean; 62 | } 63 | 64 | /** Bolt-on interface that represents the id of the stored item. In Firebase this isn't a field on the record. */ 65 | export interface WithId { 66 | id: string; 67 | } 68 | 69 | /** Bolt-on interface that represents the user id of the stored item */ 70 | export interface WithUid { 71 | uid: string; 72 | } 73 | 74 | export interface IBackend { 75 | signup( 76 | email: string, 77 | password: string, 78 | data: Profile 79 | ): Promise; 80 | 81 | login(email: string, password: string): Promise; 82 | 83 | logout(): Promise; 84 | 85 | resetPassword(email: string): Promise; 86 | 87 | sendEmailVerification(user: User): Promise; 88 | 89 | /*** Gets the user profile for a given user */ 90 | getProfile(uid: string): Promise>; 91 | 92 | /*** Sets the user profile for a given user */ 93 | setProfile(uid: string, data: Profile): Promise; 94 | 95 | /*** Adds an item for the given user. Works with any collection, as long as you have set up firestore rules to allow 96 | * writes. */ 97 | addUserItem(collectionName: string, uid: string, item: T): Promise; 98 | 99 | /*** UPdates an item for the given user. Works with any collection, as long as you have set up firestore rules to allow 100 | * writes. */ 101 | setUserItem(collectionName: string, id: string, item: Partial): Promise; 102 | 103 | /*** Deletes an item for the given user. Works with any collection, as long as you have set up firestore rules to allow 104 | * writes. */ 105 | deleteUserItem(collectionName: string, id: string): Promise; 106 | 107 | /*** Gets items for the given user. Works with any collection, as long as you have set up firestore rules to allow 108 | * reads. */ 109 | getUserItems(collectionName: string, uid: string): Promise>; 110 | } -------------------------------------------------------------------------------- /nextjs/src/backend/Login.ts: -------------------------------------------------------------------------------- 1 | import { signInWithEmailAndPassword, UserCredential } from "firebase/auth"; 2 | import { FirebaseError } from "@firebase/util"; 3 | import { LoginResult } from "./IBackend"; 4 | import { auth } from "@/firebase/init"; 5 | import { AUTH_INVALID_EMAIL, AUTH_USER_DISABLED, AUTH_USER_NOT_FOUND, AUTH_WRONG_PASSWORD } from "@/firebase/errorCodes"; 6 | 7 | export default async function doLogin(email: string, password: string): Promise { 8 | try { 9 | const { user: {uid, emailVerified}} = await signInWithEmailAndPassword(auth, email, password); 10 | return { result: 'success', uid, emailVerified }; 11 | } catch (e: unknown) { 12 | console.error(e); 13 | if (!(e instanceof FirebaseError)) { 14 | return { result: 'fail', message: '' }; 15 | }; 16 | if (e.code === AUTH_INVALID_EMAIL || e.code === AUTH_USER_NOT_FOUND) { 17 | // Treat invalid email as user not found to have fewer specific cases 18 | return { result: 'user-not-found' }; 19 | } 20 | if (e.code === AUTH_USER_DISABLED) { 21 | return { result: 'user-disabled' }; 22 | } 23 | if (e.code === AUTH_WRONG_PASSWORD) { 24 | return { result: 'wrong-password' }; 25 | } 26 | return { result: 'fail', message: e.message }; 27 | } 28 | } -------------------------------------------------------------------------------- /nextjs/src/backend/Logout.ts: -------------------------------------------------------------------------------- 1 | import { 2 | signOut, 3 | } from "firebase/auth"; 4 | import { LogoutResult } from "./IBackend"; 5 | import { auth } from "@/firebase/init"; 6 | 7 | export default async function doLogout(): Promise { 8 | try { 9 | await signOut(auth); 10 | return { result: "success" }; 11 | } catch (e: unknown) { 12 | console.error(e); 13 | return { result: "fail" }; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /nextjs/src/backend/Profile.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from "@/firebase/init"; 2 | import { 3 | collection, 4 | CollectionReference, 5 | doc, 6 | DocumentReference, 7 | getDoc, 8 | setDoc, 9 | updateDoc, 10 | } from "firebase/firestore"; 11 | import { GetItemResult, Profile, SetResult } from "./IBackend"; 12 | 13 | const UsersCollectionName = "users"; 14 | 15 | function usersCollection() { 16 | return collection( 17 | firestore, 18 | UsersCollectionName 19 | ) as CollectionReference; 20 | } 21 | 22 | export async function setProfile( 23 | uid: string, 24 | data: Partial 25 | ): Promise { 26 | try { 27 | await setDoc(doc(usersCollection(), uid), data); 28 | return { result: "success" }; 29 | } catch (e: unknown) { 30 | console.error(e); 31 | if (e instanceof Error) { 32 | return { result: "fail", message: e.message }; 33 | } 34 | return { result: "fail", message: "" }; 35 | } 36 | } 37 | 38 | export async function getProfile(uid: string): Promise> { 39 | try { 40 | const userRef = doc(usersCollection(), uid) as DocumentReference; 41 | const profile = await getDoc(userRef); 42 | const profileData = profile.data(); 43 | if (profileData) return { result: "success", item: profileData }; 44 | return { result: "success", item: { firstName: "", lastName: "" } }; 45 | } catch (e: unknown) { 46 | console.error(e); 47 | if (e instanceof Error) { 48 | return { result: "fail", message: e.message }; 49 | } 50 | return { result: "fail", message: "" }; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /nextjs/src/backend/ResetPassword.ts: -------------------------------------------------------------------------------- 1 | import { sendPasswordResetEmail } from "firebase/auth"; 2 | import { FirebaseError } from "@firebase/util"; 3 | import { PasswordResetResult } from "./IBackend"; 4 | import { auth } from "@/firebase/init"; 5 | import { AUTH_INVALID_EMAIL, AUTH_USER_NOT_FOUND } from "@/firebase/errorCodes"; 6 | 7 | export default async function doResetPassword(email: string): Promise { 8 | try { 9 | await sendPasswordResetEmail(auth, email); 10 | } catch (e: unknown) { 11 | console.error(e); 12 | if (!(e instanceof FirebaseError)) { 13 | return { result: 'fail', message: '' } 14 | }; 15 | if (e.code === AUTH_INVALID_EMAIL || e.code === AUTH_USER_NOT_FOUND) { 16 | // Treat invalid email as user not found to have fewer specific cases 17 | return { result: 'user-not-found' }; 18 | } 19 | // We don't classify certain codes like `auth/missing-continue-uri` that would be 20 | // caused by a bug in the frontend code rather than a problem with the user input. 21 | return { result: 'fail', message: e.message }; 22 | } 23 | 24 | return { result: 'success' }; 25 | } -------------------------------------------------------------------------------- /nextjs/src/backend/SendEmailVerification.ts: -------------------------------------------------------------------------------- 1 | import { sendEmailVerification as sendEmailVerificationFirebase, User } from "firebase/auth"; 2 | import { FirebaseError } from "@firebase/util"; 3 | import { EmailVerificationResult } from "./IBackend"; 4 | 5 | export default async function sendEmailVerification(user: User): Promise { 6 | try { 7 | await sendEmailVerificationFirebase(user); 8 | } catch (e: unknown) { 9 | console.error(e); 10 | if (!(e instanceof FirebaseError)) { 11 | return { result: 'fail', message: '' } 12 | }; 13 | // We don't classify certain codes like `auth/missing-continue-uri` that would be 14 | // caused by a bug in the frontend code rather than a problem with the user input. 15 | return { result: 'fail', message: e.message }; 16 | } 17 | 18 | return { result: 'success' }; 19 | } -------------------------------------------------------------------------------- /nextjs/src/backend/ServerSideUtil.ts: -------------------------------------------------------------------------------- 1 | export const isServerSide = () => typeof(window) === 'undefined'; -------------------------------------------------------------------------------- /nextjs/src/backend/Signup.ts: -------------------------------------------------------------------------------- 1 | import { createUserWithEmailAndPassword, sendEmailVerification, UserCredential } from "firebase/auth"; 2 | import { FirebaseError } from "@firebase/util"; 3 | import { Profile, SignupResult } from "./IBackend"; 4 | import { auth, firestore } from "@/firebase/init"; 5 | import { AUTH_EMAIL_ALREADY_IN_USE, AUTH_INVALID_EMAIL, AUTH_OPERATION_NOT_ALLOWED, AUTH_WEAK_PASSWORD } from "@/firebase/errorCodes"; 6 | import { doc, setDoc } from "firebase/firestore"; 7 | 8 | export default async function doSignup(email: string, password: string, data: Profile): Promise { 9 | let credential: UserCredential; 10 | try { 11 | credential = await createUserWithEmailAndPassword(auth, email, password); 12 | } catch (e: unknown) { 13 | console.error(e); 14 | if (!(e instanceof FirebaseError)) { 15 | return { result: 'fail', message: '' } 16 | }; 17 | if (e.code === AUTH_WEAK_PASSWORD) { 18 | return { result: 'weak-password' }; 19 | } 20 | if (e.code === AUTH_EMAIL_ALREADY_IN_USE) { 21 | return { result: 'email-in-use' }; 22 | } 23 | if (e.code === AUTH_INVALID_EMAIL) { 24 | return { result: 'invalid-email' }; 25 | } 26 | if (e.code === AUTH_OPERATION_NOT_ALLOWED) { 27 | return { result: 'accounts-not-enabled' }; 28 | } 29 | return { result: 'fail', message: e.message }; 30 | } 31 | 32 | try { 33 | await sendEmailVerification(credential.user); 34 | } catch { 35 | // Ignore any errors with this: If the user was successfully created this is likely 36 | // to succeed. If it doesn't then when the user tries to log in they will be given 37 | // an opportunity to resend it. 38 | } 39 | 40 | try { 41 | const userRef = doc(firestore, "users", credential.user.uid); 42 | await setDoc(userRef, data) 43 | } catch (e: unknown) { 44 | console.error(e); 45 | return { result: 'partial-success', uid: credential.user.uid } 46 | } 47 | 48 | return { result: 'success', uid: credential.user.uid }; 49 | } -------------------------------------------------------------------------------- /nextjs/src/backend/UserItem.ts: -------------------------------------------------------------------------------- 1 | import { firestore } from "@/firebase/init"; 2 | import { 3 | addDoc, 4 | collection, 5 | CollectionReference, 6 | deleteDoc, 7 | doc, 8 | getDocs, 9 | query, 10 | UpdateData, 11 | updateDoc, 12 | where, 13 | } from "firebase/firestore"; 14 | import { 15 | AddResult, 16 | DeleteResult, 17 | GetListResult, 18 | SetResult, 19 | WithId, 20 | WithUid, 21 | } from "./IBackend"; 22 | 23 | function collectionFor(collectionName: string) { 24 | return collection(firestore, collectionName) as CollectionReference; 25 | } 26 | 27 | export async function addUserItem( 28 | collection: string, 29 | uid: string, 30 | item: T 31 | ): Promise { 32 | try { 33 | const result = await addDoc(collectionFor(collection), { ...item, uid }); 34 | return { result: "success", id: result.id }; 35 | } catch (e: unknown) { 36 | console.error(e); 37 | if (e instanceof Error) { 38 | return { result: "fail", message: e.message }; 39 | } 40 | return { result: "fail", message: "" }; 41 | } 42 | } 43 | 44 | export async function deleteUserItem( 45 | collection: string, 46 | id: string 47 | ): Promise { 48 | try { 49 | await deleteDoc(doc(collectionFor(collection), id)); 50 | return { result: "success" }; 51 | } catch (e: unknown) { 52 | console.error(e); 53 | if (e instanceof Error) { 54 | return { result: "fail", message: e.message }; 55 | } 56 | return { result: "fail", message: "" }; 57 | } 58 | } 59 | 60 | export async function setUserItem( 61 | collection: string, 62 | id: string, 63 | item: Partial 64 | ): Promise { 65 | try { 66 | await updateDoc( 67 | doc(collectionFor(collection), id), 68 | item as UpdateData 69 | ); 70 | return { result: "success" }; 71 | } catch (e: unknown) { 72 | console.error(e); 73 | if (e instanceof Error) { 74 | return { result: "fail", message: e.message }; 75 | } 76 | return { result: "fail", message: "" }; 77 | } 78 | } 79 | 80 | export async function getUserItems( 81 | collection: string, 82 | uid: string 83 | ): Promise> { 84 | try { 85 | const q = query(collectionFor(collection), where("uid", "==", uid)); 86 | const docs = (await getDocs(q)).docs.map((d) => ({ 87 | ...d.data(), 88 | id: d.id, 89 | })); 90 | return { result: "success", items: docs }; 91 | } catch (e: unknown) { 92 | console.error(e); 93 | if (e instanceof Error) { 94 | return { result: "fail", message: e.message }; 95 | } 96 | return { result: "fail", message: "" }; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /nextjs/src/common/util.ts: -------------------------------------------------------------------------------- 1 | export const max = (xs: number[]) => Math.max(...xs); -------------------------------------------------------------------------------- /nextjs/src/components/Alert.tsx: -------------------------------------------------------------------------------- 1 | interface Props { 2 | level: AlertLevel; 3 | children: React.ReactNode; 4 | } 5 | 6 | export type AlertLevel = keyof typeof styles; 7 | 8 | const styles = { 9 | danger: 'bg-red-100 text-red-700', 10 | warning: 'bg-orange-100 text-orange-700', 11 | success: 'bg-green-100 text-green-700', 12 | } 13 | 14 | export function Alert({ level, children }: Props) { 15 | return
16 | {children} 17 |
; 18 | } -------------------------------------------------------------------------------- /nextjs/src/components/FieldErrorAlert.tsx: -------------------------------------------------------------------------------- 1 | import { FieldError } from "react-hook-form"; 2 | import { Alert } from "./Alert"; 3 | 4 | interface Props { 5 | error?: FieldError; 6 | } 7 | 8 | export default function FieldErrorAlert({ error }: Props) { 9 | return error ?
{error.message}
: <>; 10 | } -------------------------------------------------------------------------------- /nextjs/src/components/HeroSectionWithImage.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image' 2 | 3 | interface Props { 4 | imgsrc: string; 5 | heading: React.ReactNode; 6 | } 7 | 8 | // Based on https://tailwind-elements.com/docs/standard/designblocks/hero-sections/ 9 | 10 | export default function HeroSectionWithImage({ imgsrc, heading }: Props) { 11 | return
12 |
13 |
14 |
15 |

16 | {heading} 17 |

18 | Get started 19 | Learn more 20 |
21 |
22 | 23 |
24 |
25 |
26 |
; 27 | } -------------------------------------------------------------------------------- /nextjs/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from '@/context/Context'; 2 | import Head from 'next/head'; 3 | import React, { useContext } from 'react'; 4 | import Nav from './Nav'; 5 | import { Toasts } from './Toasts'; 6 | 7 | interface Props { 8 | children: React.ReactNode; 9 | } 10 | 11 | 12 | export default function Layout(props: Props) { 13 | 14 | const {toasts} = useContext(Context); 15 | return <> 16 | 17 | Firestarter! 18 | 19 | 20 | 21 | 22 |
23 |
24 |
29 |
30 | 31 | 32 | } 33 | 34 | -------------------------------------------------------------------------------- /nextjs/src/components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from "@/context/Context"; 2 | import { useContext } from "react"; 3 | import { FieldError, useForm } from "react-hook-form"; 4 | import { match } from "ts-pattern"; 5 | import { Alert } from "./Alert"; 6 | import router from "next/router"; 7 | import Link from "next/link"; 8 | 9 | type FormData = { 10 | email: string; 11 | password: string; 12 | }; 13 | 14 | export default function LoginForm() { 15 | const { 16 | register, 17 | handleSubmit, 18 | formState: { errors }, 19 | setError, 20 | } = useForm(); 21 | const { addToast, backend } = useContext(Context); 22 | 23 | const onSubmit = async ({ email, password }: FormData) => { 24 | const result = await backend.login(email, password); 25 | 26 | if (result.result === "success") { 27 | if (result.emailVerified) { 28 | addToast("You are now logged in.", "success"); 29 | router.push("/"); 30 | return; 31 | } else { 32 | addToast("You need to verify your email.", "warning"); 33 | router.push("/signup/checkinbox"); 34 | return; 35 | } 36 | } 37 | 38 | match(result.result) 39 | .with("user-disabled", () => 40 | setError("root.serverError", { 41 | message: 42 | "Your login has been disabled. Please contact support for assistance.", 43 | }) 44 | ) 45 | .with("user-not-found", () => 46 | setError("email", { message: "No user exists with this email" }) 47 | ) 48 | .with("wrong-password", () => 49 | setError("password", { message: "Password is incorrect" }) 50 | ) 51 | .otherwise(() => 52 | setError("root.serverError", { 53 | message: 54 | "Sorry there was a server problem while logging in, please try again later.", 55 | }) 56 | ); 57 | }; 58 | 59 | const fieldErrorAlertMsg = (err: FieldError | undefined) => 60 | err && ( 61 |
62 | {err.message} 63 |
64 | ); 65 | 66 | return ( 67 |
68 |
69 |
70 | 83 | {fieldErrorAlertMsg(errors.email)} 84 |
85 |
86 | 93 | {fieldErrorAlertMsg(errors.password)} 94 |
95 | 98 |
99 | 100 | Click here 101 | {" "} 102 | to reset your password. 103 |
104 | {errors.root?.serverError && ( 105 |
106 | {errors.root.serverError.message} 107 |
108 | )} 109 |
110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /nextjs/src/components/Nav.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import { useContext, useState } from "react"; 4 | import { Context } from "@/context/Context"; 5 | import router from "next/router"; 6 | import { faTrash } from "@fortawesome/free-solid-svg-icons"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | 9 | export default function Nav() { 10 | const [expandedNav, setExpandedNav] = useState(false); 11 | const [expandedUser, setExpandedUser] = useState(false); 12 | const { user, backend, addToast, authLoading } = useContext(Context); 13 | 14 | const logout = async () => { 15 | setExpandedUser(false); 16 | await backend.logout(); 17 | await router.push("/login"); 18 | addToast("You are now logged out.", "success"); 19 | }; 20 | 21 | return ( 22 | 136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /nextjs/src/components/ProfileForm.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from "@/context/Context"; 2 | import { useContext, useEffect, useState } from "react"; 3 | import { FieldError, useForm } from "react-hook-form"; 4 | import { match } from "ts-pattern"; 5 | import { Alert } from "./Alert"; 6 | import router from "next/router"; 7 | 8 | type FormData = { 9 | firstName: string; 10 | lastName: string; 11 | }; 12 | 13 | export default function SignupForm() { 14 | const { 15 | register, 16 | handleSubmit, 17 | formState: { errors }, 18 | setValue, 19 | } = useForm(); 20 | const { addToast, backend, user } = useContext(Context); 21 | const [isLoading, setIsLoading] = useState(true); 22 | 23 | const onSubmit = async ({ firstName, lastName }: FormData) => { 24 | if (user) { 25 | const result = await backend.setProfile(user.uid, { 26 | firstName, 27 | lastName, 28 | }); 29 | if (result.result === "success") { 30 | addToast("Your changes have been saved", "success"); 31 | } else { 32 | addToast( 33 | "There was a problem. Your changes have not been saved. Please try again.", 34 | "danger" 35 | ); 36 | } 37 | } 38 | }; 39 | 40 | useEffect(() => { 41 | if (user) { 42 | setIsLoading(true); 43 | backend.getProfile(user.uid).then((res) => { 44 | if (res.result === "success") { 45 | setIsLoading(false); 46 | setValue("firstName", res.item.firstName); 47 | setValue("lastName", res.item.lastName); 48 | } else { 49 | addToast( 50 | "Sorry. A problem occurred loading your profile. Please referesh the page to try again" 51 | ); 52 | } 53 | }); 54 | } 55 | }, [user, addToast, backend, setValue]); 56 | 57 | const fieldErrorAlertMsg = (err: FieldError | undefined) => 58 | err && ( 59 |
60 | {err.message} 61 |
62 | ); 63 | 64 | return ( 65 |
66 | {isLoading ? ( 67 |
68 |
69 |
70 |
71 |
72 | ) : ( 73 |
74 |
75 | 83 | {fieldErrorAlertMsg(errors.firstName)} 84 |
85 |
86 | 93 | {fieldErrorAlertMsg(errors.lastName)} 94 |
95 | 98 | 99 | {errors.root?.serverError && ( 100 |
101 | {errors.root.serverError.message} 102 |
103 | )} 104 |
105 | )} 106 |
107 | ); 108 | } 109 | -------------------------------------------------------------------------------- /nextjs/src/components/RequiresLoginNotice.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Alert } from "./Alert"; 3 | 4 | export default function RequiresLoginNotice() { 5 | return ( 6 |
7 | 8 | This page requires you to be signed in.{" "} 9 | You can either log in or,{" "} 10 | sign up. 11 | 12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /nextjs/src/components/ResetPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from "@/context/Context"; 2 | import { useContext } from "react"; 3 | import { FieldError, useForm } from "react-hook-form"; 4 | import { match } from "ts-pattern"; 5 | import { Alert } from "./Alert"; 6 | import FieldErrorAlert from "./FieldErrorAlert"; 7 | 8 | type FormData = { 9 | email: string; 10 | password: string; 11 | }; 12 | 13 | export default function ResetPasswordForm() { 14 | const { register, handleSubmit, formState: { errors }, setError } = useForm(); 15 | const { addToast, backend } = useContext(Context); 16 | 17 | const onSubmit = async ({ email }: FormData) => { 18 | const result = await backend.resetPassword(email); 19 | 20 | if (result.result === 'success') { 21 | addToast('Your password reset link has been sent.', 'success'); 22 | return; 23 | } 24 | 25 | match(result.result) 26 | .with('user-not-found', () => setError('email', { message: 'No user exists with this email' })) 27 | .otherwise(() => setError('root.serverError', { message: "Sorry there was a server problem while resetting the password, please try again later." })); 28 | }; 29 | 30 | const fieldErrorAlertMsg = (err: FieldError | undefined) => err &&
{err.message}
; 31 | 32 | return
33 |
34 |
35 | 36 | 37 |
38 | 39 | {errors.root?.serverError &&
{errors.root.serverError.message}
} 40 |
41 |
; 42 | } 43 | -------------------------------------------------------------------------------- /nextjs/src/components/SignupForm.tsx: -------------------------------------------------------------------------------- 1 | import { Context } from "@/context/Context"; 2 | import { useContext } from "react"; 3 | import { FieldError, useForm } from "react-hook-form"; 4 | import { match } from "ts-pattern"; 5 | import { Alert } from "./Alert"; 6 | import router from "next/router"; 7 | 8 | type FormData = { 9 | firstName: string; 10 | lastName: string; 11 | email: string; 12 | password: string; 13 | }; 14 | 15 | export default function SignupForm() { 16 | const { register, handleSubmit, formState: { errors }, setError } = useForm(); 17 | const { addToast, backend } = useContext(Context); 18 | 19 | const onSubmit = async ({ firstName, lastName, email, password }: FormData) => { 20 | 21 | const result = await backend.signup(email, password, { firstName, lastName }); 22 | 23 | if (result.result === 'success' || result.result === 'partial-success') { 24 | if (result.result !== 'success') { 25 | addToast('There was an issue trying to save your name to the profile, so you will need to do this again.', 'warning'); 26 | } 27 | router.push({ pathname: '/signup/checkinbox' }); 28 | return; 29 | } 30 | 31 | match(result.result) 32 | .with('invalid-email', () => setError('email', { message: "Email address is invalid." })) 33 | .with('email-in-use', () => setError('email', { message: "An account with this email already exists. Please pick another email, or try signing in." })) 34 | .with('weak-password', () => setError('password', { message: "Password doesn't meet the requirements. Password should have at least 6 characters." })) 35 | .otherwise(() => setError('root.serverError', { message: "Sorry there was a server problem while signing up, please try again later." })); 36 | }; 37 | 38 | const fieldErrorAlertMsg = (err: FieldError | undefined) => err &&
{err.message}
; 39 | 40 | return
41 |
42 |
43 |
44 | 45 | {fieldErrorAlertMsg(errors.firstName)} 46 |
47 |
48 | 49 | {fieldErrorAlertMsg(errors.lastName)} 50 |
51 |
52 |
53 | 54 | {fieldErrorAlertMsg(errors.email)} 55 |
56 |
57 | 58 | {fieldErrorAlertMsg(errors.password)} 59 |
60 | 61 | 62 | {errors.root?.serverError &&
{errors.root.serverError.message}
} 63 |
64 |
; 65 | } 66 | 67 | -------------------------------------------------------------------------------- /nextjs/src/components/Toasts.tsx: -------------------------------------------------------------------------------- 1 | import { max } from "@/common/util"; 2 | import { Key, useCallback, useState } from "react"; 3 | import { Alert, AlertLevel } from "./Alert"; 4 | 5 | export interface Toast { 6 | message: string; 7 | level?: AlertLevel; 8 | id: number; 9 | removed: boolean; 10 | tag?: string; 11 | } 12 | 13 | export interface ToastsProps { 14 | toasts: Toast[]; 15 | } 16 | 17 | export interface ToastProps { 18 | key?: Key | undefined | null; 19 | show: boolean; 20 | children?: React.ReactNode; 21 | } 22 | 23 | export const Toasts = ({ toasts }: ToastsProps) => ( 24 |
25 | {toasts?.map((t) => ( 26 | 27 |

{t.message}

28 |
29 | ))} 30 |
31 | ); 32 | 33 | export type ToastAdder = ( 34 | message: string, 35 | level?: AlertLevel, 36 | tag?: string, 37 | removeOthersWithTag?: boolean 38 | ) => void; 39 | 40 | export const useToasts = () => { 41 | const [toasts, setToasts] = useState([]); 42 | 43 | const timeOut = 10000; 44 | const animationTime = 150; 45 | 46 | const addToast = useCallback( 47 | ( 48 | message: string, 49 | level?: AlertLevel, 50 | tag?: string, 51 | removeOthersWithTag?: boolean 52 | ) => { 53 | // Add toast with unique id 54 | setToasts((toasts) => { 55 | const id = toasts.length === 0 ? 0 : max(toasts.map((t) => t.id)) + 1; 56 | 57 | // Remove toast after 10 seconds 58 | setTimeout(() => { 59 | setToasts((t) => 60 | t.map((t) => (t.id === id ? { ...t, removed: true } : t)) 61 | ); 62 | }, timeOut); 63 | setTimeout(() => { 64 | setToasts((t) => t.filter((t) => t.id !== id)); 65 | }, timeOut + animationTime); 66 | 67 | return [ 68 | ...toasts.filter((t) => !removeOthersWithTag || t.tag !== tag), 69 | { message, level, id, tag, removed: false }, 70 | ]; 71 | }); 72 | }, 73 | [setToasts] 74 | ); 75 | 76 | return [toasts, addToast] as [Toast[], ToastAdder]; 77 | }; 78 | -------------------------------------------------------------------------------- /nextjs/src/context/Context.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from "@/backend/Backend"; 2 | import { IBackend } from "@/backend/IBackend"; 3 | import { Toast, ToastAdder } from "@/components/Toasts"; 4 | import { User } from "firebase/auth"; 5 | import React from "react"; 6 | 7 | export interface ContextInterface { 8 | backend: IBackend; 9 | toasts: Toast[]; 10 | addToast: ToastAdder; 11 | user?: User; 12 | authLoading: boolean; 13 | } 14 | 15 | export const Context = React.createContext({} as any); -------------------------------------------------------------------------------- /nextjs/src/firebase/config.ts: -------------------------------------------------------------------------------- 1 | const firebaseConfig = { 2 | apiKey: "AIzaSyASt_NLUD6El7RK6KlvXC4Y1Eq1VNFbUfM", 3 | authDomain: "fire-starter-demo.firebaseapp.com", 4 | projectId: "fire-starter-demo", 5 | storageBucket: "fire-starter-demo.appspot.com", 6 | messagingSenderId: "827388889265", 7 | appId: "1:827388889265:web:ef19224fcacb90bd613a90", 8 | measurementId: "G-BHZBC53LCQ" 9 | }; 10 | 11 | export default firebaseConfig; -------------------------------------------------------------------------------- /nextjs/src/firebase/errorCodes.ts: -------------------------------------------------------------------------------- 1 | // From https://firebase.google.com/docs/reference/js/v8/firebase.auth.Auth 2 | export const AUTH_EMAIL_ALREADY_IN_USE = 'auth/email-already-in-use'; 3 | export const AUTH_INVALID_EMAIL = 'auth/invalid-email'; 4 | export const AUTH_OPERATION_NOT_ALLOWED = 'auth/operation-not-allowed'; 5 | export const AUTH_WEAK_PASSWORD = 'auth/weak-password'; 6 | 7 | 8 | export const AUTH_USER_DISABLED = 'auth/user-disabled'; 9 | export const AUTH_USER_NOT_FOUND = 'auth/user-not-found'; 10 | export const AUTH_WRONG_PASSWORD = 'auth/wrong-password'; 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /nextjs/src/firebase/init.ts: -------------------------------------------------------------------------------- 1 | 2 | import { isServerSide } from "@/backend/ServerSideUtil"; 3 | import { FirebaseApp, getApp, initializeApp } from "firebase/app"; 4 | import { Auth, getAuth } from "firebase/auth"; 5 | import { Firestore, getFirestore } from "firebase/firestore"; 6 | import firebaseConfig from "./config"; 7 | 8 | export let app: FirebaseApp; 9 | export let auth: Auth; 10 | export let firestore: Firestore; 11 | 12 | export function initFirebase() { 13 | if (!isServerSide()) { 14 | app = initializeApp(firebaseConfig); 15 | auth = getAuth(app); 16 | firestore = getFirestore(app); 17 | } 18 | } -------------------------------------------------------------------------------- /nextjs/src/index.d.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/nextjs/src/index.d.ts -------------------------------------------------------------------------------- /nextjs/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import "../styles/index.css"; 4 | import { useCallback, useEffect, useMemo } from "react"; 5 | import { initFirebase } from "@/firebase/init"; 6 | import { useToasts } from "@/components/Toasts"; 7 | import { Context } from "@/context/Context"; 8 | import { Backend } from "@/backend/Backend"; 9 | import { AlertLevel } from "@/components/Alert"; 10 | import useAuthState from "@/auth/useAuthState"; 11 | import { config } from "@fortawesome/fontawesome-svg-core"; 12 | import "@fortawesome/fontawesome-svg-core/styles.css"; 13 | 14 | config.autoAddCss = false; 15 | 16 | const backend = new Backend(); 17 | 18 | export default function App({ Component, pageProps }: AppProps) { 19 | // Code that we want in the app level, but not when testing components 20 | useEffect(() => { 21 | initFirebase(); 22 | }, []); 23 | 24 | const [toasts, addToast] = useToasts(); 25 | 26 | const [user, authLoading, authError] = useAuthState(); 27 | 28 | const addToastThenScroll = useCallback( 29 | ( 30 | message: string, 31 | level?: AlertLevel, 32 | tag?: string, 33 | removeOthersWithTag?: boolean 34 | ) => { 35 | addToast(message, level, tag, removeOthersWithTag); 36 | window.scrollTo({ top: 0, left: 0, behavior: "smooth" }); 37 | }, 38 | [addToast] 39 | ); 40 | 41 | const ctx = useMemo( 42 | () => ({ 43 | backend, 44 | toasts, 45 | addToast: addToastThenScroll, 46 | user, 47 | authLoading, 48 | }), 49 | [toasts, addToastThenScroll, user, authLoading] 50 | ); 51 | 52 | return ( 53 | 54 | 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /nextjs/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /nextjs/src/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /nextjs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import HeroSectionWithImage from "@/components/HeroSectionWithImage"; 2 | import Layout from "@/components/Layout"; 3 | import { Alert } from "@/components/Alert"; 4 | 5 | export default function Home() { 6 | return ( 7 | <> 8 |
9 | 10 | This is a demo site for 🔥 Firestarter! A starter kit for web apps. 11 | You can see the source on{" "} 12 | 13 | Github 🐙 14 | 15 | 16 |
17 | 18 | 22 | The best offer
23 | for your business 24 | 25 | } 26 | >
27 |
28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /nextjs/src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import Layout from '@/components/Layout' 2 | import LoginForm from '@/components/LoginForm' 3 | import Link from 'next/link' 4 | 5 | export default function Login() { 6 | 7 | return ( 8 | <> 9 | 10 |
11 |

Log in

12 |

Or click here to sign up

13 |
14 |
15 | 16 |
17 |
18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /nextjs/src/pages/profile/index.tsx: -------------------------------------------------------------------------------- 1 | import ProfileForm from '@/components/ProfileForm' 2 | import Layout from '@/components/Layout' 3 | 4 | export default function Signup() { 5 | return ( 6 | <> 7 | 8 |
9 |

Your Profile

10 |
11 |
12 | 13 |
14 |
15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /nextjs/src/pages/resetpassword/index.tsx: -------------------------------------------------------------------------------- 1 | import Layout from '@/components/Layout' 2 | import LoginForm from '@/components/LoginForm' 3 | import ResetPasswordForm from '@/components/ResetPasswordForm' 4 | import Link from 'next/link' 5 | 6 | export default function ResetPassword() { 7 | 8 | return ( 9 | <> 10 | 11 |
12 |

Reset Password

13 |
14 |
15 | 16 |
17 |
18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /nextjs/src/pages/signup/checkinbox.tsx: -------------------------------------------------------------------------------- 1 | import Layout from "@/components/Layout"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { faEnvelope } from "@fortawesome/free-solid-svg-icons"; 4 | import { useContext, useState } from "react"; 5 | import { Context } from "@/context/Context"; 6 | import Link from "next/link"; 7 | 8 | const PERIOD_TO_DISABLE_EMAIL_VERIFICATION_BUTTON_AFTER_USE_MS = 60000; 9 | 10 | export default function Signup() { 11 | const { user, backend, addToast } = useContext(Context); 12 | const [disableEmailVerificationButton, setDisableEmailVerificationButton] = 13 | useState(false); 14 | 15 | const sendEmail = async () => { 16 | if (user && !disableEmailVerificationButton) { 17 | try { 18 | await backend.sendEmailVerification(user); 19 | setDisableEmailVerificationButton(true); 20 | setTimeout( 21 | () => setDisableEmailVerificationButton(false), 22 | PERIOD_TO_DISABLE_EMAIL_VERIFICATION_BUTTON_AFTER_USE_MS 23 | ); 24 | addToast("Your confirmation link has been sent", "success"); 25 | } catch (e) { 26 | console.error(e); 27 | addToast( 28 | "Your confirmation link could not be sent, please try again later. Sorry.", 29 | "danger" 30 | ); 31 | } 32 | } 33 | }; 34 | 35 | return ( 36 | <> 37 | 38 |
39 |
40 |

Check your inbox, please!

41 | 42 |
43 |

44 | To complete sign up, open the email we have sent you, and click the 45 | confirmation link. When you have done this, click here to return to the home page. 46 |

47 | {user && ( 48 | 62 | )} 63 |
64 |
65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /nextjs/src/pages/signup/index.tsx: -------------------------------------------------------------------------------- 1 | import SignupForm from '@/components/SignupForm' 2 | import Layout from '@/components/Layout' 3 | 4 | export default function Signup() { 5 | return ( 6 | <> 7 | 8 |
9 |

Reap the benefits

10 |

Sign up for free now!

11 |
12 |
13 | 14 |
15 |
16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /nextjs/src/pages/todos/index.tsx: -------------------------------------------------------------------------------- 1 | import { Todo, WithId, WithUid } from "@/backend/IBackend"; 2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 3 | import { Alert } from "@/components/Alert"; 4 | import FieldErrorAlert from "@/components/FieldErrorAlert"; 5 | import Layout from "@/components/Layout"; 6 | import { Context } from "@/context/Context"; 7 | import { useContext, useEffect, useState } from "react"; 8 | import { useForm } from "react-hook-form"; 9 | import RequiresLoginNotice from "@/components/RequiresLoginNotice"; 10 | import { faTrash, faToggleOn, faToggleOff } from "@fortawesome/free-solid-svg-icons"; 11 | 12 | type FormData = { 13 | title: string; 14 | }; 15 | 16 | const TodoCollectionName = "todo"; 17 | 18 | export default function Todos() { 19 | const { 20 | register, 21 | handleSubmit, 22 | formState: { errors }, 23 | reset, 24 | } = useForm(); 25 | const { user, backend, addToast, authLoading } = useContext(Context); 26 | 27 | const [todos, setTodos] = useState<(Todo & WithId & WithUid)[]>([]); 28 | const [hasError, setHasError] = useState(false); 29 | const [loading, setLoading] = useState(false); 30 | 31 | const onSubmit = async ({ title }: FormData) => { 32 | if (user) { 33 | const addTodoResult = await backend.addUserItem(TodoCollectionName, user.uid, { title, done: false }); 34 | if (addTodoResult.result === "success") { 35 | todos.push({ id: addTodoResult.id, title, done: false, uid: user.uid }); 36 | reset(); 37 | } else { 38 | addToast( 39 | "Sorry the todo was not added, there was a problem connecting to the server.", 40 | "danger" 41 | ); 42 | } 43 | } 44 | }; 45 | 46 | const toggle = async (id: string) => { 47 | const todo = todos.find((t) => t.id === id); 48 | if (todo) backend.setUserItem(TodoCollectionName, todo.id, { done: !todo.done }); 49 | setTodos((todos) => { 50 | return todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)); 51 | }); 52 | }; 53 | 54 | const deleteTodo = async (id: string) => { 55 | backend.deleteUserItem(TodoCollectionName, id); 56 | setTodos((todos) => { 57 | return todos.filter((t) => t.id !== id); 58 | }); 59 | }; 60 | 61 | useEffect(() => { 62 | if (user) { 63 | setLoading(true); 64 | backend.getUserItems(TodoCollectionName, user.uid).then((todos) => { 65 | if (todos.result === "success") { 66 | setTodos(todos.items); 67 | setLoading(false); 68 | } else { 69 | setHasError(true); 70 | } 71 | }); 72 | } 73 | }, [backend, user, addToast]); 74 | 75 | let content: JSX.Element; 76 | 77 | if (!user && authLoading) { 78 | content = <>; 79 | } else if (!user) { 80 | content = ; 81 | } else if (hasError) { 82 | content = ( 83 |

84 | Sorry there was an issue connecting to the server. Please reload to try 85 | again. 86 |

87 | ); 88 | } else { 89 | content = ( 90 |
91 | {(loading || todos.length) > 0 && ( 92 | 93 | 94 | 95 | 98 | 101 | 104 | 105 | 106 | 107 | {loading ? ( 108 | <> 109 | 110 | 113 | 116 | 119 | 120 | 121 | ) : ( 122 | todos.map((todo, index) => ( 123 | 127 | 130 | 138 | 154 | 155 | )) 156 | )} 157 | 158 |
96 | # 97 | 99 | What 100 | 102 | Actions 103 |
111 |
112 |
114 |
115 |
117 |
118 |
128 | {index + 1} 129 | 136 | {todo.title} 137 | 139 | 146 | 153 |
159 | )} 160 |
161 |
162 |
163 | 171 | 172 |
173 | 176 | {errors.root?.serverError && ( 177 |
178 | {errors.root.serverError.message} 179 |
180 | )} 181 |
182 |
183 |
184 | ); 185 | } 186 | 187 | return ( 188 | <> 189 | 190 |
191 |

192 | Todos 193 |

194 | {content} 195 |
196 |
197 | 198 | ); 199 | } 200 | -------------------------------------------------------------------------------- /nextjs/src/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(4, minmax(25%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 30ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ''; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo, 108 | .thirteen { 109 | position: relative; 110 | } 111 | 112 | .thirteen { 113 | display: flex; 114 | justify-content: center; 115 | align-items: center; 116 | width: 75px; 117 | height: 75px; 118 | padding: 25px 10px; 119 | margin-left: 16px; 120 | transform: translateZ(0); 121 | border-radius: var(--border-radius); 122 | overflow: hidden; 123 | box-shadow: 0px 2px 8px -1px #0000001a; 124 | } 125 | 126 | .thirteen::before, 127 | .thirteen::after { 128 | content: ''; 129 | position: absolute; 130 | z-index: -1; 131 | } 132 | 133 | /* Conic Gradient Animation */ 134 | .thirteen::before { 135 | animation: 6s rotate linear infinite; 136 | width: 200%; 137 | height: 200%; 138 | background: var(--tile-border); 139 | } 140 | 141 | /* Inner Square */ 142 | .thirteen::after { 143 | inset: 0; 144 | padding: 1px; 145 | border-radius: var(--border-radius); 146 | background: linear-gradient( 147 | to bottom right, 148 | rgba(var(--tile-start-rgb), 1), 149 | rgba(var(--tile-end-rgb), 1) 150 | ); 151 | background-clip: content-box; 152 | } 153 | 154 | /* Enable hover only on non-touch devices */ 155 | @media (hover: hover) and (pointer: fine) { 156 | .card:hover { 157 | background: rgba(var(--card-rgb), 0.1); 158 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 159 | } 160 | 161 | .card:hover span { 162 | transform: translateX(4px); 163 | } 164 | } 165 | 166 | @media (prefers-reduced-motion) { 167 | .thirteen::before { 168 | animation: none; 169 | } 170 | 171 | .card:hover span { 172 | transform: none; 173 | } 174 | } 175 | 176 | /* Mobile */ 177 | @media (max-width: 700px) { 178 | .content { 179 | padding: 4rem; 180 | } 181 | 182 | .grid { 183 | grid-template-columns: 1fr; 184 | margin-bottom: 120px; 185 | max-width: 320px; 186 | text-align: center; 187 | } 188 | 189 | .card { 190 | padding: 1rem 2.5rem; 191 | } 192 | 193 | .card h2 { 194 | margin-bottom: 0.5rem; 195 | } 196 | 197 | .center { 198 | padding: 8rem 0 6rem; 199 | } 200 | 201 | .center::before { 202 | transform: none; 203 | height: 300px; 204 | } 205 | 206 | .description { 207 | font-size: 0.8rem; 208 | } 209 | 210 | .description a { 211 | padding: 1rem; 212 | } 213 | 214 | .description p, 215 | .description div { 216 | display: flex; 217 | justify-content: center; 218 | position: fixed; 219 | width: 100%; 220 | } 221 | 222 | .description p { 223 | align-items: center; 224 | inset: 0 0 auto; 225 | padding: 2rem 1rem 1.4rem; 226 | border-radius: 0; 227 | border: none; 228 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 229 | background: linear-gradient( 230 | to bottom, 231 | rgba(var(--background-start-rgb), 1), 232 | rgba(var(--callout-rgb), 0.5) 233 | ); 234 | background-clip: padding-box; 235 | backdrop-filter: blur(24px); 236 | } 237 | 238 | .description div { 239 | align-items: flex-end; 240 | pointer-events: none; 241 | inset: auto 0 0; 242 | padding: 2rem; 243 | height: 200px; 244 | background: linear-gradient( 245 | to bottom, 246 | transparent 0%, 247 | rgb(var(--background-end-rgb)) 40% 248 | ); 249 | z-index: 1; 250 | } 251 | } 252 | 253 | /* Tablet and Smaller Desktop */ 254 | @media (min-width: 701px) and (max-width: 1120px) { 255 | .grid { 256 | grid-template-columns: repeat(2, 50%); 257 | } 258 | } 259 | 260 | @media (prefers-color-scheme: dark) { 261 | .vercelLogo { 262 | filter: invert(1); 263 | } 264 | 265 | .logo, 266 | .thirteen img { 267 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 268 | } 269 | } 270 | 271 | @keyframes rotate { 272 | from { 273 | transform: rotate(360deg); 274 | } 275 | to { 276 | transform: rotate(0deg); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /nextjs/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* Tailwind */ 6 | 7 | .anchor { 8 | @apply underline text-blue-600; 9 | } 10 | 11 | .anchor:hover { 12 | @apply text-blue-800; 13 | } 14 | 15 | .anchor:visited:visited { 16 | @apply text-purple-600; 17 | } 18 | 19 | .button { 20 | @apply px-2 md:px-6 py-2.5 font-medium text-xs leading-tight uppercase rounded hover:shadow-lg focus:shadow-lg focus:outline-none focus:ring-0 active:shadow-lg transition duration-150 ease-in-out; 21 | } 22 | 23 | .button.big { 24 | @apply px-7 py-3 25 | } 26 | 27 | .button.flatwhite { 28 | @apply shadow-none bg-transparent text-blue-600 hover:text-blue-700 hover:bg-gray-100 focus:bg-gray-100 active:bg-gray-200; 29 | } 30 | 31 | .button.red { 32 | @apply shadow-md bg-red-600 text-white hover:bg-red-700 focus:bg-red-700 active:bg-red-800; 33 | } 34 | 35 | .button.blue { 36 | @apply shadow-md bg-blue-600 text-white hover:bg-blue-700 focus:bg-blue-700 active:bg-blue-800; 37 | } 38 | 39 | .button.disabled { 40 | @apply bg-gray-300 text-white hover:bg-gray-300 focus:bg-gray-300 active:bg-gray-300 cursor-auto; 41 | } 42 | 43 | .input { 44 | @apply block w-full px-3 py-1.5 text-base font-normal text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none; 45 | } 46 | 47 | @keyframes contentPlaceholderFrames { 48 | 0% { 49 | background: #eeeeee; 50 | } 51 | 25% { 52 | background: #cccccc; 53 | } 54 | 50% { 55 | background: #eeeeee; 56 | } 57 | 75% { 58 | background: #cccccc; 59 | } 60 | 100% { 61 | background: #eeeeee; 62 | } 63 | } 64 | 65 | .content-placeholder { 66 | animation: contentPlaceholderFrames 2s linear 0s infinite normal none; 67 | } 68 | 69 | .menu-item { 70 | @apply block pr-2 lg:px-2 py-2 text-gray-600 hover:text-gray-900 hover:font-semibold focus:text-gray-900 focus:font-semibold transition duration-150 ease-in-out; 71 | } 72 | 73 | .title { 74 | @apply text-5xl md:text-6xl xl:text-7xl py-6 font-bold tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-indigo-500 to-black; 75 | } 76 | -------------------------------------------------------------------------------- /nextjs/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /nextjs/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | corePlugins: { 8 | aspectRatio: false, 9 | }, 10 | plugins: [ 11 | require('@tailwindcss/aspect-ratio') 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /readmeassets/firestarter-screenshot-1-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/readmeassets/firestarter-screenshot-1-2.png -------------------------------------------------------------------------------- /readmeassets/firestarter-screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/readmeassets/firestarter-screenshot-1.png -------------------------------------------------------------------------------- /readmeassets/firestarter-screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/readmeassets/firestarter-screenshot-2.png -------------------------------------------------------------------------------- /readmeassets/firestarter-screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/readmeassets/firestarter-screenshot-3.png -------------------------------------------------------------------------------- /readmeassets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mcapodici/firestarter/a24aae80b697b2ef8cced421d2685388a6f9b3a8/readmeassets/logo.png --------------------------------------------------------------------------------