├── .editorconfig ├── .github └── workflows │ └── maven.yml ├── .gitignore ├── .mvn └── wrapper │ └── maven-wrapper.properties ├── .nvmrc ├── README.md ├── docs └── img │ └── spring-react-crud.png ├── mvnw ├── mvnw.cmd ├── package-lock.json ├── package.json ├── pom.xml ├── src ├── main │ ├── java │ │ └── ar │ │ │ └── com │ │ │ └── mrdev │ │ │ └── app │ │ │ ├── AppApplication.java │ │ │ ├── Constants.java │ │ │ ├── HomeController.java │ │ │ └── user │ │ │ ├── DatabaseLoader.java │ │ │ ├── SecurityConfig.java │ │ │ ├── SpringDataJpaUserDetailsService.java │ │ │ ├── User.java │ │ │ ├── UserController.java │ │ │ ├── UserRepository.java │ │ │ └── UserService.java │ ├── js │ │ ├── client.js │ │ ├── commons.js │ │ ├── components │ │ │ ├── About.js │ │ │ ├── App.js │ │ │ ├── Navbar.js │ │ │ ├── RouteNoMatch.js │ │ │ ├── commons │ │ │ │ ├── Loading.js │ │ │ │ ├── LoadingPagination.js │ │ │ │ └── Message.js │ │ │ └── user │ │ │ │ ├── User.js │ │ │ │ ├── UserItem.js │ │ │ │ ├── UserList.js │ │ │ │ └── UsersHome.js │ │ ├── errors.js │ │ └── index.js │ └── resources │ │ ├── application.yml │ │ ├── static │ │ └── main.css │ │ └── templates │ │ └── index.html └── test │ └── java │ └── ar │ └── com │ └── mrdev │ └── app │ └── AppApplicationTests.java └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | # Unix-style newlines 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.{java,xml}] 13 | indent_style = tab 14 | indent_size = 4 15 | -------------------------------------------------------------------------------- /.github/workflows/maven.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Java project with Maven, and cache/restore any dependencies to improve the workflow execution time 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: CI Build and Test 10 | 11 | on: [push, pull_request] 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Set up JDK 21 21 | uses: actions/setup-java@v4 22 | with: 23 | java-version: '21' 24 | distribution: 'oracle' 25 | cache: maven 26 | - name: Build 27 | run: ./mvnw compile 28 | - name: Test 29 | run: ./mvnw test 30 | - name: Package 31 | run: ./mvnw package -Dmaven.test.skip 32 | 33 | # Optional: Uploads the full dependency graph to GitHub to improve the quality of Dependabot alerts this repository can receive 34 | - name: Update dependency graph 35 | uses: advanced-security/maven-dependency-submission-action@571e99aab1055c2e71a1e2309b9691de18d6b7d6 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | node_modules/ 3 | !.mvn/wrapper/maven-wrapper.jar 4 | !**/src/main/** 5 | !**/src/test/** 6 | src/main/resources/static/built/ 7 | 8 | ### STS ### 9 | .apt_generated 10 | .classpath 11 | .factorypath 12 | .project 13 | .settings 14 | .springBeans 15 | .sts4-cache 16 | 17 | ### IntelliJ IDEA ### 18 | .idea 19 | *.iws 20 | *.iml 21 | *.ipr 22 | 23 | ### NetBeans ### 24 | /nbproject/private/ 25 | /nbbuild/ 26 | /dist/ 27 | /nbdist/ 28 | /.nb-gradle/ 29 | build/ 30 | 31 | ### VS Code ### 32 | .vscode/ 33 | -------------------------------------------------------------------------------- /.mvn/wrapper/maven-wrapper.properties: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | wrapperVersion=3.3.2 18 | distributionType=only-script 19 | distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip 20 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.20 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React.js and Spring Data REST - CRUD 2 | ==================================== 3 | 4 | ![React Spring CRUD](docs/img/spring-react-crud.png) 5 | 6 | CRUD application with security enabled: a PoC with 7 | ReactJS in the frontend and Spring Data REST in the backend. 8 | 9 | ## 🚀 Stack 10 | 11 | - **Backend**: Java 21 LTS, Spring Boot 3 (web, security, JPA), 12 | Postgres / H2 (or whatever DB is compatible with JPA). 13 | - **Frontend**: ReactJS, React Router, Bootstrap, 14 | Axios, Reactstrap, Node 18 / Npm / Webpack (build). 15 | 16 | 17 | ### ⚙️ Requirements 18 | 19 | - **JDK 21+** 20 | - **Maven 3+**, or you can use the script `./mvnw` instead that it will install 21 | Maven 3.9 in the user space automatically if the required version isn't there 22 | 23 | To build the web assets the project uses **Node.js**, **Webpack**, ... 24 | but all of them are installed and triggered by Maven automatically 25 | in the user space. 26 | 27 | 28 | ## 🎮 Usage 29 | 30 | Launch the application with: 31 | 32 | $ mvn spring-boot:run 33 | 34 | Or use `./mvnw` instead of `mvn` (`mvnw.cmd` for Window platforms). 35 | 36 | Then access the application with http://localhost:8080/, or access 37 | to the API with http://localhost:8080/api/. 38 | 39 | One of the users to access the app with privileged permissions is `frodo@local`, 40 | and the password `admin`. Check and edit the initial dataset in 41 | the [DatabaseLoader.java](src/main/java/ar/com/mrdev/app/user/DatabaseLoader.java#L46-L50) 42 | file. 43 | 44 | If you access the API through the HAL browser, it will require to sign-in 45 | like with the dash panel using a web page provided by Spring Security, 46 | but if you are going to consume the API with a 3rd party tool 47 | you will need to authenticate using 48 | [HTTP Basic Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication), 49 | eg. with `curl` the `Authorization: Basic ...` header can be generated using 50 | the `-u user:pass` argument: 51 | 52 | $ curl -u gf@local:admin http://localhost:8080/api/users 53 | 54 | #### 🌶 Hot reloading 55 | 56 | To edit Javascript or CSS resources and see the changes in the 57 | browser without the need to re-launch the application, execute within 58 | a command line: 59 | 60 | $ npm run watch 61 | 62 | And leave it running (if it doesn't work, try with `target/node/npm run watch`). 63 | 64 | 65 | #### 📦 Packaging 66 | 67 | Pack the application in a single .jar with all the dependencies 68 | and the web server with: 69 | 70 | $ mvn package 71 | 72 | Add the argument `-Dmaven.test.skip` if you don't want to run the tests before packaging. 73 | 74 | Then you can run the .jar with: 75 | 76 | $ java -jar target/app-0.0.1-SNAPSHOT.jar 77 | 78 | 79 | ### ⏯ Tests 80 | 81 | For now only a test that checks that the spring context 82 | can be loaded is in the source code. Execute with: 83 | 84 | $ mvn test 85 | 86 | A _GitHub Action_ workflow is configured as well 87 | as **CI** environment, check out [maven.yml](.github/workflows/maven.yml). 88 | 89 | Versions 90 | -------- 91 | 92 | For now there are two versions, each one with its own branch: 93 | 94 | - `master`: main version with an in-memory database (H2) to quickly 95 | launch the application without the need of a database installed (local tests). 96 | - `postgres`: modified version with PostgreSQL configured, 97 | ready for "production" usage. 98 | 99 | 100 | About 101 | ----- 102 | 103 | **Source code**: https://github.com/mrsarm/spring-react-crud 104 | 105 | **Authors**: 106 | * Mariano Ruiz 107 | 108 | The goal was to learn React and using Spring as backend, so I started 109 | following this [guide](https://spring.io/guides/tutorials/react-and-spring-data-rest) 110 | from _Spring.io_ (Greg Turnquist and other authors from _Pivotal_), but I ended up 111 | rewriting almost all from scratch and adding a lot of features, like Bootstrap, 112 | client side validations, routing, updating dependencies to major versions ... 113 | 114 | 2015-2025 | Apache-2.0 115 | -------------------------------------------------------------------------------- /docs/img/spring-react-crud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrsarm/spring-react-crud/0441abc9fd7b6f590ffcb43690eb7ffbb12484ea/docs/img/spring-react-crud.png -------------------------------------------------------------------------------- /mvnw: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # ---------------------------------------------------------------------------- 3 | # Licensed to the Apache Software Foundation (ASF) under one 4 | # or more contributor license agreements. See the NOTICE file 5 | # distributed with this work for additional information 6 | # regarding copyright ownership. The ASF licenses this file 7 | # to you under the Apache License, Version 2.0 (the 8 | # "License"); you may not use this file except in compliance 9 | # with the License. You may obtain a copy of the License at 10 | # 11 | # http://www.apache.org/licenses/LICENSE-2.0 12 | # 13 | # Unless required by applicable law or agreed to in writing, 14 | # software distributed under the License is distributed on an 15 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | # KIND, either express or implied. See the License for the 17 | # specific language governing permissions and limitations 18 | # under the License. 19 | # ---------------------------------------------------------------------------- 20 | 21 | # ---------------------------------------------------------------------------- 22 | # Apache Maven Wrapper startup batch script, version 3.3.2 23 | # 24 | # Optional ENV vars 25 | # ----------------- 26 | # JAVA_HOME - location of a JDK home dir, required when download maven via java source 27 | # MVNW_REPOURL - repo url base for downloading maven distribution 28 | # MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 29 | # MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output 30 | # ---------------------------------------------------------------------------- 31 | 32 | set -euf 33 | [ "${MVNW_VERBOSE-}" != debug ] || set -x 34 | 35 | # OS specific support. 36 | native_path() { printf %s\\n "$1"; } 37 | case "$(uname)" in 38 | CYGWIN* | MINGW*) 39 | [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" 40 | native_path() { cygpath --path --windows "$1"; } 41 | ;; 42 | esac 43 | 44 | # set JAVACMD and JAVACCMD 45 | set_java_home() { 46 | # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched 47 | if [ -n "${JAVA_HOME-}" ]; then 48 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 49 | # IBM's JDK on AIX uses strange locations for the executables 50 | JAVACMD="$JAVA_HOME/jre/sh/java" 51 | JAVACCMD="$JAVA_HOME/jre/sh/javac" 52 | else 53 | JAVACMD="$JAVA_HOME/bin/java" 54 | JAVACCMD="$JAVA_HOME/bin/javac" 55 | 56 | if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then 57 | echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 58 | echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 59 | return 1 60 | fi 61 | fi 62 | else 63 | JAVACMD="$( 64 | 'set' +e 65 | 'unset' -f command 2>/dev/null 66 | 'command' -v java 67 | )" || : 68 | JAVACCMD="$( 69 | 'set' +e 70 | 'unset' -f command 2>/dev/null 71 | 'command' -v javac 72 | )" || : 73 | 74 | if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then 75 | echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 76 | return 1 77 | fi 78 | fi 79 | } 80 | 81 | # hash string like Java String::hashCode 82 | hash_string() { 83 | str="${1:-}" h=0 84 | while [ -n "$str" ]; do 85 | char="${str%"${str#?}"}" 86 | h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) 87 | str="${str#?}" 88 | done 89 | printf %x\\n $h 90 | } 91 | 92 | verbose() { :; } 93 | [ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } 94 | 95 | die() { 96 | printf %s\\n "$1" >&2 97 | exit 1 98 | } 99 | 100 | trim() { 101 | # MWRAPPER-139: 102 | # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. 103 | # Needed for removing poorly interpreted newline sequences when running in more 104 | # exotic environments such as mingw bash on Windows. 105 | printf "%s" "${1}" | tr -d '[:space:]' 106 | } 107 | 108 | # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties 109 | while IFS="=" read -r key value; do 110 | case "${key-}" in 111 | distributionUrl) distributionUrl=$(trim "${value-}") ;; 112 | distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; 113 | esac 114 | done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" 115 | [ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" 116 | 117 | case "${distributionUrl##*/}" in 118 | maven-mvnd-*bin.*) 119 | MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ 120 | case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in 121 | *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; 122 | :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; 123 | :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; 124 | :Linux*x86_64*) distributionPlatform=linux-amd64 ;; 125 | *) 126 | echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 127 | distributionPlatform=linux-amd64 128 | ;; 129 | esac 130 | distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" 131 | ;; 132 | maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; 133 | *) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; 134 | esac 135 | 136 | # apply MVNW_REPOURL and calculate MAVEN_HOME 137 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 138 | [ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" 139 | distributionUrlName="${distributionUrl##*/}" 140 | distributionUrlNameMain="${distributionUrlName%.*}" 141 | distributionUrlNameMain="${distributionUrlNameMain%-bin}" 142 | MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" 143 | MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" 144 | 145 | exec_maven() { 146 | unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : 147 | exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" 148 | } 149 | 150 | if [ -d "$MAVEN_HOME" ]; then 151 | verbose "found existing MAVEN_HOME at $MAVEN_HOME" 152 | exec_maven "$@" 153 | fi 154 | 155 | case "${distributionUrl-}" in 156 | *?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; 157 | *) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; 158 | esac 159 | 160 | # prepare tmp dir 161 | if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then 162 | clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } 163 | trap clean HUP INT TERM EXIT 164 | else 165 | die "cannot create temp dir" 166 | fi 167 | 168 | mkdir -p -- "${MAVEN_HOME%/*}" 169 | 170 | # Download and Install Apache Maven 171 | verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 172 | verbose "Downloading from: $distributionUrl" 173 | verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 174 | 175 | # select .zip or .tar.gz 176 | if ! command -v unzip >/dev/null; then 177 | distributionUrl="${distributionUrl%.zip}.tar.gz" 178 | distributionUrlName="${distributionUrl##*/}" 179 | fi 180 | 181 | # verbose opt 182 | __MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' 183 | [ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v 184 | 185 | # normalize http auth 186 | case "${MVNW_PASSWORD:+has-password}" in 187 | '') MVNW_USERNAME='' MVNW_PASSWORD='' ;; 188 | has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; 189 | esac 190 | 191 | if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then 192 | verbose "Found wget ... using wget" 193 | wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" 194 | elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then 195 | verbose "Found curl ... using curl" 196 | curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" 197 | elif set_java_home; then 198 | verbose "Falling back to use Java to download" 199 | javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" 200 | targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" 201 | cat >"$javaSource" <<-END 202 | public class Downloader extends java.net.Authenticator 203 | { 204 | protected java.net.PasswordAuthentication getPasswordAuthentication() 205 | { 206 | return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); 207 | } 208 | public static void main( String[] args ) throws Exception 209 | { 210 | setDefault( new Downloader() ); 211 | java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); 212 | } 213 | } 214 | END 215 | # For Cygwin/MinGW, switch paths to Windows format before running javac and java 216 | verbose " - Compiling Downloader.java ..." 217 | "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" 218 | verbose " - Running Downloader.java ..." 219 | "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" 220 | fi 221 | 222 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 223 | if [ -n "${distributionSha256Sum-}" ]; then 224 | distributionSha256Result=false 225 | if [ "$MVN_CMD" = mvnd.sh ]; then 226 | echo "Checksum validation is not supported for maven-mvnd." >&2 227 | echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 228 | exit 1 229 | elif command -v sha256sum >/dev/null; then 230 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then 231 | distributionSha256Result=true 232 | fi 233 | elif command -v shasum >/dev/null; then 234 | if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then 235 | distributionSha256Result=true 236 | fi 237 | else 238 | echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 239 | echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 240 | exit 1 241 | fi 242 | if [ $distributionSha256Result = false ]; then 243 | echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 244 | echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 245 | exit 1 246 | fi 247 | fi 248 | 249 | # unzip and move 250 | if command -v unzip >/dev/null; then 251 | unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" 252 | else 253 | tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" 254 | fi 255 | printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" 256 | mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" 257 | 258 | clean || : 259 | exec_maven "$@" 260 | -------------------------------------------------------------------------------- /mvnw.cmd: -------------------------------------------------------------------------------- 1 | <# : batch portion 2 | @REM ---------------------------------------------------------------------------- 3 | @REM Licensed to the Apache Software Foundation (ASF) under one 4 | @REM or more contributor license agreements. See the NOTICE file 5 | @REM distributed with this work for additional information 6 | @REM regarding copyright ownership. The ASF licenses this file 7 | @REM to you under the Apache License, Version 2.0 (the 8 | @REM "License"); you may not use this file except in compliance 9 | @REM with the License. You may obtain a copy of the License at 10 | @REM 11 | @REM http://www.apache.org/licenses/LICENSE-2.0 12 | @REM 13 | @REM Unless required by applicable law or agreed to in writing, 14 | @REM software distributed under the License is distributed on an 15 | @REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 16 | @REM KIND, either express or implied. See the License for the 17 | @REM specific language governing permissions and limitations 18 | @REM under the License. 19 | @REM ---------------------------------------------------------------------------- 20 | 21 | @REM ---------------------------------------------------------------------------- 22 | @REM Apache Maven Wrapper startup batch script, version 3.3.2 23 | @REM 24 | @REM Optional ENV vars 25 | @REM MVNW_REPOURL - repo url base for downloading maven distribution 26 | @REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven 27 | @REM MVNW_VERBOSE - true: enable verbose log; others: silence the output 28 | @REM ---------------------------------------------------------------------------- 29 | 30 | @IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) 31 | @SET __MVNW_CMD__= 32 | @SET __MVNW_ERROR__= 33 | @SET __MVNW_PSMODULEP_SAVE=%PSModulePath% 34 | @SET PSModulePath= 35 | @FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( 36 | IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) 37 | ) 38 | @SET PSModulePath=%__MVNW_PSMODULEP_SAVE% 39 | @SET __MVNW_PSMODULEP_SAVE= 40 | @SET __MVNW_ARG0_NAME__= 41 | @SET MVNW_USERNAME= 42 | @SET MVNW_PASSWORD= 43 | @IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) 44 | @echo Cannot start maven from wrapper >&2 && exit /b 1 45 | @GOTO :EOF 46 | : end batch / begin powershell #> 47 | 48 | $ErrorActionPreference = "Stop" 49 | if ($env:MVNW_VERBOSE -eq "true") { 50 | $VerbosePreference = "Continue" 51 | } 52 | 53 | # calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties 54 | $distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl 55 | if (!$distributionUrl) { 56 | Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" 57 | } 58 | 59 | switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { 60 | "maven-mvnd-*" { 61 | $USE_MVND = $true 62 | $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" 63 | $MVN_CMD = "mvnd.cmd" 64 | break 65 | } 66 | default { 67 | $USE_MVND = $false 68 | $MVN_CMD = $script -replace '^mvnw','mvn' 69 | break 70 | } 71 | } 72 | 73 | # apply MVNW_REPOURL and calculate MAVEN_HOME 74 | # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ 75 | if ($env:MVNW_REPOURL) { 76 | $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } 77 | $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" 78 | } 79 | $distributionUrlName = $distributionUrl -replace '^.*/','' 80 | $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' 81 | $MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" 82 | if ($env:MAVEN_USER_HOME) { 83 | $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" 84 | } 85 | $MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' 86 | $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" 87 | 88 | if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { 89 | Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" 90 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 91 | exit $? 92 | } 93 | 94 | if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { 95 | Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" 96 | } 97 | 98 | # prepare tmp dir 99 | $TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile 100 | $TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" 101 | $TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null 102 | trap { 103 | if ($TMP_DOWNLOAD_DIR.Exists) { 104 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 105 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 106 | } 107 | } 108 | 109 | New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null 110 | 111 | # Download and Install Apache Maven 112 | Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." 113 | Write-Verbose "Downloading from: $distributionUrl" 114 | Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" 115 | 116 | $webclient = New-Object System.Net.WebClient 117 | if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { 118 | $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) 119 | } 120 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 121 | $webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null 122 | 123 | # If specified, validate the SHA-256 sum of the Maven distribution zip file 124 | $distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum 125 | if ($distributionSha256Sum) { 126 | if ($USE_MVND) { 127 | Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." 128 | } 129 | Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash 130 | if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { 131 | Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." 132 | } 133 | } 134 | 135 | # unzip and move 136 | Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null 137 | Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null 138 | try { 139 | Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null 140 | } catch { 141 | if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { 142 | Write-Error "fail to move MAVEN_HOME" 143 | } 144 | } finally { 145 | try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } 146 | catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } 147 | } 148 | 149 | Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" 150 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spring-react-crud", 3 | "version": "0.1.0", 4 | "description": "Demo of ReactJS + Spring Data REST", 5 | "keywords": [ 6 | "rest", 7 | "hateoas", 8 | "spring", 9 | "data", 10 | "react" 11 | ], 12 | "license": "Apache-2.0", 13 | "dependencies": { 14 | "availity-reactstrap-validation-safe": "^2.6.1", 15 | "axios": "^1.8.2", 16 | "bootstrap": "^5.2.3", 17 | "lodash": "^4.17.21", 18 | "react": "^16.14.0", 19 | "react-dom": "^16.14.0", 20 | "react-router": "^5.3.4", 21 | "react-router-dom": "^5.3.4", 22 | "reactstrap": "^9.2.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.22.11", 26 | "@babel/preset-env": "^7.22.14", 27 | "@babel/preset-react": "^7.22.5", 28 | "babel-loader": "^9.1.3", 29 | "webpack": "5.94.0", 30 | "webpack-cli": "^5.1.4" 31 | }, 32 | "scripts": { 33 | "watch": "webpack --watch" 34 | }, 35 | "engines": { 36 | "node": ">= 18.20.0", 37 | "npm": ">= 9.6.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 3 | 4.0.0 4 | 5 | org.springframework.boot 6 | spring-boot-starter-parent 7 | 3.4.0 8 | 9 | 10 | ar.com.mrdev 11 | app 12 | 0.0.1-SNAPSHOT 13 | app 14 | React.js and Spring Data REST - CRUD with security enabled 15 | 16 | 17 | 21 18 | 19 | 20 | 21 | 22 | org.springframework.boot 23 | spring-boot-starter-data-jpa 24 | 25 | 26 | org.springframework.boot 27 | spring-boot-starter-data-rest 28 | 29 | 30 | org.springframework.boot 31 | spring-boot-starter-thymeleaf 32 | 33 | 34 | org.springframework.boot 35 | spring-boot-starter-security 36 | 37 | 38 | org.springframework.boot 39 | spring-boot-starter-validation 40 | 41 | 42 | org.thymeleaf.extras 43 | thymeleaf-extras-springsecurity6 44 | 45 | 46 | org.springframework.data 47 | spring-data-rest-hal-explorer 48 | 49 | 50 | 51 | 52 | com.h2database 53 | h2 54 | 2.2.224 55 | runtime 56 | 57 | 58 | 59 | org.projectlombok 60 | lombok 61 | 1.18.30 62 | true 63 | 64 | 65 | org.springframework.boot 66 | spring-boot-starter-test 67 | test 68 | 69 | 70 | 71 | 72 | 73 | 74 | org.springframework.boot 75 | spring-boot-maven-plugin 76 | 77 | true 78 | 79 | 80 | 81 | com.github.eirslett 82 | frontend-maven-plugin 83 | 1.14.0 84 | 85 | target 86 | 87 | 88 | 89 | install node and npm 90 | 91 | install-node-and-npm 92 | 93 | 94 | v18.20.7 95 | 9.6.0 96 | 97 | 98 | 99 | npm install 100 | 101 | npm 102 | 103 | 104 | install 105 | 106 | 107 | 108 | webpack build 109 | 110 | webpack 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/AppApplication.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2019 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app; 17 | 18 | import org.springframework.boot.SpringApplication; 19 | import org.springframework.boot.autoconfigure.SpringBootApplication; 20 | 21 | @SpringBootApplication 22 | public class AppApplication { 23 | 24 | public static void main(String[] args) { 25 | SpringApplication.run(AppApplication.class, args); 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/Constants.java: -------------------------------------------------------------------------------- 1 | package ar.com.mrdev.app; 2 | 3 | public interface Constants { 4 | 5 | String EMAIL_REGEXP = 6 | "[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\." 7 | + "[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@" 8 | + "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.?)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"; 9 | 10 | int SIZE_FIELD = 50; 11 | int SIZE_DESCRIPTION = 400; 12 | int SIZE_LONG_DESCRIPTION = 16_000; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/HomeController.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2019 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app; 17 | 18 | import org.springframework.stereotype.Controller; 19 | import org.springframework.web.bind.annotation.RequestMapping; 20 | 21 | @Controller 22 | public class HomeController { 23 | 24 | @RequestMapping(value = { 25 | "/", 26 | "/about", 27 | "/users", 28 | "/users/create", 29 | "/users/{id}" 30 | }) 31 | public String index() { 32 | return "index"; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/user/DatabaseLoader.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2019 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app.user; 17 | 18 | import lombok.extern.slf4j.Slf4j; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.boot.CommandLineRunner; 21 | import org.springframework.core.env.Environment; 22 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 23 | import org.springframework.security.core.authority.AuthorityUtils; 24 | import org.springframework.security.core.context.SecurityContextHolder; 25 | import org.springframework.stereotype.Component; 26 | import java.util.Arrays; 27 | import java.util.stream.Stream; 28 | import static ar.com.mrdev.app.user.User.ROLE_MANAGER; 29 | 30 | 31 | @Component 32 | @Slf4j 33 | public class DatabaseLoader implements CommandLineRunner { 34 | 35 | final UserRepository userRepository; 36 | final Environment env; 37 | 38 | @Override 39 | public void run(String... strings) throws Exception { 40 | 41 | if (userRepository.count() == 0l) { 42 | SecurityContextHolder.getContext().setAuthentication( 43 | new UsernamePasswordAuthenticationToken("greg", "doesn't matter", 44 | AuthorityUtils.createAuthorityList("ROLE_MANAGER"))); 45 | 46 | Stream.of( 47 | new User("frodo@local", "Frodo", "Baggins", "Ring bearer", "admin", ROLE_MANAGER), 48 | new User("bilbo@local", "Bilbo", "Baggins", "Burglar", "test"), 49 | new User("gf@local", "Gandalf", "the Grey", "Wizard", "admin", ROLE_MANAGER), 50 | new User("lego@local", "Legolas", "Greenleaf", "Elf prince", "test"), 51 | new User("sam@local", "Sam", "Gamgee", "The gardener", "test") 52 | ).forEach(user -> { 53 | log.info("Created {}", userRepository.save(user)); 54 | }); 55 | 56 | SecurityContextHolder.clearContext(); 57 | } else { 58 | log.info("Creation of default users skipped"); 59 | } 60 | } 61 | 62 | @Autowired 63 | public DatabaseLoader( 64 | UserRepository userRepository, Environment env) { 65 | this.userRepository = userRepository; 66 | this.env = env; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/user/SecurityConfig.java: -------------------------------------------------------------------------------- 1 | package ar.com.mrdev.app.user; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.security.authentication.AuthenticationManager; 6 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 7 | import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; 8 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 9 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 10 | import org.springframework.security.crypto.password.PasswordEncoder; 11 | import org.springframework.security.web.SecurityFilterChain; 12 | 13 | import static org.springframework.security.config.Customizer.withDefaults; 14 | 15 | @EnableWebSecurity 16 | @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true) 17 | @Configuration 18 | public class SecurityConfig { 19 | 20 | @Bean 21 | public PasswordEncoder bCryptPasswordEncoder() { 22 | return User.PASSWORD_ENCODER; 23 | } 24 | 25 | @Bean 26 | public AuthenticationManager authenticationManager( 27 | HttpSecurity http, 28 | PasswordEncoder passwordEncoder, 29 | SpringDataJpaUserDetailsService userDetailsService 30 | ) throws Exception { 31 | return http.getSharedObject(AuthenticationManagerBuilder.class) 32 | .userDetailsService(userDetailsService) 33 | .passwordEncoder(passwordEncoder) 34 | .and() 35 | .build(); 36 | } 37 | 38 | @Bean 39 | public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { 40 | http 41 | .csrf() 42 | .disable() 43 | .authorizeHttpRequests(requests -> requests 44 | .requestMatchers("/built/**").permitAll() 45 | .anyRequest().authenticated() 46 | ) 47 | .formLogin(form -> form 48 | .defaultSuccessUrl("/", true) 49 | .permitAll() 50 | ) 51 | .httpBasic(withDefaults()) 52 | .logout(logout -> logout 53 | //.permitAll() 54 | .logoutSuccessUrl("/") 55 | ); 56 | 57 | return http.build(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/user/SpringDataJpaUserDetailsService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2023 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app.user; 17 | 18 | import lombok.extern.slf4j.Slf4j; 19 | import org.springframework.security.core.authority.AuthorityUtils; 20 | import org.springframework.security.core.userdetails.User; 21 | import org.springframework.security.core.userdetails.UserDetails; 22 | import org.springframework.security.core.userdetails.UserDetailsService; 23 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 24 | import org.springframework.stereotype.Component; 25 | 26 | 27 | @Component 28 | @Slf4j 29 | public class SpringDataJpaUserDetailsService implements UserDetailsService { 30 | 31 | private final UserRepository repository; 32 | 33 | public SpringDataJpaUserDetailsService(UserRepository repository) { 34 | this.repository = repository; 35 | } 36 | 37 | @Override 38 | public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException { 39 | ar.com.mrdev.app.user.User user = this.repository.findByEmail(name); 40 | if (user != null) { 41 | log.info("User trying to access... username={}", name); 42 | return new User( 43 | user.getEmail(), 44 | user.getPassword(), 45 | AuthorityUtils.createAuthorityList(user.getRoles().toArray(String[]::new)) 46 | ); 47 | } 48 | log.info("User not found, username={}", name); 49 | throw new UsernameNotFoundException("User not found"); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/user/User.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2019 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app.user; 17 | 18 | import com.fasterxml.jackson.annotation.JsonIgnore; 19 | import com.fasterxml.jackson.annotation.JsonProperty; 20 | import lombok.*; 21 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 22 | import org.springframework.security.crypto.password.PasswordEncoder; 23 | import jakarta.persistence.*; 24 | import jakarta.validation.constraints.AssertTrue; 25 | import jakarta.validation.constraints.NotNull; 26 | import jakarta.validation.constraints.Pattern; 27 | import jakarta.validation.constraints.Size; 28 | import java.util.ArrayList; 29 | import java.util.Arrays; 30 | import java.util.List; 31 | import static ar.com.mrdev.app.Constants.*; 32 | import static jakarta.validation.constraints.Pattern.Flag.CASE_INSENSITIVE; 33 | 34 | 35 | @Data 36 | @ToString(exclude = {"password", "clearPassword"}) 37 | @Entity(name = "users") 38 | public class User { 39 | 40 | public static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); 41 | 42 | // Add more roles here (TODO: move roles to a enum class) 43 | public static final String ROLE_MANAGER = "ROLE_MANAGER"; 44 | public static final List ROLES = Arrays.asList(ROLE_MANAGER); 45 | 46 | private @Id @GeneratedValue 47 | Long id; 48 | 49 | private @NotNull @Size(min = 3, max = SIZE_FIELD) String firstName; 50 | private @NotNull @Size(min = 0, max = SIZE_FIELD) String lastName; 51 | private @Size(min = 0, max = SIZE_DESCRIPTION) String description; 52 | 53 | @Column(unique=true, nullable = false) 54 | @Pattern(regexp = EMAIL_REGEXP, flags = CASE_INSENSITIVE, message="Invalid email address") 55 | private @NotNull @Size(min = 3, max = 50) String email; 56 | 57 | private @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) 58 | String password; 59 | 60 | @JsonIgnore @Transient @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) 61 | private transient String clearPassword; 62 | 63 | @ElementCollection(fetch = FetchType.EAGER) 64 | @CollectionTable(name = "users_roles", joinColumns = @JoinColumn(name = "id")) 65 | @Column(name = "role") 66 | private List roles = new ArrayList<>(); 67 | 68 | public User() {} 69 | 70 | public User(String email, String firstName, String lastName, String description, String password, String... roles) { 71 | this.email = email; 72 | this.firstName = firstName; 73 | this.lastName = lastName; 74 | this.description = description; 75 | this.setPassword(password); 76 | this.roles = Arrays.asList(roles); 77 | } 78 | 79 | @AssertTrue(message = "size must be between 4 and 16") 80 | public boolean hasRightSizePassword() { 81 | return clearPassword ==null || (clearPassword.length()>=4 && clearPassword.length()<=16); 82 | } 83 | 84 | public void setPassword(String password) { 85 | this.clearPassword = password; 86 | this.password = PASSWORD_ENCODER.encode(password); 87 | } 88 | 89 | @JsonIgnore @Transient 90 | public void setAlreadyEncodedPassword(String password) { 91 | this.clearPassword = null; 92 | this.password = password; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/user/UserController.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2023 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app.user; 17 | 18 | import org.springframework.security.access.prepost.PreAuthorize; 19 | import org.springframework.validation.annotation.Validated; 20 | import org.springframework.web.bind.annotation.PathVariable; 21 | import org.springframework.web.bind.annotation.PutMapping; 22 | import org.springframework.web.bind.annotation.RequestBody; 23 | import org.springframework.web.bind.annotation.RestController; 24 | import jakarta.servlet.http.HttpServletRequest; 25 | import static ar.com.mrdev.app.user.User.ROLE_MANAGER; 26 | 27 | 28 | @RestController 29 | public class UserController { 30 | 31 | final UserService userService; 32 | 33 | /** 34 | * To update the profile without modifying the 35 | * password (if it's not provided) and without modifying the 36 | * user roles (if the authenticated user isn't a Manager) 37 | */ 38 | @PutMapping("/api/users/{id}/profile") 39 | @PreAuthorize("hasRole('ROLE_MANAGER') or #user?.email == authentication?.name") 40 | public User updateProfile(HttpServletRequest request, @PathVariable Long id, @Validated @RequestBody User user) { 41 | return userService.updateProfile(id, user, request.isUserInRole(ROLE_MANAGER)); 42 | } 43 | 44 | public UserController(UserService userService) { 45 | this.userService = userService; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/user/UserRepository.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2019 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app.user; 17 | 18 | import org.springframework.data.repository.ListCrudRepository; 19 | import org.springframework.data.repository.PagingAndSortingRepository; 20 | import org.springframework.data.repository.query.Param; 21 | import org.springframework.security.access.prepost.PreAuthorize; 22 | import org.springframework.data.rest.core.annotation.RepositoryRestResource; 23 | 24 | @RepositoryRestResource 25 | public interface UserRepository extends PagingAndSortingRepository, ListCrudRepository { 26 | 27 | User findByEmail(String email); 28 | 29 | @Override 30 | @PreAuthorize("hasRole('ROLE_MANAGER') or #user?.email == authentication?.name") 31 | User save(@Param("user") User user); 32 | 33 | @Override 34 | @PreAuthorize("hasRole('ROLE_MANAGER') or #user?.email == authentication?.name") 35 | void deleteById(@Param("id") Long id); 36 | 37 | @Override 38 | @PreAuthorize("hasRole('ROLE_MANAGER') or #user?.email == authentication?.name") 39 | void delete(@Param("user") User user); 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/ar/com/mrdev/app/user/UserService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2015-2020 the original author or authors. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package ar.com.mrdev.app.user; 17 | 18 | import org.springframework.security.access.prepost.PreAuthorize; 19 | import org.springframework.stereotype.Service; 20 | 21 | @Service 22 | public class UserService { 23 | 24 | final UserRepository userRepository; 25 | 26 | @PreAuthorize("hasRole('ROLE_MANAGER') or #user?.email == authentication?.name") 27 | public User updateProfile(Long id, User user, boolean updateAsManager) { 28 | User userEntity = userRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("User not found")); 29 | if (updateAsManager) { 30 | // Only Manager can edit roles 31 | if (user.getRoles()!=null) { 32 | userEntity.setRoles(user.getRoles()); 33 | } 34 | } 35 | if (user.getPassword() != null) { 36 | userEntity.setAlreadyEncodedPassword(user.getPassword()); 37 | } 38 | userEntity.setEmail(user.getEmail()); 39 | userEntity.setFirstName(user.getFirstName()); 40 | userEntity.setLastName(user.getLastName()); 41 | userEntity.setDescription(user.getDescription()); 42 | return userRepository.save(userEntity); 43 | } 44 | 45 | public UserService(UserRepository userRepository) { 46 | this.userRepository = userRepository; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/js/client.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | const appData = document.querySelector('#app-data').dataset; 4 | const apiBaseUrl = appData.apiUrl || '/api'; 5 | 6 | export const client = axios.create({ 7 | baseURL: apiBaseUrl, 8 | //timeout: 1000, 9 | headers: {'Accept': 'application/hal+json'} 10 | }); 11 | 12 | export const get = axios.create({ 13 | method: 'get', 14 | baseURL: apiBaseUrl, 15 | //timeout: 1000, 16 | headers: {'Accept': 'application/hal+json'} 17 | }); 18 | 19 | export const post = axios.create({ 20 | method: 'post', 21 | baseURL: apiBaseUrl, 22 | //timeout: 1000, 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | 'Accept': 'application/hal+json' 26 | } 27 | }); 28 | 29 | export const put = axios.create({ 30 | method: 'put', 31 | baseURL: apiBaseUrl, 32 | //timeout: 1000, 33 | headers: { 34 | 'Content-Type': 'application/json', 35 | 'Accept': 'application/hal+json' 36 | } 37 | }); 38 | 39 | export const del = axios.create({ 40 | method: 'delete', 41 | baseURL: apiBaseUrl, 42 | //timeout: 1000, 43 | headers: {'Accept': 'application/hal+json'} 44 | }); 45 | -------------------------------------------------------------------------------- /src/main/js/commons.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | /** 4 | * Gets the value from the target event. 5 | * @param target can be the target object from an event (event.target) or 6 | * any form DOM element that have a value, like a input element (HTMLInputElement), 7 | * a select element (HTMLSelectElement)... 8 | * @param emptyToNull (default false), set to true if empty string "" should be 9 | * replaced by `null` 10 | * @returns the string value, an array of strings if it's a multi-value element, 11 | * and if target is a checkbox component the boolean value 12 | */ 13 | function getTargetValue(target, emptyToNull = false) { 14 | if (target.type === "checkbox") return target.checked; 15 | if (target.type === "select-multiple") { 16 | const values = []; 17 | for (let i = 0; i < target.options.length; i++) { 18 | if (target.options[i].selected) { 19 | values.push(target.options[i].value); 20 | } 21 | } 22 | return values; 23 | } 24 | if (target.type === "select-one" && target.value === "") { 25 | return null; 26 | } 27 | if (emptyToNull && target.value === "") { 28 | return null; 29 | } 30 | return target.value; 31 | } 32 | 33 | /** 34 | * Changes the element at the root of the state named `stateElName' 35 | * calling `setState` with the name and the value got from `event.target'. 36 | * 37 | * E.g. having a state like: 38 | * 39 | * ``` 40 | * { 41 | * "user": { 42 | * "username": "", 43 | * "...": "", 44 | * }, 45 | * ... 46 | * } 47 | * ``` 48 | * 49 | * A form: 50 | * 51 | * ``` 52 | *
53 | * 54 | * 56 | *
57 | * ``` 58 | * 59 | * The following implementation of `handleChange` will update 60 | * the username in the "user" part of the state when the form field 61 | * is changed: 62 | * 63 | * ``` 64 | * handleChange(event) { 65 | * applyEventToState(event, this.state, "user", this.setState.bind(this)) 66 | * } 67 | * ``` 68 | * 69 | * Empty string values "" are replaced by null. 70 | * 71 | * @param event with the value, it will be processed with 72 | * `#getTargetValue(target)` in case it has multi-values. 73 | * @param state the current state 74 | * @param setState the function to change the state 75 | * @param stateElName the key at the root of the state to change, 76 | * e.g. "user", but it can be also a path e.g. if `stateElName` is 77 | * "user.profile" and `event.target.name` is "username", the 78 | * value from the `event` object will be applied to 79 | * the path `state.user.profile.username`. 80 | * If stateElName is "" (default), the value is applied at root level, 81 | * e.g. in the same example where `event.target.name` is "username" 82 | * the path will be `state.username`. 83 | */ 84 | export function applyEventToState(event, state, setState, stateElName = '') { 85 | let stateEl; 86 | if (stateElName === '') { 87 | stateEl = _.cloneDeep(state); 88 | } else { 89 | stateEl = _.cloneDeep(state[stateElName]); 90 | } 91 | const name = event.target.name; 92 | const value = getTargetValue(event.target, true); 93 | _.set(stateEl, name, value); 94 | if (stateElName === '') { 95 | setState(stateEl); 96 | } else { 97 | const stateElNameRoot = stateElName.split('.')[0]; 98 | setState({[stateElNameRoot]: stateEl}); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/main/js/components/About.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Container } from 'reactstrap'; 3 | import { Link, withRouter } from 'react-router-dom'; 4 | 5 | function About() { 6 | return ( 7 | 8 |

React.js and Spring Data REST - CRUD

9 |

10 | CRUD application with security enabled: a PoC with 11 | ReactJS in the frontend and Spring Data REST in the backend. 12 |

13 |

Authors

14 | 17 |

18 | Source Code:  19 | 21 | https://github.com/mrsarm/spring-react-crud 22 | 23 |

24 |
2015 ‒ 2023 | Apache-2.0
25 | 26 |
27 | ); 28 | } 29 | 30 | export default withRouter(About); 31 | -------------------------------------------------------------------------------- /src/main/js/components/App.js: -------------------------------------------------------------------------------- 1 | import { 2 | BrowserRouter as Router, 3 | Redirect, 4 | Route, 5 | Switch, 6 | } from 'react-router-dom'; 7 | import React from 'react'; 8 | import UsersHome from './user/UsersHome'; 9 | import About from './About'; 10 | import User from './user/User'; 11 | import Navbar from './Navbar'; 12 | import RouteNoMatch from './RouteNoMatch'; 13 | 14 | export default function App() { 15 | 16 | const loggedUserData = document.querySelector('#user-data').dataset; 17 | const loggedUser = { 18 | name: loggedUserData.username, 19 | isAdmin: loggedUserData.isAdmin === 'true' 20 | }; 21 | 22 | return ( 23 | 24 | 25 | 26 | } 29 | /> 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /src/main/js/components/Navbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export default function Navbar({ loggedUser }) { 5 | return ( 6 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/main/js/components/RouteNoMatch.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, Container } from 'reactstrap'; 3 | import { Link, useLocation } from 'react-router-dom'; 4 | 5 | export default function RouteNoMatch() { 6 | let location = useLocation(); 7 | 8 | return ( 9 | 10 | 11 |

12 | ⚠ No match for {location.pathname} 13 |

14 |

15 | > Go to Home. 16 |

17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/main/js/components/commons/Loading.js: -------------------------------------------------------------------------------- 1 | import { Spinner } from 'reactstrap'; 2 | import React from 'react'; 3 | 4 | export default function Loading({ 5 | message = "Loading...", 6 | size = 'sm', 7 | color = 'primary', 8 | display = true 9 | }) { 10 | if (!display) return ''; 11 | return ( 12 | <> 13 | 14 |   {message} 15 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/js/components/commons/LoadingPagination.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Loading from './Loading'; 3 | 4 | export default function LoadingPagination(props) { 5 | return ( 6 |
  • 7 |
    8 | 9 |
    10 |
  • 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/js/components/commons/Message.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Alert, Container } from 'reactstrap'; 3 | 4 | /** 5 | * React Component to show a message 6 | * in the UI: errors, warning, info or success. 7 | */ 8 | export default function Message({ info, warning, error, success, display }) { 9 | 10 | if (display !== undefined && !display) { 11 | return ''; 12 | } 13 | 14 | let color; 15 | let msg; 16 | if (error) { 17 | color = 'danger'; 18 | msg = error; 19 | } else if (success) { 20 | color = 'success'; 21 | msg = success; 22 | } else if (info) { 23 | color = 'info'; 24 | msg = info; 25 | } else if (warning) { 26 | color = 'warning'; 27 | msg = warning; 28 | } else { 29 | return ''; 30 | } 31 | 32 | function renderMessage(msg) { 33 | if (msg.title && msg.message) { 34 | return ( 35 | <> 36 |
    {msg.title}
    37 | {msg.message} 38 | 39 | ); 40 | } 41 | if (msg.message) { 42 | return {msg.message}; 43 | } 44 | if (typeof msg === 'string') { 45 | return {msg}; 46 | } 47 | } 48 | 49 | return ( 50 | 51 | 52 | {renderMessage(msg)} 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/main/js/components/user/User.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { Link, withRouter, useHistory } from 'react-router-dom'; 3 | import { useRouteMatch } from 'react-router'; 4 | import { get, post, put } from '../../client'; 5 | import { applyEventToState } from '../../commons'; 6 | import { Button, Container, Input, Label, Row } from 'reactstrap'; 7 | import { AvForm, AvGroup, AvInput, AvFeedback } from 'availity-reactstrap-validation-safe'; 8 | import Loading from '../commons/Loading'; 9 | import Message from '../commons/Message'; 10 | import { reduceError } from '../../errors'; 11 | 12 | function User() { 13 | const passwordRef = useRef(null); 14 | const match = useRouteMatch(); 15 | const userId = match.params.id; 16 | const history = useHistory(); 17 | const isCreateUser = userId === undefined; 18 | const [isLoadingUser, setIsLoadingUser] = useState(!isCreateUser); 19 | const [isSavingUser, setIsSavingUser] = useState(false); 20 | const [error, setError] = useState(null); 21 | const [showForm, setShowForm] = useState(isCreateUser); 22 | const [user, setUser] = useState({ 23 | email: '', 24 | firstName: '', 25 | lastName: '', 26 | roles: [], 27 | description: '' 28 | }); 29 | 30 | useEffect( () => { 31 | async function fetchUser() { 32 | try { 33 | const response = await get(`/users/${userId}`); 34 | setUser(response.data); 35 | setIsLoadingUser(false); 36 | setShowForm(true); 37 | } catch (ex) { 38 | setError(reduceError(ex, "user", "get")); 39 | setIsLoadingUser(false); 40 | setShowForm(false); 41 | } 42 | } 43 | 44 | if (!isCreateUser) { 45 | fetchUser(); 46 | } 47 | }, [isCreateUser, userId]); 48 | 49 | function handleChange(event) { 50 | applyEventToState(event, user, setUser); 51 | } 52 | 53 | function save(updatedUser) { 54 | if (isCreateUser) { 55 | return post({ 56 | url: 'users', 57 | data: updatedUser 58 | }); 59 | } else { 60 | return put({ 61 | url: updatedUser._links.self.href + '/profile', 62 | data: updatedUser 63 | }); 64 | } 65 | } 66 | 67 | async function handleValidSubmit(event, values) { 68 | setIsSavingUser(true); 69 | const updatedUser = { ...user }; 70 | const password = passwordRef.current.value.trim(); 71 | if (password) { 72 | updatedUser.password = password; 73 | } 74 | setUser(updatedUser); 75 | try { 76 | await save(updatedUser); 77 | history.push('/'); 78 | } catch(err) { 79 | setError(reduceError(err, "user", "save")); 80 | setIsSavingUser(false); 81 | } 82 | } 83 | 84 | return ( 85 | 86 |

    User Details

    87 | 88 | 89 | {!isLoadingUser && showForm && 90 | 91 | 92 | 93 | 95 | Email required 96 | 97 | 98 | 99 | 103 | Password required 104 | 105 | 106 | 107 | 108 | 111 | First name required 112 | 113 | 114 | 115 | 118 | Last name required 119 | 120 | 121 | 122 | 123 | 124 | 126 | 127 | 128 | 129 | 130 | 131 | 134 | 135 | 136 | 137 | 138 |    141 | 142 | 143 | 144 | } 145 |
    146 | ); 147 | } 148 | 149 | export default withRouter(User); 150 | -------------------------------------------------------------------------------- /src/main/js/components/user/UserItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Link, withRouter } from 'react-router-dom'; 3 | import { Button, ButtonGroup } from 'reactstrap'; 4 | 5 | function UserItem({ user, onDelete }) { 6 | 7 | const editUrl = user._links.self.href.split("/api")[1]; 8 | const [isLoading, setIsLoading] = useState(false); 9 | 10 | function handleDelete() { 11 | setIsLoading(true); 12 | if (window.confirm(`Do you really want to delete the user with e-mail ${user.email} ?`)) { 13 | onDelete(user).catch(() => setIsLoading(false)); 14 | } else { 15 | setIsLoading(false); 16 | } 17 | } 18 | 19 | return ( 20 | 21 | {user.firstName} 22 | {user.lastName} 23 | {user.email} 24 | {user.description} 25 | 26 | 27 | 29 | 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default withRouter(UserItem); 40 | -------------------------------------------------------------------------------- /src/main/js/components/user/UserList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Container, Pagination, 3 | PaginationItem, PaginationLink, Table } from 'reactstrap'; 4 | import { Link, withRouter } from 'react-router-dom'; 5 | import UserItem from './UserItem'; 6 | import Loading from '../commons/Loading'; 7 | import Message from '../commons/Message'; 8 | import LoadingPagination from '../commons/LoadingPagination'; 9 | 10 | function UserList({ 11 | users, 12 | links, 13 | isLoadingUsers, 14 | isLoading, 15 | error, 16 | loggedUser, 17 | onNavigate, 18 | onDelete 19 | }) { 20 | const allowCreateUser = loggedUser.isAdmin; 21 | const createUserTooltip = loggedUser.isAdmin ? 22 | 'Add User' : 'Only "Manager" users are allowed to create other users'; 23 | const [isLoadingPagination, setIsLoadingPagination] = useState(isLoading || false); 24 | 25 | function _handleNavFirst(e, link) { 26 | e.preventDefault(); 27 | setIsLoadingPagination(true); 28 | onNavigate(links[link].href) 29 | .then(() => setIsLoadingPagination(false)); 30 | } 31 | 32 | const handleNavFirst = (e) => _handleNavFirst(e, "first"); 33 | const handleNavPrev = (e) => _handleNavFirst(e, "prev"); 34 | const handleNavNext = (e) => _handleNavFirst(e, "next"); 35 | const handleNavLast = (e) => _handleNavFirst(e, "last"); 36 | 37 | function getNavLinks() { 38 | const navLinks = []; 39 | if ("first" in links) { 40 | navLinks.push( 41 | 42 | 43 | 44 | ); 45 | } 46 | if ("prev" in links) { 47 | navLinks.push( 48 | 49 | 50 | 51 | ); 52 | } 53 | if ("next" in links) { 54 | navLinks.push( 55 | 56 | 57 | 58 | ); 59 | } 60 | if ("last" in links) { 61 | navLinks.push( 62 | 63 | 64 | 65 | ); 66 | } 67 | return navLinks; 68 | } 69 | 70 | const navLinks = getNavLinks(); 71 | return ( 72 | 73 |
    74 | 79 |
    80 |

    Users

    81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | {isLoadingUsers && 93 | 94 | 95 | 96 | } 97 | {!isLoadingUsers && !error && users.length === 0 && 98 | 99 | } 100 | {!isLoadingUsers && users.length > 0 && 101 | users.map(user => 102 | 103 | ) 104 | } 105 | 106 |
    First NameLast NameEmailNotes
    ☒ No users found.
    107 | {navLinks.length > 0 && 108 | 109 | {navLinks} 110 | {isLoadingPagination && 111 | 112 | } 113 | 114 | } 115 | 116 |
    117 | ); 118 | } 119 | 120 | export default withRouter(UserList); 121 | -------------------------------------------------------------------------------- /src/main/js/components/user/UsersHome.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { client } from '../../client'; 3 | import { Container } from 'reactstrap'; 4 | import UserList from './UserList'; 5 | import { withRouter } from 'react-router-dom'; 6 | import { reduceError } from '../../errors'; 7 | 8 | const PAGE_SIZE = 20; 9 | 10 | function UsersHome({ loggedUser }) { 11 | const [users, setUsers] = useState([]); 12 | const [links, setLinks] = useState([]); 13 | const [isLoadingUsers, setIsLoadingUsers] = useState(true); 14 | const [isLoadingPagination, setIsLoadingPagination] = useState(false); 15 | const [error, setError] = useState(null); 16 | 17 | async function loadFromServer() { 18 | try { 19 | const response = await client({ url: 'users', params: { size: PAGE_SIZE } }); 20 | setUsers(response.data._embedded.users); 21 | setLinks(response.data._links); 22 | setIsLoadingUsers(false); 23 | setError(null); 24 | return response; 25 | } catch(ex) { 26 | setError(reduceError(ex, "users", "get")); 27 | setIsLoadingUsers(false); 28 | setIsLoadingPagination(false); 29 | } 30 | } 31 | 32 | async function onDelete(user) { 33 | try { 34 | await client({method: 'delete', url: user._links.self.href}); 35 | await loadFromServer(); 36 | } catch(ex) { 37 | if (ex.response.status === 403) { 38 | alert("Access DENIED: You are not authorized to " + 39 | "delete the user with email " + user.email); 40 | } else { 41 | //TODO Improve how the error is shown 42 | console.error("Unknown error deleting user -", ex); 43 | alert('Unexpected error'); 44 | } 45 | throw ex; 46 | } 47 | } 48 | 49 | useEffect(() => { 50 | loadFromServer(); 51 | }, []); 52 | 53 | async function onNavigate(navUri) { 54 | try { 55 | const userCollection = await client.get(navUri); 56 | setUsers(userCollection.data._embedded.users); 57 | setLinks(userCollection.data._links); 58 | setIsLoadingUsers(false); 59 | setIsLoadingPagination(false); 60 | setError(null); 61 | } catch(ex) { 62 | setError(reduceError(ex, "users", "get")); 63 | setIsLoadingUsers(false); 64 | setIsLoadingPagination(false); 65 | } 66 | } 67 | 68 | return ( 69 | 70 | 78 | 79 | ); 80 | } 81 | 82 | export default withRouter(UsersHome); 83 | -------------------------------------------------------------------------------- /src/main/js/errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Reduce an error to an object with 3 | * a title and a message to show in the UI. 4 | * 5 | * E.g.: 6 | * 7 | * ``` 8 | * { 9 | * "title": "An error occurred", 10 | * "message": "Cannot update the user.", 11 | * "cause": ex 12 | * } 13 | * ``` 14 | * 15 | * @param ex the exception or an error message string 16 | * @param entity the object name related with the error, e.g. "user", "inbox"... 17 | * @param action the event that caused the error, e.g. "update", "delete"... 18 | * @param logger (default `console.error`) if the error is unknown it will 19 | * be logged out with this logger 20 | */ 21 | export function reduceError( 22 | ex, 23 | entity='record', 24 | action='process', 25 | logger= console.error 26 | ) { 27 | if (typeof ex === 'string') { 28 | return { 29 | title: 'An error occurred', 30 | message: ex 31 | }; 32 | } 33 | // Error does not come from a response 34 | if (!ex.response) { 35 | if (logger) logger(`Error ${action} ${entity} - `, ex); 36 | if (ex.message === 'Network Error') { 37 | return { 38 | title: '\uD83D\uDD0C Network Error', 39 | message: 'You are having network issues, check your Internet connection.', 40 | cause: ex 41 | }; 42 | } 43 | if (ex.message) { 44 | return { 45 | title: 'Unexpected error', 46 | message: ex.message, 47 | cause: ex 48 | }; 49 | } 50 | return { 51 | title: 'Unknown error', 52 | message: `An error occurred trying to ${action} the ${entity}. Try again later.`, 53 | cause: ex 54 | }; 55 | } 56 | // Error comes from a response 57 | if (ex.response.status === 403) { 58 | return { 59 | title: 'Access denied', 60 | message: `You are not authorized to ${action} this ${entity}.`, 61 | cause: ex 62 | }; 63 | } 64 | if (ex.response.status === 404) { 65 | return { 66 | title: 'Record not found', 67 | message: `Unable to ${action} the ${entity}. Either it doesn't exist or it has been deleted.`, 68 | cause: ex 69 | }; 70 | } 71 | if (ex.response.status === 412) { 72 | return { 73 | title: 'Access denied', 74 | message: `Unable to ${action} the ${entity}. Your copy is stale.`, 75 | cause: ex 76 | }; 77 | } 78 | 79 | if (logger) logger(`Unknown error ${action} ${entity} - `, ex); 80 | return { 81 | title: 'Unknown error', 82 | message: `An error occurred trying to ${action} the ${entity}. Try again later.`, 83 | cause: ex 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /src/main/js/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { client } from './client'; 4 | import App from './components/App'; 5 | 6 | window.client = client; // To be accessible from the browser to debug requests 7 | 8 | ReactDOM.render( 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | server: 2 | port: ${PORT:8080} 3 | 4 | spring: 5 | profiles: 6 | active: ${APP_ENV:development} 7 | data: 8 | rest: 9 | base-path: /api 10 | mvc: 11 | log-resolved-exception: false 12 | h2: 13 | console: 14 | enabled: false 15 | datasource: 16 | username: sa 17 | -------------------------------------------------------------------------------- /src/main/resources/static/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Rubik", "Varela Round", "Oxygen", 5 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | nav { 12 | margin-bottom: 1rem; 13 | } 14 | 15 | .container-message { 16 | margin-top: 0.8rem; 17 | padding-left: 0; 18 | padding-right: 0; 19 | } 20 | -------------------------------------------------------------------------------- /src/main/resources/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ReactJS + Spring Data REST 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
    19 |
    21 |
    22 |
    24 |
    25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/test/java/ar/com/mrdev/app/AppApplicationTests.java: -------------------------------------------------------------------------------- 1 | package ar.com.mrdev.app; 2 | 3 | import org.junit.jupiter.api.Test; 4 | import org.junit.jupiter.api.extension.ExtendWith; 5 | import org.springframework.test.context.ActiveProfiles; 6 | import org.springframework.test.context.junit.jupiter.SpringExtension; 7 | 8 | @ExtendWith(SpringExtension.class) 9 | @ActiveProfiles("test") 10 | //TODO Remove DB context 11 | public class AppApplicationTests { 12 | 13 | @Test 14 | public void contextLoads() { 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/main/js/index.js', 5 | devtool: 'eval-cheap-module-source-map', 6 | cache: true, 7 | mode: 'development', 8 | output: { 9 | path: __dirname, 10 | filename: './src/main/resources/static/built/bundle.js' 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: path.join(__dirname, '.'), 16 | exclude: /(node_modules)/, 17 | use: [{ 18 | loader: 'babel-loader', 19 | options: { 20 | presets: ["@babel/preset-env", "@babel/preset-react"] 21 | } 22 | }] 23 | } 24 | ] 25 | } 26 | }; 27 | --------------------------------------------------------------------------------