├── .gitignore ├── Makefile ├── packages ├── client │ ├── src │ │ ├── react-app-env.d.ts │ │ ├── trpc.ts │ │ ├── libs │ │ │ └── types.ts │ │ ├── components │ │ │ ├── Layout.tsx │ │ │ ├── FullScreenLoader.tsx │ │ │ ├── Message.tsx │ │ │ ├── modals │ │ │ │ └── post.modal.tsx │ │ │ ├── LoadingButton.tsx │ │ │ ├── FormInput.tsx │ │ │ ├── TextInput.tsx │ │ │ ├── requireUser.tsx │ │ │ ├── Spinner.tsx │ │ │ ├── FileUpload.tsx │ │ │ ├── Header.tsx │ │ │ └── posts │ │ │ │ ├── create.post.tsx │ │ │ │ ├── update.post.tsx │ │ │ │ └── post.component.tsx │ │ ├── global.css │ │ ├── lib │ │ │ └── types.ts │ │ ├── index.tsx │ │ ├── store │ │ │ └── index.ts │ │ ├── pages │ │ │ ├── profile.page.tsx │ │ │ ├── home.page.tsx │ │ │ ├── login.page.tsx │ │ │ └── register.page.tsx │ │ ├── router │ │ │ └── index.tsx │ │ ├── App.tsx │ │ └── middleware │ │ │ └── AuthMiddleware.tsx │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── index.html │ ├── postcss.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── package.json │ └── README.md └── server │ ├── tsconfig.json │ ├── src │ ├── utils │ │ ├── connectDB.ts │ │ ├── connectRedis.ts │ │ └── jwt.ts │ ├── controllers │ │ ├── user.controller.ts │ │ ├── post.controller.ts │ │ └── auth.controller.ts │ ├── config │ │ └── default.ts │ ├── services │ │ ├── post.service.ts │ │ └── user.service.ts │ ├── models │ │ ├── post.model.ts │ │ └── user.model.ts │ ├── schema │ │ ├── post.schema.ts │ │ └── user.schema.ts │ ├── middleware │ │ └── deserializeUser.ts │ └── app.ts │ ├── package.json │ └── .env ├── .env ├── package.json ├── docker-compose.yml └── readMe.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | dev: 2 | docker-compose up -d 3 | 4 | dev-down: 5 | docker-compose down -------------------------------------------------------------------------------- /packages/client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | MONGO_INITDB_ROOT_USERNAME=admin 2 | MONGO_INITDB_ROOT_PASSWORD=password123 3 | MONGO_INITDB_DATABASE=trpc_mongodb 4 | -------------------------------------------------------------------------------- /packages/client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpcodevo/trpc-react-node-mongodb/HEAD/packages/client/public/favicon.ico -------------------------------------------------------------------------------- /packages/client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpcodevo/trpc-react-node-mongodb/HEAD/packages/client/public/logo192.png -------------------------------------------------------------------------------- /packages/client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wpcodevo/trpc-react-node-mongodb/HEAD/packages/client/public/logo512.png -------------------------------------------------------------------------------- /packages/client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /packages/client/src/trpc.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from "@trpc/react-query"; 2 | import type { AppRouter } from "server"; 3 | 4 | export const trpc = createTRPCReact(); 5 | -------------------------------------------------------------------------------- /packages/client/src/libs/types.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | name: string; 3 | email: string; 4 | role: string; 5 | photo: string; 6 | _id: string; 7 | id: string; 8 | createdAt: string; 9 | updatedAt: string; 10 | __v: number; 11 | } 12 | -------------------------------------------------------------------------------- /packages/client/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from 'react-router-dom'; 2 | import Header from './Header'; 3 | 4 | const Layout = () => { 5 | return ( 6 | <> 7 |
8 | 9 | 10 | ); 11 | }; 12 | 13 | export default Layout; 14 | -------------------------------------------------------------------------------- /packages/client/src/global.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | @layer base { 8 | html { 9 | font-family: 'Poppins', sans-serif; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trpc-node-react", 3 | "private": "true", 4 | "scripts": { 5 | "start": "concurrently \"wsrun --parallel start\"" 6 | }, 7 | "workspaces": [ 8 | "packages/*" 9 | ], 10 | "devDependencies": { 11 | "concurrently": "^7.2.2", 12 | "wsrun": "^5.2.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "experimentalDecorators": true, 5 | "emitDecoratorMetadata": true, 6 | "module": "commonjs", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "strictPropertyInitialization": false, 11 | "skipLibCheck": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/client/src/components/FullScreenLoader.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from './Spinner'; 2 | 3 | const FullScreenLoader = () => { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | }; 12 | 13 | export default FullScreenLoader; 14 | -------------------------------------------------------------------------------- /packages/server/src/utils/connectDB.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import customConfig from '../config/default'; 3 | 4 | const dbUrl = customConfig.dbUri; 5 | 6 | const connectDB = async () => { 7 | try { 8 | await mongoose.connect(dbUrl); 9 | console.log('🚀 Database connected...'); 10 | } catch (error: any) { 11 | console.log(error); 12 | process.exit(1); 13 | } 14 | }; 15 | 16 | export default connectDB; 17 | -------------------------------------------------------------------------------- /packages/server/src/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { Context } from '../app'; 3 | 4 | export const getMeHandler = ({ ctx }: { ctx: Context }) => { 5 | try { 6 | const user = ctx.user; 7 | return { 8 | status: 'success', 9 | data: { 10 | user, 11 | }, 12 | }; 13 | } catch (err: any) { 14 | throw new TRPCError({ 15 | code: 'INTERNAL_SERVER_ERROR', 16 | message: err.message, 17 | }); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /packages/client/src/components/Message.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | 3 | type IMessageProps = { 4 | children: React.ReactNode; 5 | }; 6 | const Message: FC = ({ children }) => { 7 | return ( 8 |
12 | {children} 13 |
14 | ); 15 | }; 16 | 17 | export default Message; 18 | -------------------------------------------------------------------------------- /packages/client/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | name: string; 3 | email: string; 4 | role: string; 5 | photo: string; 6 | _id: string; 7 | id: string; 8 | createdAt: string; 9 | updatedAt: string; 10 | __v: number; 11 | } 12 | 13 | export type IPost = { 14 | _id: string; 15 | id: string; 16 | title: string; 17 | content: string; 18 | category: string; 19 | image: string; 20 | createdAt: string; 21 | updatedAt: string; 22 | user: { 23 | email: string; 24 | name: string; 25 | photo: string; 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/server/src/utils/connectRedis.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | 3 | const redisUrl = `redis://localhost:6379`; 4 | const redisClient = createClient({ 5 | url: redisUrl, 6 | }); 7 | 8 | const connectRedis = async () => { 9 | try { 10 | await redisClient.connect(); 11 | console.log('🚀 Redis client connected...'); 12 | } catch (err: any) { 13 | console.log(err.message); 14 | process.exit(1); 15 | } 16 | }; 17 | 18 | connectRedis(); 19 | 20 | redisClient.on('error', (err) => console.log(err)); 21 | 22 | export default redisClient; 23 | -------------------------------------------------------------------------------- /packages/client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import { ToastContainer } from "react-toastify"; 5 | import App from "./App"; 6 | import "./global.css"; 7 | import "react-toastify/dist/ReactToastify.css"; 8 | 9 | const root = ReactDOM.createRoot( 10 | document.getElementById("root") as HTMLElement 11 | ); 12 | root.render( 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /packages/client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | mongo: 4 | image: mongo:latest 5 | container_name: mongo 6 | env_file: 7 | - ./.env 8 | environment: 9 | MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME} 10 | MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD} 11 | MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE} 12 | volumes: 13 | - mongo:/data/db 14 | ports: 15 | - '6000:27017' 16 | redis: 17 | image: redis:latest 18 | container_name: redis 19 | ports: 20 | - '6379:6379' 21 | volumes: 22 | - redis:/data 23 | volumes: 24 | mongo: 25 | redis: 26 | -------------------------------------------------------------------------------- /packages/client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 4 | theme: { 5 | extend: { 6 | colors: { 7 | 'ct-dark-600': '#222', 8 | 'ct-dark-200': '#e5e7eb', 9 | 'ct-dark-100': '#f5f6f7', 10 | 'ct-blue-600': '#2363eb', 11 | 'ct-yellow-600': '#f9d13e', 12 | }, 13 | container: { 14 | center: true, 15 | padding: '1rem', 16 | screens: { 17 | lg: '1125px', 18 | xl: '1125px', 19 | '2xl': '1125px', 20 | }, 21 | }, 22 | }, 23 | }, 24 | plugins: [], 25 | }; 26 | -------------------------------------------------------------------------------- /packages/server/src/config/default.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | require('dotenv').config({ path: path.join(__dirname, '../../.env') }); 3 | 4 | const customConfig = { 5 | port: 8000, 6 | accessTokenExpiresIn: 15, 7 | refreshTokenExpiresIn: 60, 8 | origin: 'http://localhost:3000', 9 | 10 | dbUri: process.env.MONGODB_URI as string, 11 | accessTokenPrivateKey: process.env.ACCESS_TOKEN_PRIVATE_KEY as string, 12 | accessTokenPublicKey: process.env.ACCESS_TOKEN_PUBLIC_KEY as string, 13 | refreshTokenPrivateKey: process.env.REFRESH_TOKEN_PRIVATE_KEY as string, 14 | refreshTokenPublicKey: process.env.REFRESH_TOKEN_PUBLIC_KEY as string, 15 | }; 16 | 17 | export default customConfig; 18 | -------------------------------------------------------------------------------- /packages/client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "strictPropertyInitialization": false, 17 | "experimentalDecorators": true, 18 | "emitDecoratorMetadata": true, 19 | "noEmit": true, 20 | "jsx": "react-jsx" 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /packages/client/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import create from "zustand"; 2 | import { IUser } from "../libs/types"; 3 | 4 | type Store = { 5 | authUser: IUser | null; 6 | uploadingImage: boolean; 7 | pageLoading: boolean; 8 | setAuthUser: (user: IUser) => void; 9 | setUploadingImage: (isUploading: boolean) => void; 10 | setPageLoading: (isLoading: boolean) => void; 11 | }; 12 | 13 | const useStore = create((set) => ({ 14 | authUser: null, 15 | uploadingImage: false, 16 | pageLoading: false, 17 | setAuthUser: (user) => set((state) => ({ ...state, authUser: user })), 18 | setUploadingImage: (isUploading) => 19 | set((state) => ({ ...state, uploadingImage: isUploading })), 20 | setPageLoading: (isLoading) => 21 | set((state) => ({ ...state, pageLoading: isLoading })), 22 | })); 23 | 24 | export default useStore; 25 | -------------------------------------------------------------------------------- /packages/server/src/services/post.service.ts: -------------------------------------------------------------------------------- 1 | import { FilterQuery, QueryOptions, UpdateQuery } from 'mongoose'; 2 | import postModel, { Post } from '../models/post.model'; 3 | 4 | export const createPost = async (input: Partial) => { 5 | return postModel.create(input); 6 | }; 7 | 8 | export const getPost = async ( 9 | query: FilterQuery, 10 | options?: QueryOptions 11 | ) => { 12 | return postModel.findOne(query, {}, options); 13 | }; 14 | 15 | export const updatePost = async ( 16 | query: FilterQuery, 17 | update: UpdateQuery, 18 | options: QueryOptions 19 | ) => { 20 | return postModel.findOneAndUpdate(query, update, options); 21 | }; 22 | 23 | export const deletePost = async ( 24 | query: FilterQuery, 25 | options: QueryOptions 26 | ) => { 27 | return postModel.findOneAndDelete(query, options); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/client/src/pages/profile.page.tsx: -------------------------------------------------------------------------------- 1 | import useStore from "../store"; 2 | 3 | const ProfilePage = () => { 4 | const store = useStore(); 5 | const user = store.authUser; 6 | 7 | return ( 8 |
9 |
10 |
11 |

Profile Page

12 |
13 |

ID: {user?.id}

14 |

Name: {user?.name}

15 |

Email: {user?.email}

16 |

Role: {user?.role}

17 |
18 |
19 |
20 |
21 | ); 22 | }; 23 | 24 | export default ProfilePage; 25 | -------------------------------------------------------------------------------- /packages/server/src/models/post.model.ts: -------------------------------------------------------------------------------- 1 | import { getModelForClass, modelOptions, prop } from '@typegoose/typegoose'; 2 | import type { Ref } from '@typegoose/typegoose'; 3 | import { User } from './user.model'; 4 | 5 | @modelOptions({ 6 | schemaOptions: { 7 | // Add createdAt and updatedAt fields 8 | timestamps: true, 9 | }, 10 | }) 11 | 12 | // Export the Post class to be used as TypeScript type 13 | export class Post { 14 | @prop({ unique: true, required: true }) 15 | title: string; 16 | 17 | @prop({ required: true }) 18 | content: string; 19 | 20 | @prop({ required: true }) 21 | category: string; 22 | 23 | @prop({ required: true }) 24 | image: string; 25 | 26 | @prop({ ref: () => User }) 27 | user: Ref; 28 | } 29 | 30 | // Create the post model from the Post class 31 | const postModel = getModelForClass(Post); 32 | export default postModel; 33 | -------------------------------------------------------------------------------- /packages/server/src/utils/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt, { SignOptions } from 'jsonwebtoken'; 2 | import customConfig from '../config/default'; 3 | 4 | export const signJwt = ( 5 | payload: Object, 6 | key: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey', 7 | options: SignOptions = {} 8 | ) => { 9 | const privateKey = Buffer.from(customConfig[key], 'base64').toString('ascii'); 10 | return jwt.sign(payload, privateKey, { 11 | ...(options && options), 12 | algorithm: 'RS256', 13 | }); 14 | }; 15 | 16 | export const verifyJwt = ( 17 | token: string, 18 | key: 'accessTokenPublicKey' | 'refreshTokenPublicKey' 19 | ): T | null => { 20 | try { 21 | const publicKey = Buffer.from(customConfig[key], 'base64').toString( 22 | 'ascii' 23 | ); 24 | return jwt.verify(token, publicKey) as T; 25 | } catch (error) { 26 | console.log(error); 27 | return null; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /packages/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 18 | React App 19 | 20 | 21 | 22 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /packages/client/src/components/modals/post.modal.tsx: -------------------------------------------------------------------------------- 1 | import ReactDom from 'react-dom'; 2 | import React, { FC } from 'react'; 3 | 4 | type IPostModal = { 5 | openPostModal: boolean; 6 | setOpenPostModal: (openPostModal: boolean) => void; 7 | children: React.ReactNode; 8 | }; 9 | 10 | const PostModal: FC = ({ 11 | openPostModal, 12 | setOpenPostModal, 13 | children, 14 | }) => { 15 | if (!openPostModal) return null; 16 | return ReactDom.createPortal( 17 | <> 18 |
setOpenPostModal(false)} 21 | >
22 |
23 | {children} 24 |
25 | , 26 | document.getElementById('post-modal') as HTMLElement 27 | ); 28 | }; 29 | 30 | export default PostModal; 31 | -------------------------------------------------------------------------------- /readMe.md: -------------------------------------------------------------------------------- 1 | # Build tRPC API with React.js, Node.js & MongoDB 2 | 3 | ### 1. Build tRPC API with React.js, Node.js & MongoDB: Project Setup 4 | 5 | [Build tRPC API with React.js, Node.js & MongoDB: Project Setup](https://codevoweb.com/trpc-api-reactjs-nodejs-mongodb-project-setup) 6 | 7 | ### 2. Build tRPC API with React.js & Node.js: Access and Refresh Tokens 8 | 9 | [Build tRPC API with React.js & Node.js: Access and Refresh Tokens](https://codevoweb.com/trpc-api-with-reactjs-nodejs-access-and-refresh-tokens) 10 | 11 | ### 3. Full-Stack App tRPC, React.js, & Node.js: JWT Authentication 12 | 13 | [Full-Stack App tRPC, React.js, & Node.js: JWT Authentication](https://codevoweb.com/fullstack-app-trpc-reactjs-nodejs-jwt-authentication) 14 | 15 | ### 4. Build Full-Stack tRPC CRUD Application with Node.js, and React.js 16 | 17 | [Build Full-Stack tRPC CRUD Application with Node.js, and React.js](https://codevoweb.com/fullstack-trpc-crud-application-with-nodejs-and-reactjs) 18 | -------------------------------------------------------------------------------- /packages/client/src/components/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Spinner from './Spinner'; 3 | 4 | type LoadingButtonProps = { 5 | loading: boolean; 6 | btnColor?: string; 7 | textColor?: string; 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const LoadingButton: React.FC = ({ 12 | textColor = 'text-white', 13 | btnColor = 'bg-ct-yellow-600', 14 | children, 15 | loading = false, 16 | }) => { 17 | return ( 18 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/client/src/components/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useFormContext } from 'react-hook-form'; 3 | 4 | type FormInputProps = { 5 | label: string; 6 | name: string; 7 | type?: string; 8 | }; 9 | 10 | const FormInput: React.FC = ({ 11 | label, 12 | name, 13 | type = 'text', 14 | }) => { 15 | const { 16 | register, 17 | formState: { errors }, 18 | } = useFormContext(); 19 | return ( 20 |
21 | 24 | 30 | {errors[name] && ( 31 | 32 | {errors[name]?.message as string} 33 | 34 | )} 35 |
36 | ); 37 | }; 38 | 39 | export default FormInput; 40 | -------------------------------------------------------------------------------- /packages/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "main": "src/app.ts", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "ts-node-dev --respawn --transpile-only src/app.ts" 8 | }, 9 | "dependencies": { 10 | "@trpc/server": "^10.0.0-proxy-beta.26", 11 | "@typegoose/typegoose": "^9.12.1", 12 | "bcryptjs": "^2.4.3", 13 | "cookie": "^0.5.0", 14 | "cookie-parser": "^1.4.6", 15 | "cors": "^2.8.5", 16 | "dotenv": "^16.0.3", 17 | "express": "^4.18.2", 18 | "jsonwebtoken": "^8.5.1", 19 | "lodash": "^4.17.21", 20 | "mongoose": "^6.7.0", 21 | "redis": "^4.3.1", 22 | "zod": "^3.19.1" 23 | }, 24 | "devDependencies": { 25 | "@types/bcryptjs": "^2.4.2", 26 | "@types/cookie-parser": "^1.4.3", 27 | "@types/cors": "^2.8.12", 28 | "@types/jsonwebtoken": "^8.5.9", 29 | "@types/lodash": "^4.14.186", 30 | "@types/morgan": "^1.9.3", 31 | "morgan": "^1.10.0", 32 | "ts-node-dev": "^2.0.0", 33 | "typescript": "^4.8.4" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/server/src/schema/post.schema.ts: -------------------------------------------------------------------------------- 1 | import { number, object, string, TypeOf } from 'zod'; 2 | 3 | export const createPostSchema = object({ 4 | title: string({ 5 | required_error: 'Title is required', 6 | }), 7 | category: string({ 8 | required_error: 'Category is required', 9 | }), 10 | content: string({ 11 | required_error: 'Content is required', 12 | }), 13 | image: string({ 14 | required_error: 'Image is required', 15 | }), 16 | }); 17 | 18 | export const params = object({ 19 | postId: string(), 20 | }); 21 | 22 | export const updatePostSchema = object({ 23 | params, 24 | body: object({ 25 | title: string(), 26 | category: string(), 27 | content: string(), 28 | image: string(), 29 | }).partial(), 30 | }); 31 | 32 | export const filterQuery = object({ 33 | limit: number().default(1), 34 | page: number().default(10), 35 | }); 36 | 37 | export type CreatePostInput = TypeOf; 38 | export type ParamsInput = TypeOf; 39 | export type UpdatePostInput = TypeOf['body']; 40 | export type FilterQueryInput = TypeOf; 41 | -------------------------------------------------------------------------------- /packages/server/src/schema/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { object, string, TypeOf } from "zod"; 2 | 3 | export const createUserSchema = object({ 4 | name: string({ required_error: "Name is required" }), 5 | email: string({ required_error: "Email is required" }).email("Invalid email"), 6 | photo: string({ required_error: "Photo is required" }), 7 | password: string({ required_error: "Password is required" }) 8 | .min(8, "Password must be more than 8 characters") 9 | .max(32, "Password must be less than 32 characters"), 10 | passwordConfirm: string({ required_error: "Please confirm your password" }), 11 | }).refine((data) => data.password === data.passwordConfirm, { 12 | path: ["passwordConfirm"], 13 | message: "Passwords do not match", 14 | }); 15 | 16 | export const loginUserSchema = object({ 17 | email: string({ required_error: "Email is required" }).email( 18 | "Invalid email or password" 19 | ), 20 | password: string({ required_error: "Password is required" }).min( 21 | 8, 22 | "Invalid email or password" 23 | ), 24 | }); 25 | 26 | export type CreateUserInput = TypeOf; 27 | export type LoginUserInput = TypeOf; 28 | -------------------------------------------------------------------------------- /packages/client/src/components/TextInput.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useFormContext } from 'react-hook-form'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | type TextInputProps = { 6 | label: string; 7 | name: string; 8 | type?: string; 9 | }; 10 | 11 | const TextInput: React.FC = ({ 12 | label, 13 | name, 14 | type = 'text', 15 | }) => { 16 | const { 17 | register, 18 | formState: { errors }, 19 | } = useFormContext(); 20 | return ( 21 |
22 | 25 | 33 |

39 | {errors[name]?.message as string} 40 |

41 |
42 | ); 43 | }; 44 | 45 | export default TextInput; 46 | -------------------------------------------------------------------------------- /packages/server/src/models/user.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getModelForClass, 3 | index, 4 | modelOptions, 5 | pre, 6 | prop, 7 | } from '@typegoose/typegoose'; 8 | import bcrypt from 'bcryptjs'; 9 | 10 | @index({ email: 1 }) 11 | @pre('save', async function () { 12 | // Hash password if the password is new or was updated 13 | if (!this.isModified('password')) return; 14 | 15 | // Hash password with costFactor of 12 16 | this.password = await bcrypt.hash(this.password, 12); 17 | }) 18 | @modelOptions({ 19 | schemaOptions: { 20 | // Add createdAt and updatedAt fields 21 | timestamps: true, 22 | }, 23 | }) 24 | 25 | // Export the User class to be used as TypeScript type 26 | export class User { 27 | @prop() 28 | name: string; 29 | 30 | @prop({ unique: true, required: true }) 31 | email: string; 32 | 33 | @prop({ required: true, minlength: 8, maxLength: 32, select: false }) 34 | password: string; 35 | 36 | @prop({ default: 'user' }) 37 | role: string; 38 | 39 | @prop({ required: true }) 40 | photo: string; 41 | 42 | // Instance method to check if passwords match 43 | async comparePasswords(hashedPassword: string, candidatePassword: string) { 44 | return await bcrypt.compare(candidatePassword, hashedPassword); 45 | } 46 | } 47 | 48 | // Create the user model from the User class 49 | const userModel = getModelForClass(User); 50 | export default userModel; 51 | -------------------------------------------------------------------------------- /packages/client/src/router/index.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense, lazy } from 'react'; 2 | import type { RouteObject } from 'react-router-dom'; 3 | import FullScreenLoader from '../components/FullScreenLoader'; 4 | import Layout from '../components/Layout'; 5 | import RequireUser from '../components/requireUser'; 6 | import HomePage from '../pages/home.page'; 7 | import LoginPage from '../pages/login.page'; 8 | import ProfilePage from '../pages/profile.page'; 9 | 10 | const Loadable = 11 | (Component: React.ComponentType) => (props: JSX.IntrinsicAttributes) => 12 | ( 13 | }> 14 | 15 | 16 | ); 17 | 18 | const RegisterPage = Loadable(lazy(() => import('../pages/register.page'))); 19 | 20 | const authRoutes: RouteObject = { 21 | path: '*', 22 | children: [ 23 | { 24 | path: 'login', 25 | element: , 26 | }, 27 | { 28 | path: 'register', 29 | element: , 30 | }, 31 | ], 32 | }; 33 | 34 | const normalRoutes: RouteObject = { 35 | path: '*', 36 | element: , 37 | children: [ 38 | { 39 | index: true, 40 | element: , 41 | }, 42 | { 43 | path: 'profile', 44 | element: , 45 | children: [{ path: '', element: }], 46 | }, 47 | ], 48 | }; 49 | 50 | const routes: RouteObject[] = [authRoutes, normalRoutes]; 51 | 52 | export default routes; 53 | -------------------------------------------------------------------------------- /packages/client/src/pages/home.page.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useCookies } from "react-cookie"; 3 | import { useNavigate } from "react-router-dom"; 4 | import { toast } from "react-toastify"; 5 | import Message from "../components/Message"; 6 | import PostItem from "../components/posts/post.component"; 7 | import useStore from "../store"; 8 | import { trpc } from "../trpc"; 9 | 10 | const HomePage = () => { 11 | const [cookies] = useCookies(["logged_in"]); 12 | const store = useStore(); 13 | const navigate = useNavigate(); 14 | const { data: posts } = trpc.getPosts.useQuery( 15 | { limit: 10, page: 1 }, 16 | { 17 | select: (data) => data.data.posts, 18 | retry: 1, 19 | onSuccess: (data) => { 20 | store.setPageLoading(false); 21 | }, 22 | onError(error: any) { 23 | store.setPageLoading(false); 24 | toast(error.message, { 25 | type: "error", 26 | position: "top-right", 27 | }); 28 | }, 29 | } 30 | ); 31 | 32 | useEffect(() => { 33 | if (!cookies.logged_in) { 34 | navigate("/login"); 35 | } 36 | }, [cookies.logged_in, navigate]); 37 | 38 | return ( 39 | <> 40 |
41 |
42 | {posts?.length === 0 ? ( 43 | There are no posts at the moment 44 | ) : ( 45 |
46 | {posts?.map((post: any) => ( 47 | 48 | ))} 49 |
50 | )} 51 |
52 |
53 | 54 | ); 55 | }; 56 | 57 | export default HomePage; 58 | -------------------------------------------------------------------------------- /packages/client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; 3 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 4 | import { useRoutes } from "react-router-dom"; 5 | import { getFetch, httpBatchLink, loggerLink } from "@trpc/client"; 6 | import routes from "./router"; 7 | import { trpc } from "./trpc"; 8 | import AuthMiddleware from "./middleware/AuthMiddleware"; 9 | import { CookiesProvider } from "react-cookie"; 10 | 11 | function AppContent() { 12 | const content = useRoutes(routes); 13 | return content; 14 | } 15 | 16 | function App() { 17 | const [queryClient] = useState( 18 | () => 19 | new QueryClient({ 20 | defaultOptions: { 21 | queries: { 22 | staleTime: 5 * 1000, 23 | }, 24 | }, 25 | }) 26 | ); 27 | 28 | const [trpcClient] = useState(() => 29 | trpc.createClient({ 30 | links: [ 31 | loggerLink(), 32 | httpBatchLink({ 33 | url: "http://localhost:8000/api/trpc", 34 | fetch: async (input, init?) => { 35 | const fetch = getFetch(); 36 | return fetch(input, { 37 | ...init, 38 | credentials: "include", 39 | }); 40 | }, 41 | }), 42 | ], 43 | }) 44 | ); 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export default App; 60 | -------------------------------------------------------------------------------- /packages/client/src/components/requireUser.tsx: -------------------------------------------------------------------------------- 1 | import { useCookies } from "react-cookie"; 2 | import { Navigate, Outlet, useLocation } from "react-router-dom"; 3 | import { IUser } from "../lib/types"; 4 | import useStore from "../store"; 5 | import { trpc } from "../trpc"; 6 | import FullScreenLoader from "./FullScreenLoader"; 7 | 8 | const RequireUser = ({ allowedRoles }: { allowedRoles: string[] }) => { 9 | const [cookies] = useCookies(["logged_in"]); 10 | const location = useLocation(); 11 | const store = useStore(); 12 | 13 | const { isLoading, isFetching } = trpc.getMe.useQuery(undefined, { 14 | retry: 1, 15 | select: (data) => data.data.user, 16 | onSuccess: (data) => { 17 | store.setPageLoading(false); 18 | store.setAuthUser(data as IUser); 19 | }, 20 | onError: (error) => { 21 | let retryRequest = true; 22 | store.setPageLoading(false); 23 | if (error.message.includes("must be logged in") && retryRequest) { 24 | retryRequest = false; 25 | try { 26 | } catch (err: any) { 27 | console.log(err); 28 | if (err.message.includes("Could not refresh access token")) { 29 | document.location.href = "/login"; 30 | } 31 | } 32 | } 33 | }, 34 | }); 35 | 36 | const loading = isLoading || isFetching; 37 | const user = store.authUser; 38 | 39 | if (loading) { 40 | return ; 41 | } 42 | 43 | return (cookies.logged_in || user) && 44 | allowedRoles.includes(user?.role as string) ? ( 45 | 46 | ) : cookies.logged_in && user ? ( 47 | 48 | ) : ( 49 | 50 | ); 51 | }; 52 | 53 | export default RequireUser; 54 | -------------------------------------------------------------------------------- /packages/server/src/middleware/deserializeUser.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import * as trpcExpress from '@trpc/server/adapters/express'; 3 | import { findUserById } from '../services/user.service'; 4 | import redisClient from '../utils/connectRedis'; 5 | import { verifyJwt } from '../utils/jwt'; 6 | 7 | export const deserializeUser = async ({ 8 | req, 9 | res, 10 | }: trpcExpress.CreateExpressContextOptions) => { 11 | try { 12 | // Get the token 13 | let access_token; 14 | if ( 15 | req.headers.authorization && 16 | req.headers.authorization.startsWith('Bearer') 17 | ) { 18 | access_token = req.headers.authorization.split(' ')[1]; 19 | } else if (req.cookies.access_token) { 20 | access_token = req.cookies.access_token; 21 | } 22 | 23 | const notAuthenticated = { 24 | req, 25 | res, 26 | user: null, 27 | }; 28 | 29 | if (!access_token) { 30 | return notAuthenticated; 31 | } 32 | 33 | // Validate Access Token 34 | const decoded = verifyJwt<{ sub: string }>( 35 | access_token, 36 | 'accessTokenPublicKey' 37 | ); 38 | 39 | if (!decoded) { 40 | return notAuthenticated; 41 | } 42 | 43 | // Check if user has a valid session 44 | const session = await redisClient.get(decoded.sub); 45 | 46 | if (!session) { 47 | return notAuthenticated; 48 | } 49 | 50 | // Check if user still exist 51 | const user = await findUserById(JSON.parse(session)._id); 52 | 53 | if (!user) { 54 | return notAuthenticated; 55 | } 56 | 57 | return { 58 | req, 59 | res, 60 | user: { ...user, id: user._id.toString() }, 61 | }; 62 | } catch (err: any) { 63 | throw new TRPCError({ 64 | code: 'INTERNAL_SERVER_ERROR', 65 | message: err.message, 66 | }); 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /packages/client/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { twMerge } from 'tailwind-merge'; 3 | type SpinnerProps = { 4 | width?: number; 5 | height?: number; 6 | color?: string; 7 | bgColor?: string; 8 | }; 9 | const Spinner: React.FC = ({ 10 | width = 5, 11 | height = 5, 12 | color, 13 | bgColor, 14 | }) => { 15 | return ( 16 | 26 | 30 | 34 | 35 | ); 36 | }; 37 | 38 | export default Spinner; 39 | -------------------------------------------------------------------------------- /packages/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@hookform/resolvers": "^2.9.10", 7 | "@tanstack/react-query": "^4.13.0", 8 | "@tanstack/react-query-devtools": "^4.13.0", 9 | "@testing-library/jest-dom": "^5.16.5", 10 | "@testing-library/react": "^13.4.0", 11 | "@testing-library/user-event": "^14.4.3", 12 | "@trpc/client": "^10.0.0-proxy-beta.26", 13 | "@trpc/react": "^9.27.4", 14 | "@trpc/react-query": "^10.0.0-proxy-beta.26", 15 | "@trpc/server": "^10.0.0-proxy-beta.26", 16 | "@types/jest": "^29.2.0", 17 | "@types/node": "^18.11.6", 18 | "@types/react": "^18.0.23", 19 | "@types/react-dom": "^18.0.7", 20 | "react": "^18.2.0", 21 | "react-cookie": "^4.1.1", 22 | "react-dom": "^18.2.0", 23 | "react-hook-form": "^7.38.0", 24 | "react-router-dom": "^6.4.2", 25 | "react-scripts": "5.0.1", 26 | "react-toastify": "^9.0.8", 27 | "server": "^1.0.37", 28 | "tailwind-merge": "^1.7.0", 29 | "typescript": "^4.8.4", 30 | "web-vitals": "^3.0.4", 31 | "zod": "^3.19.1", 32 | "zustand": "^4.1.3" 33 | }, 34 | "scripts": { 35 | "start": "react-scripts start", 36 | "build": "react-scripts build", 37 | "test": "react-scripts test", 38 | "eject": "react-scripts eject" 39 | }, 40 | "eslintConfig": { 41 | "extends": [ 42 | "react-app", 43 | "react-app/jest" 44 | ] 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.2%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | }, 58 | "devDependencies": { 59 | "autoprefixer": "^10.4.12", 60 | "postcss": "^8.4.18", 61 | "tailwindcss": "^3.2.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/client/src/middleware/AuthMiddleware.tsx: -------------------------------------------------------------------------------- 1 | import { useCookies } from "react-cookie"; 2 | import FullScreenLoader from "../components/FullScreenLoader"; 3 | import React from "react"; 4 | import { trpc } from "../trpc"; 5 | import { IUser } from "../libs/types"; 6 | import { useQueryClient } from "@tanstack/react-query"; 7 | import useStore from "../store"; 8 | 9 | type AuthMiddlewareProps = { 10 | children: React.ReactElement; 11 | }; 12 | 13 | const AuthMiddleware: React.FC = ({ children }) => { 14 | const [cookies] = useCookies(["logged_in"]); 15 | const store = useStore(); 16 | 17 | const queryClient = useQueryClient(); 18 | const { refetch } = trpc.refreshToken.useQuery(undefined, { 19 | retry: 1, 20 | enabled: false, 21 | onSuccess: (data) => { 22 | queryClient.invalidateQueries([["getMe"]]); 23 | }, 24 | onError: (error) => { 25 | document.location.href = "/login"; 26 | }, 27 | }); 28 | 29 | const query = trpc.getMe.useQuery(undefined, { 30 | retry: 1, 31 | enabled: !!cookies.logged_in, 32 | select: (data) => data.data.user, 33 | onSuccess: (data) => { 34 | store.setAuthUser(data as IUser); 35 | }, 36 | onError: (error) => { 37 | let retryRequest = true; 38 | if (error.message.includes("must be logged in") && retryRequest) { 39 | retryRequest = false; 40 | try { 41 | refetch({ throwOnError: true }); 42 | } catch (err: any) { 43 | console.log(err); 44 | if (err.message.includes("Could not refresh access token")) { 45 | document.location.href = "/login"; 46 | } 47 | } 48 | } 49 | }, 50 | }); 51 | 52 | if (query.isLoading && cookies.logged_in) { 53 | console.log("Is loading..."); 54 | return ; 55 | } 56 | 57 | return children; 58 | }; 59 | 60 | export default AuthMiddleware; 61 | -------------------------------------------------------------------------------- /packages/server/src/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { omit } from 'lodash'; 2 | import { FilterQuery, QueryOptions } from 'mongoose'; 3 | import userModel, { User } from '../models/user.model'; 4 | import { signJwt } from '../utils/jwt'; 5 | import redisClient from '../utils/connectRedis'; 6 | import { DocumentType } from '@typegoose/typegoose'; 7 | import customConfig from '../config/default'; 8 | 9 | // Exclude this fields from the response 10 | export const excludedFields = ['password']; 11 | 12 | // CreateUser service 13 | export const createUser = async (input: Partial) => { 14 | const user = await userModel.create(input); 15 | return omit(user.toJSON(), excludedFields); 16 | }; 17 | 18 | // Find User by Id 19 | export const findUserById = async (id: string) => { 20 | const user = await userModel.findById(id).lean(); 21 | return omit(user, excludedFields); 22 | }; 23 | 24 | // Find All users 25 | export const findAllUsers = async () => { 26 | return await userModel.find(); 27 | }; 28 | 29 | // Find one user by any fields 30 | export const findUser = async ( 31 | query: FilterQuery, 32 | options: QueryOptions = {} 33 | ) => { 34 | return await userModel.findOne(query, {}, options).select('+password'); 35 | }; 36 | 37 | // Sign Token 38 | export const signToken = async (user: DocumentType) => { 39 | const userId = user._id.toString(); 40 | // Sign the access token 41 | const access_token = signJwt({ sub: userId }, 'accessTokenPrivateKey', { 42 | expiresIn: `${customConfig.accessTokenExpiresIn}m`, 43 | }); 44 | 45 | // Sign the refresh token 46 | const refresh_token = signJwt({ sub: userId }, 'refreshTokenPrivateKey', { 47 | expiresIn: `${customConfig.refreshTokenExpiresIn}m`, 48 | }); 49 | 50 | // Create a Session 51 | redisClient.set(userId, JSON.stringify(user), { 52 | EX: 60 * 60, 53 | }); 54 | 55 | // Return access token 56 | return { access_token, refresh_token }; 57 | }; 58 | -------------------------------------------------------------------------------- /packages/server/.env: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | PORT=3000 3 | 4 | ORIGIN=http://localhost:3000 5 | 6 | MONGODB_URI=mongodb://admin:password123@localhost:6000/trpc_mongodb?authSource=admin 7 | 8 | ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBTlFLQStSV2ZQZFdHR25iYS9WRVo1TUs5cG1nMUlQay9paEE5dXF2Ny8rNVlzRjNUVURoCnFHZXN1bGJhdFFGdkNPaHVmSlNJQmFWT3RjbVZrTWZoWmRrQ0F3RUFBUUpBYkVlTkF6NnpaQzhBR3BhbGc4TmgKelBJdFNmaWFiWnd6dWVTcTh0L1RoRmQrUGhqN2IxTmphdjBMTjNGamhycjlzV3B2UjBBNW13OFpoSUFUNzZMUgpzUUloQU95Zmdhdy9BSTVoeGs3NmtWaVRRV0JNdjdBeERwdi9oSG1aUFdxclpyL1ZBaUVBNVdjalpmK0NaYlhTCnlpV3dUbEVENGVZQ3BSNk16Qk8wbFVhbExKdVRFL1VDSUhWTWZSUE9CNUNObDZqL1BaNFRJWTJEZm1MeGJyU1cKYmkxNWNhQzNaekFoQWlBNmUrVG1hQkdTWkp4c3ROY1I0RTJoRmNhdTJlOERTRExOcThrSWFsRkEwUUloQUlwUApUODFlWlNzYmVrNTlidGJPZ3J3bTJBdzJqUVk4TitJa3FMSTNySWFFCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t 9 | ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBTlFLQStSV2ZQZFdHR25iYS9WRVo1TUs5cG1nMUlQawovaWhBOXVxdjcvKzVZc0YzVFVEaHFHZXN1bGJhdFFGdkNPaHVmSlNJQmFWT3RjbVZrTWZoWmRrQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ== 10 | 11 | REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBSnBBM08xOEZQRWtQR3lGZzUrS0xQUjJuSWFsQk1UeXo2bjJhdG1xQVNJZUFIMVBjeDRHCmZWV0pCWjRQUTBGTzlRYzBGYmxwMzB4UTl3WVpYSnBOVDdFQ0F3RUFBUUpBT1JwTDd1cGhRa2VjeXJ1K1Z5QXEKdGpEMmp1Mmx6MWJudzA2Q2phTmVtZ2NWMk9Fa25lbGplQTZOZGNGT3h6N0hRbTduRVVBbXJLV1JBM2htZ2hyNApRUUloQU96RmNGRmJuOUdoSzFrZ0RidWNqSFJYS2JEekcrQXBXbDlzTFVEZGJGMnBBaUVBcHNmWTZWdmJoTU5tCjlEcy9HRHNMZVhKaVVVWG9HNjUveldVQUJTRlpWc2tDSVFDcmFZMFUrWFpNdDVmQVlGcFExdGRBYXRIK0R5TEIKT0c3NjRrQW8wNlRlY1FJZ0gzb2ViVVNoOUxld2FhMzQ1WWpYVEkrVEVNWEIzZCtjVFZhZm4xaEE5VWtDSURNcApCMnVmMk85TDBENm1FbTBkSE5HZU5ITk9yMUhrRC9ZWjBWWFFESFgyCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t 12 | REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBSnBBM08xOEZQRWtQR3lGZzUrS0xQUjJuSWFsQk1UeQp6Nm4yYXRtcUFTSWVBSDFQY3g0R2ZWV0pCWjRQUTBGTzlRYzBGYmxwMzB4UTl3WVpYSnBOVDdFQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ== 13 | -------------------------------------------------------------------------------- /packages/client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /packages/client/src/components/FileUpload.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | import { Controller, useController, useFormContext } from 'react-hook-form'; 3 | import useStore from '../store'; 4 | import Spinner from './Spinner'; 5 | 6 | type FileUpLoaderProps = { 7 | name: string; 8 | }; 9 | const FileUpLoader: React.FC = ({ name }) => { 10 | const { 11 | control, 12 | formState: { errors }, 13 | } = useFormContext(); 14 | const { field } = useController({ name, control }); 15 | const store = useStore(); 16 | 17 | const onFileDrop = useCallback( 18 | async (e: React.SyntheticEvent) => { 19 | const target = e.target as HTMLInputElement; 20 | if (!target.files || target.files.length === 0) return; 21 | const newFile = Object.values(target.files).map((file: File) => file); 22 | const formData = new FormData(); 23 | formData.append('file', newFile[0]); 24 | formData.append('upload_preset', 'trpc-api'); 25 | 26 | store.setUploadingImage(true); 27 | const data = await fetch( 28 | 'https://api.cloudinary.com/v1_1/Codevo/image/upload', 29 | { 30 | method: 'POST', 31 | body: formData, 32 | } 33 | ) 34 | .then((res) => { 35 | store.setUploadingImage(false); 36 | 37 | return res.json(); 38 | }) 39 | .catch((err) => { 40 | store.setUploadingImage(false); 41 | console.log(err); 42 | }); 43 | 44 | if (data.secure_url) { 45 | field.onChange(data.secure_url); 46 | } 47 | }, 48 | 49 | [field, store] 50 | ); 51 | 52 | return ( 53 | ( 58 | <> 59 |
60 |
61 | Choose profile photo 62 | 72 |
73 |
74 | {store.uploadingImage && } 75 |
76 |
77 |

82 | {errors[name] && (errors[name]?.message as string)} 83 |

84 | 85 | )} 86 | /> 87 | ); 88 | }; 89 | 90 | export default FileUpLoader; 91 | -------------------------------------------------------------------------------- /packages/client/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useQueryClient } from "@tanstack/react-query"; 3 | import { Link } from "react-router-dom"; 4 | import useStore from "../store"; 5 | import { trpc } from "../trpc"; 6 | import PostModal from "./modals/post.modal"; 7 | import CreatePost from "./posts/create.post"; 8 | import Spinner from "./Spinner"; 9 | 10 | const Header = () => { 11 | const [openPostModal, setOpenPostModal] = useState(false); 12 | const store = useStore(); 13 | const user = store.authUser; 14 | 15 | const queryClient = useQueryClient(); 16 | const { mutate: logoutUser } = trpc.logoutUser.useMutation({ 17 | onSuccess(data) { 18 | queryClient.clear(); 19 | document.location.href = "/login"; 20 | }, 21 | onError(error) { 22 | queryClient.clear(); 23 | document.location.href = "/login"; 24 | }, 25 | }); 26 | 27 | const handleLogout = () => { 28 | logoutUser(); 29 | }; 30 | 31 | return ( 32 | <> 33 |
34 | 80 |
81 | 85 | 86 | 87 |
88 | {store.pageLoading && } 89 |
90 | 91 | ); 92 | }; 93 | 94 | export default Header; 95 | -------------------------------------------------------------------------------- /packages/client/src/pages/login.page.tsx: -------------------------------------------------------------------------------- 1 | import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; 2 | import { object, string, TypeOf } from "zod"; 3 | import { zodResolver } from "@hookform/resolvers/zod"; 4 | import { useEffect } from "react"; 5 | import { Link, useNavigate } from "react-router-dom"; 6 | import FormInput from "../components/FormInput"; 7 | import { LoadingButton } from "../components/LoadingButton"; 8 | import { toast } from "react-toastify"; 9 | import { trpc } from "../trpc"; 10 | 11 | const loginSchema = object({ 12 | email: string() 13 | .min(1, "Email address is required") 14 | .email("Email Address is invalid"), 15 | password: string() 16 | .min(1, "Password is required") 17 | .min(8, "Password must be more than 8 characters") 18 | .max(32, "Password must be less than 32 characters"), 19 | }); 20 | 21 | export type LoginInput = TypeOf; 22 | 23 | const LoginPage = () => { 24 | const navigate = useNavigate(); 25 | 26 | const { isLoading, mutate: loginUser } = trpc.loginUser.useMutation({ 27 | onSuccess(data) { 28 | toast("Logged in successfully", { 29 | type: "success", 30 | position: "top-right", 31 | }); 32 | navigate("/profile"); 33 | }, 34 | onError(error) { 35 | toast(error.message, { 36 | type: "error", 37 | position: "top-right", 38 | }); 39 | }, 40 | }); 41 | 42 | const methods = useForm({ 43 | resolver: zodResolver(loginSchema), 44 | }); 45 | 46 | const { 47 | reset, 48 | handleSubmit, 49 | formState: { isSubmitSuccessful }, 50 | } = methods; 51 | 52 | useEffect(() => { 53 | if (isSubmitSuccessful) { 54 | reset(); 55 | } 56 | // eslint-disable-next-line react-hooks/exhaustive-deps 57 | }, [isSubmitSuccessful]); 58 | 59 | const onSubmitHandler: SubmitHandler = (values) => { 60 | // 👇 Executing the loginUser Mutation 61 | loginUser(values); 62 | }; 63 | 64 | return ( 65 |
66 |
67 |

68 | Welcome Back 69 |

70 |

71 | Login to have access 72 |

73 | 74 |
78 | 79 | 80 | 81 |
82 | 83 | Forgot Password? 84 | 85 |
86 | 87 | Login 88 | 89 | 90 | Need an account?{" "} 91 | 92 | Sign Up Here 93 | 94 | 95 | 96 |
97 |
98 |
99 | ); 100 | }; 101 | 102 | export default LoginPage; 103 | -------------------------------------------------------------------------------- /packages/server/src/controllers/post.controller.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError } from '@trpc/server'; 2 | import { Context } from '../app'; 3 | import postModel from '../models/post.model'; 4 | import { 5 | CreatePostInput, 6 | FilterQueryInput, 7 | ParamsInput, 8 | UpdatePostInput, 9 | } from '../schema/post.schema'; 10 | import { 11 | createPost, 12 | deletePost, 13 | getPost, 14 | updatePost, 15 | } from '../services/post.service'; 16 | import { findUserById } from '../services/user.service'; 17 | 18 | export const createPostHandler = async ({ 19 | input, 20 | ctx, 21 | }: { 22 | input: CreatePostInput; 23 | ctx: Context; 24 | }) => { 25 | try { 26 | const userId = ctx.user!.id; 27 | const user = await findUserById(userId); 28 | 29 | const post = await createPost({ 30 | title: input.title, 31 | content: input.content, 32 | category: input.category, 33 | image: input.image, 34 | user: user._id, 35 | }); 36 | 37 | return { 38 | status: 'success', 39 | data: { 40 | post, 41 | }, 42 | }; 43 | } catch (err: any) { 44 | if (err.code === '11000') { 45 | throw new TRPCError({ 46 | code: 'CONFLICT', 47 | message: 'Post with that title already exists', 48 | }); 49 | } 50 | throw err; 51 | } 52 | }; 53 | 54 | export const getPostHandler = async ({ 55 | paramsInput, 56 | }: { 57 | paramsInput: ParamsInput; 58 | }) => { 59 | try { 60 | const post = await getPost({ _id: paramsInput.postId }, { lean: true }); 61 | 62 | if (!post) { 63 | throw new TRPCError({ 64 | code: 'NOT_FOUND', 65 | message: 'Post with that ID not found', 66 | }); 67 | } 68 | 69 | return { 70 | status: 'success', 71 | data: { 72 | post, 73 | }, 74 | }; 75 | } catch (err: any) { 76 | throw err; 77 | } 78 | }; 79 | 80 | export const getPostsHandler = async ({ 81 | filterQuery, 82 | }: { 83 | filterQuery: FilterQueryInput; 84 | }) => { 85 | try { 86 | const limit = filterQuery.limit || 10; 87 | const page = filterQuery.page || 1; 88 | const skip = (page - 1) * limit; 89 | const posts = await postModel 90 | .find() 91 | .skip(skip) 92 | .limit(limit) 93 | .populate({ path: 'user', select: 'name email photo' }); 94 | 95 | return { 96 | status: 'success', 97 | results: posts.length, 98 | data: { 99 | posts, 100 | }, 101 | }; 102 | } catch (err: any) { 103 | throw new TRPCError({ 104 | code: 'INTERNAL_SERVER_ERROR', 105 | message: err.message, 106 | }); 107 | } 108 | }; 109 | 110 | export const updatePostHandler = async ({ 111 | paramsInput, 112 | input, 113 | }: { 114 | paramsInput: ParamsInput; 115 | input: UpdatePostInput; 116 | }) => { 117 | try { 118 | const post = await updatePost({ _id: paramsInput.postId }, input, { 119 | lean: true, 120 | }); 121 | 122 | if (!post) { 123 | throw new TRPCError({ 124 | code: 'NOT_FOUND', 125 | message: 'Post with that ID not found', 126 | }); 127 | } 128 | 129 | return { 130 | status: 'success', 131 | data: { 132 | post, 133 | }, 134 | }; 135 | } catch (err: any) { 136 | throw err; 137 | } 138 | }; 139 | 140 | export const deletePostHandler = async ({ 141 | paramsInput, 142 | }: { 143 | paramsInput: ParamsInput; 144 | }) => { 145 | try { 146 | const post = await deletePost({ _id: paramsInput.postId }, { lean: true }); 147 | 148 | if (!post) { 149 | throw new TRPCError({ 150 | code: 'NOT_FOUND', 151 | message: 'Post with that ID not found', 152 | }); 153 | } 154 | 155 | return { 156 | status: 'success', 157 | data: null, 158 | }; 159 | } catch (err: any) { 160 | throw err; 161 | } 162 | }; 163 | -------------------------------------------------------------------------------- /packages/client/src/components/posts/create.post.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect } from "react"; 2 | import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; 3 | import { twMerge } from "tailwind-merge"; 4 | import { object, string, TypeOf } from "zod"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import FileUpLoader from "../FileUpload"; 7 | import { LoadingButton } from "../LoadingButton"; 8 | import TextInput from "../TextInput"; 9 | import { toast } from "react-toastify"; 10 | import useStore from "../../store"; 11 | import { trpc } from "../../trpc"; 12 | import { useQueryClient } from "@tanstack/react-query"; 13 | 14 | const createPostSchema = object({ 15 | title: string().min(1, "Title is required"), 16 | category: string().min(1, "Category is required"), 17 | content: string().min(1, "Content is required"), 18 | image: string().min(1, "Image is required"), 19 | }); 20 | 21 | type CreatePostInput = TypeOf; 22 | 23 | type ICreatePostProp = { 24 | setOpenPostModal: (openPostModal: boolean) => void; 25 | }; 26 | 27 | const CreatePost: FC = ({ setOpenPostModal }) => { 28 | const store = useStore(); 29 | const queryClient = useQueryClient(); 30 | const { isLoading, mutate: createPost } = trpc.createPost.useMutation({ 31 | onSuccess(data) { 32 | store.setPageLoading(false); 33 | setOpenPostModal(false); 34 | queryClient.refetchQueries([["getPosts"]]); 35 | toast("Post created successfully", { 36 | type: "success", 37 | position: "top-right", 38 | }); 39 | }, 40 | onError(error: any) { 41 | store.setPageLoading(false); 42 | setOpenPostModal(false); 43 | toast(error.message, { 44 | type: "error", 45 | position: "top-right", 46 | }); 47 | }, 48 | }); 49 | const methods = useForm({ 50 | resolver: zodResolver(createPostSchema), 51 | }); 52 | 53 | const { 54 | register, 55 | handleSubmit, 56 | formState: { errors }, 57 | } = methods; 58 | 59 | useEffect(() => { 60 | if (isLoading) { 61 | store.setPageLoading(true); 62 | } 63 | // eslint-disable-next-line react-hooks/exhaustive-deps 64 | }, [isLoading]); 65 | 66 | const onSubmitHandler: SubmitHandler = async (data) => { 67 | createPost(data); 68 | }; 69 | return ( 70 |
71 |

Create Post

72 | 73 | 74 |
75 | 76 | 77 |
78 | 81 |