├── .gitignore ├── Readme.md ├── deployments ├── kustomization.yaml ├── mysql-deployment.yaml ├── polling-app-client.yaml └── polling-app-server.yaml ├── docker-compose.yml ├── minikube.txt ├── polling-app-client ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── config-overrides.js ├── nginx.conf ├── package-lock.json ├── package.json ├── public │ ├── favicon.png │ ├── index.html │ └── manifest.json ├── src │ ├── app │ │ ├── App.css │ │ └── App.js │ ├── common │ │ ├── AppHeader.css │ │ ├── AppHeader.js │ │ ├── LoadingIndicator.js │ │ ├── NotFound.css │ │ ├── NotFound.js │ │ ├── PrivateRoute.js │ │ ├── ServerError.css │ │ └── ServerError.js │ ├── constants │ │ └── index.js │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── poll.svg │ ├── poll │ │ ├── NewPoll.css │ │ ├── NewPoll.js │ │ ├── Poll.css │ │ ├── Poll.js │ │ ├── PollList.css │ │ └── PollList.js │ ├── registerServiceWorker.js │ ├── user │ │ ├── login │ │ │ ├── Login.css │ │ │ └── Login.js │ │ ├── profile │ │ │ ├── Profile.css │ │ │ └── Profile.js │ │ └── signup │ │ │ ├── Signup.css │ │ │ └── Signup.js │ └── util │ │ ├── APIUtils.js │ │ ├── Colors.js │ │ └── Helpers.js └── yarn.lock ├── polling-app-server ├── .gitignore ├── .mvn │ └── wrapper │ │ ├── maven-wrapper.jar │ │ └── maven-wrapper.properties ├── Dockerfile ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── example │ │ │ └── polls │ │ │ ├── PollsApplication.java │ │ │ ├── config │ │ │ ├── AuditingConfig.java │ │ │ ├── SecurityConfig.java │ │ │ └── WebMvcConfig.java │ │ │ ├── controller │ │ │ ├── AuthController.java │ │ │ ├── PollController.java │ │ │ └── UserController.java │ │ │ ├── exception │ │ │ ├── AppException.java │ │ │ ├── BadRequestException.java │ │ │ └── ResourceNotFoundException.java │ │ │ ├── model │ │ │ ├── Choice.java │ │ │ ├── ChoiceVoteCount.java │ │ │ ├── Poll.java │ │ │ ├── Role.java │ │ │ ├── RoleName.java │ │ │ ├── User.java │ │ │ ├── Vote.java │ │ │ └── audit │ │ │ │ ├── DateAudit.java │ │ │ │ └── UserDateAudit.java │ │ │ ├── payload │ │ │ ├── ApiResponse.java │ │ │ ├── ChoiceRequest.java │ │ │ ├── ChoiceResponse.java │ │ │ ├── JwtAuthenticationResponse.java │ │ │ ├── LoginRequest.java │ │ │ ├── PagedResponse.java │ │ │ ├── PollLength.java │ │ │ ├── PollRequest.java │ │ │ ├── PollResponse.java │ │ │ ├── SignUpRequest.java │ │ │ ├── UserIdentityAvailability.java │ │ │ ├── UserProfile.java │ │ │ ├── UserSummary.java │ │ │ └── VoteRequest.java │ │ │ ├── repository │ │ │ ├── PollRepository.java │ │ │ ├── RoleRepository.java │ │ │ ├── UserRepository.java │ │ │ └── VoteRepository.java │ │ │ ├── security │ │ │ ├── CurrentUser.java │ │ │ ├── CustomUserDetailsService.java │ │ │ ├── JwtAuthenticationEntryPoint.java │ │ │ ├── JwtAuthenticationFilter.java │ │ │ ├── JwtTokenProvider.java │ │ │ └── UserPrincipal.java │ │ │ ├── service │ │ │ └── PollService.java │ │ │ └── util │ │ │ ├── AppConstants.java │ │ │ └── ModelMapper.java │ └── resources │ │ ├── application.properties │ │ ├── data.sql │ │ └── db │ │ └── migration │ │ ├── V1__schema.sql │ │ └── V2__default_roles.sql │ └── test │ └── java │ └── com │ └── example │ └── polls │ └── PollsApplicationTests.java └── screenshot.png /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ## Building a Full Stack Polls app similar to twitter polls with Spring Boot, Spring Security, JWT, React and Ant Design 2 | 3 | ![App Screenshot](screenshot.png) 4 | 5 | ### Tutorials 6 | 7 | I've written a complete tutorial series for this application on The CalliCoder Blog - 8 | 9 | + [Part 1: Bootstrapping the Project and creating the basic domain models and repositories](https://www.callicoder.com/spring-boot-spring-security-jwt-mysql-react-app-part-1/) 10 | 11 | + [Part 2: Configuring Spring Security along with JWT authentication and Building Rest APIs for Login and SignUp](https://www.callicoder.com/spring-boot-spring-security-jwt-mysql-react-app-part-2/) 12 | 13 | + [Part 3: Building Rest APIs for creating Polls, voting for a choice in a Poll, retrieving user profile etc](https://www.callicoder.com/spring-boot-spring-security-jwt-mysql-react-app-part-3/) 14 | 15 | + [Part 4: Building the front-end using React and Ant Design](https://www.callicoder.com/spring-boot-spring-security-jwt-mysql-react-app-part-4/) 16 | 17 | ## Steps to Setup the Spring Boot Back end app (polling-app-server) 18 | 19 | 1. **Clone the application** 20 | 21 | ```bash 22 | git clone https://github.com/callicoder/spring-security-react-ant-design-polls-app.git 23 | cd polling-app-server 24 | ``` 25 | 26 | 2. **Create MySQL database** 27 | 28 | ```bash 29 | create database polling_app 30 | ``` 31 | 32 | 3. **Change MySQL username and password as per your MySQL installation** 33 | 34 | + open `src/main/resources/application.properties` file. 35 | 36 | + change `spring.datasource.username` and `spring.datasource.password` properties as per your mysql installation 37 | 38 | 4. **Run the app** 39 | 40 | You can run the spring boot app by typing the following command - 41 | 42 | ```bash 43 | mvn spring-boot:run 44 | ``` 45 | 46 | The server will start on port 8080. 47 | 48 | You can also package the application in the form of a `jar` file and then run it like so - 49 | 50 | ```bash 51 | mvn package 52 | java -jar target/polls-0.0.1-SNAPSHOT.jar 53 | ``` 54 | 5. **Default Roles** 55 | 56 | The spring boot app uses role based authorization powered by spring security. To add the default roles in the database, I have added the following sql queries in `src/main/resources/data.sql` file. Spring boot will automatically execute this script on startup - 57 | 58 | ```sql 59 | INSERT IGNORE INTO roles(name) VALUES('ROLE_USER'); 60 | INSERT IGNORE INTO roles(name) VALUES('ROLE_ADMIN'); 61 | ``` 62 | 63 | Any new user who signs up to the app is assigned the `ROLE_USER` by default. 64 | 65 | ## Steps to Setup the React Front end app (polling-app-client) 66 | 67 | First go to the `polling-app-client` folder - 68 | 69 | ```bash 70 | cd polling-app-client 71 | ``` 72 | 73 | Then type the following command to install the dependencies and start the application - 74 | 75 | ```bash 76 | npm install && npm start 77 | ``` 78 | 79 | The front-end server will start on port `3000`. 80 | -------------------------------------------------------------------------------- /deployments/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # The secrets file should not be checked into Git. It's published only for demonstration purpose. 2 | secretGenerator: 3 | - name: mysql-root-pass 4 | literals: 5 | - password=R00t 6 | - name: mysql-user-pass 7 | literals: 8 | - username=callicoder 9 | - password=c@ll1c0d3r 10 | - name: mysql-db-url 11 | literals: 12 | - database=polls 13 | - url=jdbc:mysql://polling-app-mysql:3306/polls?useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false 14 | resources: 15 | - mysql-deployment.yaml 16 | - polling-app-server.yaml 17 | - polling-app-client.yaml 18 | -------------------------------------------------------------------------------- /deployments/mysql-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolume # Create a PersistentVolume 3 | metadata: 4 | name: mysql-pv 5 | labels: 6 | type: local 7 | spec: 8 | storageClassName: standard # Storage class. A PV Claim requesting the same storageClass can be bound to this volume. 9 | capacity: 10 | storage: 250Mi 11 | accessModes: 12 | - ReadWriteOnce 13 | hostPath: # hostPath PersistentVolume is used for development and testing. It uses a file/directory on the Node to emulate network-attached storage 14 | path: "/mnt/data" 15 | persistentVolumeReclaimPolicy: Retain # Retain the PersistentVolume even after PersistentVolumeClaim is deleted. The volume is considered “released”. But it is not yet available for another claim because the previous claimant’s data remains on the volume. 16 | --- 17 | apiVersion: v1 18 | kind: PersistentVolumeClaim # Create a PersistentVolumeClaim to request a PersistentVolume storage 19 | metadata: # Claim name and labels 20 | name: mysql-pv-claim 21 | labels: 22 | app: polling-app 23 | spec: # Access mode and resource limits 24 | storageClassName: standard # Request a certain storage class 25 | accessModes: 26 | - ReadWriteOnce # ReadWriteOnce means the volume can be mounted as read-write by a single Node 27 | resources: 28 | requests: 29 | storage: 250Mi 30 | --- 31 | apiVersion: v1 # API version 32 | kind: Service # Type of kubernetes resource 33 | metadata: 34 | name: polling-app-mysql # Name of the resource 35 | labels: # Labels that will be applied to the resource 36 | app: polling-app 37 | spec: 38 | ports: 39 | - port: 3306 40 | selector: # Selects any Pod with labels `app=polling-app,tier=mysql` 41 | app: polling-app 42 | tier: mysql 43 | clusterIP: None 44 | --- 45 | apiVersion: apps/v1 46 | kind: Deployment # Type of the kubernetes resource 47 | metadata: 48 | name: polling-app-mysql # Name of the deployment 49 | labels: # Labels applied to this deployment 50 | app: polling-app 51 | spec: 52 | selector: 53 | matchLabels: # This deployment applies to the Pods matching the specified labels 54 | app: polling-app 55 | tier: mysql 56 | strategy: 57 | type: Recreate 58 | template: # Template for the Pods in this deployment 59 | metadata: 60 | labels: # Labels to be applied to the Pods in this deployment 61 | app: polling-app 62 | tier: mysql 63 | spec: # The spec for the containers that will be run inside the Pods in this deployment 64 | containers: 65 | - image: mysql:5.6 # The container image 66 | name: mysql 67 | env: # Environment variables passed to the container 68 | - name: MYSQL_ROOT_PASSWORD 69 | valueFrom: # Read environment variables from kubernetes secrets 70 | secretKeyRef: 71 | name: mysql-root-pass 72 | key: password 73 | - name: MYSQL_DATABASE 74 | valueFrom: 75 | secretKeyRef: 76 | name: mysql-db-url 77 | key: database 78 | - name: MYSQL_USER 79 | valueFrom: 80 | secretKeyRef: 81 | name: mysql-user-pass 82 | key: username 83 | - name: MYSQL_PASSWORD 84 | valueFrom: 85 | secretKeyRef: 86 | name: mysql-user-pass 87 | key: password 88 | ports: 89 | - containerPort: 3306 # The port that the container exposes 90 | name: mysql 91 | volumeMounts: 92 | - name: mysql-persistent-storage # This name should match the name specified in `volumes.name` 93 | mountPath: /var/lib/mysql 94 | volumes: # A PersistentVolume is mounted as a volume to the Pod 95 | - name: mysql-persistent-storage 96 | persistentVolumeClaim: 97 | claimName: mysql-pv-claim -------------------------------------------------------------------------------- /deployments/polling-app-client.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 # API version 2 | kind: Deployment # Type of kubernetes resource 3 | metadata: 4 | name: polling-app-client # Name of the kubernetes resource 5 | spec: 6 | replicas: 1 # No of replicas/pods to run 7 | selector: 8 | matchLabels: # This deployment applies to Pods matching the specified labels 9 | app: polling-app-client 10 | template: # Template for creating the Pods in this deployment 11 | metadata: 12 | labels: # Labels that will be applied to all the Pods in this deployment 13 | app: polling-app-client 14 | spec: # Spec for the containers that will run inside the Pods 15 | containers: 16 | - name: polling-app-client 17 | image: callicoder/polling-app-client:1.0.0 18 | imagePullPolicy: IfNotPresent 19 | ports: 20 | - name: http 21 | containerPort: 80 # Should match the Port that the container listens on 22 | resources: 23 | limits: 24 | cpu: 0.2 25 | memory: "10Mi" 26 | --- 27 | apiVersion: v1 # API version 28 | kind: Service # Type of kubernetes resource 29 | metadata: 30 | name: polling-app-client # Name of the kubernetes resource 31 | spec: 32 | type: NodePort # Exposes the service by opening a port on each node 33 | selector: 34 | app: polling-app-client # Any Pod matching the label `app=polling-app-client` will be picked up by this service 35 | ports: # Forward incoming connections on port 80 to the target port 80 in the Pod 36 | - name: http 37 | port: 80 38 | targetPort: 80 -------------------------------------------------------------------------------- /deployments/polling-app-server.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 # API version 2 | kind: Deployment # Type of kubernetes resource 3 | metadata: 4 | name: polling-app-server # Name of the kubernetes resource 5 | labels: # Labels that will be applied to this resource 6 | app: polling-app-server 7 | spec: 8 | replicas: 1 # No. of replicas/pods to run in this deployment 9 | selector: 10 | matchLabels: # The deployment applies to any pods mayching the specified labels 11 | app: polling-app-server 12 | template: # Template for creating the pods in this deployment 13 | metadata: 14 | labels: # Labels that will be applied to each Pod in this deployment 15 | app: polling-app-server 16 | spec: # Spec for the containers that will be run in the Pods 17 | containers: 18 | - name: polling-app-server 19 | image: callicoder/polling-app-server:1.0.0 20 | imagePullPolicy: IfNotPresent 21 | ports: 22 | - name: http 23 | containerPort: 8080 # The port that the container exposes 24 | resources: 25 | limits: 26 | cpu: 0.2 27 | memory: "200Mi" 28 | env: # Environment variables supplied to the Pod 29 | - name: SPRING_DATASOURCE_USERNAME # Name of the environment variable 30 | valueFrom: # Get the value of environment variable from kubernetes secrets 31 | secretKeyRef: 32 | name: mysql-user-pass 33 | key: username 34 | - name: SPRING_DATASOURCE_PASSWORD 35 | valueFrom: 36 | secretKeyRef: 37 | name: mysql-user-pass 38 | key: password 39 | - name: SPRING_DATASOURCE_URL 40 | valueFrom: 41 | secretKeyRef: 42 | name: mysql-db-url 43 | key: url 44 | --- 45 | apiVersion: v1 # API version 46 | kind: Service # Type of the kubernetes resource 47 | metadata: 48 | name: polling-app-server # Name of the kubernetes resource 49 | labels: # Labels that will be applied to this resource 50 | app: polling-app-server 51 | spec: 52 | type: NodePort # The service will be exposed by opening a Port on each node and proxying it. 53 | selector: 54 | app: polling-app-server # The service exposes Pods with label `app=polling-app-server` 55 | ports: # Forward incoming connections on port 8080 to the target port 8080 56 | - name: http 57 | port: 8080 58 | targetPort: 8080 59 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # Docker Compose file Reference (https://docs.docker.com/compose/compose-file/) 2 | 3 | version: '3.7' 4 | 5 | # Define services 6 | services: 7 | # App backend service 8 | app-server: 9 | # Configuration for building the docker image for the backend service 10 | build: 11 | context: polling-app-server # Use an image built from the specified dockerfile in the `polling-app-server` directory. 12 | dockerfile: Dockerfile 13 | ports: 14 | - "8080:8080" # Forward the exposed port 8080 on the container to port 8080 on the host machine 15 | restart: always 16 | depends_on: 17 | - db # This service depends on mysql. Start that first. 18 | environment: # Pass environment variables to the service 19 | SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/polls?useSSL=false&serverTimezone=UTC&useLegacyDatetimeCode=false 20 | SPRING_DATASOURCE_USERNAME: callicoder 21 | SPRING_DATASOURCE_PASSWORD: callicoder 22 | networks: # Networks to join (Services on the same network can communicate with each other using their name) 23 | - backend 24 | - frontend 25 | 26 | # Frontend Service 27 | app-client: 28 | build: 29 | context: polling-app-client # Use an image built from the specified dockerfile in the `polling-app-client` directory. 30 | dockerfile: Dockerfile 31 | args: 32 | REACT_APP_API_BASE_URL: http://127.0.0.1:8080/api 33 | ports: 34 | - "9090:80" # Forward the exposed port 80 on the container to port 80 on the host machine 35 | restart: always 36 | depends_on: 37 | - app-server 38 | networks: 39 | - frontend 40 | 41 | # Database Service (Mysql) 42 | db: 43 | image: mysql:5.7 44 | ports: 45 | - "3306:3306" 46 | restart: always 47 | environment: 48 | MYSQL_DATABASE: polls 49 | MYSQL_USER: callicoder 50 | MYSQL_PASSWORD: callicoder 51 | MYSQL_ROOT_PASSWORD: root 52 | volumes: 53 | - db-data:/var/lib/mysql 54 | networks: 55 | - backend 56 | 57 | # Volumes 58 | volumes: 59 | db-data: 60 | 61 | # Networks to be created to facilitate communication between containers 62 | networks: 63 | backend: 64 | frontend: -------------------------------------------------------------------------------- /minikube.txt: -------------------------------------------------------------------------------- 1 | $ minikube list 2 | $ kubectl port-forward service/polling-app-server 8080:8080 -------------------------------------------------------------------------------- /polling-app-client/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /polling-app-client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /polling-app-client/Dockerfile: -------------------------------------------------------------------------------- 1 | #### Stage 1: Build the react application 2 | FROM node:12.4.0-alpine as build 3 | 4 | # Configure the main working directory inside the docker image. 5 | # This is the base directory used in any further RUN, COPY, and ENTRYPOINT 6 | # commands. 7 | WORKDIR /app 8 | 9 | # Copy the package.json as well as the package-lock.json and install 10 | # the dependencies. This is a separate step so the dependencies 11 | # will be cached unless changes to one of those two files 12 | # are made. 13 | COPY package.json package-lock.json ./ 14 | RUN npm install 15 | 16 | # Copy the main application 17 | COPY . ./ 18 | 19 | # Arguments 20 | ARG REACT_APP_API_BASE_URL 21 | ENV REACT_APP_API_BASE_URL=${REACT_APP_API_BASE_URL} 22 | 23 | # Build the application 24 | RUN npm run build 25 | 26 | #### Stage 2: Serve the React application from Nginx 27 | FROM nginx:1.17.0-alpine 28 | 29 | # Copy the react build from Stage 1 30 | COPY --from=build /app/build /var/www 31 | 32 | # Copy our custom nginx config 33 | COPY nginx.conf /etc/nginx/nginx.conf 34 | 35 | # Expose port 3000 to the Docker host, so we can access it 36 | # from the outside. 37 | EXPOSE 80 38 | 39 | ENTRYPOINT ["nginx","-g","daemon off;"] 40 | -------------------------------------------------------------------------------- /polling-app-client/config-overrides.js: -------------------------------------------------------------------------------- 1 | const { injectBabelPlugin } = require('react-app-rewired'); 2 | const rewireLess = require('react-app-rewire-less'); 3 | 4 | module.exports = function override(config, env) { 5 | config = injectBabelPlugin(['import', { libraryName: 'antd', style: true }], config); 6 | config = rewireLess.withLoaderOptions({ 7 | modifyVars: { 8 | "@layout-body-background": "#FFFFFF", 9 | "@layout-header-background": "#FFFFFF", 10 | "@layout-footer-background": "#FFFFFF" 11 | }, 12 | javascriptEnabled: true 13 | })(config, env); 14 | return config; 15 | }; -------------------------------------------------------------------------------- /polling-app-client/nginx.conf: -------------------------------------------------------------------------------- 1 | # auto detects a good number of processes to run 2 | worker_processes auto; 3 | 4 | #Provides the configuration file context in which the directives that affect connection processing are specified. 5 | events { 6 | # Sets the maximum number of simultaneous connections that can be opened by a worker process. 7 | worker_connections 8000; 8 | # Tells the worker to accept multiple connections at a time 9 | multi_accept on; 10 | } 11 | 12 | 13 | http { 14 | # what times to include 15 | include /etc/nginx/mime.types; 16 | # what is the default one 17 | default_type application/octet-stream; 18 | 19 | # Sets the path, format, and configuration for a buffered log write 20 | log_format compression '$remote_addr - $remote_user [$time_local] ' 21 | '"$request" $status $upstream_addr ' 22 | '"$http_referer" "$http_user_agent"'; 23 | 24 | server { 25 | # listen on port 80 26 | listen 80; 27 | 28 | # save logs here 29 | access_log /var/log/nginx/access.log compression; 30 | 31 | # nginx root directory 32 | root /var/www; 33 | 34 | # what file to server as index 35 | index index.html index.htm; 36 | 37 | location / { 38 | # First attempt to serve request as file, then 39 | # as directory, then fall back to redirecting to index.html 40 | try_files $uri $uri/ /index.html; 41 | } 42 | 43 | # Media: images, icons, video, audio, HTC 44 | location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ { 45 | expires 1M; 46 | access_log off; 47 | add_header Cache-Control "public"; 48 | } 49 | 50 | # Javascript and CSS files 51 | location ~* \.(?:css|js)$ { 52 | try_files $uri =404; 53 | expires 1y; 54 | access_log off; 55 | add_header Cache-Control "public"; 56 | } 57 | 58 | # Any route containing a file extension (e.g. /devicesfile.js) 59 | location ~ ^.+\..+$ { 60 | try_files $uri =404; 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /polling-app-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "polling-app-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "antd": "^3.2.2", 7 | "react": "^16.5.2", 8 | "react-dom": "^16.5.2", 9 | "react-router-dom": "^4.3.1", 10 | "react-scripts": "1.1.5" 11 | }, 12 | "scripts": { 13 | "start": "react-app-rewired start", 14 | "build": "react-app-rewired build", 15 | "test": "react-app-rewired test --env=jsdom", 16 | "eject": "react-scripts eject" 17 | }, 18 | "devDependencies": { 19 | "babel-plugin-import": "^1.6.5", 20 | "react-app-rewire-less": "^2.1.0", 21 | "react-app-rewired": "^1.4.1" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /polling-app-client/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/callicoder/spring-security-react-ant-design-polls-app/362fad90cab17e76453b3b9e273c594de6ee3d7f/polling-app-client/public/favicon.png -------------------------------------------------------------------------------- /polling-app-client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | Polling App | CalliCoder 23 | 24 | 25 | 26 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /polling-app-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 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /polling-app-client/src/app/App.css: -------------------------------------------------------------------------------- 1 | .app-content { 2 | margin-top: 64px; 3 | } -------------------------------------------------------------------------------- /polling-app-client/src/app/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './App.css'; 3 | import { 4 | Route, 5 | withRouter, 6 | Switch 7 | } from 'react-router-dom'; 8 | 9 | import { getCurrentUser } from '../util/APIUtils'; 10 | import { ACCESS_TOKEN } from '../constants'; 11 | 12 | import PollList from '../poll/PollList'; 13 | import NewPoll from '../poll/NewPoll'; 14 | import Login from '../user/login/Login'; 15 | import Signup from '../user/signup/Signup'; 16 | import Profile from '../user/profile/Profile'; 17 | import AppHeader from '../common/AppHeader'; 18 | import NotFound from '../common/NotFound'; 19 | import LoadingIndicator from '../common/LoadingIndicator'; 20 | import PrivateRoute from '../common/PrivateRoute'; 21 | 22 | import { Layout, notification } from 'antd'; 23 | const { Content } = Layout; 24 | 25 | class App extends Component { 26 | constructor(props) { 27 | super(props); 28 | this.state = { 29 | currentUser: null, 30 | isAuthenticated: false, 31 | isLoading: true 32 | } 33 | this.handleLogout = this.handleLogout.bind(this); 34 | this.loadCurrentUser = this.loadCurrentUser.bind(this); 35 | this.handleLogin = this.handleLogin.bind(this); 36 | 37 | notification.config({ 38 | placement: 'topRight', 39 | top: 70, 40 | duration: 3, 41 | }); 42 | } 43 | 44 | loadCurrentUser() { 45 | getCurrentUser() 46 | .then(response => { 47 | this.setState({ 48 | currentUser: response, 49 | isAuthenticated: true, 50 | isLoading: false 51 | }); 52 | }).catch(error => { 53 | this.setState({ 54 | isLoading: false 55 | }); 56 | }); 57 | } 58 | 59 | componentDidMount() { 60 | this.loadCurrentUser(); 61 | } 62 | 63 | handleLogout(redirectTo="/", notificationType="success", description="You're successfully logged out.") { 64 | localStorage.removeItem(ACCESS_TOKEN); 65 | 66 | this.setState({ 67 | currentUser: null, 68 | isAuthenticated: false 69 | }); 70 | 71 | this.props.history.push(redirectTo); 72 | 73 | notification[notificationType]({ 74 | message: 'Polling App', 75 | description: description, 76 | }); 77 | } 78 | 79 | handleLogin() { 80 | notification.success({ 81 | message: 'Polling App', 82 | description: "You're successfully logged in.", 83 | }); 84 | this.loadCurrentUser(); 85 | this.props.history.push("/"); 86 | } 87 | 88 | render() { 89 | if(this.state.isLoading) { 90 | return 91 | } 92 | 93 | return ( 94 | 95 | 98 | 99 | 100 |
101 | 102 | }> 105 | 106 | }> 108 | 109 | }> 111 | 112 | 113 | 114 | 115 |
116 |
117 |
118 | ); 119 | } 120 | } 121 | 122 | export default withRouter(App); 123 | -------------------------------------------------------------------------------- /polling-app-client/src/common/AppHeader.css: -------------------------------------------------------------------------------- 1 | .app-title { 2 | float: left; 3 | } 4 | 5 | .app-title a { 6 | text-decoration: none; 7 | line-height: 64px; 8 | font-size: 21px; 9 | display: inline-block; 10 | } 11 | 12 | .app-title a:hover { 13 | text-decoration: none; 14 | } 15 | 16 | .app-header { 17 | position: fixed; 18 | width: 100%; 19 | box-shadow: 0 2px 8px #f0f1f2; 20 | z-index: 10; 21 | padding: 0; 22 | } 23 | 24 | .app-menu { 25 | float: right; 26 | } 27 | 28 | .app-menu > li { 29 | padding: 0 20px; 30 | } 31 | 32 | .app-menu > li > a { 33 | padding: 0 20px; 34 | margin: 0 -20px; 35 | } 36 | 37 | .app-menu > li > a > i { 38 | margin-right: 0 !important; 39 | } 40 | 41 | .profile-dropdown-menu { 42 | min-width: 180px; 43 | } 44 | 45 | .profile-menu .user-full-name-info { 46 | font-size: 17px; 47 | font-weight: 600; 48 | color: rgba(0,0,0,0.85); 49 | } 50 | 51 | .profile-menu .username-info { 52 | font-size: 14px; 53 | color: rgba(0,0,0,0.65); 54 | } 55 | 56 | .dropdown-item { 57 | padding: 10px 12px; 58 | } 59 | 60 | .dropdown-item a { 61 | padding: 10px 12px; 62 | margin: -10px -12px; 63 | } 64 | 65 | .nav-icon { 66 | font-size: 20px; 67 | } 68 | 69 | .poll-icon { 70 | margin-top: -4px; 71 | } 72 | 73 | @media (max-width: 768px) { 74 | .app-title a { 75 | font-size: 20px; 76 | } 77 | 78 | .app-menu > li { 79 | padding: 0 15px; 80 | } 81 | 82 | .app-menu > li > a { 83 | padding: 0 15px; 84 | margin: 0 -15px; 85 | } 86 | } -------------------------------------------------------------------------------- /polling-app-client/src/common/AppHeader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { 3 | Link, 4 | withRouter 5 | } from 'react-router-dom'; 6 | import './AppHeader.css'; 7 | import pollIcon from '../poll.svg'; 8 | import { Layout, Menu, Dropdown, Icon } from 'antd'; 9 | const Header = Layout.Header; 10 | 11 | class AppHeader extends Component { 12 | constructor(props) { 13 | super(props); 14 | this.handleMenuClick = this.handleMenuClick.bind(this); 15 | } 16 | 17 | handleMenuClick({ key }) { 18 | if(key === "logout") { 19 | this.props.onLogout(); 20 | } 21 | } 22 | 23 | render() { 24 | let menuItems; 25 | if(this.props.currentUser) { 26 | menuItems = [ 27 | 28 | 29 | 30 | 31 | , 32 | 33 | 34 | poll 35 | 36 | , 37 | 38 | 41 | 42 | ]; 43 | } else { 44 | menuItems = [ 45 | 46 | Login 47 | , 48 | 49 | Signup 50 | 51 | ]; 52 | } 53 | 54 | return ( 55 |
56 |
57 |
58 | Polling App 59 |
60 | 65 | {menuItems} 66 | 67 |
68 |
69 | ); 70 | } 71 | } 72 | 73 | function ProfileDropdownMenu(props) { 74 | const dropdownMenu = ( 75 | 76 | 77 |
78 | {props.currentUser.name} 79 |
80 |
81 | @{props.currentUser.username} 82 |
83 |
84 | 85 | 86 | Profile 87 | 88 | 89 | Logout 90 | 91 |
92 | ); 93 | 94 | return ( 95 | document.getElementsByClassName('profile-menu')[0]}> 99 | 100 | 101 | 102 | 103 | ); 104 | } 105 | 106 | 107 | export default withRouter(AppHeader); -------------------------------------------------------------------------------- /polling-app-client/src/common/LoadingIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Spin, Icon } from 'antd'; 3 | 4 | export default function LoadingIndicator(props) { 5 | const antIcon = ; 6 | return ( 7 | 8 | ); 9 | } -------------------------------------------------------------------------------- /polling-app-client/src/common/NotFound.css: -------------------------------------------------------------------------------- 1 | .page-not-found { 2 | max-width: 500px; 3 | margin: 0 auto; 4 | margin-top: 50px; 5 | padding: 40px; 6 | border: 1px solid #c8c8c8; 7 | text-align: center; 8 | } 9 | 10 | .page-not-found .title { 11 | font-size: 50px; 12 | letter-spacing: 10px; 13 | margin-bottom: 10px; 14 | } 15 | 16 | .page-not-found .desc { 17 | font-size: 20px; 18 | margin-bottom: 20px; 19 | } 20 | 21 | .go-back-btn { 22 | min-width: 160px; 23 | } -------------------------------------------------------------------------------- /polling-app-client/src/common/NotFound.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './NotFound.css'; 3 | import { Link } from 'react-router-dom'; 4 | import { Button } from 'antd'; 5 | 6 | class NotFound extends Component { 7 | render() { 8 | return ( 9 |
10 |

11 | 404 12 |

13 |
14 | The Page you're looking for was not found. 15 |
16 | 17 |
18 | ); 19 | } 20 | } 21 | 22 | export default NotFound; -------------------------------------------------------------------------------- /polling-app-client/src/common/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Route, 4 | Redirect 5 | } from "react-router-dom"; 6 | 7 | 8 | const PrivateRoute = ({ component: Component, authenticated, ...rest }) => ( 9 | 12 | authenticated ? ( 13 | 14 | ) : ( 15 | 21 | ) 22 | } 23 | /> 24 | ); 25 | 26 | export default PrivateRoute -------------------------------------------------------------------------------- /polling-app-client/src/common/ServerError.css: -------------------------------------------------------------------------------- 1 | .server-error-page { 2 | max-width: 500px; 3 | margin: 0 auto; 4 | margin-top: 50px; 5 | padding: 40px; 6 | border: 1px solid #c8c8c8; 7 | text-align: center; 8 | } 9 | 10 | .server-error-page .server-error-title { 11 | font-size: 50px; 12 | letter-spacing: 10px; 13 | margin-bottom: 10px; 14 | color: #f44336; 15 | } 16 | 17 | .server-error-page .server-error-desc { 18 | font-size: 20px; 19 | margin-bottom: 20px; 20 | } 21 | 22 | .server-error-go-back-btn { 23 | min-width: 160px; 24 | } -------------------------------------------------------------------------------- /polling-app-client/src/common/ServerError.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import './ServerError.css'; 3 | import { Link } from 'react-router-dom'; 4 | import { Button } from 'antd'; 5 | 6 | class ServerError extends Component { 7 | render() { 8 | return ( 9 |
10 |

11 | 500 12 |

13 |
14 | Oops! Something went wrong at our Server. Why don't you go back? 15 |
16 | 17 |
18 | ); 19 | } 20 | } 21 | 22 | export default ServerError; -------------------------------------------------------------------------------- /polling-app-client/src/constants/index.js: -------------------------------------------------------------------------------- 1 | export const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080/api'; 2 | export const ACCESS_TOKEN = 'accessToken'; 3 | 4 | export const POLL_LIST_SIZE = 30; 5 | export const MAX_CHOICES = 6; 6 | export const POLL_QUESTION_MAX_LENGTH = 140; 7 | export const POLL_CHOICE_MAX_LENGTH = 40; 8 | 9 | export const NAME_MIN_LENGTH = 4; 10 | export const NAME_MAX_LENGTH = 40; 11 | 12 | export const USERNAME_MIN_LENGTH = 3; 13 | export const USERNAME_MAX_LENGTH = 15; 14 | 15 | export const EMAIL_MAX_LENGTH = 40; 16 | 17 | export const PASSWORD_MIN_LENGTH = 6; 18 | export const PASSWORD_MAX_LENGTH = 20; 19 | -------------------------------------------------------------------------------- /polling-app-client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | 7 | .container { 8 | max-width: 1000px; 9 | margin-left: auto; 10 | margin-right: auto; 11 | padding-left: 15px; 12 | padding-right: 15px; 13 | } 14 | 15 | @media (max-width: 768px) { 16 | h1 { 17 | font-size: 1.7em; 18 | } 19 | } -------------------------------------------------------------------------------- /polling-app-client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './app/App'; 5 | import registerServiceWorker from './registerServiceWorker'; 6 | import { BrowserRouter as Router } from 'react-router-dom'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | 15 | registerServiceWorker(); 16 | -------------------------------------------------------------------------------- /polling-app-client/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /polling-app-client/src/poll.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /polling-app-client/src/poll/NewPoll.css: -------------------------------------------------------------------------------- 1 | .new-poll-container { 2 | max-width: 520px; 3 | margin: 0 auto; 4 | margin-top: 40px; 5 | } 6 | 7 | .create-poll-form-button { 8 | width: 100%; 9 | } 10 | 11 | .create-poll-form-button[disabled], .create-poll-form-button[disabled]:hover, .create-poll-form-button[disabled]:focus { 12 | opacity: 0.6; 13 | color: #fff; 14 | background-color: #1890ff; 15 | border-color: #1890ff; 16 | } 17 | 18 | 19 | .dynamic-delete-button { 20 | cursor: pointer; 21 | position: relative; 22 | top: 4px; 23 | font-size: 24px; 24 | color: #999; 25 | transition: all .3s; 26 | } 27 | 28 | .optional-choice { 29 | width: calc(100% - 35px); 30 | margin-right: 8px; 31 | } 32 | 33 | .dynamic-delete-button:hover { 34 | color: #777; 35 | } 36 | 37 | .dynamic-delete-button[disabled] { 38 | cursor: not-allowed; 39 | opacity: 0.5; 40 | } 41 | 42 | .poll-form-row { 43 | margin-bottom: 20px; 44 | } 45 | 46 | .poll-form-row input, .poll-form-row textarea { 47 | margin-bottom: 4px; 48 | } -------------------------------------------------------------------------------- /polling-app-client/src/poll/NewPoll.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { createPoll } from '../util/APIUtils'; 3 | import { MAX_CHOICES, POLL_QUESTION_MAX_LENGTH, POLL_CHOICE_MAX_LENGTH } from '../constants'; 4 | import './NewPoll.css'; 5 | import { Form, Input, Button, Icon, Select, Col, notification } from 'antd'; 6 | const Option = Select.Option; 7 | const FormItem = Form.Item; 8 | const { TextArea } = Input 9 | 10 | class NewPoll extends Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | question: { 15 | text: '' 16 | }, 17 | choices: [{ 18 | text: '' 19 | }, { 20 | text: '' 21 | }], 22 | pollLength: { 23 | days: 1, 24 | hours: 0 25 | } 26 | }; 27 | this.addChoice = this.addChoice.bind(this); 28 | this.removeChoice = this.removeChoice.bind(this); 29 | this.handleSubmit = this.handleSubmit.bind(this); 30 | this.handleQuestionChange = this.handleQuestionChange.bind(this); 31 | this.handleChoiceChange = this.handleChoiceChange.bind(this); 32 | this.handlePollDaysChange = this.handlePollDaysChange.bind(this); 33 | this.handlePollHoursChange = this.handlePollHoursChange.bind(this); 34 | this.isFormInvalid = this.isFormInvalid.bind(this); 35 | } 36 | 37 | addChoice(event) { 38 | const choices = this.state.choices.slice(); 39 | this.setState({ 40 | choices: choices.concat([{ 41 | text: '' 42 | }]) 43 | }); 44 | } 45 | 46 | removeChoice(choiceNumber) { 47 | const choices = this.state.choices.slice(); 48 | this.setState({ 49 | choices: [...choices.slice(0, choiceNumber), ...choices.slice(choiceNumber+1)] 50 | }); 51 | } 52 | 53 | handleSubmit(event) { 54 | event.preventDefault(); 55 | const pollData = { 56 | question: this.state.question.text, 57 | choices: this.state.choices.map(choice => { 58 | return {text: choice.text} 59 | }), 60 | pollLength: this.state.pollLength 61 | }; 62 | 63 | createPoll(pollData) 64 | .then(response => { 65 | this.props.history.push("/"); 66 | }).catch(error => { 67 | if(error.status === 401) { 68 | this.props.handleLogout('/login', 'error', 'You have been logged out. Please login create poll.'); 69 | } else { 70 | notification.error({ 71 | message: 'Polling App', 72 | description: error.message || 'Sorry! Something went wrong. Please try again!' 73 | }); 74 | } 75 | }); 76 | } 77 | 78 | validateQuestion = (questionText) => { 79 | if(questionText.length === 0) { 80 | return { 81 | validateStatus: 'error', 82 | errorMsg: 'Please enter your question!' 83 | } 84 | } else if (questionText.length > POLL_QUESTION_MAX_LENGTH) { 85 | return { 86 | validateStatus: 'error', 87 | errorMsg: `Question is too long (Maximum ${POLL_QUESTION_MAX_LENGTH} characters allowed)` 88 | } 89 | } else { 90 | return { 91 | validateStatus: 'success', 92 | errorMsg: null 93 | } 94 | } 95 | } 96 | 97 | handleQuestionChange(event) { 98 | const value = event.target.value; 99 | this.setState({ 100 | question: { 101 | text: value, 102 | ...this.validateQuestion(value) 103 | } 104 | }); 105 | } 106 | 107 | validateChoice = (choiceText) => { 108 | if(choiceText.length === 0) { 109 | return { 110 | validateStatus: 'error', 111 | errorMsg: 'Please enter a choice!' 112 | } 113 | } else if (choiceText.length > POLL_CHOICE_MAX_LENGTH) { 114 | return { 115 | validateStatus: 'error', 116 | errorMsg: `Choice is too long (Maximum ${POLL_CHOICE_MAX_LENGTH} characters allowed)` 117 | } 118 | } else { 119 | return { 120 | validateStatus: 'success', 121 | errorMsg: null 122 | } 123 | } 124 | } 125 | 126 | handleChoiceChange(event, index) { 127 | const choices = this.state.choices.slice(); 128 | const value = event.target.value; 129 | 130 | choices[index] = { 131 | text: value, 132 | ...this.validateChoice(value) 133 | } 134 | 135 | this.setState({ 136 | choices: choices 137 | }); 138 | } 139 | 140 | 141 | handlePollDaysChange(value) { 142 | const pollLength = Object.assign(this.state.pollLength, {days: value}); 143 | this.setState({ 144 | pollLength: pollLength 145 | }); 146 | } 147 | 148 | handlePollHoursChange(value) { 149 | const pollLength = Object.assign(this.state.pollLength, {hours: value}); 150 | this.setState({ 151 | pollLength: pollLength 152 | }); 153 | } 154 | 155 | isFormInvalid() { 156 | if(this.state.question.validateStatus !== 'success') { 157 | return true; 158 | } 159 | 160 | for(let i = 0; i < this.state.choices.length; i++) { 161 | const choice = this.state.choices[i]; 162 | if(choice.validateStatus !== 'success') { 163 | return true; 164 | } 165 | } 166 | } 167 | 168 | render() { 169 | const choiceViews = []; 170 | this.state.choices.forEach((choice, index) => { 171 | choiceViews.push(); 172 | }); 173 | 174 | return ( 175 |
176 |

Create Poll

177 |
178 |
179 | 181 |