├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── docker-compose.yml ├── documentation ├── jobs-portal-preview.gif ├── okta-admin-dashboard.jpeg ├── project-diagram.excalidraw └── project-diagram.jpeg ├── jobs-api ├── .mvn │ └── wrapper │ │ └── maven-wrapper.properties ├── mvnw ├── mvnw.cmd ├── pom.xml └── src │ ├── main │ ├── java │ │ └── com │ │ │ └── ivanfranchin │ │ │ └── jobsapi │ │ │ ├── JobsApiApplication.java │ │ │ ├── config │ │ │ ├── RequestLoggingFilterConfig.java │ │ │ └── SwaggerConfig.java │ │ │ ├── exception │ │ │ └── JobNotFoundException.java │ │ │ ├── mapper │ │ │ ├── JobMapper.java │ │ │ └── JobMapperImpl.java │ │ │ ├── model │ │ │ └── Job.java │ │ │ ├── repository │ │ │ └── JobRepository.java │ │ │ ├── rest │ │ │ ├── CallbackController.java │ │ │ ├── JobController.java │ │ │ └── dto │ │ │ │ ├── CreateJobRequest.java │ │ │ │ ├── JobResponse.java │ │ │ │ ├── SearchRequest.java │ │ │ │ └── UpdateJobRequest.java │ │ │ ├── runner │ │ │ └── DatabaseInitializer.java │ │ │ ├── security │ │ │ ├── CorsConfig.java │ │ │ └── WebSecurityConfig.java │ │ │ └── service │ │ │ ├── JobService.java │ │ │ └── JobServiceImpl.java │ └── resources │ │ ├── application.yml │ │ └── banner.txt │ └── test │ └── java │ └── com │ └── ivanfranchin │ └── jobsapi │ └── JobsApiApplicationTests.java └── jobs-ui ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── jobs-portal-logo.svg ├── manifest.json ├── nyc.jpg └── robots.txt └── src ├── App.js ├── components ├── customer │ ├── Customer.js │ ├── JobCard.js │ ├── JobList.js │ └── JobView.js ├── home │ ├── Home.js │ ├── JobCard.js │ └── JobList.js ├── misc │ ├── Logo.js │ ├── Navbar.js │ ├── Pagination.js │ ├── Search.js │ ├── TimesAgo.js │ └── api.js └── staff │ ├── DeleteDialog.js │ ├── JobForm.js │ ├── JobList.js │ └── Staff.js ├── index.css ├── index.js ├── reportWebVitals.js └── setupTests.js /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ivangfr 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## --- 2 | ## Spring Boot project 3 | 4 | target/ 5 | !.mvn/wrapper/maven-wrapper.jar 6 | !**/src/main/**/target/ 7 | !**/src/test/**/target/ 8 | 9 | ### STS ### 10 | .apt_generated 11 | .classpath 12 | .factorypath 13 | .project 14 | .settings 15 | .springBeans 16 | .sts4-cache 17 | 18 | ### IntelliJ IDEA ### 19 | .idea 20 | *.iws 21 | *.iml 22 | *.ipr 23 | 24 | ### NetBeans ### 25 | /nbproject/private/ 26 | /nbbuild/ 27 | /dist/ 28 | /nbdist/ 29 | /.nb-gradle/ 30 | build/ 31 | !**/src/main/**/build/ 32 | !**/src/test/**/build/ 33 | 34 | ### VS Code ### 35 | .vscode/ 36 | 37 | ## --- 38 | ## React project 39 | 40 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 41 | 42 | # dependencies 43 | node_modules/ 44 | /.pnp 45 | .pnp.js 46 | 47 | # testing 48 | coverage/ 49 | 50 | # production 51 | build/ 52 | 53 | # misc 54 | .env.local 55 | .env.development.local 56 | .env.test.local 57 | .env.production.local 58 | 59 | npm-debug.log* 60 | yarn-debug.log* 61 | yarn-error.log* 62 | 63 | .eslintcache 64 | 65 | ### MAC OS ### 66 | *.DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # okta-springboot-react 2 | 3 | The goal of this project is to implement an application where a user can manage (create/read/update/delete) jobs. For it, we will create a backend Restful API called `jobs-api` and a frontend application called `jobs-ui`. Furthermore, we will use [`Okta`](https://www.okta.com/) to secure the complete application. 4 | 5 | ## Proof-of-Concepts & Articles 6 | 7 | On [ivangfr.github.io](https://ivangfr.github.io), I have compiled my Proof-of-Concepts (PoCs) and articles. You can easily search for the technology you are interested in by using the filter. Who knows, perhaps I have already implemented a PoC or written an article about what you are looking for. 8 | 9 | ## Additional Readings 10 | 11 | - \[**Medium**\] [**Implementing and Securing a Simple Spring Boot REST API with Okta**](https://medium.com/javarevisited/implementing-and-securing-a-simple-spring-boot-rest-api-with-okta-a5143696cd60) 12 | - \[**Medium**\] [**Implementing and Securing a Simple Spring Boot UI (Thymeleaf + RBAC) with Okta**](https://medium.com/javarevisited/implementing-and-securing-a-simple-spring-boot-ui-thymeleaf-rbac-with-okta-9489cbbcec25) 13 | - \[**Medium**\] [**Implementing and Securing a Spring Boot GraphQL API with Okta**](https://medium.com/javarevisited/implementing-and-securing-a-spring-boot-graphql-api-with-okta-78bc997359b4) 14 | - \[**Medium**\] [**Building a Single Spring Boot App with Keycloak or Okta as IdP: Introduction**](https://medium.com/@ivangfr/building-a-single-spring-boot-app-with-keycloak-or-okta-as-idp-introduction-2814a4829aed) 15 | 16 | ## Project User Interface Preview 17 | 18 | ![jobs-portal-preview](documentation/jobs-portal-preview.gif) 19 | 20 | ## Project diagram 21 | 22 | ![project-diagram](documentation/project-diagram.jpeg) 23 | 24 | ## Applications 25 | 26 | - ### jobs-api 27 | 28 | [`Spring Boot`](https://docs.spring.io/spring-boot/index.html) Web Java application that exposes a REST API for managing jobs. The job data are stored in [Elasticsearch](https://www.elastic.co/elasticsearch) It has some endpoints that are secured. `Okta` is used to handle authentication and authorization. 29 | 30 | The table below shows the endpoins, whether they are secured or not, and the authorization role required to access the secured ones. 31 | 32 | | Endpoint | Secured | Role | 33 | | ----------------------- | ------- |-----------------------------| 34 | | `GET /actuator/*` | No | | 35 | | `POST /callback/token` | No | | 36 | | `GET /api/jobs/newest` | No | | 37 | | `POST /api/jobs` | Yes | `JOBS_STAFF` | 38 | | `PUT /api/jobs/{id}` | Yes | `JOBS_STAFF` | 39 | | `DELETE /api/jobs/{id}` | Yes | `JOBS_STAFF` | 40 | | `GET /api/jobs/{id}` | Yes | `JOBS_STAFF, JOBS_CUSTOMER` | 41 | | `PUT /api/jobs/search` | Yes | `JOBS_STAFF, JOBS_CUSTOMER` | 42 | 43 | - ### jobs-ui 44 | 45 | [`React`](https://react.dev/) frontend application where customers can look for a job and staff members can handle jobs. In order to access it, a person must login. The authentication is handled by `Okta`. 46 | 47 | ## Prerequisites 48 | 49 | - [`Java 21+`](https://www.oracle.com/java/technologies/downloads/#java21) 50 | - [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) 51 | - [`Docker`](https://www.docker.com/) 52 | - [`Okta` account](https://developer.okta.com/signup/) 53 | 54 | ## Configuring Okta 55 | 56 | ### Access Developer Edition Account 57 | 58 | - If you do not have a Developer Edition Account, you can create one at https://developer.okta.com/signup/ 59 | - If you already have, access https://developer.okta.com/login/ 60 | 61 | ### Access Okta Admin Dashboard 62 | 63 | The picture below shows how `Okta Admin Dashboard` looks like 64 | 65 | ![okta-admin-dashboard](documentation/okta-admin-dashboard.jpeg) 66 | 67 | ### Add Application 68 | 69 | - In the `Okta Admin Dashboard` main menu on the left, click `Applications` menu and then `Applications` sub-menu 70 | - On the next page, click `Create App Integration` button 71 | - Select `OIDC - OpenID Connect` as _Sign on method_ and `Single-Page Application` as _Application type_. Click `Next` button 72 | - Enter the following values in the form. 73 | - General Settings 74 | - App integration name: `Jobs Portal SPA` 75 | - Grant type: check `Authorization Code` and `Implicit (hybrid)` 76 | - Sign-in redirect URIs: `http://localhost:3000/implicit/callback` and `http://localhost:8080/callback/token` 77 | - Sign-out redirect URIs: `http://localhost:3000` 78 | - Assignments 79 | - Controlled access: `Skip group assignment for now` 80 | - Click `Save` button 81 | - On the next screen, the `Client ID` of `Jobs Portal SPA` is displayed. The `Okta Domain` can be obtained by clicking the button-menu present on the up-right corner of the screen. 82 | 83 | ### Create groups 84 | 85 | - In the `Okta Admin Dashboard` main menu on the left, click `Directory` menu and then `Groups` sub-menu 86 | - Add Staff Group 87 | - Click `Add Group` button 88 | - Enter `JOBS_STAFF` for the _Name_ text-field 89 | - Click `Save` button 90 | - Add Customer Group 91 | - Click `Add Group` button 92 | - Enter `JOBS_CUSTOMER` for the _Name_ text-field 93 | - Click `Save` button 94 | 95 | ### Add people 96 | 97 | - In the `Okta Admin Dashboard` main menu on the left, click `Directory` menu and then `People` sub-menu 98 | - Click `Add person` button 99 | - Enter the following information for the Staff person 100 | - First name: `Mario` 101 | - Last name: `Bros` 102 | - Username: `mario.bros@test.com` 103 | - Primary email: `mario.bros@test.com` 104 | - Groups: `JOBS_STAFF` (the group will popup; select it to add it) 105 | - Password: `Set by admin` 106 | - Set a strong password in the text-field that will appear 107 | - `Uncheck` the check-box that says _"User must change password on first login"_ 108 | - Click `Save and Add Another` button 109 | - Enter the following information for the Customer person 110 | - First name: `Luigi` 111 | - Last name: `Bros` 112 | - Username: `luigi.bros@test.com` 113 | - Primary email: `luigi.bros@test.com` 114 | - Groups: `JOBS_CUSTOMER` (the group will popup; select it to add it) 115 | - Password: `Set by admin` 116 | - Set a strong password in the text-field that will appear 117 | - Leave `Uncheck` the check-box that says _"User must change password on first login"_ 118 | - Click `Save` button 119 | 120 | ### Assign Groups to Application 121 | 122 | - In the `Okta Admin Dashboard` main menu on the left, click `Directory` menu and then `Groups` sub-menu 123 | - Select `JOBS_STAFF` 124 | - Click `Applications` tab 125 | - Click `Assign Applications` button 126 | - Click the `Assign` button related to `Jobs Portal SPA` and then click `Done` 127 | - In the `Okta Admin Dashboard` main menu on the left, click `Groups` sub-menu again 128 | - Select `JOBS_CUSTOMER` 129 | - Click `Applications` tab 130 | - Click `Assign Applications` button 131 | - Click the `Assign` button related to `Jobs Portal SPA` and then click `Done` 132 | 133 | ### Add Claim 134 | 135 | - In the `Okta Admin Dashboard` main menu on the left, click `Security` menu and then `API` sub-menu 136 | - In `Authorization Servers` tab, select the `default` 137 | - In `Claims` tab, click `Add Claim` 138 | - Enter the following information for the claim 139 | - Name: `groups` 140 | - Include in token type: 141 | - Select `Access Token` 142 | - Keep `Always` in the right field 143 | - Value type: `Groups` 144 | - Filter: 145 | - Select `Matches regrex` 146 | - Set `.*` in the right field 147 | - Include in: `Any scope` 148 | - Click `Create` button 149 | 150 | ## Start Environment 151 | 152 | - Open a terminal and inside `okta-springboot-react` root folder run 153 | ``` 154 | docker compose up -d 155 | ``` 156 | 157 | - Wait for `elasticsearch` Docker container to be up and running. To check it, run 158 | ``` 159 | docker compose ps 160 | ``` 161 | 162 | ## Running applications 163 | 164 | - **jobs-api** 165 | 166 | - In a terminal, navigate to `okta-springboot-react/jobs-api` folder 167 | 168 | - Export the following environment variables. Those values were obtained while [adding Application](#add-application) 169 | ``` 170 | export OKTA_DOMAIN=... 171 | export OKTA_CLIENT_ID=... 172 | ``` 173 | 174 | - Run the [`Maven`](https://maven.apache.org/) command below to start `jobs-api` 175 | ``` 176 | ./mvnw clean spring-boot:run 177 | ``` 178 | 179 | - **jobs-ui** 180 | 181 | - Open a new terminal and navigate to `okta-springboot-react/jobs-ui` folder 182 | 183 | - Create a file called `.env.local` with the following content. Those values were obtained while [adding Application](#add-application) 184 | ``` 185 | REACT_APP_OKTA_ORG_URL=https:// 186 | REACT_APP_OKTA_CLIENT_ID= 187 | ``` 188 | 189 | - If you are running `jobs-ui` for the first time, execute the [`npm`](https://www.npmjs.com/) command below 190 | ``` 191 | npm install 192 | ``` 193 | 194 | - To start `jobs-api` run 195 | ``` 196 | npm start 197 | ``` 198 | It will open `job-ui` in a browser automatically. 199 | 200 | ## Applications URLs 201 | 202 | | Application | URL | 203 | | ----------- | ------------------------------------- | 204 | | jobs-api | http://localhost:8080/swagger-ui.html | 205 | | jobs-ui | http://localhost:3000 | 206 | 207 | ## Using jobs-ui 208 | 209 | - Open a browser and access http://localhost:3000 210 | 211 | - Click `Login` in the navigation bar 212 | 213 | - The Okta login page will appear. Enter the username & password of the person added at the step [`Configuring Okta > Add people`](#add-people) and click `Sign In`. 214 | 215 | - Done! 216 | 217 | > **Note**: If you are using the person `luigi.bros@test.com`, you will not be able to create/update/delete a job because it doesn't have the required role for it. 218 | 219 | ## Getting Access Token 220 | 221 | In order to use just the `jobs-api` endpoints, you must have an `JWT` access token. Below are the steps to get it. 222 | 223 | - In a terminal, create the following environment variables. Those values were obtained while [adding Application](#add-application) 224 | ``` 225 | OKTA_DOMAIN=... 226 | OKTA_CLIENT_ID=... 227 | ``` 228 | 229 | - Get Okta Access Token Url 230 | ``` 231 | OKTA_ACCESS_TOKEN_URL="https://${OKTA_DOMAIN}/oauth2/default/v1/authorize?\ 232 | client_id=${OKTA_CLIENT_ID}\ 233 | &redirect_uri=http://localhost:8080/callback/token\ 234 | &scope=openid\ 235 | &response_type=token\ 236 | &response_mode=form_post\ 237 | &state=state\ 238 | &nonce=myNonceValue" 239 | 240 | echo $OKTA_ACCESS_TOKEN_URL 241 | ``` 242 | 243 | - Copy the Okta Access Token Url from the previous step and paste it in a browser 244 | 245 | - The Okta login page will appear. Enter the username & password of the person added at the step [`Configuring Okta > Add people`](#add-people) and click `Sign In` button 246 | 247 | - It will redirect to `/callback/token` endpoint of `jobs-api` and the `Access token` will be displayed, together with other information 248 | ``` 249 | { 250 | "state": "state", 251 | "access_token": "eyJraWQiOiJyNFdY...", 252 | "token_type": "Bearer", 253 | "expires_in": "3600", 254 | "scope": "openid" 255 | } 256 | ``` 257 | > **Note**: In [jwt.io](https://jwt.io), you can decode and verify the `JWT` access token 258 | 259 | ## Calling jobs-api endpoints using curl 260 | 261 | - **`GET /api/jobs/newest`** 262 | 263 | The `api/jobs/newest` endpoint is public, so we can access it without any problem. 264 | ``` 265 | curl -i http://localhost:8080/api/jobs/newest?number=2 266 | ``` 267 | It should return 268 | ``` 269 | HTTP/1.1 200 270 | [{"id":"uuulE2sBTYouQKNL1uoV", ...},{"id":"u-ulE2sBTYouQKNL1-qb", ...}] 271 | ``` 272 | 273 | - **`GET /api/jobs` without Access Token** 274 | 275 | Try to get the list of jobs without informing the access token. 276 | ``` 277 | curl -i http://localhost:8080/api/jobs 278 | ``` 279 | It should return 280 | ``` 281 | HTTP/1.1 401 282 | ``` 283 | 284 | - **`GET /api/jobs` with Access Token** 285 | 286 | First, get the access token as explained in [`Getting Access Token`](#getting-access-token) section. Then, create an environment variable for the access token. 287 | ``` 288 | ACCESS_TOKEN=... 289 | ``` 290 | 291 | Call the get the list of jobs informing the access token 292 | ``` 293 | curl -i http://localhost:8080/api/jobs -H "Authorization: Bearer $ACCESS_TOKEN" 294 | ``` 295 | Response 296 | ``` 297 | HTTP/1.1 200 298 | {"content":[{"id":"uISqEWsBpDcNLtN2kZv3","title":"Expert Java Developer - Cloud","company":"Microsoft","logoUrl"...} 299 | ``` 300 | 301 | > **Note**: If you are using the person `luigi.bros@test.com`, you will not be able to create/update/delete a job because it doesn't have the required role for it. 302 | 303 | ## Using jobs-api with Swagger 304 | 305 | - First, get the access token as explained in [`Getting Access Token`](#getting-access-token) section. 306 | 307 | - Open `jobs-api` Swagger website http://localhost:8080/swagger-ui.html 308 | 309 | - Click `Authorize` button. Paste the access token in the `Value` field. Then, click `Authorize` and `Close` to finalize. 310 | 311 | - Done! You can now access the sensitive endpoints. 312 | 313 | > **Note**: If you are using the person `luigi.bros@test.com`, you will not be able to create/update/delete a job because it doesn't have the required role for it. 314 | 315 | ## Shutdown 316 | 317 | - Go to the terminals where `jobs-api` and `jobs-ui` are running and press `Ctrl+C` 318 | 319 | - To stop and remove containers, network and volumes, run in a terminal the following command inside `okta-springboot-react` root folder 320 | ``` 321 | docker compose down -v 322 | ``` 323 | 324 | ## Cleanup 325 | 326 | ### Okta Configuration 327 | 328 | #### Delete Person 329 | 330 | - In the `Okta Admin Dashboard` main menu on the left, click `Directory` menu and then `People` sub-menu 331 | - Click `Mario Bros` in the People list 332 | - In `Mario Bros` profile, click `More Actions` multi-button and then `Deactivate` 333 | - Confirm deactivation by clicking `Deactivate` button 334 | - Still in `Mario Bros` profile, click `Delete` button 335 | - Confirm deletion by clicking `Delete` button 336 | - Click `Luigi Bros` in the People list 337 | - In `Luigi Bros` profile, click `More Actions` multi-button and then `Deactivate` 338 | - Confirm deactivation by clicking `Deactivate` button 339 | - Still in `Luigi Bros` profile, click `Delete` button 340 | - Confirm deletion by clicking `Delete` button 341 | 342 | #### Delete Groups 343 | 344 | - In the `Okta Admin Dashboard` main menu on the left, click `Directory` menu and then `Groups` sub-menu 345 | - Select `JOBS_CUSTOMER` 346 | - In `JOBS_CUSTOMER` profile, click `Actions` button and then `Delete` sub-button 347 | - Confirm deletion by clicking `Delete Group` button 348 | - Select `JOBS_STAFF` 349 | - In `JOBS_STAFF` profile, click `Actions` button and then `Delete` sub-button 350 | - Confirm deletion by clicking `Delete Group` button 351 | 352 | #### Delete Application 353 | 354 | - In the `Okta Admin Dashboard` main menu on the left, click `Applications` menu and then `Applications` sub-menu 355 | - In Application list whose status is `ACTIVE`, click `Jobs Portal SPA`'s `gear` icon and then click `Deactivate` 356 | - Confirm deactivation by clicking `Deactivate Application` button 357 | - In Application list whose status is `INACTIVE`, click `Jobs Portal SPA`'s `gear` icon and then click `Delete` 358 | - Confirm deletion by clicking `Delete Application` button 359 | 360 | #### Delete Claim 361 | 362 | - In the `Okta Admin Dashboard` main menu on the left, click `Security` menu and then `API` sub-menu 363 | - In `Authorization Servers` tab, select the `default` 364 | - In `Claims` tab, click the `x` icon related to the `groups` claim created 365 | - Confirm deletion by clicking `OK` button 366 | 367 | ## How to upgrade jobs-ui dependencies to latest version 368 | 369 | - In a terminal, make sure you are in `okta-springboot-react/jobs-ui` folder 370 | 371 | - Run the following commands 372 | ``` 373 | npm upgrade 374 | npm i -g npm-check-updates 375 | ncu -u 376 | npm install 377 | ``` 378 | 379 | ## References 380 | 381 | - https://www.npmjs.com/package/@okta/okta-react 382 | - https://developer.okta.com/code/react/okta_react_sign-in_widget/ 383 | - https://developer.okta.com/blog/2019/03/06/simple-user-authentication-in-react 384 | - https://dzone.com/articles/23-useful-elasticsearch-example-queries 385 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | elasticsearch: 4 | image: docker.elastic.co/elasticsearch/elasticsearch:8.13.4 5 | container_name: elasticsearch 6 | restart: unless-stopped 7 | ports: 8 | - "9200:9200" 9 | - "9300:9300" 10 | environment: 11 | discovery.type: single-node 12 | xpack.security.enabled: false 13 | ES_JAVA_OPTS: "-Xms512m -Xmx512m" 14 | healthcheck: 15 | test: "curl -f http://localhost:9200 || exit 1" 16 | -------------------------------------------------------------------------------- /documentation/jobs-portal-preview.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/okta-springboot-react/513b2e5164289efab38e5a704eca6b94439270c8/documentation/jobs-portal-preview.gif -------------------------------------------------------------------------------- /documentation/okta-admin-dashboard.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/okta-springboot-react/513b2e5164289efab38e5a704eca6b94439270c8/documentation/okta-admin-dashboard.jpeg -------------------------------------------------------------------------------- /documentation/project-diagram.excalidraw: -------------------------------------------------------------------------------- 1 | { 2 | "type": "excalidraw", 3 | "version": 2, 4 | "source": "https://excalidraw.com", 5 | "elements": [ 6 | { 7 | "type": "ellipse", 8 | "version": 2815, 9 | "versionNonce": 655811847, 10 | "isDeleted": false, 11 | "id": "gtxwJYFD_rWyI3IFUHRq8", 12 | "fillStyle": "hachure", 13 | "strokeWidth": 2, 14 | "strokeStyle": "solid", 15 | "roughness": 1, 16 | "opacity": 100, 17 | "angle": 6.272333650882224, 18 | "x": -791.8598400919077, 19 | "y": -342.68790384258944, 20 | "strokeColor": "#000000", 21 | "backgroundColor": "transparent", 22 | "width": 26.930389404296875, 23 | "height": 27.545562744140625, 24 | "seed": 1898651571, 25 | "groupIds": [ 26 | "vxHUCovKJb3bkhd0oKBHD" 27 | ], 28 | "roundness": { 29 | "type": 2 30 | }, 31 | "boundElements": [], 32 | "updated": 1678809219583, 33 | "link": null, 34 | "locked": false 35 | }, 36 | { 37 | "type": "line", 38 | "version": 2833, 39 | "versionNonce": 637211369, 40 | "isDeleted": false, 41 | "id": "gqVtaWcROkZOtc1mEh2Ol", 42 | "fillStyle": "hachure", 43 | "strokeWidth": 2, 44 | "strokeStyle": "solid", 45 | "roughness": 1, 46 | "opacity": 100, 47 | "angle": 6.272333650882224, 48 | "x": -780.0484485486493, 49 | "y": -314.66472070776297, 50 | "strokeColor": "#000000", 51 | "backgroundColor": "transparent", 52 | "width": 0.473419189453125, 53 | "height": 40.3687744140625, 54 | "seed": 1650338045, 55 | "groupIds": [ 56 | "vxHUCovKJb3bkhd0oKBHD" 57 | ], 58 | "roundness": { 59 | "type": 2 60 | }, 61 | "boundElements": [], 62 | "updated": 1678809219583, 63 | "link": null, 64 | "locked": false, 65 | "startBinding": null, 66 | "endBinding": null, 67 | "lastCommittedPoint": null, 68 | "startArrowhead": null, 69 | "endArrowhead": null, 70 | "points": [ 71 | [ 72 | 0, 73 | 0 74 | ], 75 | [ 76 | -0.473419189453125, 77 | 40.3687744140625 78 | ] 79 | ] 80 | }, 81 | { 82 | "type": "line", 83 | "version": 2784, 84 | "versionNonce": 421702695, 85 | "isDeleted": false, 86 | "id": "CxeMTAZzkHrmjcBiIFGvs", 87 | "fillStyle": "hachure", 88 | "strokeWidth": 2, 89 | "strokeStyle": "solid", 90 | "roughness": 1, 91 | "opacity": 100, 92 | "angle": 6.272333650882224, 93 | "x": -779.9248318971804, 94 | "y": -272.7693068233323, 95 | "strokeColor": "#000000", 96 | "backgroundColor": "transparent", 97 | "width": 17.21380615234375, 98 | "height": 33.91400146484375, 99 | "seed": 414363987, 100 | "groupIds": [ 101 | "vxHUCovKJb3bkhd0oKBHD" 102 | ], 103 | "roundness": { 104 | "type": 2 105 | }, 106 | "boundElements": [], 107 | "updated": 1678809219583, 108 | "link": null, 109 | "locked": false, 110 | "startBinding": null, 111 | "endBinding": null, 112 | "lastCommittedPoint": null, 113 | "startArrowhead": null, 114 | "endArrowhead": null, 115 | "points": [ 116 | [ 117 | 0, 118 | 0 119 | ], 120 | [ 121 | -17.21380615234375, 122 | 33.91400146484375 123 | ] 124 | ] 125 | }, 126 | { 127 | "type": "line", 128 | "version": 2803, 129 | "versionNonce": 13290953, 130 | "isDeleted": false, 131 | "id": "9VcxgbSo3B6kcrAiCeGPa", 132 | "fillStyle": "hachure", 133 | "strokeWidth": 2, 134 | "strokeStyle": "solid", 135 | "roughness": 1, 136 | "opacity": 100, 137 | "angle": 6.272333650882224, 138 | "x": -779.8017895454461, 139 | "y": -272.81404801591066, 140 | "strokeColor": "#000000", 141 | "backgroundColor": "transparent", 142 | "width": 12.9422607421875, 143 | "height": 35.16510009765625, 144 | "seed": 101319005, 145 | "groupIds": [ 146 | "vxHUCovKJb3bkhd0oKBHD" 147 | ], 148 | "roundness": { 149 | "type": 2 150 | }, 151 | "boundElements": [], 152 | "updated": 1678809219583, 153 | "link": null, 154 | "locked": false, 155 | "startBinding": null, 156 | "endBinding": null, 157 | "lastCommittedPoint": null, 158 | "startArrowhead": null, 159 | "endArrowhead": null, 160 | "points": [ 161 | [ 162 | 0, 163 | 0 164 | ], 165 | [ 166 | 12.9422607421875, 167 | 35.16510009765625 168 | ] 169 | ] 170 | }, 171 | { 172 | "type": "line", 173 | "version": 2819, 174 | "versionNonce": 1617465159, 175 | "isDeleted": false, 176 | "id": "0ORnc4v-MsEjeJVvjITN8", 177 | "fillStyle": "hachure", 178 | "strokeWidth": 2, 179 | "strokeStyle": "solid", 180 | "roughness": 1, 181 | "opacity": 100, 182 | "angle": 6.272333650882224, 183 | "x": -779.1762533491583, 184 | "y": -297.3393159297941, 185 | "strokeColor": "#000000", 186 | "backgroundColor": "transparent", 187 | "width": 29.445220947265625, 188 | "height": 20.990234375, 189 | "seed": 949423859, 190 | "groupIds": [ 191 | "vxHUCovKJb3bkhd0oKBHD" 192 | ], 193 | "roundness": { 194 | "type": 2 195 | }, 196 | "boundElements": [], 197 | "updated": 1678809219583, 198 | "link": null, 199 | "locked": false, 200 | "startBinding": null, 201 | "endBinding": null, 202 | "lastCommittedPoint": null, 203 | "startArrowhead": null, 204 | "endArrowhead": null, 205 | "points": [ 206 | [ 207 | 0, 208 | 0 209 | ], 210 | [ 211 | 29.445220947265625, 212 | -20.990234375 213 | ] 214 | ] 215 | }, 216 | { 217 | "type": "line", 218 | "version": 2858, 219 | "versionNonce": 597699753, 220 | "isDeleted": false, 221 | "id": "bZosH4SArwOUonNY4YoUh", 222 | "fillStyle": "hachure", 223 | "strokeWidth": 2, 224 | "strokeStyle": "solid", 225 | "roughness": 1, 226 | "opacity": 100, 227 | "angle": 6.272333650882224, 228 | "x": -780.013009537829, 229 | "y": -297.7624295868129, 230 | "strokeColor": "#000000", 231 | "backgroundColor": "transparent", 232 | "width": 25.4169921875, 233 | "height": 9.85821533203125, 234 | "seed": 190565821, 235 | "groupIds": [ 236 | "vxHUCovKJb3bkhd0oKBHD" 237 | ], 238 | "roundness": { 239 | "type": 2 240 | }, 241 | "boundElements": [], 242 | "updated": 1678809219583, 243 | "link": null, 244 | "locked": false, 245 | "startBinding": null, 246 | "endBinding": null, 247 | "lastCommittedPoint": null, 248 | "startArrowhead": null, 249 | "endArrowhead": null, 250 | "points": [ 251 | [ 252 | 0, 253 | 0 254 | ], 255 | [ 256 | -25.4169921875, 257 | -9.85821533203125 258 | ] 259 | ] 260 | }, 261 | { 262 | "type": "text", 263 | "version": 2966, 264 | "versionNonce": 121966183, 265 | "isDeleted": false, 266 | "id": "pZb8018TU1XB_Gdh53K4R", 267 | "fillStyle": "hachure", 268 | "strokeWidth": 2, 269 | "strokeStyle": "solid", 270 | "roughness": 1, 271 | "opacity": 100, 272 | "angle": 6.272333650882224, 273 | "x": -800.9918553866551, 274 | "y": -380.6108281787154, 275 | "strokeColor": "#000000", 276 | "backgroundColor": "transparent", 277 | "width": 44.679962158203125, 278 | "height": 24, 279 | "seed": 1130281107, 280 | "groupIds": [ 281 | "vxHUCovKJb3bkhd0oKBHD" 282 | ], 283 | "roundness": null, 284 | "boundElements": [], 285 | "updated": 1678809219583, 286 | "link": null, 287 | "locked": false, 288 | "fontSize": 20, 289 | "fontFamily": 1, 290 | "text": "User", 291 | "textAlign": "left", 292 | "verticalAlign": "top", 293 | "containerId": null, 294 | "originalText": "User" 295 | }, 296 | { 297 | "type": "rectangle", 298 | "version": 3445, 299 | "versionNonce": 1132398739, 300 | "isDeleted": false, 301 | "id": "ZjI3Z6FPHek8WPEDr4dbF", 302 | "fillStyle": "hachure", 303 | "strokeWidth": 1, 304 | "strokeStyle": "solid", 305 | "roughness": 1, 306 | "opacity": 100, 307 | "angle": 0, 308 | "x": -619.3729803683464, 309 | "y": -331.2200944561379, 310 | "strokeColor": "#000000", 311 | "backgroundColor": "#be4bdb", 312 | "width": 209.18356323242188, 313 | "height": 99.67071533203125, 314 | "seed": 360254205, 315 | "groupIds": [], 316 | "roundness": { 317 | "type": 3 318 | }, 319 | "boundElements": [ 320 | { 321 | "type": "text", 322 | "id": "a3CdfrdBQ6Uw0ZpvYhIR7" 323 | }, 324 | { 325 | "id": "gl8kV3eGPyLuccbvi2FsN", 326 | "type": "arrow" 327 | }, 328 | { 329 | "id": "Mj46Jv7AHMD3YHNhmOT80", 330 | "type": "arrow" 331 | }, 332 | { 333 | "id": "_jskLl3tJP7_I3eRPWsyv", 334 | "type": "arrow" 335 | }, 336 | { 337 | "id": "RSTGyvQh12feJdODCVco4", 338 | "type": "arrow" 339 | } 340 | ], 341 | "updated": 1678807301532, 342 | "link": null, 343 | "locked": false 344 | }, 345 | { 346 | "type": "text", 347 | "version": 2431, 348 | "versionNonce": 536772029, 349 | "isDeleted": false, 350 | "id": "a3CdfrdBQ6Uw0ZpvYhIR7", 351 | "fillStyle": "hachure", 352 | "strokeWidth": 1, 353 | "strokeStyle": "solid", 354 | "roughness": 0, 355 | "opacity": 100, 356 | "angle": 0, 357 | "x": -558.6151754977409, 358 | "y": -298.18473679012226, 359 | "strokeColor": "#000000", 360 | "backgroundColor": "transparent", 361 | "width": 87.66795349121094, 362 | "height": 33.6, 363 | "seed": 1655631699, 364 | "groupIds": [], 365 | "roundness": null, 366 | "boundElements": [], 367 | "updated": 1678807301532, 368 | "link": null, 369 | "locked": false, 370 | "fontSize": 28, 371 | "fontFamily": 1, 372 | "text": "jobs-ui", 373 | "textAlign": "center", 374 | "verticalAlign": "middle", 375 | "containerId": "ZjI3Z6FPHek8WPEDr4dbF", 376 | "originalText": "jobs-ui" 377 | }, 378 | { 379 | "type": "rectangle", 380 | "version": 3341, 381 | "versionNonce": 1405994719, 382 | "isDeleted": false, 383 | "id": "cYPF5q8mR4N53gvrSxZyI", 384 | "fillStyle": "hachure", 385 | "strokeWidth": 1, 386 | "strokeStyle": "solid", 387 | "roughness": 1, 388 | "opacity": 100, 389 | "angle": 0, 390 | "x": -185.2941076876823, 391 | "y": -331.2200944561379, 392 | "strokeColor": "#000000", 393 | "backgroundColor": "#ced4da", 394 | "width": 209.18356323242188, 395 | "height": 99.67071533203125, 396 | "seed": 1417139837, 397 | "groupIds": [], 398 | "roundness": { 399 | "type": 3 400 | }, 401 | "boundElements": [ 402 | { 403 | "type": "text", 404 | "id": "llgUuHweFA1lIWZC4202T" 405 | }, 406 | { 407 | "id": "w_-AKzrMhgZMfRNrWCz0t", 408 | "type": "arrow" 409 | }, 410 | { 411 | "id": "aemJKBM5uDSYuer5A5v03", 412 | "type": "arrow" 413 | }, 414 | { 415 | "id": "Mj46Jv7AHMD3YHNhmOT80", 416 | "type": "arrow" 417 | }, 418 | { 419 | "id": "_jskLl3tJP7_I3eRPWsyv", 420 | "type": "arrow" 421 | }, 422 | { 423 | "id": "_rrK8msPnCkst8q7Aw3lW", 424 | "type": "arrow" 425 | } 426 | ], 427 | "updated": 1678809599371, 428 | "link": null, 429 | "locked": false 430 | }, 431 | { 432 | "type": "text", 433 | "version": 2330, 434 | "versionNonce": 710317703, 435 | "isDeleted": false, 436 | "id": "llgUuHweFA1lIWZC4202T", 437 | "fillStyle": "hachure", 438 | "strokeWidth": 1, 439 | "strokeStyle": "solid", 440 | "roughness": 0, 441 | "opacity": 100, 442 | "angle": 0, 443 | "x": -114.93230654022136, 444 | "y": -298.18473679012226, 445 | "strokeColor": "#000000", 446 | "backgroundColor": "transparent", 447 | "width": 68.4599609375, 448 | "height": 33.6, 449 | "seed": 812948435, 450 | "groupIds": [], 451 | "roundness": null, 452 | "boundElements": [], 453 | "updated": 1678809265301, 454 | "link": null, 455 | "locked": false, 456 | "fontSize": 28, 457 | "fontFamily": 1, 458 | "text": "Okta", 459 | "textAlign": "center", 460 | "verticalAlign": "middle", 461 | "containerId": "cYPF5q8mR4N53gvrSxZyI", 462 | "originalText": "Okta" 463 | }, 464 | { 465 | "type": "rectangle", 466 | "version": 3458, 467 | "versionNonce": 1682914001, 468 | "isDeleted": false, 469 | "id": "cbecl37JdZu0G_9NPARNd", 470 | "fillStyle": "hachure", 471 | "strokeWidth": 1, 472 | "strokeStyle": "solid", 473 | "roughness": 1, 474 | "opacity": 100, 475 | "angle": 0, 476 | "x": -190.0028479220573, 477 | "y": -49.307458652915216, 478 | "strokeColor": "#000000", 479 | "backgroundColor": "#15aabf", 480 | "width": 209.18356323242188, 481 | "height": 99.67071533203125, 482 | "seed": 1319251389, 483 | "groupIds": [], 484 | "roundness": { 485 | "type": 3 486 | }, 487 | "boundElements": [ 488 | { 489 | "type": "text", 490 | "id": "bFQMWQQ-L0kvtwRCXXAoq" 491 | }, 492 | { 493 | "id": "I8K_bwnhA16o468H1bkRb", 494 | "type": "arrow" 495 | }, 496 | { 497 | "id": "KETRbv5wNXIFFRq_xhkSH", 498 | "type": "arrow" 499 | }, 500 | { 501 | "id": "RSTGyvQh12feJdODCVco4", 502 | "type": "arrow" 503 | }, 504 | { 505 | "id": "_rrK8msPnCkst8q7Aw3lW", 506 | "type": "arrow" 507 | } 508 | ], 509 | "updated": 1678809636368, 510 | "link": null, 511 | "locked": false 512 | }, 513 | { 514 | "type": "text", 515 | "version": 2447, 516 | "versionNonce": 930305727, 517 | "isDeleted": false, 518 | "id": "bFQMWQQ-L0kvtwRCXXAoq", 519 | "fillStyle": "hachure", 520 | "strokeWidth": 1, 521 | "strokeStyle": "solid", 522 | "roughness": 0, 523 | "opacity": 100, 524 | "angle": 0, 525 | "x": -137.53303743621746, 526 | "y": -16.272100986899602, 527 | "strokeColor": "#000000", 528 | "backgroundColor": "transparent", 529 | "width": 104.24394226074219, 530 | "height": 33.6, 531 | "seed": 103700627, 532 | "groupIds": [], 533 | "roundness": null, 534 | "boundElements": [], 535 | "updated": 1678809636368, 536 | "link": null, 537 | "locked": false, 538 | "fontSize": 28, 539 | "fontFamily": 1, 540 | "text": "jobs-api", 541 | "textAlign": "center", 542 | "verticalAlign": "middle", 543 | "containerId": "cbecl37JdZu0G_9NPARNd", 544 | "originalText": "jobs-api" 545 | }, 546 | { 547 | "type": "rectangle", 548 | "version": 3495, 549 | "versionNonce": 261438239, 550 | "isDeleted": false, 551 | "id": "pPI0tA9ZxPwTmTuZCKiuM", 552 | "fillStyle": "hachure", 553 | "strokeWidth": 1, 554 | "strokeStyle": "solid", 555 | "roughness": 1, 556 | "opacity": 100, 557 | "angle": 0, 558 | "x": 149.74225400665364, 559 | "y": -49.307458652915216, 560 | "strokeColor": "#000000", 561 | "backgroundColor": "#82c91e", 562 | "width": 209.18356323242188, 563 | "height": 99.67071533203125, 564 | "seed": 1484447933, 565 | "groupIds": [], 566 | "roundness": { 567 | "type": 3 568 | }, 569 | "boundElements": [ 570 | { 571 | "type": "text", 572 | "id": "giFELWfGICylPI_KUD8EH" 573 | }, 574 | { 575 | "id": "I8K_bwnhA16o468H1bkRb", 576 | "type": "arrow" 577 | } 578 | ], 579 | "updated": 1678809636369, 580 | "link": null, 581 | "locked": false 582 | }, 583 | { 584 | "type": "text", 585 | "version": 2503, 586 | "versionNonce": 1271319121, 587 | "isDeleted": false, 588 | "id": "giFELWfGICylPI_KUD8EH", 589 | "fillStyle": "hachure", 590 | "strokeWidth": 1, 591 | "strokeStyle": "solid", 592 | "roughness": 0, 593 | "opacity": 100, 594 | "angle": 0, 595 | "x": 161.71007322052083, 596 | "y": -16.272100986899602, 597 | "strokeColor": "#000000", 598 | "backgroundColor": "transparent", 599 | "width": 185.2479248046875, 600 | "height": 33.6, 601 | "seed": 699291027, 602 | "groupIds": [], 603 | "roundness": null, 604 | "boundElements": [], 605 | "updated": 1678809636369, 606 | "link": null, 607 | "locked": false, 608 | "fontSize": 28, 609 | "fontFamily": 1, 610 | "text": "Elasticsearch", 611 | "textAlign": "center", 612 | "verticalAlign": "middle", 613 | "containerId": "pPI0tA9ZxPwTmTuZCKiuM", 614 | "originalText": "Elasticsearch" 615 | }, 616 | { 617 | "type": "arrow", 618 | "version": 653, 619 | "versionNonce": 1512253969, 620 | "isDeleted": false, 621 | "id": "I8K_bwnhA16o468H1bkRb", 622 | "fillStyle": "hachure", 623 | "strokeWidth": 1, 624 | "strokeStyle": "solid", 625 | "roughness": 1, 626 | "opacity": 100, 627 | "angle": 0, 628 | "x": 29.598500954895826, 629 | "y": -1.5072140540790855, 630 | "strokeColor": "#000000", 631 | "backgroundColor": "transparent", 632 | "width": 115.48663330078125, 633 | "height": 1.0587929205351543, 634 | "seed": 1136198909, 635 | "groupIds": [], 636 | "roundness": { 637 | "type": 2 638 | }, 639 | "boundElements": [], 640 | "updated": 1678809637756, 641 | "link": null, 642 | "locked": false, 643 | "startBinding": { 644 | "elementId": "cbecl37JdZu0G_9NPARNd", 645 | "focus": -0.06082442906107466, 646 | "gap": 10.41778564453125 647 | }, 648 | "endBinding": { 649 | "elementId": "pPI0tA9ZxPwTmTuZCKiuM", 650 | "focus": -0.0004977850932718897, 651 | "gap": 4.6571197509765625 652 | }, 653 | "lastCommittedPoint": null, 654 | "startArrowhead": null, 655 | "endArrowhead": null, 656 | "points": [ 657 | [ 658 | 0, 659 | 0 660 | ], 661 | [ 662 | 115.48663330078125, 663 | 1.0587929205351543 664 | ] 665 | ] 666 | }, 667 | { 668 | "type": "ellipse", 669 | "version": 2558, 670 | "versionNonce": 2045488733, 671 | "isDeleted": false, 672 | "id": "34Keux3EoU1fXbk57z3W-", 673 | "fillStyle": "hachure", 674 | "strokeWidth": 2, 675 | "strokeStyle": "solid", 676 | "roughness": 1, 677 | "opacity": 100, 678 | "angle": 6.272333650882224, 679 | "x": 350.6019647425287, 680 | "y": -326.06190033755854, 681 | "strokeColor": "#000000", 682 | "backgroundColor": "transparent", 683 | "width": 26.930389404296875, 684 | "height": 27.545562744140625, 685 | "seed": 1211922579, 686 | "groupIds": [ 687 | "6Uvqk4o6yLR-EfWzTzvnG" 688 | ], 689 | "roundness": { 690 | "type": 2 691 | }, 692 | "boundElements": [], 693 | "updated": 1678806867636, 694 | "link": null, 695 | "locked": false 696 | }, 697 | { 698 | "type": "line", 699 | "version": 2576, 700 | "versionNonce": 783462899, 701 | "isDeleted": false, 702 | "id": "aqI8MdZhJANecU_NJ7ITx", 703 | "fillStyle": "hachure", 704 | "strokeWidth": 2, 705 | "strokeStyle": "solid", 706 | "roughness": 1, 707 | "opacity": 100, 708 | "angle": 6.272333650882224, 709 | "x": 362.4133562857867, 710 | "y": -298.0387172027321, 711 | "strokeColor": "#000000", 712 | "backgroundColor": "transparent", 713 | "width": 0.473419189453125, 714 | "height": 40.3687744140625, 715 | "seed": 1112755741, 716 | "groupIds": [ 717 | "6Uvqk4o6yLR-EfWzTzvnG" 718 | ], 719 | "roundness": { 720 | "type": 2 721 | }, 722 | "boundElements": [], 723 | "updated": 1678806867636, 724 | "link": null, 725 | "locked": false, 726 | "startBinding": null, 727 | "endBinding": null, 728 | "lastCommittedPoint": null, 729 | "startArrowhead": null, 730 | "endArrowhead": null, 731 | "points": [ 732 | [ 733 | 0, 734 | 0 735 | ], 736 | [ 737 | -0.473419189453125, 738 | 40.3687744140625 739 | ] 740 | ] 741 | }, 742 | { 743 | "type": "line", 744 | "version": 2527, 745 | "versionNonce": 1350556349, 746 | "isDeleted": false, 747 | "id": "obDsdNSkrRniCgRIQe-Lm", 748 | "fillStyle": "hachure", 749 | "strokeWidth": 2, 750 | "strokeStyle": "solid", 751 | "roughness": 1, 752 | "opacity": 100, 753 | "angle": 6.272333650882224, 754 | "x": 362.53697293725554, 755 | "y": -256.1433033183014, 756 | "strokeColor": "#000000", 757 | "backgroundColor": "transparent", 758 | "width": 17.21380615234375, 759 | "height": 33.91400146484375, 760 | "seed": 971370035, 761 | "groupIds": [ 762 | "6Uvqk4o6yLR-EfWzTzvnG" 763 | ], 764 | "roundness": { 765 | "type": 2 766 | }, 767 | "boundElements": [], 768 | "updated": 1678806867636, 769 | "link": null, 770 | "locked": false, 771 | "startBinding": null, 772 | "endBinding": null, 773 | "lastCommittedPoint": null, 774 | "startArrowhead": null, 775 | "endArrowhead": null, 776 | "points": [ 777 | [ 778 | 0, 779 | 0 780 | ], 781 | [ 782 | -17.21380615234375, 783 | 33.91400146484375 784 | ] 785 | ] 786 | }, 787 | { 788 | "type": "line", 789 | "version": 2546, 790 | "versionNonce": 1544420243, 791 | "isDeleted": false, 792 | "id": "aPXscTCMKYFFlXHD2e2Sf", 793 | "fillStyle": "hachure", 794 | "strokeWidth": 2, 795 | "strokeStyle": "solid", 796 | "roughness": 1, 797 | "opacity": 100, 798 | "angle": 6.272333650882224, 799 | "x": 362.6600152889898, 800 | "y": -256.18804451087976, 801 | "strokeColor": "#000000", 802 | "backgroundColor": "transparent", 803 | "width": 12.9422607421875, 804 | "height": 35.16510009765625, 805 | "seed": 844989053, 806 | "groupIds": [ 807 | "6Uvqk4o6yLR-EfWzTzvnG" 808 | ], 809 | "roundness": { 810 | "type": 2 811 | }, 812 | "boundElements": [], 813 | "updated": 1678806867636, 814 | "link": null, 815 | "locked": false, 816 | "startBinding": null, 817 | "endBinding": null, 818 | "lastCommittedPoint": null, 819 | "startArrowhead": null, 820 | "endArrowhead": null, 821 | "points": [ 822 | [ 823 | 0, 824 | 0 825 | ], 826 | [ 827 | 12.9422607421875, 828 | 35.16510009765625 829 | ] 830 | ] 831 | }, 832 | { 833 | "type": "line", 834 | "version": 2562, 835 | "versionNonce": 814709533, 836 | "isDeleted": false, 837 | "id": "3fzEMtphzb_JWvqjfhYB0", 838 | "fillStyle": "hachure", 839 | "strokeWidth": 2, 840 | "strokeStyle": "solid", 841 | "roughness": 1, 842 | "opacity": 100, 843 | "angle": 6.272333650882224, 844 | "x": 363.28555148527767, 845 | "y": -280.7133124247632, 846 | "strokeColor": "#000000", 847 | "backgroundColor": "transparent", 848 | "width": 29.445220947265625, 849 | "height": 20.990234375, 850 | "seed": 1309650899, 851 | "groupIds": [ 852 | "6Uvqk4o6yLR-EfWzTzvnG" 853 | ], 854 | "roundness": { 855 | "type": 2 856 | }, 857 | "boundElements": [], 858 | "updated": 1678806867636, 859 | "link": null, 860 | "locked": false, 861 | "startBinding": null, 862 | "endBinding": null, 863 | "lastCommittedPoint": null, 864 | "startArrowhead": null, 865 | "endArrowhead": null, 866 | "points": [ 867 | [ 868 | 0, 869 | 0 870 | ], 871 | [ 872 | 29.445220947265625, 873 | -20.990234375 874 | ] 875 | ] 876 | }, 877 | { 878 | "type": "line", 879 | "version": 2601, 880 | "versionNonce": 2123305267, 881 | "isDeleted": false, 882 | "id": "MXvdj0tDvZsAQhUmIZB2Y", 883 | "fillStyle": "hachure", 884 | "strokeWidth": 2, 885 | "strokeStyle": "solid", 886 | "roughness": 1, 887 | "opacity": 100, 888 | "angle": 6.272333650882224, 889 | "x": 362.44879529660693, 890 | "y": -281.136426081782, 891 | "strokeColor": "#000000", 892 | "backgroundColor": "transparent", 893 | "width": 25.4169921875, 894 | "height": 9.85821533203125, 895 | "seed": 534915805, 896 | "groupIds": [ 897 | "6Uvqk4o6yLR-EfWzTzvnG" 898 | ], 899 | "roundness": { 900 | "type": 2 901 | }, 902 | "boundElements": [], 903 | "updated": 1678806867636, 904 | "link": null, 905 | "locked": false, 906 | "startBinding": null, 907 | "endBinding": null, 908 | "lastCommittedPoint": null, 909 | "startArrowhead": null, 910 | "endArrowhead": null, 911 | "points": [ 912 | [ 913 | 0, 914 | 0 915 | ], 916 | [ 917 | -25.4169921875, 918 | -9.85821533203125 919 | ] 920 | ] 921 | }, 922 | { 923 | "type": "text", 924 | "version": 2709, 925 | "versionNonce": 1116475261, 926 | "isDeleted": false, 927 | "id": "jI30mAW7ep6ar28hZTZbE", 928 | "fillStyle": "hachure", 929 | "strokeWidth": 2, 930 | "strokeStyle": "solid", 931 | "roughness": 1, 932 | "opacity": 100, 933 | "angle": 6.272333650882224, 934 | "x": 341.46994944778186, 935 | "y": -363.98482467368444, 936 | "strokeColor": "#000000", 937 | "backgroundColor": "transparent", 938 | "width": 44.679962158203125, 939 | "height": 24, 940 | "seed": 1724923251, 941 | "groupIds": [ 942 | "6Uvqk4o6yLR-EfWzTzvnG" 943 | ], 944 | "roundness": null, 945 | "boundElements": [], 946 | "updated": 1678806867636, 947 | "link": null, 948 | "locked": false, 949 | "fontSize": 20, 950 | "fontFamily": 1, 951 | "text": "User", 952 | "textAlign": "left", 953 | "verticalAlign": "top", 954 | "containerId": null, 955 | "originalText": "User" 956 | }, 957 | { 958 | "type": "arrow", 959 | "version": 229, 960 | "versionNonce": 653056253, 961 | "isDeleted": false, 962 | "id": "w_-AKzrMhgZMfRNrWCz0t", 963 | "fillStyle": "hachure", 964 | "strokeWidth": 1, 965 | "strokeStyle": "solid", 966 | "roughness": 1, 967 | "opacity": 100, 968 | "angle": 0, 969 | "x": 317.7676598904427, 970 | "y": -299.54682327693865, 971 | "strokeColor": "#c92a2a", 972 | "backgroundColor": "#ced4da", 973 | "width": 292.39642333984375, 974 | "height": 44.747524028991734, 975 | "seed": 1942843155, 976 | "groupIds": [], 977 | "roundness": { 978 | "type": 2 979 | }, 980 | "boundElements": [ 981 | { 982 | "type": "text", 983 | "id": "AXfGg1fFOh3T-dvkVgIDs" 984 | } 985 | ], 986 | "updated": 1678807298334, 987 | "link": null, 988 | "locked": false, 989 | "startBinding": null, 990 | "endBinding": { 991 | "elementId": "cYPF5q8mR4N53gvrSxZyI", 992 | "focus": 0.21556818698053679, 993 | "gap": 6.0783843994140625 994 | }, 995 | "lastCommittedPoint": null, 996 | "startArrowhead": null, 997 | "endArrowhead": "arrow", 998 | "points": [ 999 | [ 1000 | 0, 1001 | 0 1002 | ], 1003 | [ 1004 | -148.3807373046875, 1005 | -43.011566162109375 1006 | ], 1007 | [ 1008 | -292.39642333984375, 1009 | 1.7359578668823588 1010 | ] 1011 | ] 1012 | }, 1013 | { 1014 | "type": "text", 1015 | "version": 78, 1016 | "versionNonce": 1057846857, 1017 | "isDeleted": false, 1018 | "id": "AXfGg1fFOh3T-dvkVgIDs", 1019 | "fillStyle": "hachure", 1020 | "strokeWidth": 1, 1021 | "strokeStyle": "solid", 1022 | "roughness": 1, 1023 | "opacity": 100, 1024 | "angle": 0, 1025 | "x": 88.38699125030598, 1026 | "y": -354.558389439048, 1027 | "strokeColor": "#c92a2a", 1028 | "backgroundColor": "#ced4da", 1029 | "width": 161.99986267089844, 1030 | "height": 24, 1031 | "seed": 2026020573, 1032 | "groupIds": [], 1033 | "roundness": null, 1034 | "boundElements": [], 1035 | "updated": 1678809199451, 1036 | "link": null, 1037 | "locked": false, 1038 | "fontSize": 20, 1039 | "fontFamily": 1, 1040 | "text": "1. Authentication", 1041 | "textAlign": "center", 1042 | "verticalAlign": "middle", 1043 | "containerId": "w_-AKzrMhgZMfRNrWCz0t", 1044 | "originalText": "1. Authentication" 1045 | }, 1046 | { 1047 | "type": "arrow", 1048 | "version": 240, 1049 | "versionNonce": 765919571, 1050 | "isDeleted": false, 1051 | "id": "aemJKBM5uDSYuer5A5v03", 1052 | "fillStyle": "hachure", 1053 | "strokeWidth": 1, 1054 | "strokeStyle": "solid", 1055 | "roughness": 1, 1056 | "opacity": 100, 1057 | "angle": 0, 1058 | "x": 31.45912717559895, 1059 | "y": -267.99362444300624, 1060 | "strokeColor": "#c92a2a", 1061 | "backgroundColor": "#ced4da", 1062 | "width": 285.859130859375, 1063 | "height": 35.368438720703125, 1064 | "seed": 1908413299, 1065 | "groupIds": [], 1066 | "roundness": { 1067 | "type": 2 1068 | }, 1069 | "boundElements": [ 1070 | { 1071 | "type": "text", 1072 | "id": "BOJKpY9hsnSfkrv_VKbka" 1073 | } 1074 | ], 1075 | "updated": 1678807298335, 1076 | "link": null, 1077 | "locked": false, 1078 | "startBinding": { 1079 | "elementId": "cYPF5q8mR4N53gvrSxZyI", 1080 | "focus": -0.19397765649573281, 1081 | "gap": 12.166275024414062 1082 | }, 1083 | "endBinding": null, 1084 | "lastCommittedPoint": null, 1085 | "startArrowhead": null, 1086 | "endArrowhead": "arrow", 1087 | "points": [ 1088 | [ 1089 | 0, 1090 | 0 1091 | ], 1092 | [ 1093 | 127.85272216796875, 1094 | 30.2781762881379 1095 | ], 1096 | [ 1097 | 285.859130859375, 1098 | -5.0902624325652255 1099 | ] 1100 | ] 1101 | }, 1102 | { 1103 | "type": "text", 1104 | "version": 34, 1105 | "versionNonce": 1354810653, 1106 | "isDeleted": false, 1107 | "id": "BOJKpY9hsnSfkrv_VKbka", 1108 | "fillStyle": "hachure", 1109 | "strokeWidth": 1, 1110 | "strokeStyle": "solid", 1111 | "roughness": 1, 1112 | "opacity": 100, 1113 | "angle": 0, 1114 | "x": 67.79397367218098, 1115 | "y": -271.95559097713397, 1116 | "strokeColor": "#c92a2a", 1117 | "backgroundColor": "#ced4da", 1118 | "width": 162.75987243652344, 1119 | "height": 48, 1120 | "seed": 1730835795, 1121 | "groupIds": [], 1122 | "roundness": null, 1123 | "boundElements": [], 1124 | "updated": 1678807246491, 1125 | "link": null, 1126 | "locked": false, 1127 | "fontSize": 20, 1128 | "fontFamily": 1, 1129 | "text": "2. Access Token\n(JWT)", 1130 | "textAlign": "center", 1131 | "verticalAlign": "middle", 1132 | "containerId": "aemJKBM5uDSYuer5A5v03", 1133 | "originalText": "2. Access Token\n(JWT)" 1134 | }, 1135 | { 1136 | "type": "arrow", 1137 | "version": 290, 1138 | "versionNonce": 2037917407, 1139 | "isDeleted": false, 1140 | "id": "KETRbv5wNXIFFRq_xhkSH", 1141 | "fillStyle": "hachure", 1142 | "strokeWidth": 1, 1143 | "strokeStyle": "solid", 1144 | "roughness": 1, 1145 | "opacity": 100, 1146 | "angle": 0, 1147 | "x": 324.46230100372395, 1148 | "y": -227.8278138775246, 1149 | "strokeColor": "#c92a2a", 1150 | "backgroundColor": "#ced4da", 1151 | "width": 303.01333724307347, 1152 | "height": 163.12476708933343, 1153 | "seed": 870502643, 1154 | "groupIds": [], 1155 | "roundness": { 1156 | "type": 2 1157 | }, 1158 | "boundElements": [ 1159 | { 1160 | "type": "text", 1161 | "id": "eKqY1ci_zlzXyIsdy-Q8M" 1162 | } 1163 | ], 1164 | "updated": 1678809636368, 1165 | "link": null, 1166 | "locked": false, 1167 | "startBinding": null, 1168 | "endBinding": { 1169 | "elementId": "cbecl37JdZu0G_9NPARNd", 1170 | "focus": -0.07257969517112609, 1171 | "gap": 15.561782836914062 1172 | }, 1173 | "lastCommittedPoint": null, 1174 | "startArrowhead": null, 1175 | "endArrowhead": "arrow", 1176 | "points": [ 1177 | [ 1178 | 0, 1179 | 0 1180 | ], 1181 | [ 1182 | -303.01333724307347, 1183 | 163.12476708933343 1184 | ] 1185 | ] 1186 | }, 1187 | { 1188 | "type": "text", 1189 | "version": 45, 1190 | "versionNonce": 570694013, 1191 | "isDeleted": false, 1192 | "id": "eKqY1ci_zlzXyIsdy-Q8M", 1193 | "fillStyle": "hachure", 1194 | "strokeWidth": 1, 1195 | "strokeStyle": "solid", 1196 | "roughness": 1, 1197 | "opacity": 100, 1198 | "angle": 0, 1199 | "x": 66.93597196319661, 1200 | "y": -212.35363174861834, 1201 | "strokeColor": "#c92a2a", 1202 | "backgroundColor": "#ced4da", 1203 | "width": 162.13987731933594, 1204 | "height": 48, 1205 | "seed": 2046460317, 1206 | "groupIds": [], 1207 | "roundness": null, 1208 | "boundElements": [], 1209 | "updated": 1678807246491, 1210 | "link": null, 1211 | "locked": false, 1212 | "fontSize": 20, 1213 | "fontFamily": 1, 1214 | "text": "3. Access Token\n(JWT)", 1215 | "textAlign": "center", 1216 | "verticalAlign": "middle", 1217 | "containerId": "KETRbv5wNXIFFRq_xhkSH", 1218 | "originalText": "3. Access Token\n(JWT)" 1219 | }, 1220 | { 1221 | "type": "arrow", 1222 | "version": 402, 1223 | "versionNonce": 1888907529, 1224 | "isDeleted": false, 1225 | "id": "gl8kV3eGPyLuccbvi2FsN", 1226 | "fillStyle": "hachure", 1227 | "strokeWidth": 1, 1228 | "strokeStyle": "solid", 1229 | "roughness": 1, 1230 | "opacity": 100, 1231 | "angle": 0, 1232 | "x": -767.0727636935417, 1233 | "y": -287.2479646343605, 1234 | "strokeColor": "#364fc7", 1235 | "backgroundColor": "#ced4da", 1236 | "width": 139.82705688476562, 1237 | "height": 1.3351502557689514, 1238 | "seed": 141780819, 1239 | "groupIds": [], 1240 | "roundness": { 1241 | "type": 2 1242 | }, 1243 | "boundElements": [], 1244 | "updated": 1678809224767, 1245 | "link": null, 1246 | "locked": false, 1247 | "startBinding": null, 1248 | "endBinding": { 1249 | "elementId": "ZjI3Z6FPHek8WPEDr4dbF", 1250 | "focus": 0.06795052047254273, 1251 | "gap": 7.8727264404296875 1252 | }, 1253 | "lastCommittedPoint": null, 1254 | "startArrowhead": null, 1255 | "endArrowhead": "arrow", 1256 | "points": [ 1257 | [ 1258 | 0, 1259 | 0 1260 | ], 1261 | [ 1262 | 139.82705688476562, 1263 | 1.3351502557689514 1264 | ] 1265 | ] 1266 | }, 1267 | { 1268 | "type": "arrow", 1269 | "version": 559, 1270 | "versionNonce": 625127773, 1271 | "isDeleted": false, 1272 | "id": "Mj46Jv7AHMD3YHNhmOT80", 1273 | "fillStyle": "hachure", 1274 | "strokeWidth": 1, 1275 | "strokeStyle": "solid", 1276 | "roughness": 1, 1277 | "opacity": 100, 1278 | "angle": 0, 1279 | "x": -403.1367807070953, 1280 | "y": -284.6462623019179, 1281 | "strokeColor": "#364fc7", 1282 | "backgroundColor": "#ced4da", 1283 | "width": 202.87420654296875, 1284 | "height": 68.80087530607545, 1285 | "seed": 980160819, 1286 | "groupIds": [], 1287 | "roundness": { 1288 | "type": 2 1289 | }, 1290 | "boundElements": [ 1291 | { 1292 | "type": "text", 1293 | "id": "Wgjk7v9VSiPYDStZcTH5Y" 1294 | } 1295 | ], 1296 | "updated": 1678807298335, 1297 | "link": null, 1298 | "locked": false, 1299 | "startBinding": { 1300 | "elementId": "ZjI3Z6FPHek8WPEDr4dbF", 1301 | "focus": 0.6133292443010997, 1302 | "gap": 7.052636428829203 1303 | }, 1304 | "endBinding": { 1305 | "elementId": "cYPF5q8mR4N53gvrSxZyI", 1306 | "focus": -0.5731364719122032, 1307 | "gap": 10.371863082889547 1308 | }, 1309 | "lastCommittedPoint": null, 1310 | "startArrowhead": null, 1311 | "endArrowhead": "arrow", 1312 | "points": [ 1313 | [ 1314 | 0, 1315 | 0 1316 | ], 1317 | [ 1318 | 97.21010466003798, 1319 | -68.80087530607545 1320 | ], 1321 | [ 1322 | 202.87420654296875, 1323 | -2.490913006384517 1324 | ] 1325 | ] 1326 | }, 1327 | { 1328 | "type": "text", 1329 | "version": 228, 1330 | "versionNonce": 161565577, 1331 | "isDeleted": false, 1332 | "id": "Wgjk7v9VSiPYDStZcTH5Y", 1333 | "fillStyle": "hachure", 1334 | "strokeWidth": 1, 1335 | "strokeStyle": "solid", 1336 | "roughness": 1, 1337 | "opacity": 100, 1338 | "angle": 0, 1339 | "x": -386.9266073825065, 1340 | "y": -365.44713760799334, 1341 | "strokeColor": "#364fc7", 1342 | "backgroundColor": "#ced4da", 1343 | "width": 161.99986267089844, 1344 | "height": 24, 1345 | "seed": 2130644861, 1346 | "groupIds": [], 1347 | "roundness": null, 1348 | "boundElements": [], 1349 | "updated": 1678809244690, 1350 | "link": null, 1351 | "locked": false, 1352 | "fontSize": 20, 1353 | "fontFamily": 1, 1354 | "text": "1. Authentication", 1355 | "textAlign": "center", 1356 | "verticalAlign": "middle", 1357 | "containerId": "Mj46Jv7AHMD3YHNhmOT80", 1358 | "originalText": "1. Authentication" 1359 | }, 1360 | { 1361 | "type": "arrow", 1362 | "version": 117, 1363 | "versionNonce": 1249992435, 1364 | "isDeleted": false, 1365 | "id": "_jskLl3tJP7_I3eRPWsyv", 1366 | "fillStyle": "hachure", 1367 | "strokeWidth": 1, 1368 | "strokeStyle": "solid", 1369 | "roughness": 1, 1370 | "opacity": 100, 1371 | "angle": 0, 1372 | "x": -195.4171545626823, 1373 | "y": -250.90987353930353, 1374 | "strokeColor": "#364fc7", 1375 | "backgroundColor": "#ced4da", 1376 | "width": 203.749267578125, 1377 | "height": 60.90711137705506, 1378 | "seed": 1052987539, 1379 | "groupIds": [], 1380 | "roundness": { 1381 | "type": 2 1382 | }, 1383 | "boundElements": [ 1384 | { 1385 | "type": "text", 1386 | "id": "k0nHrMMxpXeZNElB-eKH4" 1387 | } 1388 | ], 1389 | "updated": 1678807298336, 1390 | "link": null, 1391 | "locked": false, 1392 | "startBinding": { 1393 | "elementId": "cYPF5q8mR4N53gvrSxZyI", 1394 | "focus": 0.3043693191149432, 1395 | "gap": 5.5264434814453125 1396 | }, 1397 | "endBinding": { 1398 | "elementId": "ZjI3Z6FPHek8WPEDr4dbF", 1399 | "focus": -0.3588709635613937, 1400 | "gap": 11.022994995117188 1401 | }, 1402 | "lastCommittedPoint": null, 1403 | "startArrowhead": null, 1404 | "endArrowhead": "arrow", 1405 | "points": [ 1406 | [ 1407 | 0, 1408 | 0 1409 | ], 1410 | [ 1411 | -101.03924560546875, 1412 | 58.63711337271644 1413 | ], 1414 | [ 1415 | -203.749267578125, 1416 | -2.2699980043386176 1417 | ] 1418 | ] 1419 | }, 1420 | { 1421 | "type": "text", 1422 | "version": 35, 1423 | "versionNonce": 227458825, 1424 | "isDeleted": false, 1425 | "id": "k0nHrMMxpXeZNElB-eKH4", 1426 | "fillStyle": "hachure", 1427 | "strokeWidth": 1, 1428 | "strokeStyle": "solid", 1429 | "roughness": 1, 1430 | "opacity": 100, 1431 | "angle": 0, 1432 | "x": -377.83633638641277, 1433 | "y": -216.2727601665871, 1434 | "strokeColor": "#364fc7", 1435 | "backgroundColor": "#ced4da", 1436 | "width": 162.75987243652344, 1437 | "height": 48, 1438 | "seed": 2125351005, 1439 | "groupIds": [], 1440 | "roundness": null, 1441 | "boundElements": [], 1442 | "updated": 1678809252817, 1443 | "link": null, 1444 | "locked": false, 1445 | "fontSize": 20, 1446 | "fontFamily": 1, 1447 | "text": "2. Access Token\n(JWT)", 1448 | "textAlign": "center", 1449 | "verticalAlign": "middle", 1450 | "containerId": "_jskLl3tJP7_I3eRPWsyv", 1451 | "originalText": "2. Access Token\n(JWT)" 1452 | }, 1453 | { 1454 | "type": "arrow", 1455 | "version": 241, 1456 | "versionNonce": 380205713, 1457 | "isDeleted": false, 1458 | "id": "RSTGyvQh12feJdODCVco4", 1459 | "fillStyle": "hachure", 1460 | "strokeWidth": 1, 1461 | "strokeStyle": "solid", 1462 | "roughness": 1, 1463 | "opacity": 100, 1464 | "angle": 0, 1465 | "x": -477.3039914841713, 1466 | "y": -219.66771866268084, 1467 | "strokeColor": "#364fc7", 1468 | "backgroundColor": "#ced4da", 1469 | "width": 277.1417349927781, 1470 | "height": 200.52102416932968, 1471 | "seed": 295851027, 1472 | "groupIds": [], 1473 | "roundness": { 1474 | "type": 2 1475 | }, 1476 | "boundElements": [ 1477 | { 1478 | "type": "text", 1479 | "id": "_I0X-VEhj3Qv3Lpk6KRNC" 1480 | } 1481 | ], 1482 | "updated": 1678809636368, 1483 | "link": null, 1484 | "locked": false, 1485 | "startBinding": { 1486 | "elementId": "ZjI3Z6FPHek8WPEDr4dbF", 1487 | "focus": 0.2219321221394831, 1488 | "gap": 12.093719482421875 1489 | }, 1490 | "endBinding": { 1491 | "elementId": "cbecl37JdZu0G_9NPARNd", 1492 | "focus": -0.46441246408123127, 1493 | "gap": 10.159408569335938 1494 | }, 1495 | "lastCommittedPoint": null, 1496 | "startArrowhead": null, 1497 | "endArrowhead": "arrow", 1498 | "points": [ 1499 | [ 1500 | 0, 1501 | 0 1502 | ], 1503 | [ 1504 | 110.91595069102027, 1505 | 92.96807861328125 1506 | ], 1507 | [ 1508 | 277.1417349927781, 1509 | 200.52102416932968 1510 | ] 1511 | ] 1512 | }, 1513 | { 1514 | "type": "text", 1515 | "version": 36, 1516 | "versionNonce": 509366185, 1517 | "isDeleted": false, 1518 | "id": "_I0X-VEhj3Qv3Lpk6KRNC", 1519 | "fillStyle": "hachure", 1520 | "strokeWidth": 1, 1521 | "strokeStyle": "solid", 1522 | "roughness": 1, 1523 | "opacity": 100, 1524 | "angle": 0, 1525 | "x": -447.457979452819, 1526 | "y": -150.6996400493996, 1527 | "strokeColor": "#364fc7", 1528 | "backgroundColor": "#ced4da", 1529 | "width": 162.13987731933594, 1530 | "height": 48, 1531 | "seed": 1797019219, 1532 | "groupIds": [], 1533 | "roundness": null, 1534 | "boundElements": [], 1535 | "updated": 1678809255495, 1536 | "link": null, 1537 | "locked": false, 1538 | "fontSize": 20, 1539 | "fontFamily": 1, 1540 | "text": "3. Access Token\n(JWT)", 1541 | "textAlign": "center", 1542 | "verticalAlign": "middle", 1543 | "containerId": "RSTGyvQh12feJdODCVco4", 1544 | "originalText": "3. Access Token\n(JWT)" 1545 | }, 1546 | { 1547 | "id": "_rrK8msPnCkst8q7Aw3lW", 1548 | "type": "arrow", 1549 | "x": -92.5615055983542, 1550 | "y": -59.149397635825395, 1551 | "width": 2.7171879558468675, 1552 | "height": 165.62881469726562, 1553 | "angle": 0, 1554 | "strokeColor": "#000000", 1555 | "backgroundColor": "transparent", 1556 | "fillStyle": "hachure", 1557 | "strokeWidth": 1, 1558 | "strokeStyle": "solid", 1559 | "roughness": 1, 1560 | "opacity": 100, 1561 | "groupIds": [], 1562 | "roundness": { 1563 | "type": 2 1564 | }, 1565 | "seed": 2046813425, 1566 | "version": 322, 1567 | "versionNonce": 1637744753, 1568 | "isDeleted": false, 1569 | "boundElements": [ 1570 | { 1571 | "type": "text", 1572 | "id": "56zE-lhUgONxB-uSSqLvc" 1573 | } 1574 | ], 1575 | "updated": 1678809636369, 1576 | "link": null, 1577 | "locked": false, 1578 | "points": [ 1579 | [ 1580 | 0, 1581 | 0 1582 | ], 1583 | [ 1584 | 2.7171879558468675, 1585 | -165.62881469726562 1586 | ] 1587 | ], 1588 | "lastCommittedPoint": null, 1589 | "startBinding": { 1590 | "elementId": "cbecl37JdZu0G_9NPARNd", 1591 | "focus": -0.07712505589894209, 1592 | "gap": 9.841938982910165 1593 | }, 1594 | "endBinding": { 1595 | "elementId": "cYPF5q8mR4N53gvrSxZyI", 1596 | "focus": 0.0779185697267684, 1597 | "gap": 6.771166791015617 1598 | }, 1599 | "startArrowhead": "arrow", 1600 | "endArrowhead": "arrow" 1601 | }, 1602 | { 1603 | "id": "56zE-lhUgONxB-uSSqLvc", 1604 | "type": "text", 1605 | "x": -172.7001641404654, 1606 | "y": -194.55520513094257, 1607 | "width": 161.3198699951172, 1608 | "height": 72, 1609 | "angle": 0, 1610 | "strokeColor": "#000000", 1611 | "backgroundColor": "transparent", 1612 | "fillStyle": "hachure", 1613 | "strokeWidth": 1, 1614 | "strokeStyle": "solid", 1615 | "roughness": 1, 1616 | "opacity": 100, 1617 | "groupIds": [], 1618 | "roundness": null, 1619 | "seed": 216257279, 1620 | "version": 37, 1621 | "versionNonce": 458359135, 1622 | "isDeleted": false, 1623 | "boundElements": null, 1624 | "updated": 1678809631005, 1625 | "link": null, 1626 | "locked": false, 1627 | "text": "4. Access Token\n(JWT)\nValidation", 1628 | "fontSize": 20, 1629 | "fontFamily": 1, 1630 | "textAlign": "center", 1631 | "verticalAlign": "middle", 1632 | "containerId": "_rrK8msPnCkst8q7Aw3lW", 1633 | "originalText": "4. Access Token\n(JWT)\nValidation" 1634 | } 1635 | ], 1636 | "appState": { 1637 | "gridSize": null, 1638 | "viewBackgroundColor": "#ffffff" 1639 | }, 1640 | "files": {} 1641 | } -------------------------------------------------------------------------------- /documentation/project-diagram.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/okta-springboot-react/513b2e5164289efab38e5a704eca6b94439270c8/documentation/project-diagram.jpeg -------------------------------------------------------------------------------- /jobs-api/.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 | -------------------------------------------------------------------------------- /jobs-api/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 | -------------------------------------------------------------------------------- /jobs-api/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 | -------------------------------------------------------------------------------- /jobs-api/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 4.0.0 5 | 6 | org.springframework.boot 7 | spring-boot-starter-parent 8 | 3.3.4 9 | 10 | 11 | 12 | com.ivanfranchin 13 | jobs-api 14 | 0.0.1-SNAPSHOT 15 | jobs-api 16 | Demo project for Spring Boot 17 | 18 | 19 | 21 20 | 3.0.7 21 | 0.2.0 22 | 2.6.0 23 | 24 | 25 | 26 | 27 | org.springframework.boot 28 | spring-boot-starter-actuator 29 | 30 | 31 | org.springframework.boot 32 | spring-boot-starter-data-elasticsearch 33 | 34 | 35 | org.springframework.boot 36 | spring-boot-starter-web 37 | 38 | 39 | 40 | 41 | com.okta.spring 42 | okta-spring-boot-starter 43 | ${okta-spring.version} 44 | 45 | 46 | 47 | 48 | org.springdoc 49 | springdoc-openapi-starter-webmvc-ui 50 | ${springdoc-openapi.version} 51 | 52 | 53 | 54 | org.projectlombok 55 | lombok 56 | true 57 | 58 | 59 | org.springframework.boot 60 | spring-boot-starter-test 61 | test 62 | 63 | 64 | 65 | 66 | 67 | 68 | org.springframework.boot 69 | spring-boot-maven-plugin 70 | 71 | 72 | 73 | org.projectlombok 74 | lombok 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/JobsApiApplication.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | 6 | @SpringBootApplication 7 | public class JobsApiApplication { 8 | 9 | public static void main(String[] args) { 10 | SpringApplication.run(JobsApiApplication.class, args); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/config/RequestLoggingFilterConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.config; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.web.filter.CommonsRequestLoggingFilter; 6 | 7 | @Configuration 8 | public class RequestLoggingFilterConfig { 9 | 10 | @Bean 11 | public CommonsRequestLoggingFilter logFilter() { 12 | CommonsRequestLoggingFilter filter = new CommonsRequestLoggingFilter(); 13 | filter.setIncludeClientInfo(true); 14 | filter.setIncludeHeaders(true); 15 | filter.setIncludeQueryString(true); 16 | filter.setIncludePayload(true); 17 | filter.setMaxPayloadLength(10000); 18 | filter.setBeforeMessagePrefix("Received request ["); 19 | filter.setAfterMessagePrefix("Finished request ["); 20 | return filter; 21 | } 22 | } -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/config/SwaggerConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.config; 2 | 3 | import io.swagger.v3.oas.models.Components; 4 | import io.swagger.v3.oas.models.OpenAPI; 5 | import io.swagger.v3.oas.models.info.Info; 6 | import io.swagger.v3.oas.models.security.SecurityScheme; 7 | import org.springdoc.core.models.GroupedOpenApi; 8 | import org.springframework.beans.factory.annotation.Value; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.annotation.Configuration; 11 | 12 | @Configuration 13 | public class SwaggerConfig { 14 | 15 | @Value("${spring.application.name}") 16 | private String applicationName; 17 | 18 | @Bean 19 | public OpenAPI customOpenAPI() { 20 | return new OpenAPI() 21 | .components( 22 | new Components().addSecuritySchemes(BEARER_KEY_SECURITY_SCHEME, 23 | new SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"))) 24 | .info(new Info().title(applicationName)); 25 | } 26 | 27 | @Bean 28 | public GroupedOpenApi customApi() { 29 | return GroupedOpenApi.builder().group("api").pathsToMatch("/api/**").build(); 30 | } 31 | 32 | @Bean 33 | public GroupedOpenApi actuatorApi() { 34 | return GroupedOpenApi.builder().group("actuator").pathsToMatch("/actuator/**").build(); 35 | } 36 | 37 | public static final String BEARER_KEY_SECURITY_SCHEME = "bearer-key"; 38 | } 39 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/exception/JobNotFoundException.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.exception; 2 | 3 | import org.springframework.http.HttpStatus; 4 | import org.springframework.web.bind.annotation.ResponseStatus; 5 | 6 | @ResponseStatus(HttpStatus.NOT_FOUND) 7 | public class JobNotFoundException extends RuntimeException { 8 | 9 | public JobNotFoundException(String id) { 10 | super(String.format("Job with id %s not found", id)); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/mapper/JobMapper.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.mapper; 2 | 3 | import com.ivanfranchin.jobsapi.model.Job; 4 | import com.ivanfranchin.jobsapi.rest.dto.CreateJobRequest; 5 | import com.ivanfranchin.jobsapi.rest.dto.JobResponse; 6 | import com.ivanfranchin.jobsapi.rest.dto.UpdateJobRequest; 7 | 8 | public interface JobMapper { 9 | 10 | JobResponse toJobResponse(Job job); 11 | 12 | Job toJob(CreateJobRequest createJobRequest); 13 | 14 | void updateJobFromRequest(UpdateJobRequest updateJobRequest, Job job); 15 | } -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/mapper/JobMapperImpl.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.mapper; 2 | 3 | import com.ivanfranchin.jobsapi.model.Job; 4 | import com.ivanfranchin.jobsapi.rest.dto.CreateJobRequest; 5 | import com.ivanfranchin.jobsapi.rest.dto.JobResponse; 6 | import com.ivanfranchin.jobsapi.rest.dto.UpdateJobRequest; 7 | import org.springframework.stereotype.Service; 8 | 9 | @Service 10 | public class JobMapperImpl implements JobMapper { 11 | 12 | @Override 13 | public JobResponse toJobResponse(Job job) { 14 | if (job == null) { 15 | return null; 16 | } 17 | return new JobResponse(job.getId(), job.getTitle(), job.getCompany(), job.getLogoUrl(), job.getDescription(), job.getCreateDate()); 18 | } 19 | 20 | @Override 21 | public Job toJob(CreateJobRequest createJobRequest) { 22 | if (createJobRequest == null) { 23 | return null; 24 | } 25 | return new Job(createJobRequest.getTitle(), createJobRequest.getCompany(), createJobRequest.getLogoUrl(), createJobRequest.getDescription()); 26 | } 27 | 28 | @Override 29 | public void updateJobFromRequest(UpdateJobRequest updateJobRequest, Job job) { 30 | if (updateJobRequest == null) { 31 | return; 32 | } 33 | 34 | if (updateJobRequest.getTitle() != null) { 35 | job.setTitle(updateJobRequest.getTitle()); 36 | } 37 | if (updateJobRequest.getCompany() != null) { 38 | job.setCompany(updateJobRequest.getCompany()); 39 | } 40 | if (updateJobRequest.getLogoUrl() != null) { 41 | job.setLogoUrl(updateJobRequest.getLogoUrl()); 42 | } 43 | if (updateJobRequest.getDescription() != null) { 44 | job.setDescription(updateJobRequest.getDescription()); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/model/Job.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.model; 2 | 3 | import lombok.Data; 4 | import org.springframework.data.annotation.Id; 5 | import org.springframework.data.elasticsearch.annotations.Document; 6 | import org.springframework.data.elasticsearch.annotations.Field; 7 | import org.springframework.data.elasticsearch.annotations.FieldType; 8 | 9 | import java.time.Instant; 10 | 11 | @Data 12 | @Document(indexName = "jobs") 13 | public class Job { 14 | 15 | @Id 16 | private String id; 17 | 18 | @Field(type = FieldType.Text) 19 | private String title; 20 | 21 | @Field(type = FieldType.Text) 22 | private String company; 23 | 24 | @Field(type = FieldType.Text) 25 | private String logoUrl; 26 | 27 | @Field(type = FieldType.Text) 28 | private String description; 29 | 30 | @Field(type = FieldType.Date) 31 | private String createDate; 32 | 33 | public Job() { 34 | this.createDate = Instant.ofEpochSecond(Instant.now().getEpochSecond()).toString(); 35 | } 36 | 37 | public Job(String title, String company, String logoUrl, String description) { 38 | this.title = title; 39 | this.company = company; 40 | this.logoUrl = logoUrl; 41 | this.description = description; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/repository/JobRepository.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.repository; 2 | 3 | import com.ivanfranchin.jobsapi.model.Job; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | import org.springframework.data.elasticsearch.annotations.Query; 7 | import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; 8 | import org.springframework.stereotype.Repository; 9 | 10 | @Repository 11 | public interface JobRepository extends ElasticsearchRepository { 12 | 13 | @Query("{ \"query_string\" : { \"query\" : \"?0\" } }") 14 | Page findJobsUsingQueryStringQuery(String text, Pageable pageable); 15 | } 16 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/rest/CallbackController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.rest; 2 | 3 | import io.swagger.v3.oas.annotations.Operation; 4 | import org.springframework.util.MultiValueMap; 5 | import org.springframework.web.bind.annotation.PostMapping; 6 | import org.springframework.web.bind.annotation.RequestBody; 7 | import org.springframework.web.bind.annotation.RestController; 8 | 9 | import java.util.Map; 10 | 11 | @RestController 12 | public class CallbackController { 13 | 14 | @Operation(summary = "Endpoint used by Okta to send back the JWT access token") 15 | @PostMapping("/callback/token") 16 | public Map callbackToken(@RequestBody MultiValueMap queryMap) { 17 | return queryMap.toSingleValueMap(); 18 | } 19 | } -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/rest/JobController.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.rest; 2 | 3 | import com.ivanfranchin.jobsapi.mapper.JobMapper; 4 | import com.ivanfranchin.jobsapi.model.Job; 5 | import com.ivanfranchin.jobsapi.rest.dto.CreateJobRequest; 6 | import com.ivanfranchin.jobsapi.rest.dto.JobResponse; 7 | import com.ivanfranchin.jobsapi.rest.dto.SearchRequest; 8 | import com.ivanfranchin.jobsapi.rest.dto.UpdateJobRequest; 9 | import com.ivanfranchin.jobsapi.service.JobService; 10 | import io.swagger.v3.oas.annotations.Operation; 11 | import io.swagger.v3.oas.annotations.security.SecurityRequirement; 12 | import jakarta.validation.Valid; 13 | import lombok.RequiredArgsConstructor; 14 | import lombok.extern.slf4j.Slf4j; 15 | import org.springdoc.core.annotations.ParameterObject; 16 | import org.springframework.data.domain.Page; 17 | import org.springframework.data.domain.Pageable; 18 | import org.springframework.data.domain.Sort.Direction; 19 | import org.springframework.data.web.PageableDefault; 20 | import org.springframework.http.HttpStatus; 21 | import org.springframework.web.bind.annotation.DeleteMapping; 22 | import org.springframework.web.bind.annotation.GetMapping; 23 | import org.springframework.web.bind.annotation.PathVariable; 24 | import org.springframework.web.bind.annotation.PostMapping; 25 | import org.springframework.web.bind.annotation.PutMapping; 26 | import org.springframework.web.bind.annotation.RequestBody; 27 | import org.springframework.web.bind.annotation.RequestMapping; 28 | import org.springframework.web.bind.annotation.RequestParam; 29 | import org.springframework.web.bind.annotation.ResponseStatus; 30 | import org.springframework.web.bind.annotation.RestController; 31 | 32 | import java.security.Principal; 33 | import java.util.List; 34 | import java.util.stream.Collectors; 35 | 36 | import static com.ivanfranchin.jobsapi.config.SwaggerConfig.BEARER_KEY_SECURITY_SCHEME; 37 | 38 | @Slf4j 39 | @RequiredArgsConstructor 40 | @RestController 41 | @RequestMapping("/api/jobs") 42 | public class JobController { 43 | 44 | private final JobService jobService; 45 | private final JobMapper jobMapper; 46 | 47 | @Operation( 48 | summary = "Get jobs with pagination", 49 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 50 | @GetMapping 51 | public Page getJobs( 52 | @ParameterObject @PageableDefault(sort = {"createDate"}, direction = Direction.DESC) Pageable pageable, 53 | Principal principal) { 54 | log.info("Request to get a page of jobs (offset = {}, pageSize = {}) made by {}", 55 | pageable.getOffset(), pageable.getPageSize(), principal.getName()); 56 | return jobService.getJobsByPage(pageable).map(jobMapper::toJobResponse); 57 | } 58 | 59 | @Operation(summary = "Get the newest jobs") 60 | @GetMapping("/newest") 61 | public List getNewestJobs(@RequestParam(value = "number", required = false, defaultValue = "4") int number) { 62 | if (number > 10) { 63 | log.warn("The parameter number cannot be bigger than 10"); 64 | number = 10; 65 | } 66 | return jobService.getNewestJobs(number) 67 | .stream() 68 | .map(jobMapper::toJobResponse) 69 | .collect(Collectors.toList()); 70 | } 71 | 72 | @Operation( 73 | summary = "Get a job by id", 74 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 75 | @GetMapping("/{id}") 76 | public JobResponse getJobById(@PathVariable String id, Principal principal) { 77 | log.info("Request to get a job with id {} made by {}", id, principal.getName()); 78 | Job job = jobService.validateAndGetJobById(id); 79 | return jobMapper.toJobResponse(job); 80 | } 81 | 82 | @Operation( 83 | summary = "Create a job", 84 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 85 | @ResponseStatus(HttpStatus.CREATED) 86 | @PostMapping 87 | public JobResponse createJob(@Valid @RequestBody CreateJobRequest createJobRequest, Principal principal) { 88 | log.info("Request to create a job made by {}", principal.getName()); 89 | Job job = jobMapper.toJob(createJobRequest); 90 | job = jobService.saveJob(job); 91 | return jobMapper.toJobResponse(job); 92 | } 93 | 94 | @Operation( 95 | summary = "Delete a job", 96 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 97 | @DeleteMapping("/{id}") 98 | public JobResponse deleteJob(@PathVariable String id, Principal principal) { 99 | log.info("Request to delete a job with id {} made by {}", id, principal.getName()); 100 | Job job = jobService.validateAndGetJobById(id); 101 | jobService.deleteJob(job); 102 | return jobMapper.toJobResponse(job); 103 | } 104 | 105 | @Operation( 106 | summary = "Update a job", 107 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 108 | @PutMapping("/{id}") 109 | public JobResponse updateJob(@PathVariable String id, 110 | @Valid @RequestBody UpdateJobRequest updateJobRequest, Principal principal) { 111 | log.info("Request to update a job with id {} made by {}", id, principal.getName()); 112 | Job job = jobService.validateAndGetJobById(id); 113 | jobMapper.updateJobFromRequest(updateJobRequest, job); 114 | jobService.saveJob(job); 115 | return jobMapper.toJobResponse(job); 116 | } 117 | 118 | @Operation( 119 | summary = "Search for jobs", 120 | security = {@SecurityRequirement(name = BEARER_KEY_SECURITY_SCHEME)}) 121 | @PutMapping("/search") 122 | public Page searchJobs(@Valid @RequestBody SearchRequest searchRequest, 123 | @ParameterObject @PageableDefault(sort = {"createDate"}, direction = Direction.DESC) Pageable pageable, 124 | Principal principal) { 125 | log.info("Request to search a job with text {} made by {}", searchRequest.getText(), principal.getName()); 126 | return jobService.search(searchRequest.getText(), pageable); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/rest/dto/CreateJobRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.rest.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | import lombok.Data; 6 | 7 | @Data 8 | public class CreateJobRequest { 9 | 10 | @Schema(example = "Software Developer") 11 | @NotBlank 12 | private String title; 13 | 14 | @Schema(example = "Google") 15 | @NotBlank 16 | private String company; 17 | 18 | @Schema(example = "https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/500px-Google_2015_logo.svg.png") 19 | private String logoUrl; 20 | 21 | @Schema(example = "Software Developer with more than 5 years experience") 22 | @NotBlank 23 | private String description; 24 | } 25 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/rest/dto/JobResponse.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.rest.dto; 2 | 3 | public record JobResponse(String id, String title, String company, String logoUrl, String description, 4 | String createDate) { 5 | } 6 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/rest/dto/SearchRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.rest.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import jakarta.validation.constraints.NotBlank; 5 | import lombok.Data; 6 | 7 | @Data 8 | public class SearchRequest { 9 | 10 | @NotBlank 11 | @Schema(title = "text to be searched", example = "\"java developer\" AND (google OR facebook) NOT junior") 12 | private String text; 13 | } -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/rest/dto/UpdateJobRequest.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.rest.dto; 2 | 3 | import io.swagger.v3.oas.annotations.media.Schema; 4 | import lombok.Data; 5 | 6 | @Data 7 | public class UpdateJobRequest { 8 | 9 | @Schema(example = "Senior Software Developer") 10 | private String title; 11 | 12 | @Schema(example = "Facebook") 13 | private String company; 14 | 15 | @Schema(example = "https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Facebook_Logo_%282019%29.svg/500px-Facebook_Logo_%282019%29.svg.png") 16 | private String logoUrl; 17 | 18 | @Schema(example = "Senior Software Developer with more than 8 years experience") 19 | private String description; 20 | } 21 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/runner/DatabaseInitializer.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.runner; 2 | 3 | import com.ivanfranchin.jobsapi.model.Job; 4 | import com.ivanfranchin.jobsapi.service.JobService; 5 | import lombok.RequiredArgsConstructor; 6 | import lombok.extern.slf4j.Slf4j; 7 | import org.springframework.beans.factory.annotation.Value; 8 | import org.springframework.boot.CommandLineRunner; 9 | import org.springframework.stereotype.Component; 10 | 11 | import java.security.SecureRandom; 12 | import java.util.Arrays; 13 | import java.util.List; 14 | import java.util.Random; 15 | 16 | @Slf4j 17 | @RequiredArgsConstructor 18 | @Component 19 | public class DatabaseInitializer implements CommandLineRunner { 20 | 21 | private final JobService jobService; 22 | 23 | @Value("${app.number-of-fake-jobs:0}") 24 | private int numberOfFakeJobs; 25 | 26 | @Override 27 | public void run(String... args) { 28 | log.info("Number of fake jobs to be created: {}", numberOfFakeJobs); 29 | if (numberOfFakeJobs <= 0) { 30 | return; 31 | } 32 | 33 | if (!jobService.getNewestJobs(1).isEmpty()) { 34 | log.info("Database has already data!"); 35 | return; 36 | } 37 | 38 | log.info("Starting creating jobs ..."); 39 | for (int i = 0; i < numberOfFakeJobs; i++) { 40 | String tech = TECHS.get(random.nextInt(TECHS.size())); 41 | String level = LEVELS.get(random.nextInt(LEVELS.size())); 42 | String levelName = level.split(SPLIT_CHAR)[0]; 43 | String levelYears = level.split(SPLIT_CHAR)[1]; 44 | String area = AREAS.get(random.nextInt(AREAS.size())); 45 | String company = COMPANIES.get(random.nextInt(COMPANIES.size())); 46 | String companyName = company.split(SPLIT_CHAR)[0]; 47 | String companyLogoUrl = company.split(SPLIT_CHAR)[1]; 48 | String location = LOCATIONS.get(random.nextInt(LOCATIONS.size())); 49 | String moreInfo = MORE_INFO.get(random.nextInt(MORE_INFO.size())); 50 | 51 | Job job = new Job(); 52 | job.setTitle(String.format(TITLE_TEMPLATE, levelName, tech, area)); 53 | job.setCompany(companyName); 54 | job.setLogoUrl(companyLogoUrl); 55 | job.setDescription( 56 | String.format(DESCRIPTION_TEMPLATE, companyName, levelName, tech, levelYears, location, moreInfo)); 57 | jobService.saveJob(job); 58 | 59 | log.info("Job created! => {}", job); 60 | } 61 | log.info("Created {} jobs successfully!", numberOfFakeJobs); 62 | } 63 | 64 | private static final Random random = new SecureRandom(); 65 | 66 | private static final String SPLIT_CHAR = ";"; 67 | private static final List TECHS = Arrays.asList("Java", "C", "Python", "React", "Angular", "Scala"); 68 | private static final List LEVELS = Arrays.asList("Junior;2", "Mid-career;4", "Senior;6", "Expert;8"); 69 | private static final List AREAS = Arrays.asList("Finance", "Cloud", "Back-office", 70 | "Artificial Intelligence", "Mobile"); 71 | private static final List COMPANIES = Arrays.asList( 72 | "Google;https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Google_2015_logo.svg/500px-Google_2015_logo.svg.png", 73 | "Facebook;https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Facebook_Logo_%282019%29.svg/500px-Facebook_Logo_%282019%29.svg.png", 74 | "Yahoo;https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Yahoo%21_%282019%29.svg/420px-Yahoo%21_%282019%29.svg.png", 75 | "Microsoft;https://upload.wikimedia.org/wikipedia/commons/thumb/9/96/Microsoft_logo_%282012%29.svg/440px-Microsoft_logo_%282012%29.svg.png", 76 | "Just Eat Takeaway;https://upload.wikimedia.org/wikipedia/commons/thumb/d/d6/Just_Eat_Takeaway_Logo_6.2020.svg/2880px-Just_Eat_Takeaway_Logo_6.2020.svg.png", 77 | "Oracle;https://upload.wikimedia.org/wikipedia/commons/thumb/c/c3/Oracle_Logo.svg/440px-Oracle_Logo.svg.png"); 78 | private static final List LOCATIONS = Arrays.asList("Berlin/Germany", "New York City/US", "Porto/Portugal", 79 | "Sao Paulo/Brazil"); 80 | private static final String TITLE_TEMPLATE = "%s %s Developer - %s"; 81 | private static final String DESCRIPTION_TEMPLATE = "We at %s are looking for a %s %s Developer with around %s years of experience. The candidate must have Bachelor, Master or PhD in Computer Science. The position is for our office in %s. If you are looking for new challenges every day, has good communication skills and are fluent in English, you are the exact candidate we are looking for.%s"; 82 | private static final List MORE_INFO = Arrays.asList("", 83 | "\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam at nunc sed elit interdum sodales. Curabitur interdum est quis ex molestie, ac viverra eros pellentesque. Sed dolor nibh, efficitur gravida mauris nec, molestie dapibus lacus. Phasellus eget turpis in arcu lacinia volutpat vitae ac est. Maecenas imperdiet nisl velit, eget consequat ipsum porta a. Nunc eu risus enim. Morbi bibendum neque nec massa convallis, at maximus neque ullamcorper. Quisque venenatis ante diam, vel tincidunt urna pulvinar et. Donec tincidunt in diam eget ullamcorper. Mauris enim tellus, sollicitudin ut volutpat ut, finibus nec libero. Sed sodales ultrices metus. Mauris id nibh nec ante feugiat accumsan. Donec eget congue sapien. Proin vel nulla eu nunc facilisis varius non quis ipsum. Suspendisse malesuada eros nec odio placerat commodo.", 84 | "\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam at nunc sed elit interdum sodales. Curabitur interdum est quis ex molestie, ac viverra eros pellentesque. Sed dolor nibh, efficitur gravida mauris nec, molestie dapibus lacus. Phasellus eget turpis in arcu lacinia volutpat vitae ac est. Maecenas imperdiet nisl velit, eget consequat ipsum porta a. Nunc eu risus enim. Morbi bibendum neque nec massa convallis, at maximus neque ullamcorper. Quisque venenatis ante diam, vel tincidunt urna pulvinar et. Donec tincidunt in diam eget ullamcorper. Mauris enim tellus, sollicitudin ut volutpat ut, finibus nec libero. Sed sodales ultrices metus. Mauris id nibh nec ante feugiat accumsan. Donec eget congue sapien. Proin vel nulla eu nunc facilisis varius non quis ipsum. Suspendisse malesuada eros nec odio placerat commodo.\n\nMauris id eros porta elit gravida interdum a a massa. Donec turpis libero, commodo sed erat eget, mattis volutpat arcu. Sed tortor tellus, viverra ac cursus sit amet, ullamcorper eget ligula. Pellentesque laoreet metus sit amet dolor euismod aliquam. Nulla a ante felis. In vitae mollis dolor. In vitae felis tortor. Phasellus felis ligula, fringilla a nunc sit amet, tempor tristique arcu. Nullam dictum non leo eu pharetra. Pellentesque ac ligula eros.", 85 | "\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam at nunc sed elit interdum sodales. Curabitur interdum est quis ex molestie, ac viverra eros pellentesque. Sed dolor nibh, efficitur gravida mauris nec, molestie dapibus lacus. Phasellus eget turpis in arcu lacinia volutpat vitae ac est. Maecenas imperdiet nisl velit, eget consequat ipsum porta a. Nunc eu risus enim. Morbi bibendum neque nec massa convallis, at maximus neque ullamcorper. Quisque venenatis ante diam, vel tincidunt urna pulvinar et. Donec tincidunt in diam eget ullamcorper. Mauris enim tellus, sollicitudin ut volutpat ut, finibus nec libero. Sed sodales ultrices metus. Mauris id nibh nec ante feugiat accumsan. Donec eget congue sapien. Proin vel nulla eu nunc facilisis varius non quis ipsum. Suspendisse malesuada eros nec odio placerat commodo.\n\nMauris id eros porta elit gravida interdum a a massa. Donec turpis libero, commodo sed erat eget, mattis volutpat arcu. Sed tortor tellus, viverra ac cursus sit amet, ullamcorper eget ligula. Pellentesque laoreet metus sit amet dolor euismod aliquam. Nulla a ante felis. In vitae mollis dolor. In vitae felis tortor. Phasellus felis ligula, fringilla a nunc sit amet, tempor tristique arcu. Nullam dictum non leo eu pharetra. Pellentesque ac ligula eros.\n\nDuis bibendum felis in velit consectetur vestibulum vitae at augue. Donec sed bibendum felis. Suspendisse tincidunt molestie nunc vehicula ornare. Aliquam fringilla sem ligula, a dignissim purus eleifend id. Suspendisse blandit gravida porta. Nam interdum non tellus at efficitur. Fusce feugiat sed lorem ut mollis. Vestibulum tempor turpis vitae efficitur vehicula. In sapien tellus, sollicitudin id dui ut, pretium ornare quam."); 86 | } -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/security/CorsConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.security; 2 | 3 | import org.springframework.beans.factory.annotation.Value; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.cors.CorsConfiguration; 7 | import org.springframework.web.cors.CorsConfigurationSource; 8 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 9 | 10 | import java.util.List; 11 | 12 | @Configuration 13 | public class CorsConfig { 14 | 15 | @Bean 16 | public CorsConfigurationSource corsConfigurationSource(@Value("${app.cors.allowed-origins}") List allowedOrigins) { 17 | CorsConfiguration configuration = new CorsConfiguration(); 18 | configuration.setAllowCredentials(true); 19 | configuration.setAllowedOriginPatterns(allowedOrigins); 20 | configuration.addAllowedMethod("*"); 21 | configuration.addAllowedHeader("*"); 22 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 23 | source.registerCorsConfiguration("/**", configuration); 24 | return source; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/security/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.security; 2 | 3 | import org.springframework.context.annotation.Bean; 4 | import org.springframework.context.annotation.Configuration; 5 | import org.springframework.http.HttpMethod; 6 | import org.springframework.security.config.Customizer; 7 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 8 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 9 | import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; 10 | import org.springframework.security.web.SecurityFilterChain; 11 | 12 | @Configuration 13 | @EnableWebSecurity 14 | public class WebSecurityConfig { 15 | 16 | @Bean 17 | public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { 18 | return http 19 | .authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests 20 | .requestMatchers(HttpMethod.POST, "/callback/token").permitAll() 21 | .requestMatchers(HttpMethod.GET, "/actuator/**").permitAll() 22 | .requestMatchers(HttpMethod.GET, "/api/jobs/newest").permitAll() 23 | .requestMatchers(HttpMethod.GET, "/api/jobs", "/api/jobs/**").hasAnyAuthority(JOBS_CUSTOMER, JOBS_STAFF) 24 | .requestMatchers(HttpMethod.PUT, "/api/jobs/search").hasAnyAuthority(JOBS_CUSTOMER, JOBS_STAFF) 25 | .requestMatchers("/api/jobs", "/api/jobs/**").hasAuthority(JOBS_STAFF) 26 | .requestMatchers("/", "/error", "/csrf", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs", "/v3/api-docs/**").permitAll() 27 | .anyRequest().authenticated()) 28 | .oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer.jwt(Customizer.withDefaults())) 29 | .cors(Customizer.withDefaults()) 30 | .csrf(AbstractHttpConfigurer::disable) 31 | .build(); 32 | } 33 | 34 | private static final String JOBS_STAFF = "JOBS_STAFF"; 35 | private static final String JOBS_CUSTOMER = "JOBS_CUSTOMER"; 36 | } 37 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/service/JobService.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.service; 2 | 3 | import com.ivanfranchin.jobsapi.model.Job; 4 | import org.springframework.data.domain.Page; 5 | import org.springframework.data.domain.Pageable; 6 | 7 | import java.util.List; 8 | 9 | public interface JobService { 10 | 11 | List getNewestJobs(int number); 12 | 13 | Page getJobsByPage(Pageable pageable); 14 | 15 | Job validateAndGetJobById(String id); 16 | 17 | Job saveJob(Job job); 18 | 19 | void deleteJob(Job job); 20 | 21 | Page search(String text, Pageable pageable); 22 | } 23 | -------------------------------------------------------------------------------- /jobs-api/src/main/java/com/ivanfranchin/jobsapi/service/JobServiceImpl.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi.service; 2 | 3 | import java.util.List; 4 | 5 | import com.ivanfranchin.jobsapi.exception.JobNotFoundException; 6 | import com.ivanfranchin.jobsapi.repository.JobRepository; 7 | import com.ivanfranchin.jobsapi.model.Job; 8 | 9 | import org.springframework.data.domain.Page; 10 | import org.springframework.data.domain.PageRequest; 11 | import org.springframework.data.domain.Pageable; 12 | import org.springframework.data.domain.Sort; 13 | import org.springframework.stereotype.Service; 14 | 15 | import lombok.RequiredArgsConstructor; 16 | 17 | @RequiredArgsConstructor 18 | @Service 19 | public class JobServiceImpl implements JobService { 20 | 21 | private final JobRepository jobRepository; 22 | 23 | @Override 24 | public List getNewestJobs(int number) { 25 | return jobRepository.findAll(PageRequest.of(0, number, Sort.by("createDate").descending())).getContent(); 26 | } 27 | 28 | @Override 29 | public Page getJobsByPage(Pageable pageable) { 30 | return jobRepository.findAll(pageable); 31 | } 32 | 33 | @Override 34 | public Job validateAndGetJobById(String id) { 35 | return jobRepository.findById(id).orElseThrow(() -> new JobNotFoundException(id)); 36 | } 37 | 38 | @Override 39 | public Job saveJob(Job job) { 40 | return jobRepository.save(job); 41 | } 42 | 43 | @Override 44 | public void deleteJob(Job job) { 45 | jobRepository.delete(job); 46 | } 47 | 48 | @Override 49 | public Page search(String text, Pageable pageable) { 50 | return jobRepository.findJobsUsingQueryStringQuery(text, pageable); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /jobs-api/src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | spring: 2 | application: 3 | name: jobs-api 4 | elasticsearch: 5 | uris: http://${ELASTICSEARCH_HOST:localhost}:${ELASTICSEARCH_REST_PORT:9200} 6 | 7 | okta: 8 | oauth2: 9 | issuer: https://${OKTA_DOMAIN}/oauth2/default 10 | clientId: ${OKTA_CLIENT_ID} 11 | groupsClaim: groups 12 | 13 | management: 14 | endpoints: 15 | web: 16 | exposure.include: beans, env, health, info, metrics, mappings 17 | endpoint: 18 | health: 19 | show-details: always 20 | 21 | springdoc: 22 | show-actuator: true 23 | swagger-ui: 24 | groups-order: DESC 25 | disable-swagger-default-url: true 26 | 27 | app: 28 | cors: 29 | allowed-origins: "*" 30 | number-of-fake-jobs: 100 31 | 32 | logging: 33 | level: 34 | org.springframework.data.elasticsearch.core: DEBUG 35 | org.springframework.web.filter.CommonsRequestLoggingFilter: DEBUG 36 | org.springframework.security: DEBUG 37 | -------------------------------------------------------------------------------- /jobs-api/src/main/resources/banner.txt: -------------------------------------------------------------------------------- 1 | _ _ _ 2 | (_) ___ | |__ ___ __ _ _ __ (_) 3 | | |/ _ \| '_ \/ __|_____ / _` | '_ \| | 4 | | | (_) | |_) \__ \_____| (_| | |_) | | 5 | _/ |\___/|_.__/|___/ \__,_| .__/|_| 6 | |__/ |_| 7 | :: Spring Boot :: ${spring-boot.formatted-version} 8 | -------------------------------------------------------------------------------- /jobs-api/src/test/java/com/ivanfranchin/jobsapi/JobsApiApplicationTests.java: -------------------------------------------------------------------------------- 1 | package com.ivanfranchin.jobsapi; 2 | 3 | import org.junit.jupiter.api.Disabled; 4 | import org.junit.jupiter.api.Test; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | 7 | @Disabled 8 | @SpringBootTest 9 | class JobsApiApplicationTests { 10 | 11 | @Test 12 | void contextLoads() { 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /jobs-ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jobs-ui", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^6.5.0", 7 | "@testing-library/react": "^16.0.1", 8 | "@testing-library/user-event": "^14.5.2", 9 | "react": "^18.2.0", 10 | "react-dom": "^18.2.0", 11 | "react-scripts": "5.0.1", 12 | "web-vitals": "^4.2.3", 13 | "react-router-dom": "^5.3.4", 14 | "@okta/okta-react": "^6.9.0", 15 | "axios": "^1.7.7", 16 | "materialize-css": "^1.0.0", 17 | "moment": "^2.30.1", 18 | "moment-timezone": "^0.5.46" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "react-scripts build", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /jobs-ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/okta-springboot-react/513b2e5164289efab38e5a704eca6b94439270c8/jobs-ui/public/favicon.ico -------------------------------------------------------------------------------- /jobs-ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | 23 | 24 | Jobs Portal 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /jobs-ui/public/jobs-portal-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | background 5 | 6 | 7 | 8 | 9 | 10 | 11 | Layer 1 12 | Jobs Portal 13 | 14 | 15 | -------------------------------------------------------------------------------- /jobs-ui/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": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /jobs-ui/public/nyc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ivangfr/okta-springboot-react/513b2e5164289efab38e5a704eca6b94439270c8/jobs-ui/public/nyc.jpg -------------------------------------------------------------------------------- /jobs-ui/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /jobs-ui/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route } from 'react-router-dom' 3 | import { OktaAuth, toRelativeUrl } from '@okta/okta-auth-js' 4 | import { Security, SecureRoute, LoginCallback } from '@okta/okta-react' 5 | import { useHistory } from 'react-router-dom'; 6 | import Navbar from './components/misc/Navbar' 7 | import Home from './components/home/Home' 8 | import Customer from './components/customer/Customer' 9 | import JobView from './components/customer/JobView' 10 | import Staff from './components/staff/Staff' 11 | import JobForm from './components/staff/JobForm' 12 | 13 | function App() { 14 | const oktaAuth = new OktaAuth({ 15 | issuer: `${process.env.REACT_APP_OKTA_ORG_URL}/oauth2/default`, 16 | clientId: process.env.REACT_APP_OKTA_CLIENT_ID, 17 | redirectUri: `${window.location.origin}/implicit/callback` 18 | }) 19 | 20 | const history = useHistory(); 21 | const restoreOriginalUri = async (_oktaAuth, originalUri) => { 22 | history.replace(toRelativeUrl(originalUri || '/', window.location.origin)); 23 | }; 24 | 25 | return ( 26 | 27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 |
39 | ) 40 | } 41 | 42 | export default App 43 | -------------------------------------------------------------------------------- /jobs-ui/src/components/customer/Customer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withOktaAuth } from '@okta/okta-react' 3 | import M from 'materialize-css' 4 | import JobList from './JobList' 5 | import Search from '../misc/Search' 6 | import API from '../misc/api' 7 | import Pagination from '../misc/Pagination' 8 | 9 | class Customer extends Component { 10 | state = { 11 | jobs: [], 12 | pagination: { 13 | first: null, 14 | last: null, 15 | number: null, 16 | size: null, 17 | totalElements: null, 18 | totalPages: null 19 | }, 20 | searchText: '' 21 | } 22 | 23 | pageDefaultNumber = 0 24 | pageDefaultSize = 10 25 | 26 | async componentDidMount() { 27 | this.getAllJobs(this.pageDefaultNumber, this.pageDefaultSize) 28 | } 29 | 30 | getAllJobs = async (page, size) => { 31 | API.get(`jobs?page=${page}&size=${size}`, { 32 | headers: { 33 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 34 | } 35 | }) 36 | .then(response => { 37 | const { content, first, last, number, size, totalElements, totalPages } = response.data 38 | this.setState({ 39 | jobs: content, 40 | pagination: { 41 | first, 42 | last, 43 | number, 44 | size, 45 | totalElements, 46 | totalPages 47 | } 48 | }) 49 | }) 50 | .catch(error => { 51 | console.log(error) 52 | M.toast({ html: error, classes: 'rounded' }) 53 | }) 54 | } 55 | 56 | getJobsWithText = async (text, page, size) => { 57 | API.put(`jobs/search?page=${page}&size=${size}`, { 'text': text }, { 58 | headers: { 59 | 'Content-Type': 'application/json', 60 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 61 | } 62 | }) 63 | .then(response => { 64 | const { content, first, last, number, size, totalElements, totalPages } = response.data 65 | this.setState({ 66 | jobs: content, 67 | pagination: { 68 | first, 69 | last, 70 | number, 71 | size, 72 | totalElements, 73 | totalPages 74 | } 75 | }) 76 | }) 77 | .catch(error => { 78 | console.log(error) 79 | M.toast({ html: error, classes: 'rounded' }) 80 | }) 81 | } 82 | 83 | searchJob = async (searchText, pageNumber, pageSize) => { 84 | this.setState({ searchText }) 85 | searchText ? this.getJobsWithText(searchText, pageNumber, pageSize) : this.getAllJobs(pageNumber, pageSize) 86 | } 87 | 88 | render() { 89 | const { jobs, pagination, searchText } = this.state 90 | 91 | return ( 92 |
93 | 94 | 95 | 100 | 101 | 102 |
103 | ) 104 | } 105 | } 106 | 107 | export default withOktaAuth(Customer) -------------------------------------------------------------------------------- /jobs-ui/src/components/customer/JobCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Logo from '../misc/Logo' 3 | import TimesAgo from '../misc/TimesAgo' 4 | 5 | function JobCard({ job }) { 6 | return ( 7 |
8 |
9 |
10 |
11 | 12 |
13 |
14 |
15 |
16 | {job.company} 17 |
18 |
19 |
20 |
21 | 22 |
23 |
24 |
25 |
26 | {job.id} 27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | {job.title} 35 |
36 |
37 |
38 |
39 | {job.description} 40 |
41 |
42 |
43 |
44 | ) 45 | } 46 | 47 | export default JobCard -------------------------------------------------------------------------------- /jobs-ui/src/components/customer/JobList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import JobCard from './JobCard' 4 | 5 | function Jobs({ jobs }) { 6 | const jobList = jobs.map(job => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | }) 13 | 14 | return ( 15 |
16 | {jobList} 17 |
18 | ) 19 | } 20 | 21 | export default Jobs -------------------------------------------------------------------------------- /jobs-ui/src/components/customer/JobView.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withOktaAuth } from '@okta/okta-react' 3 | import M from 'materialize-css' 4 | import Logo from '../misc/Logo' 5 | import TimesAgo from '../misc/TimesAgo' 6 | import API from '../misc/api' 7 | 8 | class JobView extends Component { 9 | state = { 10 | job: null 11 | } 12 | 13 | async componentDidMount() { 14 | const id = this.props.match.params.job_id 15 | 16 | API.get(`jobs/${id}`, { 17 | headers: { 18 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 19 | } 20 | }) 21 | .then(response => { 22 | this.setState({ 23 | job: response.data 24 | }) 25 | }) 26 | .catch(error => { 27 | console.log(error) 28 | M.toast({ html: error, classes: 'rounded' }) 29 | }) 30 | } 31 | 32 | render() { 33 | const { job } = this.state 34 | const jobInfo = job && ( 35 |
36 |
37 |
38 |
39 | 40 |
41 |
42 |
    43 |
  • {job.company}
  • 44 |
  • {job.id}
  • 45 |
  • 46 |
47 |
48 |
49 |
50 |
51 |
52 | {job.title} 53 | Apply 54 |
55 |
56 |
57 |
58 | {job.description} 59 |
60 |
61 |
62 |
63 | ) 64 | return ( 65 |
66 | {jobInfo} 67 |
68 | ) 69 | } 70 | } 71 | 72 | export default withOktaAuth(JobView) -------------------------------------------------------------------------------- /jobs-ui/src/components/home/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import M from 'materialize-css' 3 | import JobList from './JobList' 4 | import API from '../misc/api' 5 | 6 | class Home extends Component { 7 | state = { 8 | jobs: [] 9 | } 10 | 11 | componentDidMount() { 12 | API.get(`jobs/newest?number=8`) 13 | .then(response => { 14 | this.setState({ 15 | jobs: response.data 16 | }) 17 | }) 18 | .catch(error => { 19 | console.log(error) 20 | M.toast({ html: error, classes: 'rounded' }) 21 | }) 22 | 23 | M.Parallax.init(document.querySelectorAll('.parallax')) 24 | } 25 | 26 | render() { 27 | const { jobs } = this.state 28 | 29 | return ( 30 |
31 |
32 |
33 |
34 |
35 |
36 | 37 |
38 |
39 |
40 | ) 41 | } 42 | } 43 | 44 | export default Home -------------------------------------------------------------------------------- /jobs-ui/src/components/home/JobCard.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Logo from '../misc/Logo' 3 | import TimesAgo from '../misc/TimesAgo' 4 | 5 | function JobCard({ job }) { 6 | return ( 7 |
8 |
9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 |
17 |
18 | {job.company} 19 |
20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {job.title} 32 |
33 |
34 |
35 |
36 |
37 |
38 | ) 39 | } 40 | 41 | export default JobCard -------------------------------------------------------------------------------- /jobs-ui/src/components/home/JobList.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import JobCard from './JobCard' 4 | 5 | function Jobs({ jobs }) { 6 | const jobList = jobs.map(job => { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | }) 13 | 14 | return ( 15 |
16 | {jobList} 17 |
18 | ) 19 | } 20 | 21 | export default Jobs -------------------------------------------------------------------------------- /jobs-ui/src/components/misc/Logo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function Logo({ logoUrl }) { 4 | const defaultLogoUrl = '/jobs-portal-logo.svg' 5 | return ( 6 |
7 | 8 |
9 | ) 10 | } 11 | 12 | export default Logo -------------------------------------------------------------------------------- /jobs-ui/src/components/misc/Navbar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Link, NavLink } from 'react-router-dom' 3 | import { withOktaAuth } from '@okta/okta-react' 4 | import M from 'materialize-css' 5 | 6 | class Navbar extends Component { 7 | state = { 8 | authenticated: false, 9 | user: null 10 | } 11 | 12 | async componentDidMount() { 13 | this.checkAuthentication() 14 | 15 | const sidenav = document.querySelectorAll('.sidenav') 16 | M.Sidenav.init(sidenav) 17 | } 18 | 19 | async componentDidUpdate() { 20 | this.checkAuthentication() 21 | } 22 | 23 | checkAuthentication = async () => { 24 | if (this.props.authState) { 25 | const authenticated = await this.props.authState.isAuthenticated 26 | if (authenticated !== this.state.authenticated) { 27 | const user = await this.props.oktaAuth.getUser() 28 | this.setState({ authenticated, user }) 29 | } 30 | } 31 | } 32 | 33 | logHandleLogInOut = async () => { 34 | this.state.authenticated ? this.props.oktaAuth.signOut('/') : this.props.oktaAuth.signInWithRedirect('/') 35 | } 36 | 37 | render() { 38 | const linkVisibility = this.state.authenticated ? { "display": "block" } : { "display": "none" } 39 | const username = this.state.authenticated && this.state.user.preferred_username 40 | const logInOut = this.state.authenticated ? "Logout" : "Login" 41 | return ( 42 |
43 |
44 | 59 |
60 | 61 |
    62 |
  • Home
  • 63 |
  • Customer
  • 64 |
  • Staff
  • 65 |
  • {logInOut}
  • 66 |
67 |
68 | ) 69 | } 70 | } 71 | 72 | export default withOktaAuth(Navbar) -------------------------------------------------------------------------------- /jobs-ui/src/components/misc/Pagination.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class Pagination extends Component { 4 | 5 | handlePagination = (number) => { 6 | if (number < 0 || number >= this.props.pagination.totalPages) { 7 | return 8 | } 9 | this.props.searchJob(this.props.searchText, number, this.props.pagination.size) 10 | } 11 | 12 | render() { 13 | const chevronLeftClassName = this.props.pagination && this.props.pagination.first ? "disabled" : "waves-effect" 14 | const chevronRightClassName = this.props.pagination && this.props.pagination.last ? "disabled" : "waves-effect" 15 | 16 | return ( 17 | 32 | ) 33 | } 34 | } 35 | 36 | export default Pagination -------------------------------------------------------------------------------- /jobs-ui/src/components/misc/Search.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class Search extends Component { 4 | state = { 5 | searchText: '' 6 | } 7 | 8 | handleChange = (e) => { 9 | const { id, value } = e.target 10 | this.setState({ 11 | [id]: value 12 | }) 13 | } 14 | 15 | handleEnterPressed = (e) => { 16 | if (e.key === 'Enter') { 17 | this.props.searchJob(this.state.searchText) 18 | } 19 | } 20 | 21 | render() { 22 | return ( 23 |
24 |
25 |
26 |
27 | search 28 | 35 |
36 |
37 |
38 |
39 | ) 40 | } 41 | } 42 | 43 | export default Search -------------------------------------------------------------------------------- /jobs-ui/src/components/misc/TimesAgo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import moment from 'moment' 3 | 4 | function TimesAgo({ className, createDate }) { 5 | return ( 6 | {moment(createDate, 'YYYY-MM-DD HH:mm:ssZ').local().fromNow()} 7 | ) 8 | } 9 | 10 | export default TimesAgo -------------------------------------------------------------------------------- /jobs-ui/src/components/misc/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | export default axios.create({ 4 | baseURL: `http://localhost:8080/api/` 5 | }) -------------------------------------------------------------------------------- /jobs-ui/src/components/staff/DeleteDialog.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | function DeleteDialog({ job, deleteJob }) { 4 | const jobId = job && job.id 5 | const jobCompany = job && job.company 6 | 7 | return ( 8 |
9 |
10 |

Delete Job?

11 |

Do you confirm the deletion of the {jobCompany} job, id {jobId}?

12 |
13 |
14 | 15 | 16 |
17 |
18 | ) 19 | } 20 | 21 | export default DeleteDialog -------------------------------------------------------------------------------- /jobs-ui/src/components/staff/JobForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withOktaAuth } from '@okta/okta-react' 3 | import M from 'materialize-css' 4 | import JobCardHome from '../home/JobCard' 5 | import JobCardCustomer from '../customer/JobCard' 6 | import API from '../misc/api' 7 | 8 | class JobForm extends Component { 9 | state = { 10 | id: '', 11 | title: '', 12 | company: '', 13 | logoUrl: '', 14 | description: '', 15 | createDate: '' 16 | } 17 | 18 | handleChange = (e) => { 19 | const { id, value } = e.target 20 | this.setState({ 21 | [id]: value 22 | }) 23 | } 24 | 25 | redirectJobList = () => { 26 | this.props.history.push("/staff") 27 | } 28 | 29 | async componentDidMount() { 30 | const id = this.props.match.params.job_id 31 | if (id) { 32 | API.get(`jobs/${id}`, { 33 | headers: { 34 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 35 | } 36 | }) 37 | .then(response => { 38 | const job = response.data 39 | this.setState({ 40 | id: job.id, 41 | title: job.title, 42 | company: job.company, 43 | logoUrl: job.logoUrl, 44 | description: job.description, 45 | createDate: job.createDate 46 | }) 47 | }) 48 | .catch(error => { 49 | console.log(error) 50 | M.toast({html: error, classes: 'rounded'}) 51 | }) 52 | } 53 | 54 | M.Tabs.init(document.querySelectorAll('.tabs')) 55 | } 56 | 57 | saveJob = async () => { 58 | if (!this.validateForm()) { 59 | return 60 | } 61 | 62 | const job = this.state 63 | let method = 'POST' 64 | let url = 'http://localhost:8080/api/jobs' 65 | if (job.id) { 66 | method = 'PUT' 67 | url += '/' + job.id 68 | } 69 | 70 | API.request({ 71 | method: method, 72 | url: url, 73 | headers: { 74 | 'Content-Type': 'application/json', 75 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 76 | }, 77 | data: JSON.stringify(job) 78 | }) 79 | .then(() => { 80 | this.redirectJobList() 81 | }) 82 | .catch(error => { 83 | console.log(error) 84 | M.toast({html: error, classes: 'rounded'}) 85 | }) 86 | } 87 | 88 | validateForm = () => { 89 | const fields = document.querySelectorAll(".validate") 90 | for (let i = 0; i < fields.length; i++) { 91 | if (fields[i].value.trim() === "") { 92 | document.getElementById(fields[i].id).focus() 93 | return false 94 | } 95 | } 96 | return true 97 | } 98 | 99 | componentDidUpdate() { 100 | // It is needed to avoid labels overlapping prefilled content 101 | // Besides, the labels of this form component have "active" className 102 | M.updateTextFields() 103 | 104 | // It is needed otherwise, on editing, the textarea will start with 105 | // just 2 lines 106 | M.textareaAutoResize(document.querySelector('.materialize-textarea')) 107 | } 108 | 109 | mockJobIdAndCreateDate = () => { 110 | let job = { ...this.state } 111 | job.id = 'XXXXXXXXXXXXXXXXXXXXXXXX' 112 | job.createDate = new Date() 113 | return job 114 | } 115 | 116 | render() { 117 | const job = this.state.id ? this.state : this.mockJobIdAndCreateDate() 118 | const idFieldVisibility = this.state.id ? { display: "block" } : { display: "none" } 119 | 120 | const form = ( 121 |
122 |
123 |
124 |
125 | 126 | 127 |
128 |
129 |
130 |
131 | 132 | 133 | 134 |
135 |
136 |
137 |
138 | 139 | 140 | 141 |
142 |
143 |
144 |
145 | 146 | 147 |
148 |
149 |
150 |
151 | 152 | 153 | 154 |
155 |
156 |
157 |
158 |
159 | 160 | 161 |
162 |
163 |
164 | ) 165 | 166 | return ( 167 |
168 |
169 |
170 | 175 |
176 |
177 | {form} 178 |
179 |
180 | 181 |
182 |
183 | 184 |
185 |
186 |
187 | ) 188 | } 189 | } 190 | 191 | export default withOktaAuth(JobForm) -------------------------------------------------------------------------------- /jobs-ui/src/components/staff/JobList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Link } from 'react-router-dom' 3 | import M from 'materialize-css' 4 | import Logo from '../misc/Logo' 5 | import DeleteDialog from './DeleteDialog' 6 | 7 | class Jobs extends Component { 8 | state = { 9 | job: null 10 | } 11 | 12 | deleteModal = null 13 | 14 | componentDidMount() { 15 | this.deleteModal = M.Modal.init(document.getElementById('deleteModal')) 16 | } 17 | 18 | showDialog = (job) => { 19 | this.setState({ job }) 20 | this.deleteModal.open() 21 | } 22 | 23 | render() { 24 | const jobList = this.props.jobs.map(job => { 25 | return ( 26 |
27 |
28 | 29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 |
37 | {job.company} 38 |
39 |
40 |
41 |
42 | {job.id} 43 |
44 |
45 |
46 |
47 |
48 |
49 | {job.title} 50 |
51 |
52 |
53 | 54 |
55 | 58 |
59 |
60 |
61 | ) 62 | }) 63 | 64 | return ( 65 |
66 |
67 | {jobList} 68 |
69 | 70 | 74 |
75 | ) 76 | } 77 | } 78 | 79 | export default Jobs -------------------------------------------------------------------------------- /jobs-ui/src/components/staff/Staff.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withOktaAuth } from '@okta/okta-react' 3 | import { Link } from 'react-router-dom' 4 | import M from 'materialize-css' 5 | import API from '../misc/api' 6 | import Pagination from '../misc/Pagination' 7 | import Search from '../misc/Search' 8 | import JobList from './JobList' 9 | 10 | class Staff extends Component { 11 | state = { 12 | jobs: [], 13 | pagination: { 14 | first: null, 15 | last: null, 16 | number: null, 17 | size: null, 18 | totalElements: null, 19 | totalPages: null 20 | }, 21 | searchText: '' 22 | } 23 | 24 | pageDefaultNumber = 0 25 | pageDefaultSize = 10 26 | 27 | componentDidMount() { 28 | const floatingActionButton = document.querySelectorAll('.fixed-action-btn') 29 | M.FloatingActionButton.init(floatingActionButton, { 30 | direction: 'button' 31 | }) 32 | 33 | this.getAllJobs(this.pageDefaultNumber, this.pageDefaultSize) 34 | } 35 | 36 | getAllJobs = async (page, size) => { 37 | API.get(`jobs?page=${page}&size=${size}`, { 38 | headers: { 39 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 40 | } 41 | }) 42 | .then(response => { 43 | const { content, first, last, number, size, totalElements, totalPages } = response.data 44 | this.setState({ 45 | jobs: content, 46 | pagination: { 47 | first, 48 | last, 49 | number, 50 | size, 51 | totalElements, 52 | totalPages 53 | } 54 | }) 55 | }) 56 | .catch(error => { 57 | console.log(error) 58 | M.toast({ html: error, classes: 'rounded' }) 59 | }) 60 | } 61 | 62 | getJobsWithText = async (text, page, size) => { 63 | API.put(`jobs/search?page=${page}&size=${size}`, { 'text': text }, { 64 | headers: { 65 | 'Content-Type': 'application/json', 66 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 67 | } 68 | }) 69 | .then(response => { 70 | const { content, first, last, number, size, totalElements, totalPages } = response.data 71 | this.setState({ 72 | jobs: content, 73 | pagination: { 74 | first, 75 | last, 76 | number, 77 | size, 78 | totalElements, 79 | totalPages 80 | } 81 | }) 82 | }) 83 | .catch(error => { 84 | console.log(error) 85 | M.toast({ html: error, classes: 'rounded' }) 86 | }) 87 | } 88 | 89 | deleteJob = async (id) => { 90 | API.delete(`jobs/${id}`, { 91 | headers: { 92 | 'Authorization': 'Bearer ' + await this.props.authState.accessToken.accessToken 93 | } 94 | }) 95 | .then(() => { 96 | const { number, size } = this.state.pagination 97 | this.getAllJobs(number, size) 98 | }) 99 | .catch(error => { 100 | console.log(error) 101 | M.toast({ html: error, classes: 'rounded' }) 102 | }) 103 | } 104 | 105 | searchJob = async (searchText, pageNumber, pageSize) => { 106 | this.setState({ searchText }) 107 | searchText ? this.getJobsWithText(searchText, pageNumber, pageSize) : this.getAllJobs(pageNumber, pageSize) 108 | } 109 | 110 | render() { 111 | return ( 112 |
113 |
114 | 115 | 116 | 121 | 122 | 126 |
127 | 128 |
129 | 131 | add 132 | 133 |
134 |
135 | ) 136 | } 137 | } 138 | 139 | export default withOktaAuth(Staff) -------------------------------------------------------------------------------- /jobs-ui/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | 15 | .row { 16 | margin-bottom: 5px; 17 | } 18 | 19 | .card-panel { 20 | padding: 15px; 21 | } 22 | 23 | nav ul li a.active { 24 | background-color: #0277bd; 25 | } 26 | 27 | .tabs .tab a { 28 | color:#000; 29 | } /*Black color to the text*/ 30 | 31 | .tabs .tab a:hover { 32 | background-color:transparent; 33 | color:#000; 34 | } /*Text color on hover*/ 35 | 36 | .tabs .tab a.active { 37 | background-color:transparent; 38 | color:#000; 39 | } /*Background and text color when a tab is active*/ 40 | 41 | .tabs .indicator { 42 | background-color:#01579b; 43 | } /*Color of underline*/ 44 | 45 | .tabs .tab a:focus, .tabs .tab a:focus.active { 46 | background-color:transparent; 47 | } 48 | -------------------------------------------------------------------------------- /jobs-ui/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import { BrowserRouter as Router } from 'react-router-dom' 4 | import './index.css' 5 | import App from './App' 6 | import reportWebVitals from './reportWebVitals' 7 | 8 | const root = ReactDOM.createRoot(document.getElementById('root')) 9 | root.render( 10 | // 11 | 12 | 13 | 14 | // 15 | ) 16 | 17 | // If you want to start measuring performance in your app, pass a function 18 | // to log results (for example: reportWebVitals(console.log)) 19 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 20 | reportWebVitals() -------------------------------------------------------------------------------- /jobs-ui/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /jobs-ui/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | --------------------------------------------------------------------------------